diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4a7881a..b782e2a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co This is a **developer portfolio** whose primary audience is **recruiters and hiring managers**. Optimize every decision for demonstrating engineering skill, depth of work, and professional credibility — not for freelance/client conversion. -**Design direction (APPROVED 2026-06-02, originally validated via a now-removed `mockups/v3` prototype): a design-forward developer portfolio with a signature ~2s monogram arrival animation, mobile-first.** Nima is a **frontend developer with a designer's eye** — positioned as a developer, NOT a designer. The site has designer-grade art direction (near-black + acid-lime `#C6FF3D`, Clash Display / Satoshi / Geist Mono) and a signature ~2-second arrival: an NH monogram strokes on → flies into the nav as the logo → name reveals behind masks → one acid-lime pop (once per session; instant under `prefers-reduced-motion`). Purposeful motion only (Lenis scroll, masked reveals, magnetic CTA). Structure: hero, Selected Work rows → case studies, a live-component Playground (engineering proof), About, contact. **Hard requirements:** beautiful on BOTH mobile and desktop (explicitly verified), full a11y (reduced-motion, keyboard, contrast). See the spec's "FINAL APPROVED DIRECTION" section for the build detail. Adds GSAP + Lenis. +**Design direction (APPROVED 2026-06-02, originally validated via a now-removed `mockups/v3` prototype): a design-forward developer portfolio with a signature ~2s monogram arrival animation, mobile-first.** Nima is a **developer and AI engineer with a designer's eye** — positioned as a developer, NOT a designer. (Visible copy repositioned from "frontend developer" 2026-08-20; SEO surfaces had led since PR #16.) The site has designer-grade art direction (near-black + acid-lime `#C6FF3D`, Clash Display / Satoshi / Geist Mono) and a signature ~2-second arrival: an NH monogram strokes on → flies into the nav as the logo → name reveals behind masks → one acid-lime pop (once per session; instant under `prefers-reduced-motion`). Purposeful motion only (Lenis scroll, masked reveals, magnetic CTA). Structure: hero, Selected Work rows → case studies, About, contact. (A live-component Playground section existed until 2026-08 and was removed as filler.) **Hard requirements:** beautiful on BOTH mobile and desktop (explicitly verified), full a11y (reduced-motion, keyboard, contrast). See the spec's "FINAL APPROVED DIRECTION" section for the build detail. Adds GSAP + Lenis. Abandoned explorations (all built + rejected, since deleted — the `mockups/` prototypes no longer exist): refined-minimal multi-page (`variant-a`) & long-scroll (`variant-b`) — too understated; high-craft editorial (`v2`) — too quiet/generic; interactive 3D galaxy (`cosmos`) — felt random/gimmicky. The react-three-fiber/3D direction is dead. The approved direction was the former `v3` prototype, now realized in the live app. diff --git a/e2e/accent-contrast.spec.ts b/e2e/accent-contrast.spec.ts index b90adbc..8ad1b5c 100644 --- a/e2e/accent-contrast.spec.ts +++ b/e2e/accent-contrast.spec.ts @@ -2,7 +2,7 @@ import { test, expect, type Locator } from '@playwright/test'; import { contrast, maxChannel, parseColorChannels } from './support/color'; // Regression guard for issue #3. The dark-on-lime sites (accent buttons, the -// nav CTA, the selected segmented-control label) used to get their dark text +// nav CTA, the work-row "In progress" tag) used to get their dark text // color from `text-base` — the same utility that caused the invisible-heading // footgun. The fix renames the color token to `dark`, so these now use // `text-dark`. If a future edit drops `text-dark`, the text would fall back to @@ -11,9 +11,8 @@ import { contrast, maxChannel, parseColorChannels } from './support/color'; // against the lime accent it sits on. // The accent these elements sit on (`accent` in tailwind.config.ts, #C6FF3D). -// Both targets are placed on it by design — the nav CTA via `bg-accent`, the -// segmented label via an absolute sibling pill — so we check contrast against -// this constant rather than walking the DOM for a background. +// Both targets are placed on it by design via `bg-accent` — so we check +// contrast against this constant rather than walking the DOM for a background. const ACCENT = [198, 255, 61]; test.beforeEach(async ({ page }) => { @@ -62,14 +61,15 @@ async function assertDarkOnLime(el: Locator, label: string) { // the accent-button case for that component. test('nav "Let\'s talk" CTA renders dark text on lime', async ({ page }) => { await page.goto('/'); - await assertDarkOnLime(page.getByRole('link', { name: "Let's talk" }).first(), "nav Let's talk CTA"); + await assertDarkOnLime(page.getByRole('link', { name: 'Let’s talk' }).first(), "nav Let's talk CTA"); }); -test('selected segmented-control label renders dark text on lime', async ({ page }) => { +// The playground's segmented control (this spec's original second target) was +// removed 2026-08; the work list's "In progress" tag is the remaining +// dark-on-lime pill. +test('work-row "In progress" tag renders dark text on lime', async ({ page }) => { await page.goto('/'); - // The dark color lives on the inner text span; the sibling motion span is the - // (aria-hidden) lime pill background. - const selectedLabel = page.locator('[role="radio"][aria-checked="true"] span:not([aria-hidden])'); - await expect(selectedLabel).toHaveCount(1); - await assertDarkOnLime(selectedLabel, 'segmented selected label'); + const tag = page.getByText('In progress').first(); + await expect(tag).toBeVisible(); + await assertDarkOnLime(tag, 'work-row In progress tag'); }); diff --git a/e2e/arrival-motion.spec.ts b/e2e/arrival-motion.spec.ts index 965704d..44475d8 100644 --- a/e2e/arrival-motion.spec.ts +++ b/e2e/arrival-motion.spec.ts @@ -10,7 +10,7 @@ import { test, expect } from '@playwright/test'; * reduced motion the stylesheet hides it before first paint and the unmount * follows on a microtask. * - Hero

