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
76 changes: 76 additions & 0 deletions e2e/accent-contrast.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { test, expect, type Locator } from '@playwright/test';

// 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
// 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
// the inherited light `ink` color, rendering near-invisible light-on-lime. This
// guard asserts each one renders genuinely DARK text with adequate contrast
// 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.
const ACCENT = [198, 255, 61];

test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
});

function relLuminance([r, g, b]: number[]): number {
const lin = [r, g, b].map((c) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
function contrast(fg: number[], bg: number[]): number {
const l1 = relLuminance(fg);
const l2 = relLuminance(bg);
const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];
return (hi + 0.05) / (lo + 0.05);
}

async function assertDarkOnLime(el: Locator, label: string) {
// Re-resolve via expect.poll: motion-wrapped elements (the nav CTA, the
// segmented pill) re-render while their layout animation settles, which can
// detach a held handle. evaluate() needs the node attached but not in view —
// computed color is viewport-independent, so we never scroll.
let fg = [255, 255, 255];
await expect
.poll(
async () => {
fg = await el.evaluate((node) =>
(getComputedStyle(node).color.match(/\d+(\.\d+)?/g) || []).slice(0, 3).map(Number)
);
// Text must be dark (every channel low), not the light `ink` fallback.
return Math.max(...fg);
},
{ message: `${label} text should be dark` }
)
.toBeLessThan(80);

// And readable on the lime accent it sits on.
expect(
contrast(fg, ACCENT),
`${label} contrast ${contrast(fg, ACCENT).toFixed(2)}:1 (fg=${fg})`
).toBeGreaterThanOrEqual(4.5);
}

// The nav CTA is a MagneticButton (`bg-accent text-dark`), so this also covers
// 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");
});

