Phase 9: A11y, Responsive, Polish
Total Size: M + M + M + S + L + M + S
Prerequisites: Phases 1–8 complete (every component / page / asset is brand-correct)
New Types: None
New Files: src/__tests__/brand/contrast.test.ts, src/__tests__/brand/legibility.test.ts, src/components/site/MobileNavDrawer.tsx, src/components/site/SkipLink.tsx
Phase 9 is the regression-guard + final sweep pass. Three sentinels lock the brand's hardest-to-eyeball constraints (mobile legibility, WCAG contrast, reduce-motion / forced-colors / 200% zoom), three Header-targeted features (9.5–9.7) close gaps the Phase 4.2 desktop-only restyle left open (responsive collapse / mobile drawer, skip-link + touch-target + focus management, composite pfp+wordmark lockup), and a final sweep walks every page, runs every check, and lights the green.
Feature 9.1: Mobile-first legibility
Complexity: M — Vitest sentinel that asserts headline legibility at three viewport sizes critical to the brand: 320×180 (og:image preview crop on small displays), 1280×720 (YouTube thumbnail), 360×640 (small mobile viewport for the homepage hero). Tests render representative compositions at each viewport and assert minimum effective character height.
Problem
The brand spec is explicit: "Every thumbnail is checked at 320×180 px before shipping. If the main headline isn't legible at that scale, the design fails." Mobile-first legibility is the brand's single most-violated rule when components add long headlines or extra hierarchy. A sentinel catches this at test time.
Implementation
NEW src/__tests__/brand/legibility.test.ts:
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { EditorialCard } from '@/components/brand/EditorialCard';
import { Logo } from '@/components/brand/Logo';
import { Header } from '@/components/site/Header';
// Minimum effective character height (in pixels) at each viewport.
// Derived from the WCAG "reading text" 16px floor scaled to the viewport's
// physical perception.
const VIEWPORT_TARGETS = [
{ name: 'og-preview', width: 320, height: 168, minHeadlineCh: 18 },
{ name: 'youtube-thumb-small', width: 480, height: 270, minHeadlineCh: 24 },
{ name: 'mobile-viewport', width: 360, height: 640, minHeadlineCh: 22 },
];
// Mobile viewport widths where the Header must collapse cleanly: no horizontal
// scrollbar and the disclosure trigger must meet the touch-target floor.
const HEADER_MOBILE_WIDTHS = [320, 360];
interface ComputedSize {
fontSize: number; // px
lineHeight: number; // px
}
function measureHeadline(node: HTMLElement): ComputedSize {
const computed = window.getComputedStyle(node);
const fontSize = parseFloat(computed.fontSize);
const lineHeight = computed.lineHeight === 'normal'
? fontSize * 1.2
: parseFloat(computed.lineHeight);
return { fontSize, lineHeight };
}
describe('brand legibility sentinel', () => {
for (const target of VIEWPORT_TARGETS) {
it(`headline meets minimum ${target.minHeadlineCh}px at ${target.name} (${target.width}×${target.height})`, () => {
const sample = 'Build the whole thing alone';
Object.defineProperty(window, 'innerWidth', { value: target.width, configurable: true });
Object.defineProperty(window, 'innerHeight', { value: target.height, configurable: true });
const { container } = render(
<div style={{ width: target.width, height: target.height }}>
<EditorialCard tag="Series" headline={sample} subtitle="One person." />
</div>
);
const headline = container.querySelector('h3');
expect(headline).not.toBeNull();
const { fontSize } = measureHeadline(headline as HTMLElement);
expect(fontSize).toBeGreaterThanOrEqual(target.minHeadlineCh);
});
}
it('logo wordmark legibility at favicon scale (32×32)', () => {
const { container } = render(
<div style={{ width: 32, height: 32 }}>
<Logo size="sm" />
</div>
);
// Just asserting we render; favicon legibility is visually verified.
expect(container.firstChild).not.toBeNull();
});
// Added by 9.5/9.6 — Header must stay usable at small viewports.
for (const width of HEADER_MOBILE_WIDTHS) {
it(`Header fits at ${width}px without horizontal overflow`, () => {
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true });
const { container } = render(
<div style={{ width }}>
<Header />
</div>
);
const header = container.querySelector('header');
expect(header).not.toBeNull();
// scrollWidth must not exceed clientWidth — i.e. no horizontal scroll.
expect((header as HTMLElement).scrollWidth).toBeLessThanOrEqual(width);
});
it(`Header disclosure trigger meets 44px touch-target floor at ${width}px`, () => {
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true });
const { container } = render(
<div style={{ width }}>
<Header />
</div>
);
const trigger = container.querySelector('[aria-controls="primary-nav-drawer"]');
expect(trigger).not.toBeNull();
const cs = window.getComputedStyle(trigger as HTMLElement);
const minH = parseFloat(cs.minHeight);
const minW = parseFloat(cs.minWidth);
expect(minH).toBeGreaterThanOrEqual(44);
expect(minW).toBeGreaterThanOrEqual(44);
});
}
});
Design Decisions
- JSDOM-based, not real screenshot diff. Real visual snapshot testing
needs a headless browser (Playwright). The brand needs a fast-running
CI sentinel; computed-style assertions at simulated viewport sizes
catch the regression class we care about (someone shrinks the
display-3token below the mobile floor). For pixel-perfect validation, the user runs the dev server and visually inspects/thumb-previewat the target sizes. - Three viewport targets. 320×180 for the og preview cropped tightly (LinkedIn small preview); 480×270 for YouTube's small thumbnail crop; 360×640 for the most common phone viewport rendering the hero.
- Minimum character heights derived empirically. Not a magic number — 18px at 320px wide means a 5.6% viewport-height character; below that, Cinzel all-caps stops resolving against marble at scrolling-pace reading. The values are conservative; if a token tweak fails, that's a brand decision, not a test bug.
- Logo at favicon size is a smoke check, not measured. Favicon legibility is unmeasurable computationally (does the {10x} mark resolve at 32×32?) — verify visually.
Files
| Action | File |
|---|---|
| NEW | src/__tests__/brand/legibility.test.ts |
Feature 9.2: WCAG contrast guard
Complexity: M — Vitest sentinel that reads the token table from
globals.css, computes WCAG contrast ratios for every used
foreground / background pair declared in the contrast-pair tokens, and
asserts ≥ 4.5:1 for body and ≥ 3:1 for large display text.
Problem
The brand spec defines three explicit contrast pairs (Marble + Ink, Marble + Oxblood, Ink + Oxblood). The actual ratios need to meet WCAG AA. Without a sentinel, a future palette tweak (a slightly darker Marble, a slightly lighter Oxblood) could silently drop a pair below threshold and ship inaccessible.
Implementation
NEW src/__tests__/brand/contrast.test.ts:
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const GLOBALS_PATH = join(__dirname, '..', '..', 'app', 'globals.css');
interface RGB { r: number; g: number; b: number; }
function hexToRgb(hex: string): RGB {
const m = hex.replace('#', '');
const full = m.length === 3 ? m.split('').map(c => c + c).join('') : m;
return {
r: parseInt(full.slice(0, 2), 16),
g: parseInt(full.slice(2, 4), 16),
b: parseInt(full.slice(4, 6), 16),
};
}
function relativeLuminance({ r, g, b }: RGB): number {
const toLinear = (c: number) => {
const cs = c / 255;
return cs <= 0.03928 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4);
};
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
function contrastRatio(fg: string, bg: string): number {
const L1 = relativeLuminance(hexToRgb(fg));
const L2 = relativeLuminance(hexToRgb(bg));
const [lighter, darker] = L1 > L2 ? [L1, L2] : [L2, L1];
return (lighter + 0.05) / (darker + 0.05);
}
function extractToken(css: string, name: string): string {
const re = new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{3,6})`);
const m = css.match(re);
if (!m) throw new Error(`Token --${name} not found in globals.css`);
return m[1];
}
describe('brand WCAG contrast sentinel', () => {
const css = readFileSync(GLOBALS_PATH, 'utf8');
const marble = extractToken(css, 'color-marble');
const parchment = extractToken(css, 'color-parchment');
const ink = extractToken(css, 'color-ink');
const oxblood = extractToken(css, 'color-oxblood');
const verdigris = extractToken(css, 'color-verdigris');
const bone = extractToken(css, 'color-bone');
const shadow = extractToken(css, 'color-shadow');
const PAIRS = [
{ fg: ink, bg: marble, role: 'body', label: 'Ink on Marble' },
{ fg: ink, bg: parchment, role: 'body', label: 'Ink on Parchment' },
{ fg: ink, bg: bone, role: 'body', label: 'Ink on Bone' },
{ fg: oxblood, bg: marble, role: 'large', label: 'Oxblood on Marble' },
{ fg: oxblood, bg: bone, role: 'large', label: 'Oxblood on Bone' },
{ fg: verdigris,bg: marble, role: 'large', label: 'Verdigris on Marble' },
{ fg: verdigris,bg: bone, role: 'large', label: 'Verdigris on Bone' },
{ fg: parchment,bg: shadow, role: 'body', label: 'Parchment on Shadow' },
{ fg: marble, bg: ink, role: 'body', label: 'Marble on Ink (CTA)' },
];
for (const { fg, bg, role, label } of PAIRS) {
const minRatio = role === 'body' ? 4.5 : 3.0;
it(`${label} meets WCAG AA (${role} → ${minRatio}:1)`, () => {
const ratio = contrastRatio(fg, bg);
expect(ratio).toBeGreaterThanOrEqual(minRatio);
});
}
});
Design Decisions
- Read tokens from
globals.cssat test time. Means any palette edit triggers the test on next run. No duplication of hex values in the test file. - WCAG AA, not AAA. AAA contrast (7:1 body, 4.5:1 large) would fail Oxblood-on-Marble immediately; the brand intentionally uses Oxblood as an "eye attractor not reading material" pair, which is exactly what WCAG AA's "large" threshold accommodates.
- Pair labels in test output. When a failure happens, the test output reads "Oxblood on Marble meets WCAG AA (large → 3:1)" — the contributor sees which pair to investigate.
- Marble-on-Ink (CTA) tested. The rare Ink + Oxblood CTA hover case doesn't use Oxblood for text — the actual button pattern is Marble-on-Ink resting, with Marble-on-Oxblood on hover. Both tested.
Files
| Action | File |
|---|---|
| NEW | src/__tests__/brand/contrast.test.ts |
Feature 9.3: Reduce-motion / forced-colors / 200% zoom
Complexity: M — Verify the brushstroke seam disables under
prefers-reduced-motion, the site reflows cleanly at 200% browser zoom,
and forced-colors mode (Windows High Contrast) keeps the site readable.
Most of this is already in place from Phase 3.2's
motion-reduce:opacity-80 and the brand's no-fixed-layout discipline; this
section verifies and tightens any remaining gaps.
Problem
The brand discipline prevents most motion / contrast accessibility issues by default (no animations, no fixed layouts that break zoom, no decorative gradients that break forced-colors). But the brushstroke composition is the one component that could fail accessibility — the texture overlay should soften under reduce-motion, the seam should remain understandable under forced-colors. And 200% zoom should reflow cleanly without horizontal scroll.
Implementation
MODIFY src/components/brand/BrushstrokeSeam.tsx — tighten the
reduce-motion treatment (already partially in place from Phase 3.2):
// In the existing component, ensure:
// 1. SVG turbulence filter scale is reduced under reduce-motion (already opacity dampened)
// 2. backgroundContent opacity drops to 70% under reduce-motion (more dampened than current 80%)
<div className="absolute inset-0 motion-safe:opacity-100 motion-reduce:opacity-70">
{backgroundContent}
</div>
// Add forced-colors handling: when the system enforces high contrast, render
// the brushstroke as a flat 1px ink rule instead of a textured seam.
<style>{`
@media (forced-colors: active) {
.brushstroke-mask-wrapper { mask-image: none !important; -webkit-mask-image: none !important; }
.brushstroke-foreground { background: Canvas !important; color: CanvasText !important; border-right: 1px solid CanvasText; }
}
`}</style>
MODIFY src/app/globals.css — append a small block ensuring
typography reflows at zoom:
@layer base {
html {
/* Allow user-agent zoom to scale freely; no `text-size-adjust: none`. */
text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
@media (forced-colors: active) {
/* Map brand surfaces to system canvas colors so high-contrast mode reads. */
body { background: Canvas; color: CanvasText; }
a { color: LinkText; }
button { border: 1px solid CanvasText; }
}
}
Manual verification steps (no sentinel; verified during 9.4 final sweep):
- Open DevTools → Rendering → Emulate CSS
prefers-reduced-motion: reduce. Confirm homepage hero brushstroke is softened. - DevTools → Rendering → Emulate CSS
forced-colors: active. Confirm every page remains readable. - Browser zoom to 200% on
/,/episodes,/build-log. Confirm no horizontal scrollbar appears at any viewport width ≥ 320px.
Design Decisions
- No automated forced-colors test. Vitest + JSDOM doesn't implement
forced-colorsmedia query meaningfully; this is a manual verification step in the final sweep. CanvasText/Canvassystem colors. WCAG-recommended approach for forced-colors fallback. System provides high-contrast values; the brand steps out of the way.- Brushstroke degrades to a 1px Ink rule. Better than vanishing entirely — the seam-as-divider intent is preserved, just without the organic feather.
text-size-adjust: 100%. Some mobile browsers auto-shrink long lines; explicitly allowing 100% scaling keeps user zoom intent intact.- Phase 9.3 doesn't add a new sentinel. Forced-colors and zoom are manual checks; reduce-motion is a property change verified visually. Building automated tests for these would over-engineer the brand discipline.
Files
| Action | File |
|---|---|
| MODIFY | src/components/brand/BrushstrokeSeam.tsx |
| MODIFY | src/app/globals.css |
Feature 9.4: Final sweep
Complexity: S — Run lint, typecheck, build, and the full test suite.
Walk every page in the route inventory in the dev server. Run Lighthouse
on the three highest-stakes pages (/, /episodes, /build-log).
Capture any final regressions, fix them, re-run the sweep.
Problem
After eight phases of changes, the only way to know the brand reset is complete is a deliberate walk through every quality gate. Anything caught during the final sweep that violates the brand or fails a sentinel must be fixed before the job closes.
Implementation
Sweep checklist:
npm run lint # must be clean
npx tsc --noEmit # must be clean
npm test # forbidden-patterns + contrast + legibility sentinels green; smoke test passes
npm run build # standalone output succeeds; no Tailwind purge warnings
Manual page walk (dev server running on localhost:3000):
/— brushstroke hero, three section dividers, sister-project callout/episodes— editorial grid/episodes/<first slug>— manuscript page with Roman numerals/cases— editorial grid/cases/<first slug>— manuscript + spec-sheet sidebar/about— quiet marble, period DropAccent/build-log— Bone surface, JobsRollupTable/build-log/jobs/website-foundation— Parchment inner panel, MDX prose/build-log/jobs/website-foundation/phase-1— phase detail/build-log/status— aggregate dashboard/products— editorial grid/products/<first slug>— product detail with price bar/cohorts— editorial grid/cohorts/<first slug>— CohortDetailHero with arrow DropAccent/products/account— Bone workspace (auth gate; sign in first)/login— quiet marble sign-in/login/verify— verification confirmation with check DropAccent/not-found(visit a 404) — gentle refusal with X DropAccent/error(force an error) — retry CTA via Button/sitemap.xml— XML, no brand/robots.txt— text, no brand/llms.txt— text, no brand/favicon.svg— direct asset, renders{10x}/logo.svg— direct asset, renders wordmark + mark/opengraph-image— 1200×630 brand composition/episodes/<slug>/opengraph-image— per-episode composition/cases/<slug>/opengraph-image— per-case composition/thumb-preview?title=Test&accent=?(dev only) — 1280×720 composition
Lighthouse checks (DevTools Performance / Accessibility):
/— a11y ≥ 95, performance ≥ 90, best practices ≥ 95/episodes— same thresholds/build-log— same thresholds
Accessibility manual checks (DevTools Rendering panel):
- Emulate
prefers-reduced-motion: reduce— brushstroke softened - Emulate
forced-colors: active— pages remain readable - Browser zoom 200% — no horizontal scrollbar
Header-specific responsive + a11y walk (added by 9.5/9.6/9.7):
- Resize browser to 320, 360, 768, 1024 — Header layout flips between
hamburger and horizontal nav at the 768 (
md) boundary; no overlap with logo lockup; no horizontal scrollbar at any size ≥ 320. - Keyboard-only walk: Tab from page load — first stop is the
skip-to-content link (visible only on focus); Enter lands
<main>. - Keyboard walk continued: Shift+Tab → logo composite is reachable; Tab through every nav link in source order; on mobile, Tab reaches the disclosure trigger, Enter opens the drawer, focus moves into the drawer, Tab cycles drawer items only (no escape), Esc closes drawer and restores focus to the trigger.
- Touch-target check: with DevTools device emulation at 360×640, every interactive in the Header (logo, trigger, drawer items) measures ≥ 44 × 44 CSS pixels.
- Header logo composite renders pfp +
<Logo size="sm">with one combinedaria-label="Travis Gautier"(screen reader announces once).
Regression fix loop:
Any failure → identify the section ID owning the violated rule → file a bug fix as that section's last work → re-run the sweep. The sweep itself should be quick (15-30 min) once the work is right.
Design Decisions
- Sweep is the human verification gate. The sentinels guard regression; the sweep guards omission. A page that ships in the brand-correct palette but with a missing DropAccent isn't caught by a test — the human walk is the catch.
- Lighthouse thresholds match the project standard. Same numbers
used by
website-foundation-4.1's verification plan. Consistent bar across the project. - No new file in 9.4. Pure verification work; the section's value is the disciplined walk through every page.
Files
| Action | File |
|---|---|
| (verification only — no file changes) | — |
Feature 9.5: Responsive Header + mobile drawer
Complexity: L — Phase 4.2 shipped a desktop-only Header (6-item horizontal
flex, no breakpoint, no disclosure). 9.5 closes the responsive gap: below the
md (768px) breakpoint the nav list collapses behind a hamburger trigger that
opens a Marble-surface drawer; the trigger and drawer participate in a
project-wide z-index scale shipped as part of this section. Header behavior on
scroll is static — the brand call is "editorial pages don't follow you,"
documented once here so the question stops recurring.
Problem
Header.tsx:35 renders <ul className="flex items-center gap-(--space-5)">
with 6 all-caps labels (Episodes, Cases, Build Log, Cohorts, Products, About).
At 360×640 — the most-common phone viewport, already a Phase 9.1 target — the
row overflows horizontally. There is no breakpoint, no disclosure pattern, no
trigger, no drawer, and no z-index scale to layer the drawer over page content
when it does ship. Sticky vs static is also undefined, so every future page
re-asks the question.
Implementation
MODIFY src/app/globals.css — add a z-index token block (no project-wide
scale exists today; Header is the first surface that needs to layer above page
content and future overlays):
@theme inline {
/* Project-wide stacking order. Higher = closer to the viewer.
Page content is z-auto. Header sits above page content but below any
overlay; the mobile-nav drawer sits above the header overlay so the
trigger remains coherent during the open transition. */
--z-header: 40;
--z-overlay: 50;
--z-drawer: 60;
}
NEW src/components/site/MobileNavDrawer.tsx — disclosure pattern,
client component (uses useState for open state and useEffect for
body-scroll-lock + Esc handler). Focus-trap and focus-restoration belong to
Feature 9.6 (separate concern, layered on top of this primitive):
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { NavLink } from './NavLink';
interface NavItem {
href: string;
label: string;
}
interface MobileNavDrawerProps {
items: readonly NavItem[];
}
export function MobileNavDrawer({ items }: MobileNavDrawerProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', onKey);
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div className="md:hidden">
<button
ref={triggerRef}
type="button"
aria-expanded={open}
aria-controls="primary-nav-drawer"
aria-label={open ? 'Close menu' : 'Open menu'}
onClick={() => setOpen((v) => !v)}
className="
inline-flex items-center justify-center
min-h-11 min-w-11
text-(--pair-text-on-marble)
transition-colors duration-150
hover:text-(--color-oxblood)
"
>
<span aria-hidden="true" className="label">
{open ? '×' : '≡'}
</span>
</button>
{open ? (
<div
id="primary-nav-drawer"
role="dialog"
aria-modal="true"
aria-label="Primary navigation"
className="
fixed inset-x-0 top-0 bottom-0
z-(--z-drawer)
bg-(--color-marble) bg-marble-texture
border-b border-(--edge-color)
flex flex-col
"
>
<div className="flex items-center justify-end px-(--space-5) py-(--space-4)">
<button
type="button"
aria-label="Close menu"
onClick={() => setOpen(false)}
className="
inline-flex items-center justify-center
min-h-11 min-w-11
text-(--pair-text-on-marble)
hover:text-(--color-oxblood)
"
>
<span aria-hidden="true" className="label">×</span>
</button>
</div>
<nav aria-label="Primary" className="px-(--space-5) py-(--space-5)">
<ul className="flex flex-col gap-(--space-4)">
{items.map((item) => (
<li key={item.href}>
<NavLink
href={item.href}
className="label inline-flex items-center min-h-11"
>
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
</div>
) : null}
</div>
);
}
MODIFY src/components/site/Header.tsx — wrap the existing <nav> in a
hidden md:block and mount the drawer as a sibling under md. The desktop
markup from Phase 4.2 is preserved; below md the drawer takes over.
import { Marble } from '@/components/brand';
import { Container } from './Container';
import { NavLink } from './NavLink';
import { MobileNavDrawer } from './MobileNavDrawer';
import { HeaderLogo } from './HeaderLogo'; // composite mark from 9.7
const NAV_ITEMS = [
{ href: '/episodes', label: 'Episodes' },
{ href: '/cases', label: 'Cases' },
{ href: '/build-log', label: 'Build Log' },
{ href: '/cohorts', label: 'Cohorts' },
{ href: '/products', label: 'Products' },
{ href: '/about', label: 'About' },
] as const;
export function Header() {
return (
<Marble
as="header"
edge="none"
className="
border-b border-(--edge-color) bg-marble-texture
z-(--z-header) relative
"
>
<Container className="flex items-center justify-between py-(--space-4)">
<HeaderLogo />
<nav aria-label="Primary" className="hidden md:block">
<ul className="flex items-center gap-(--space-5)">
{NAV_ITEMS.map((item) => (
<li key={item.href}>
<NavLink href={item.href} className="label">
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
<MobileNavDrawer items={NAV_ITEMS} />
</Container>
</Marble>
);
}
Design Decisions
mdbreakpoint (768px), notsm(640px). Six 7–9-character all-caps labels at the.labelutility size measure ~520–560px combined. At 640px with Container padding (px-(--space-5)= ~20px each side) plus the composite logo (~120px), the row exceeds available width before reaching thesmbreakpoint.mdis the first breakpoint where the desktop nav actually fits without compression.- Static, not sticky. A sticky header tells the user "you're being followed"; the brand wants editorial calm. If a future surface genuinely needs scroll-context UI (e.g., a long manuscript page wanting a Back-to-Top), build that affordance per-page, not in chrome.
- No scroll-aware shadow / blur. Same brand call. Phase 3 forbade UI
shadows; reintroducing one on scroll would be a brand regression. Color
shift only (the existing
--edge-colorrule). - Drawer is a
<div role="dialog" aria-modal>, not a<dialog>element. Native<dialog>brings UA styling we'd need to undo andshowModal()needs more imperative wiring; the brand has no design budget for that complexity. ARIA roles + body-scroll-lock + Esc handler cover the behavior, focus-trap arrives in 9.6. - Project-wide z-index scale. Three tokens cover the realistic composition (header / overlay / drawer). If a future modal, toast, or command palette ships, it picks one of those three slots — not a new number — so stacking remains coherent.
'×'and'≡'glyphs instead of SVG icons. Per the no-icon-clutter brand discipline (Phase 4.3 used→for the LLL link by the same rule). Typeface glyphs sit inline with.labeland respect Inter's letterforms.- Drawer covers Marble surface to top of viewport. Editorial drawers are full surfaces, not slide-ins; full-surface treatment also avoids any shadow/scrim affordance (a scrim would be a UI-shadow regression).
Files
| Action | File |
|---|---|
| MODIFY | src/app/globals.css (add z-index tokens) |
| MODIFY | src/components/site/Header.tsx |
| NEW | src/components/site/MobileNavDrawer.tsx |
Feature 9.6: Header a11y polish — skip link + touch targets + focus management
Complexity: M — Three small, independent a11y wins that the Phase 4.2 restyle left out: a skip-to-content link as the first focusable element on every page, a 44-CSS-pixel touch-target floor enforced via a brand utility token, and a focus-trap + focus-restoration cycle around the mobile drawer shipped in 9.5.
Problem
- No skip-link. Keyboard users tab through Header (logo + 6 nav items)
before reaching
<main>on every page navigation. Standard pattern, absent fromHeader.tsxand from every page-level layout. - Touch targets not measured.
Header.tsx:24-33(logo link) renders at 40×40, just below the WCAG 2.5.5 floor. NavLink touch padding is whatever Inter's small-caps height happens to compute to — not a decision. - Drawer focus management absent. 9.5 ships open/close + Esc + scroll-lock
but does not trap focus inside the open drawer nor restore focus to the
trigger on close. Without that, a screen reader can tab past the drawer
into the background content (which is hidden by
overflow:hiddenbut not inert).
Implementation
NEW src/components/site/SkipLink.tsx:
import Link from 'next/link';
export function SkipLink() {
return (
<a
href="#main"
className="
sr-only focus:not-sr-only
focus:absolute focus:top-(--space-2) focus:left-(--space-2)
focus:z-(--z-overlay)
focus:bg-(--color-ink) focus:text-(--color-marble)
focus:px-(--space-3) focus:py-(--space-2)
focus:label
"
>
Skip to content
</a>
);
}
MODIFY src/app/layout.tsx — render <SkipLink> immediately inside
<body> (before <Header>) so it's the first focusable element. Add
id="main" to the <main> wrapper.
MODIFY src/app/globals.css — add a --tap token expressing the
touch-target floor:
@theme inline {
--tap-min: 2.75rem; /* 44px @ 16px root — WCAG 2.5.5 floor */
}
MODIFY src/components/site/Header.tsx — apply min-h-(--tap-min) to
the logo link and every NavLink wrapper. (Desktop nav already inherits via
the .label utility's vertical rhythm in most viewports; this makes the
floor explicit.)
MODIFY src/components/site/MobileNavDrawer.tsx — add focus-trap +
focus-restoration:
// In the existing component, on open:
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const drawer = document.getElementById('primary-nav-drawer');
const focusables = drawer?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled])'
);
const first = focusables?.[0];
const last = focusables?.[focusables.length - 1];
first?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab' || !first || !last) return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
previouslyFocused?.focus();
};
}, [open]);
Design Decisions
- Skip-link uses
sr-only focus:not-sr-onlyTailwind utilities so it occupies zero space until focused. On focus it renders as an Ink button in the top-left corner — readable, branded, and inside the--z-overlaystacking band so it sits above the Header's--z-header. --tap-minis a brand token, not an ad-hocmin-h-11everywhere. Centralizing it means the day WCAG 2.5.8 (or some future spec) raises the floor, one token changes.- Focus-trap is hand-rolled, not a library. The brand discipline forbids
Headless UI / Radix; ~20 lines of Tab interception is the right size for
one disclosure pattern. If a second disclosure appears later, refactor to
a
useFocusTraphook — premature today. previouslyFocused.focus()on close. Restoring focus to the trigger closes the keyboard-navigation loop; without it, focus lands on<body>and the user starts from the top of the document on the next Tab.- Skip-link uses the Ink-on-Marble CTA pair. Already validated by the 9.2 contrast sentinel (body role, ≥4.5:1). No new pair to certify.
Files
| Action | File |
|---|---|
| NEW | src/components/site/SkipLink.tsx |
| MODIFY | src/app/layout.tsx (mount SkipLink + add id="main") |
| MODIFY | src/app/globals.css (add --tap-min token) |
| MODIFY | src/components/site/Header.tsx (apply tap floor) |
| MODIFY | src/components/site/MobileNavDrawer.tsx (focus-trap) |
Feature 9.7: Header lockup composite (phase-4.1 amendment)
Complexity: S — Phase 4.1 shipped a type-set <Logo> justified by
reproducibility across surfaces (Footer, og:image, email). The Header in
practice picked up a different mark — a circular YouTube channel pfp — which
contradicted the locked phase-4.1 decision without a written amendment. 9.7
formalizes the resolution: the Header uses a composite lockup of pfp +
small <Logo>; every other surface keeps the typed Logo alone.
Problem
A visitor arriving from a YouTube end-screen expects to see the same circular avatar in the corner — that's channel recognition. The typed Logo doesn't deliver it. But pfp-alone in the Header throws away the brand wordmark on every page, which is a brand-equity regression and breaks the phase-4.1 reproducibility justification for og:images / Footer / email (those surfaces still need the typed Logo).
A composite mark — pfp left, <Logo size="sm"> right, single combined
aria-label — keeps both. This section writes the amendment into phase-4.1's
doc and ships the composite primitive used by Header.tsx.
Implementation
NEW src/components/site/HeaderLogo.tsx — the composite mark used only in
the Header surface:
import Image from 'next/image';
import Link from 'next/link';
import { Logo } from '@/components/brand/Logo';
export function HeaderLogo() {
return (
<Link
href="/"
aria-label="Travis Gautier"
className="
inline-flex items-center gap-(--space-2)
min-h-(--tap-min)
"
>
<Image
src="/media/pfp-circle-white.jpg"
alt=""
width={40}
height={40}
priority
className="rounded-full"
/>
<Logo size="sm" />
</Link>
);
}
MODIFY currentwork/styling-overhaul/phase-4-logo-chrome.md — append a
"Phase 4.1 Amendment (added by 9.7)" block under Feature 4.1 documenting the
composite lockup. Footer (4.3) and og:images (Phase 8.2) continue to use
<Logo> alone — the reproducibility justification holds for everywhere
except the Header surface.
MODIFY src/components/site/Header.tsx — replace the inline
<Link><Image /></Link> block (and any prior typed-Logo block) with
<HeaderLogo />.
Design Decisions
- Composite is Header-only. Footer is a quiet signature; pfp would shout. og:images and email are surfaces where the pfp would re-raster poorly. The composite is the right composition for the one surface where YouTube channel-recognition matters: the page chrome.
- Combined
aria-labelon the wrapper<Link>,aria-hiddensemantics on parts. Screen readers announce "Travis Gautier, link" once — not "Image, Travis Gautier, link." TheLogoprimitive already wraps its inner parts inaria-hidden; the pfp'salt=""makes it decorative; the wrapper carries the accessible name. gap-(--space-2)between pfp and wordmark. Tighter than Container padding (--space-5) — the two parts read as one mark, not two adjacent elements.- Logo at
size="sm"here, notmd. The pfp carries presence; the wordmark is the brand signature beside it.mdwould make the lockup too tall and unbalance the row against the nav links. - The amendment lives in phase-4.1, not phase-9. The composite IS a Phase 4.1 decision, just written down after the fact. Keeping the documentation co-located with the original Logo design decisions means a future contributor reads one block, not two.
Files
| Action | File |
|---|---|
| NEW | src/components/site/HeaderLogo.tsx |
| MODIFY | src/components/site/Header.tsx (use HeaderLogo) |
| MODIFY | currentwork/styling-overhaul/phase-4-logo-chrome.md (append amendment block) |
Phase 9 Exit Criteria
src/__tests__/brand/legibility.test.tsships and passes for all three viewport targets and for the two Header mobile-width cases (320, 360) added by 9.5/9.6.src/__tests__/brand/contrast.test.tsships and passes for every brand contrast pair.- BrushstrokeSeam degrades correctly under
prefers-reduced-motionandforced-colors: active. npm run lint,npx tsc --noEmit,npm test,npm run buildall clean.- Lighthouse a11y ≥ 95, perf ≥ 90 on
/,/episodes,/build-log. - Manual sweep covers every route in the inventory with no brand regressions.
- 200% zoom on the three high-stakes pages produces no horizontal scrollbar at 320px viewport width.
- Header behavior: at
<mdthe disclosure trigger opens a Marble drawer; Esc closes; body scroll locks while open; focus traps inside the drawer; focus restores to the trigger on close (9.5 + 9.6). - Skip-to-content link is the first focusable element on every page and
lands
<main>on Enter (9.6). - Every interactive in the Header meets the 44 CSS-pixel touch-target
floor via
--tap-min(9.6). - Header logo renders the pfp +
<Logo size="sm">composite with one combinedaria-label="Travis Gautier"; Footer / og:images / email surfaces continue to render the typed Logo alone (9.7). - Phase 4.1 amendment block exists in
phase-4-logo-chrome.mddocumenting why the Header surface composes the pfp with the typed Logo while every other surface does not (9.7). - The job is shippable: the editorial brand reset is complete, the sentinels guard against drift, the navbar serves every viewport from 320px up with documented a11y semantics, and the design-system doc captures the canonical reference for everything that lands afterward.