renders "Nima Hakimi" (the period rides on the same line). - * - "Selected Work" / "Playground" / "Contact" headings live further down, + * - "Selected Work" / "Contact" headings live further down, * each gated by a masked scroll Reveal (inline transform: translateY(...)). */ @@ -86,14 +86,9 @@ test.describe('scroll reveal', () => { // Let the arrival settle and the hero appear. await expect(page.getByRole('heading', { name: 'Nima Hakimi' })).toBeVisible({ timeout: 3000 }); - // Playground heading lives well below the fold behind a Reveal. - const playground = page.getByRole('heading', { name: /playground/i }); - await playground.scrollIntoViewIfNeeded(); - await expect(playground).toBeVisible({ timeout: 3000 }); - - // Contact closing moment, even further down. + // Contact closing moment, well below the fold behind a Reveal. const contact = page.getByRole('heading', { - name: /open to frontend roles/i, + name: /open to new roles/i, }); await contact.scrollIntoViewIfNeeded(); await expect(contact).toBeVisible({ timeout: 3000 }); diff --git a/e2e/navigation-contact.spec.ts b/e2e/navigation-contact.spec.ts index d42d177..4107253 100644 --- a/e2e/navigation-contact.spec.ts +++ b/e2e/navigation-contact.spec.ts @@ -30,7 +30,7 @@ test.describe('Primary navigation (desktop)', () => { await page.getByRole('link', { name: 'Work', exact: true }).click(); await expect(page).toHaveURL(/\/work$/); - await expect(page.getByRole('heading', { name: 'Selected Work' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Selected work' })).toBeVisible(); await page.getByRole('link', { name: 'About', exact: true }).click(); await expect(page).toHaveURL(/\/about$/); @@ -63,7 +63,7 @@ test.describe('Mobile menu', () => { await workLink.click(); await expect(page).toHaveURL(/\/work$/); - await expect(page.getByRole('heading', { name: 'Selected Work' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Selected work' })).toBeVisible(); // Route change closes the menu. await expect(panel).toBeHidden(); @@ -73,7 +73,7 @@ test.describe('Mobile menu', () => { test.describe('Work browse', () => { test('first case study link opens its detail page', async ({ page }) => { await page.goto('/work'); - await expect(page.getByRole('heading', { name: 'Selected Work' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Selected work' })).toBeVisible(); const firstCase = page.locator('.work-list a.work-row').first(); const title = (await firstCase.locator('.work-row__name').first().innerText()).trim(); diff --git a/e2e/seo.spec.ts b/e2e/seo.spec.ts index 5cff335..cc00cc2 100644 --- a/e2e/seo.spec.ts +++ b/e2e/seo.spec.ts @@ -64,7 +64,19 @@ test.describe('structured data', () => { await page.goto('/about'); expect((await ldTypes(page)).sort()).toEqual(['Person', 'ProfilePage', 'WebSite']); - await page.goto('/work/be-my-guide'); + await page.goto('/work/sonari'); expect((await ldTypes(page)).sort()).toEqual(['BreadcrumbList', 'Person', 'WebSite']); }); + + test('an unknown route serves the on-brand 404 with a real 404 status', async ({ page }) => { + const response = await page.goto('/work/does-not-exist'); + expect(response?.status()).toBe(404); + await expect(page.getByRole('heading', { name: 'This page doesn’t exist.' })).toBeVisible(); + }); + + test('the removed be-my-guide case 308s to /work instead of 404ing', async ({ page }) => { + const resp = await page.request.get('/work/be-my-guide', { maxRedirects: 0 }); + expect(resp.status()).toBe(308); + expect(resp.headers()['location']).toBe('/work'); + }); }); diff --git a/next.config.js b/next.config.js index 91c48b8..83c3d76 100644 --- a/next.config.js +++ b/next.config.js @@ -6,6 +6,9 @@ const nextConfig = { // for "Nima Hakimi developer"); the redesign renamed it to /work. { source: '/projects', destination: '/work', permanent: true }, { source: '/projects/:path*', destination: '/work', permanent: true }, + // Case study removed 2026-08 after being deployed in the sitemap — keep + // the indexed URL alive rather than 404 it (the /projects lesson). + { source: '/work/be-my-guide', destination: '/work', permanent: true }, // Exact-host only: preview deployments on *.vercel.app must stay untouched. { source: '/:path*', diff --git a/public/harness.html b/public/harness.html index 64dc1cf..142982c 100644 --- a/public/harness.html +++ b/public/harness.html @@ -130,7 +130,6 @@ font-family: var(--mono); font-size: 13px; color: var(--muted); z-index: 40; } - #pagenum.speaking::before { content: "\25CF "; color: var(--amber); } #progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--amber); width: 0; @@ -164,6 +163,11 @@ position: absolute; right: 84px; bottom: 60px; font-size: 14px; color: var(--muted); font-family: var(--mono); } + #s1 .keys { + position: absolute; left: 84px; bottom: 60px; + font-size: 13px; color: var(--muted); font-family: var(--mono); + letter-spacing: .04em; + } /* ---------- slide 2 ---------- */ .twocol { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; flex: 1; align-content: center; } @@ -355,27 +359,12 @@ #s11 .eq { font-family: var(--mono); font-size: 34px; color: var(--amber); margin-bottom: 44px; } #s11 .q { font-size: 44px; font-weight: 700; } - /* ---------- notes panel ---------- */ - #notes { - position: fixed; left: 0; right: 0; bottom: 0; - background: rgba(11,12,15,.94); - border-top: 1px solid var(--border); - padding: 18px 32px 22px; - font-family: var(--mono); font-size: 15px; line-height: 1.6; - color: var(--text-2); - max-height: 40vh; overflow-y: auto; - display: none; z-index: 100; - } - #notes.open { display: block; } - #notes .nhead { color: var(--amber); margin-bottom: 8px; font-size: 13px; letter-spacing: .1em; } - aside.notes { display: none; } - @media print { body { overflow: visible; } #viewport { position: static; display: block; } #stage { width: auto; height: auto; transform: none !important; } section.slide { position: relative; opacity: 1; transform: none; page-break-after: always; height: 720px; width: 1280px; } - #notes, #pagenum, #progress { display: none !important; } + #pagenum, #progress, #s1 .keys { display: none !important; } } @@ -387,7 +376,7 @@
agent = modell + harness
«Agent = Model + Harness» — LangChain
- +
← → naviger · Home/End første/siste · F fullskjerm · Esc avslutt
@@ -404,7 +393,6 @@

Samme modell. Helt forskjellig resultat.

Samme modell.
- @@ -427,7 +415,6 @@

Harness: alt som ikke er modellen

modell
- @@ -451,7 +438,6 @@

Programmatisk

Prose er forslag. Kode er garantier.
- @@ -481,7 +467,6 @@

AGENTS.md: alltid i kontekst

Leses automatisk hver session, fra git root og cwd · path-spesifikke regler: .github/instructions/*.instructions.md med applyTo-glob · leser også CLAUDE.md

- @@ -505,7 +490,6 @@

Skills: kunnskap on demand

  • Samme gate: custom agents (.agent.md, subagent med eget context window og begrensede tools)
  • - @@ -549,7 +533,6 @@

    Hvorfor hooks, og ikke bare prose?

    permissionRequest
    - @@ -597,7 +580,6 @@

    Sikkerhet

    debug: /env viser lastede hooks · stdout → ~/.copilot/logs/ · lastes ved session-start · Windows: egne bash- og powershell-nøkler

    - @@ -635,7 +617,6 @@

    Fra min egen verktøykasse

    - @@ -660,7 +641,6 @@

    Sensors fanger opp etter
    Dårlig output er ofte en manglende sensor.
    - @@ -676,7 +656,6 @@

    Oppsummert

    Åpen standard, aug 2026: Agent Plugins 1.0 · AWS · Anysphere · Microsoft · OpenAI · Vercel

    agent = modell + harness
    Spørsmål?
    - @@ -684,7 +663,6 @@

    Oppsummert

    -