Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
22 changes: 11 additions & 11 deletions e2e/accent-contrast.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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');
});
11 changes: 3 additions & 8 deletions e2e/arrival-motion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <h1> 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(...)).
*/

Expand Down Expand Up @@ -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 });
Expand Down
6 changes: 3 additions & 3 deletions e2e/navigation-contact.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/);
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
14 changes: 13 additions & 1 deletion e2e/seo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
3 changes: 3 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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*',
Expand Down
Loading
Loading