test('selected segmented-control label 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');
});
4 changes: 2 additions & 2 deletions src/app/contact/ContactClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function SubmitButton() {
return (
<button
type="submit"
className="inline-flex items-center justify-center gap-2 rounded-full bg-accent px-6 py-3 text-sm font-medium text-base transition-colors hover:bg-accent-dim focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2 focus-visible:ring-offset-base disabled:cursor-not-allowed disabled:opacity-70"
className="inline-flex items-center justify-center gap-2 rounded-full bg-accent px-6 py-3 text-sm font-medium text-dark transition-colors hover:bg-accent-dim focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2 focus-visible:ring-offset-dark disabled:cursor-not-allowed disabled:opacity-70"
disabled={pending}
aria-busy={pending}
>
Expand All @@ -29,7 +29,7 @@ function SubmitButton() {
<span className="sr-only">Sending</span>
<span
aria-hidden="true"
className="h-4 w-4 animate-spin rounded-full border-2 border-base/40 border-t-base"
className="h-4 w-4 animate-spin rounded-full border-2 border-dark/40 border-t-dark"
/>
<span aria-hidden="true">Sending…</span>
</>
Expand Down
4 changes: 2 additions & 2 deletions src/app/contact/loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ export default function LoadingContactPage() {
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="space-y-2">
<div className="h-3 w-20 rounded bg-muted/20" />
<div className="h-11 w-full rounded-lg bg-base/60" />
<div className="h-11 w-full rounded-lg bg-dark/60" />
</div>
))}
<div className="space-y-2">
<div className="h-3 w-20 rounded bg-muted/20" />
<div className="h-[140px] w-full rounded-lg bg-base/60" />
<div className="h-[140px] w-full rounded-lg bg-dark/60" />
</div>
<div className="h-11 w-36 rounded-full bg-accent/30" />
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
@apply antialiased;
}
body {
@apply bg-base text-ink font-sans;
@apply bg-dark text-ink font-sans;
}
::selection {
background: #c6ff3d;
Expand Down
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
lang="en"
className={`${clashDisplay.variable} ${satoshi.variable} ${geistMono.variable}`}
>
<body className="min-h-screen bg-base text-ink font-sans">
<body className="min-h-screen bg-dark text-ink font-sans">
<Preloader />
<script
type="application/ld+json"
Expand Down
14 changes: 7 additions & 7 deletions src/components/layout/SiteNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ export default function SiteNav() {
return (
<nav
aria-label="Primary"
className="sticky top-0 z-40 border-b border-white/10 bg-base/70 backdrop-blur"
className="sticky top-0 z-40 border-b border-white/10 bg-dark/70 backdrop-blur"
>
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-4 sm:px-6">
{/* Logo slot — arrival animation landing target. Visible by default. */}
<Link
id="nav-logo-slot"
href="/"
aria-label="Nima Hakimi — home"
className="relative flex items-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-base"
className="relative flex items-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-dark"
>
<Monogram
className="h-7 w-7 text-ink"
Expand All @@ -79,11 +79,11 @@ export default function SiteNav() {
</ul>
<MagneticButton
href="/contact"
className="mono-label !text-base"
className="mono-label !text-dark"
>
<span
aria-hidden
className="h-1.5 w-1.5 rounded-full bg-base"
className="h-1.5 w-1.5 rounded-full bg-dark"
/>
Let&apos;s talk
</MagneticButton>
Expand All @@ -93,7 +93,7 @@ export default function SiteNav() {
<div className="flex items-center gap-3 md:hidden">
<Link
href="/contact"
className="mono-label inline-flex min-h-11 items-center rounded-full bg-accent px-4 py-2.5 !text-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-base"
className="mono-label inline-flex min-h-11 items-center rounded-full bg-accent px-4 py-2.5 !text-dark focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-dark"
>
Let&apos;s talk
</Link>
Expand All @@ -103,7 +103,7 @@ export default function SiteNav() {
aria-controls="mobile-nav-panel"
aria-label={open ? 'Close menu' : 'Open menu'}
onClick={() => setOpen((v) => !v)}
className="flex h-11 w-11 items-center justify-center rounded-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-base"
className="flex h-11 w-11 items-center justify-center rounded-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-dark"
>
<span className="relative block h-3.5 w-5" aria-hidden>
<span
Expand All @@ -130,7 +130,7 @@ export default function SiteNav() {
<div
id="mobile-nav-panel"
hidden={!open}
className="border-t border-white/10 bg-base/95 backdrop-blur md:hidden"
className="border-t border-white/10 bg-dark/95 backdrop-blur md:hidden"
>
<ul className="mx-auto flex max-w-6xl flex-col px-5 py-2 sm:px-6">
{NAV_ITEMS.map((item) => (
Expand Down
2 changes: 1 addition & 1 deletion src/components/motion/MagneticButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type MagneticButtonProps = {
};

const BASE_CLASS =
'inline-flex items-center justify-center gap-2 rounded-full px-6 py-3 text-sm font-medium bg-accent text-base transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2 focus-visible:ring-offset-base';
'inline-flex items-center justify-center gap-2 rounded-full px-6 py-3 text-sm font-medium bg-accent text-dark transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2 focus-visible:ring-offset-dark';

/**
* A CTA that drifts toward the cursor on hover and springs back on leave.
Expand Down
2 changes: 1 addition & 1 deletion src/components/playground/SegmentedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export default function SegmentedControl() {
transition={reduce ? { duration: 0 } : { type: 'spring', stiffness: 480, damping: 34 }}
/>
)}
<span className={`relative z-10 ${selected ? 'text-base' : 'text-muted'}`}>{opt}</span>
<span className={`relative z-10 ${selected ? 'text-dark' : 'text-muted'}`}>{opt}</span>
</button>
);
})}
Expand Down
2 changes: 1 addition & 1 deletion src/components/playground/SpringSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export default function SpringSlider() {
style={{ left: thumbLeft }}
animate={reduce ? undefined : { scale: dragging ? 1.25 : 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 28 }}
className="absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-accent bg-base shadow-[0_0_0_4px_rgba(198,255,61,0.12)] focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:ring-offset-2 focus-visible:ring-offset-base"
className="absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-accent bg-dark shadow-[0_0_0_4px_rgba(198,255,61,0.12)] focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:ring-offset-2 focus-visible:ring-offset-dark"
/>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/work/CaseHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default function CaseHero({ c }: { c: CaseStudy }) {
{c.role} · {c.year}
</span>
{c.inProgress && (
<span className="rounded-full border border-accent bg-accent px-[11px] py-[5px] font-mono text-[10px] uppercase tracking-[0.1em] text-base">
<span className="rounded-full border border-accent bg-accent px-[11px] py-[5px] font-mono text-[10px] uppercase tracking-[0.1em] text-dark">
In progress
</span>
)}
Expand Down
2 changes: 1 addition & 1 deletion src/components/work/WorkRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default function WorkRow({ c, index }: { c: CaseStudy; index: number }) {

<span className="mt-4 flex flex-wrap gap-1.5">
{c.inProgress && (
<span className="rounded-full border border-accent bg-accent px-[11px] py-[5px] font-mono text-[10px] uppercase tracking-[0.1em] text-base">
<span className="rounded-full border border-accent bg-accent px-[11px] py-[5px] font-mono text-[10px] uppercase tracking-[0.1em] text-dark">
In progress
</span>
)}
Expand Down
2 changes: 1 addition & 1 deletion tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const config: Config = {
theme: {
extend: {
colors: {
base: '#0E0E10',
dark: '#0E0E10',
surface: '#141417',
ink: '#F5F5F0',
muted: '#8A8A82',
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/tailwind-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import resolveConfig from 'tailwindcss/resolveConfig';
import config from '../../tailwind.config';

// Regression guard for issue #3: a color token sharing a name with a Tailwind
// font-size key makes `.text-<name>` emit BOTH a font-size and a color. That bit
// us once (`text-base` → invisible Education headings). This invariant makes the
// collision structurally impossible: no custom color may reuse a font-size key.
describe('tailwind design tokens', () => {
it('no color token collides with a font-size utility key', () => {
const full = resolveConfig(config);
const colorKeys = Object.keys(full.theme.colors);
const fontSizeKeys = new Set(Object.keys(full.theme.fontSize));

const collisions = colorKeys.filter((c) => fontSizeKeys.has(c));

expect(collisions).toEqual([]);
});
});
Loading