From 869eb1a2846426cfda3897e4f8318a869c8ab4f4 Mon Sep 17 00:00:00 2001
From: dommango-sys <251805093+dommango@users.noreply.github.com>
Date: Mon, 31 Aug 2026 10:05:37 -0400
Subject: [PATCH 1/2] docs: site audit (2026-08-31) and ten executable
improvement plans
Audit ranks changes by value to visitors and records four verified P0s:
contact form unconfigured in the Pages build, Substack 403 from Actions
hiding the Writing section, a keyword-matcher assistant, and the default
404 for pre-redesign URLs. Each plan in docs/plans/ is self-contained.
---
docs/plans/01-reconnect-live-plumbing.md | 170 +++++++++
docs/plans/02-project-case-studies.md | 327 ++++++++++++++++++
.../03-writing-featured-and-subscribe.md | 274 +++++++++++++++
docs/plans/04-social-preview-card.md | 141 ++++++++
docs/plans/05-now-strip.md | 252 ++++++++++++++
docs/plans/06-honest-assistant.md | 229 ++++++++++++
docs/plans/07-not-found-rescue.md | 157 +++++++++
docs/plans/08-contrast-and-a11y-pass.md | 208 +++++++++++
docs/plans/09-travel-scrubber-and-touch.md | 214 ++++++++++++
docs/plans/10-repo-hygiene.md | 158 +++++++++
docs/plans/README.md | 87 +++++
11 files changed, 2217 insertions(+)
create mode 100644 docs/plans/01-reconnect-live-plumbing.md
create mode 100644 docs/plans/02-project-case-studies.md
create mode 100644 docs/plans/03-writing-featured-and-subscribe.md
create mode 100644 docs/plans/04-social-preview-card.md
create mode 100644 docs/plans/05-now-strip.md
create mode 100644 docs/plans/06-honest-assistant.md
create mode 100644 docs/plans/07-not-found-rescue.md
create mode 100644 docs/plans/08-contrast-and-a11y-pass.md
create mode 100644 docs/plans/09-travel-scrubber-and-touch.md
create mode 100644 docs/plans/10-repo-hygiene.md
create mode 100644 docs/plans/README.md
diff --git a/docs/plans/01-reconnect-live-plumbing.md b/docs/plans/01-reconnect-live-plumbing.md
new file mode 100644
index 0000000..6770548
--- /dev/null
+++ b/docs/plans/01-reconnect-live-plumbing.md
@@ -0,0 +1,170 @@
+# Plan 01 — Reconnect the live plumbing
+
+**Priority:** P0 (do first) · **Effort:** ~2–3 hours · **Value:** restores the two conversion paths that are dead today
+**Depends on:** nothing · **Unblocks:** plans 03, 05
+
+## Why
+
+Three things are broken on https://dommango.github.io right now. All three were verified on 2026-08-31, not inferred:
+
+1. **The contact form cannot send.** `lib/services/emailjs.ts` reads `NEXT_PUBLIC_EMAILJS_*` at build time. `.github/workflows/deploy.yml` passes **no** environment variables to `npm run build`, so the deployed bundle contains `"Email service not configured"` and no EmailJS service id. Every visitor who submits the form gets that error. (Checked by downloading the live chunks and grepping.)
+2. **The Writing section never appears.** `scripts/fetch-substack.js` runs during the deploy build, and Substack answers GitHub Actions with HTTP 403. The job log for run 33308370829 says: `[substack] feed returned 403; keeping committed posts`. The committed `POSTS` array is empty, so the section (and its nav link) stay hidden even though a real post ("The game had already started", 2026-08-04) exists.
+3. **No analytics are collected.** `NEXT_PUBLIC_GOATCOUNTER_SITE` is not in the build env, so `app/layout.tsx` never renders the GoatCounter script. `public/data/analytics.json` has been zeros since March. (The `GOATCOUNTER_SITE` secret already exists in the repo; it's just not wired to the build.)
+
+A fourth P0 — the chat widget's wrong answers — is fixed properly in plan 06. This plan includes a **one-line stopgap** so the widget stops misinforming people today.
+
+## Done when
+
+- [ ] Sending the live contact form delivers an email to Dom's inbox (not to the sender).
+- [ ] https://dommango.github.io shows a **Writing** section with the Aug 4 post and a **Writing** nav link.
+- [ ] `curl -s https://dommango.github.io | grep -c goatcounter` prints `1` or more.
+- [ ] The chat bubble is not rendered on the live site until plan 06 ships.
+- [ ] The deploy workflow **fails loudly** if any required build secret is missing, so this can't silently regress.
+
+## Files
+
+- `.github/workflows/deploy.yml` — pass secrets into the build; add a guard step
+- `lib/content/writing.ts` — regenerated by the fetch script (commit the result)
+- `scripts/fetch-substack.js` — add a browser-like User-Agent and one fallback endpoint (best-effort)
+- `app/layout.tsx` — don't render ` ` when no chat API is configured
+- `scripts/check-build-env.js` — new, tiny
+
+## Steps
+
+### 1. Put the EmailJS + reCAPTCHA values into GitHub secrets
+
+The values live in `.env.local` (gitignored). Never paste them into a chat or a commit. Run this from the repo root; it reads the file and pipes each value straight into `gh` without echoing:
+
+```bash
+for k in NEXT_PUBLIC_EMAILJS_SERVICE_ID NEXT_PUBLIC_EMAILJS_TEMPLATE_ID NEXT_PUBLIC_EMAILJS_PUBLIC_KEY NEXT_PUBLIC_RECAPTCHA_SITE_KEY; do
+ v=$(grep "^$k=" .env.local | cut -d= -f2-)
+ if [ -n "$v" ]; then printf '%s' "$v" | gh secret set "$k"; echo "set $k"; else echo "SKIP $k (empty)"; fi
+done
+gh secret list
+```
+
+`GOATCOUNTER_SITE` already exists as a secret; reuse it below.
+
+### 2. Wire the secrets into the build (`.github/workflows/deploy.yml`)
+
+Replace the `Build Next.js` step with a guard + build:
+
+```yaml
+ - name: Check required build env
+ env:
+ NEXT_PUBLIC_EMAILJS_SERVICE_ID: ${{ secrets.NEXT_PUBLIC_EMAILJS_SERVICE_ID }}
+ NEXT_PUBLIC_EMAILJS_TEMPLATE_ID: ${{ secrets.NEXT_PUBLIC_EMAILJS_TEMPLATE_ID }}
+ NEXT_PUBLIC_EMAILJS_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_EMAILJS_PUBLIC_KEY }}
+ run: node scripts/check-build-env.js
+
+ - name: Build Next.js
+ env:
+ NEXT_PUBLIC_EMAILJS_SERVICE_ID: ${{ secrets.NEXT_PUBLIC_EMAILJS_SERVICE_ID }}
+ NEXT_PUBLIC_EMAILJS_TEMPLATE_ID: ${{ secrets.NEXT_PUBLIC_EMAILJS_TEMPLATE_ID }}
+ NEXT_PUBLIC_EMAILJS_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_EMAILJS_PUBLIC_KEY }}
+ NEXT_PUBLIC_RECAPTCHA_SITE_KEY: ${{ secrets.NEXT_PUBLIC_RECAPTCHA_SITE_KEY }}
+ NEXT_PUBLIC_GOATCOUNTER_SITE: ${{ secrets.GOATCOUNTER_SITE }}
+ run: npm run build
+```
+
+Create `scripts/check-build-env.js` (CommonJS, like `fetch-substack.js`):
+
+```js
+// Fails the deploy build when a NEXT_PUBLIC_* value the site needs is missing.
+// Local builds don't run this (see deploy.yml), so `npm run build` still works
+// without a .env.local.
+const REQUIRED = [
+ 'NEXT_PUBLIC_EMAILJS_SERVICE_ID',
+ 'NEXT_PUBLIC_EMAILJS_TEMPLATE_ID',
+ 'NEXT_PUBLIC_EMAILJS_PUBLIC_KEY',
+]
+
+const missing = REQUIRED.filter((k) => !process.env[k])
+if (missing.length > 0) {
+ console.error(`[env] missing required build env: ${missing.join(', ')}`)
+ console.error('[env] set them with `gh secret set ` — see docs/plans/01-reconnect-live-plumbing.md')
+ process.exit(1)
+}
+console.log('[env] all required build env present')
+```
+
+`ci.yml` does **not** run this step and should not — CI builds without secrets on purpose.
+
+### 3. Verify the EmailJS template sends to Dom, not the visitor
+
+`sendContactEmail` passes `to_name: fromName, to_email: fromEmail`. Whether that matters depends on the EmailJS template: if the template's "To email" field is `{{to_email}}`, the message goes to the **sender**. Open the template in the EmailJS dashboard and make sure "To email" is Dom's address (hard-coded), and `reply_to` is `{{reply_to}}`. Then remove the misleading params from `lib/services/emailjs.ts`:
+
+```ts
+ {
+ from_name: fromName,
+ from_email: fromEmail,
+ reply_to: fromEmail,
+ message
+ }
+```
+
+### 4. Make the Writing section appear — commit the posts
+
+Substack blocks GitHub's IP range, but not a residential connection. From the repo root:
+
+```bash
+node scripts/fetch-substack.js # expect: [substack] wrote 1 post(s) to lib/content/writing.ts
+git diff lib/content/writing.ts # POSTS now has one entry
+npm test -- --run # 13 tests pass
+npx playwright test e2e/landing.spec.ts -g "writing section" # asserts section + nav link both present
+```
+
+Commit `lib/content/writing.ts`. This is the reliable path: the committed posts are what ship whenever the CI fetch fails, which is currently always.
+
+**Recurring:** after each new post is published, run the two commands above and commit. (If `/content-publish` is used, add these two lines to the end of that skill so it happens automatically.)
+
+### 5. Make the CI fetch a little more likely to succeed (best-effort)
+
+In `scripts/fetch-substack.js`, change the request headers and add one fallback. Keep every existing guard.
+
+```js
+const FEED_URLS = [
+ 'https://dommangonon.substack.com/feed',
+ // Substack's JSON API sometimes answers when the RSS route is challenged.
+ 'https://dommangonon.substack.com/api/v1/posts?limit=6',
+]
+const HEADERS = {
+ 'user-agent': 'Mozilla/5.0 (compatible; dommango.github.io build; +https://dommango.github.io)',
+ accept: 'application/rss+xml, application/xml, application/json;q=0.9, */*;q=0.8',
+}
+```
+
+Loop over `FEED_URLS`; for the JSON endpoint map each item to `{ title, url: canonical_url, date: post_date, subtitle }` and apply the same placeholder filter (`title` of "Coming soon"). If both fail, keep the existing `keeping committed posts` behaviour. Add one unit test in `__tests__/parse-substack-feed.test.ts` only if you extract the JSON mapping into `scripts/lib/parse-substack-json.js`; otherwise no new tests — this path is best-effort by design.
+
+### 6. Stopgap for the chat widget (`app/layout.tsx`)
+
+```tsx
+const CHAT_API_URL = process.env.NEXT_PUBLIC_CHAT_API_URL
+...
+ {children}
+ {CHAT_API_URL && }
+```
+
+With no API configured the widget — and its "Skills page" answers — disappears. Plan 06 replaces it.
+
+### 7. Deploy and verify
+
+```bash
+git checkout -b fix/reconnect-live-plumbing
+git add -A && git commit -m "fix: pass build secrets to the Pages deploy, commit Substack posts, hide unconfigured chat"
+git push -u origin fix/reconnect-live-plumbing
+gh pr create --fill
+# after merge:
+gh run watch # deploy.yml should pass the new "Check required build env" step
+curl -s https://dommango.github.io | grep -c 'id="writing"' # 1
+curl -s https://dommango.github.io | grep -c goatcounter # >= 1
+```
+
+Then send a real message through the live form and confirm it arrives in Dom's inbox. Check the dashboard at `/dashboard-m7x9k2` two days later: page views should be non-zero.
+
+## Gotchas
+
+- `NEXT_PUBLIC_*` values are inlined **at build time**. Setting a secret without re-running the deploy changes nothing; trigger `gh workflow run deploy.yml` after setting them.
+- The reCAPTCHA site key is public by design (it's shipped in the page) but keep it in secrets anyway so the workflow reads one source.
+- Don't add these env vars to `ci.yml`. CI must keep building without secrets so pull requests from forks work.
+- `writing.ts` between the `GENERATED` markers is machine-written; never hand-edit inside the markers.
diff --git a/docs/plans/02-project-case-studies.md b/docs/plans/02-project-case-studies.md
new file mode 100644
index 0000000..31f0b45
--- /dev/null
+++ b/docs/plans/02-project-case-studies.md
@@ -0,0 +1,327 @@
+# Plan 02 — Project case studies with real screenshots
+
+**Priority:** P1 (highest value) · **Effort:** ~1 day + collecting screenshots · **Value:** very high
+**Depends on:** nothing (plan 03 links into this) · **Mock-up:** section 02 of the mock-ups board
+
+## Why
+
+The page "leads with the project portfolio" and contains no image of any project. The cards link out, but:
+
+- **Bracketeer** → `fifawc26.up.railway.app` is a sign-in wall. A visitor sees "Sign in or create an account" and nothing else.
+- **SousIQ** → a waitlist landing page. Fine, but it isn't the product.
+- **PRIAL Pipeline** → no link at all. **modular-mind** → a GitHub file listing.
+
+A hiring manager or fellow builder cannot *see* what was shipped. Each card gets a thumbnail, and each project gets an in-page case study — problem → what I built → what broke → outcome — with a facts strip and links (live, source, related post). The project data stays hand-authored TypeScript in `lib/content/projects.ts`, per CLAUDE.md.
+
+## Done when
+
+- [ ] Every card has a 16:10 image (real screenshot, or a rendered "data" tile for pipeline projects).
+- [ ] Clicking/activating a card opens its case study below the grid; clicking again closes it. Only one open at a time.
+- [ ] Case study is keyboard-operable (`Enter`/`Space` on the card, tabs by arrow keys optional) with `aria-expanded` / `aria-controls`.
+- [ ] Cards still expose an external link (live / source) as a real `` with `target="_blank" rel="noreferrer"`.
+- [ ] Works in all three themes and at 320px wide with no horizontal scroll (existing e2e guards this).
+- [ ] `npx tsc --noEmit`, `npm test -- --run`, `npx playwright test` all pass.
+
+## Files
+
+- `lib/content/projects.ts` — extend the `Project` type and data
+- `components/landing/Projects.tsx` — thumbnails, expandable state, panel slot
+- `components/landing/CaseStudy.tsx` — new
+- `app/globals.css` — new classes; add grids to the `@media (max-width: 900px)` block
+- `public/projects/*.webp` — images
+- `e2e/landing.spec.ts` — update the "linked project cards" test, add a case-study test
+
+## Steps
+
+### 1. Collect the images (`public/projects/`)
+
+Target 1280×800 source, exported as WebP ≤ 120 KB, named by project id slug:
+
+| File | Source |
+|---|---|
+| `sousiq.webp` | Screenshot of the SousIQ app (a logged-in invoice → line items screen is far better than the landing page). Dom captures this; the landing page is the fallback. |
+| `bracketeer.webp` | A logged-in leaderboard or bracket screen. Fallback: the Substack post's cover image (the original HTML bracket) — download `https://substackcdn.com/image/fetch/w_1280,c_limit,f_webp,q_auto:good/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1803b868-ba18-43a3-93d9-7fe96d55c7de_1440x900.png`. |
+| `placemat.webp` | `https://dommango.github.io/claude-code-placemat/` — dismiss the "What's new" modal first. |
+| `modular-mind.webp` | A rendered VCV Rack patch from the repo's `render-service`, or the repo README hero if one exists. Do **not** use a GitHub file listing. |
+| `prial.webp` | No UI exists. Render a data tile instead (step 2) — no image file. |
+
+A capture helper, if useful (needs a logged-in `storageState` for the private apps — capture those manually):
+
+```js
+// scripts/capture-project-shots.mjs (run: node scripts/capture-project-shots.mjs)
+import { chromium } from 'playwright'
+const shots = [
+ ['placemat', 'https://dommango.github.io/claude-code-placemat/'],
+]
+const browser = await chromium.launch()
+for (const [name, url] of shots) {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
+ await page.goto(url, { waitUntil: 'networkidle' })
+ await page.keyboard.press('Escape')
+ await page.screenshot({ path: `public/projects/${name}.png` })
+ await page.close()
+}
+await browser.close()
+```
+
+Convert PNG → WebP with `cwebp -q 80 in.png -o out.webp` (install `webp` via apt) or any image tool. Delete the PNGs.
+
+### 2. Extend the data model (`lib/content/projects.ts`)
+
+Add to the `Project` interface:
+
+```ts
+export interface ProjectImage {
+ src: string
+ alt: string
+}
+
+export interface CaseStudySection {
+ /** Tab label, e.g. "Problem". */
+ label: string
+ /** Paragraphs. Plain text; no markup. */
+ paragraphs: string[]
+}
+
+export interface CaseStudy {
+ headline: string
+ sections: CaseStudySection[]
+ facts: { label: string; value: string }[]
+ links: { label: string; href: string }[]
+}
+
+export interface Project {
+ // ...existing fields...
+ /** Card thumbnail. Omit for projects with no UI; the card renders a data tile from `impact`. */
+ image?: ProjectImage
+ /** Short label over the thumbnail, e.g. "Live · sign-in". */
+ status?: string
+ caseStudy?: CaseStudy
+}
+```
+
+Fill in the data. Bracketeer's text comes from Dom's own post — use it nearly verbatim:
+
+```ts
+ {
+ id: '#brkt-0002/05',
+ name: 'Bracketeer',
+ // ...existing...
+ image: { src: '/projects/bracketeer.webp', alt: 'Bracketeer leaderboard mid-tournament' },
+ status: 'Live · sign-in',
+ caseStudy: {
+ headline: 'Three days before kickoff, with everyone’s picks already made.',
+ sections: [
+ { label: 'Problem', paragraphs: [
+ 'The pool started as one HTML file: fill in a bracket, click Export my picks (.csv), email it to the commissioner. Forty-some friends and family did exactly that before the opening match. The plan was to re-enter results after each round and score by hand.',
+ 'Three days before kickoff I decided that wasn’t good enough. Which set the first constraint before the first commit: every pick already existed, made in a tool I now had to treat as law.',
+ ]},
+ { label: 'What I built', paragraphs: [
+ 'A multi-tenant pool platform on Next 16, Prisma 7 and Auth.js, deployed to Railway. Create a pool, invite by link, make picks, watch a leaderboard update from live results. Knockout seeding implements FIFA Annex C.',
+ 'The first real piece wasn’t a feature. It was a test: the original JavaScript scoring function kept verbatim as an oracle, and the new engine run against it across two thousand randomized brackets.',
+ ]},
+ { label: 'What broke', paragraphs: [
+ 'The hard problem wasn’t building fast. It was building fast without ever changing an answer. If the port scored one bracket a single point differently, someone’s standing changed under them.',
+ 'That oracle test never left the codebase. Every refactor for six weeks had to walk past it.',
+ ]},
+ { label: 'Outcome', paragraphs: [
+ 'The pool ran on the app from the round of 32 through the final. Nobody’s score moved during the migration.',
+ ]},
+ ],
+ facts: [
+ { label: 'Status', value: 'Live · account required' },
+ { label: 'Players', value: '40+ in one pool' },
+ { label: 'Built in', value: '3 days to launch' },
+ { label: 'Source', value: 'Private' },
+ ],
+ links: [
+ { label: 'Read the build story ↗', href: 'https://dommangonon.substack.com/p/the-game-had-already-started' },
+ { label: 'Open live ↗', href: 'https://fifawc26.up.railway.app' },
+ ],
+ },
+ },
+```
+
+Write SousIQ, Placemat, modular-mind and PRIAL case studies in the same shape from the existing `points` plus what Dom knows. Keep each paragraph under ~60 words; 2–4 sections each. If a fact isn't known, leave it out — never invent numbers.
+
+### 3. Build `components/landing/CaseStudy.tsx`
+
+Client component. Props: `{ project: Project; id: string }`. Renders:
+
+```tsx
+'use client'
+import { useState } from 'react'
+import type { Project } from '@/lib/content/projects'
+
+export function CaseStudy({ project, id }: { project: Project; id: string }) {
+ const cs = project.caseStudy!
+ const [active, setActive] = useState(0)
+ return (
+
+
+ {project.image ? (
+
+ ) : (
+
No UI — it’s a pipeline {project.impact}
+ )}
+
+
+
Case study · {project.name}
+
{cs.headline}
+
+ {cs.sections.map((s, i) => (
+ setActive(i)}>{s.label}
+ ))}
+
+ {cs.sections.map((s, i) => (
+
+ {s.paragraphs.map((p) =>
{p}
)}
+
+ ))}
+
+ {cs.facts.map((f) => (
{f.label} {f.value} ))}
+
+
+
+
+ )
+}
+```
+
+`next/image` is unoptimized in this project (static export), so a plain ` ` with explicit `width`/`height` is fine; add `// eslint-disable-next-line @next/next/no-img-element` above it. Reuse `.travel-map-toggle` for the tab buttons — it's already the site's ghost-button style.
+
+### 4. Rework `components/landing/Projects.tsx`
+
+Make it a client component holding `openId: string | null`. Each card becomes an `` with a `` covering the top (thumbnail + text) and a separate `` for the external link in the footer, so the card is both expandable and linkable:
+
+```tsx
+'use client'
+import { useState } from 'react'
+import { PROJECTS, type Project } from '@/lib/content/projects'
+import { BinaryRule } from './BinaryRule'
+import { CaseStudy } from './CaseStudy'
+
+const slug = (p: Project) => p.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')
+
+export function Projects() {
+ const [openId, setOpenId] = useState(null)
+ const open = PROJECTS.find((p) => p.id === openId)
+
+ return (
+
+
+ {/* head unchanged */}
+
+ {PROJECTS.map((project) => {
+ const isOpen = project.id === openId
+ const panelId = `case-${slug(project)}`
+ return (
+
+ setOpenId(isOpen ? null : project.id)}>
+
+ {project.image
+ // eslint-disable-next-line @next/next/no-img-element
+ ?
+ :
{project.impact}
}
+ {project.status &&
{project.status} }
+
+
+ {/* existing card-top / name / stack / impact / points markup */}
+
+
+
+
+ )
+ })}
+
+ {open?.caseStudy && }
+
+ )
+}
+```
+
+Nesting rule: **no `` inside the ``** — the external link lives in `.work-foot`, outside the button.
+
+### 5. CSS (`app/globals.css`, in the Projects block)
+
+```css
+.work-card { padding: 0; } /* padding moves to .work-body */
+.work-card-hit { display: block; width: 100%; text-align: left; background: none; border: 0; padding: 0; color: inherit; cursor: pointer; font: inherit; }
+.work-card-hit:focus-visible { outline: var(--bw-2) solid var(--accent); outline-offset: 3px; }
+.work-card.is-open { border-color: var(--accent); }
+.work-shot { aspect-ratio: 16 / 10; overflow: hidden; border-bottom: 1px solid var(--rule); background: var(--bg-elevated); position: relative; }
+.work-shot img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
+.work-tile { height: 100%; display: flex; align-items: flex-end; padding: var(--s-5); font-family: var(--font-mono); font-size: 13px; letter-spacing: .04em; color: var(--accent); }
+.work-status { position: absolute; left: 10px; top: 10px; padding: 4px 8px; background: var(--bg); border: 1px solid var(--rule); font-family: var(--font-mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase; }
+.work-body { display: flex; flex-direction: column; gap: var(--s-3); padding: var(--s-4) var(--s-5) 0; }
+.work-foot { display: flex; justify-content: space-between; gap: var(--s-3); flex-wrap: wrap; padding: var(--s-3) var(--s-5) var(--s-5); margin-top: var(--s-3); border-top: 1px solid var(--rule); }
+.work-link.is-accent { color: var(--accent); }
+.case { margin-top: var(--s-5); border: 1px solid var(--accent); display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); }
+.case-media { padding: var(--s-4); border-right: 1px solid var(--rule); background: var(--bg-elevated); }
+.case-media img { width: 100%; height: auto; border: 1px solid var(--rule); }
+.case-tile { min-height: 240px; display: flex; flex-direction: column; justify-content: flex-end; gap: 8px; padding: var(--s-5); }
+.case-text { padding: var(--s-5); display: flex; flex-direction: column; gap: var(--s-4); }
+.case-headline { font-family: var(--font-display); font-size: 26px; line-height: 1; letter-spacing: -.02em; margin: 0; }
+.case-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
+.case-pane p { font-size: 14px; line-height: 1.55; margin: 0 0 10px; }
+.case-facts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--rule); border: 1px solid var(--rule); margin: 0; }
+.case-facts > div { background: var(--bg); padding: 10px 12px; }
+.case-facts dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: .18em; text-transform: uppercase; color: var(--fg-low); }
+.case-facts dd { margin: 4px 0 0; font-size: 13px; }
+.case-links { display: flex; gap: var(--s-4); flex-wrap: wrap; }
+```
+
+And in the existing `@media (max-width: 900px)` block add:
+
+```css
+ .case { grid-template-columns: 1fr; }
+ .case-media { border-right: 0; border-bottom: 1px solid var(--rule); }
+ .case-facts { grid-template-columns: 1fr 1fr; }
+```
+
+The site's `.section > .binary-rule + *` rule is unaffected — the projects head still follows the rule.
+
+### 6. Tests
+
+Update `e2e/landing.spec.ts`:
+
+- "linked project cards are anchors with a real href" → change the selector to `#projects .work-foot a[href="${project.href}"]` and the count assertion to `#projects .work-foot a`.
+- "project cards are keyboard focusable" → focus `#projects .work-card-hit` first.
+- Add:
+
+```ts
+ test("a project card opens its case study", async ({ page }) => {
+ const first = PROJECTS.find((p) => p.caseStudy)!;
+ const hit = page.locator("#projects .work-card-hit", { hasText: first.name });
+ await hit.click();
+ await expect(hit).toHaveAttribute("aria-expanded", "true");
+ await expect(page.locator("#projects .case")).toContainText(first.caseStudy!.headline);
+ await hit.click();
+ await expect(page.locator("#projects .case")).toHaveCount(0);
+ });
+```
+
+## Verify
+
+```bash
+npx tsc --noEmit && npm run lint && npm test -- --run && npm run build && npx playwright test
+```
+
+Open `localhost:3000`, cycle Gold → Oxblood → High Contrast, open two case studies, tab through a card with the keyboard, then check at 360px wide.
+
+## Commit
+
+`feat: project thumbnails and in-page case studies`
diff --git a/docs/plans/03-writing-featured-and-subscribe.md b/docs/plans/03-writing-featured-and-subscribe.md
new file mode 100644
index 0000000..5cff3ed
--- /dev/null
+++ b/docs/plans/03-writing-featured-and-subscribe.md
@@ -0,0 +1,274 @@
+# Plan 03 — Writing: featured post, reading time, subscribe
+
+**Priority:** P1 · **Effort:** ~half a day · **Value:** high for readers arriving from Substack
+**Depends on:** plan 01 (posts must be committed so the section renders) · **Mock-up:** section 03 of the mock-ups board
+
+## Why
+
+Once plan 01 makes the Writing section visible, it renders posts as the same catalog cards as projects: no cover image, no reading time, no way to follow. Readers coming from a Substack post are the visitors most likely to return. Give the latest post a featured block with its cover, list the rest as an archive, and add a subscribe form that posts straight to Substack — no new backend on a static site.
+
+## Done when
+
+- [ ] The newest post renders as a featured block: cover image, date, "N min read", title, subtitle, "Read on Substack ↗", and a chip linking to the related project when one is mapped.
+- [ ] Older posts render as a compact archive list (date · title · Read ↗). With one post the archive shows that one post; the section never shows placeholder rows.
+- [ ] A subscribe form with an email field submits to Substack and shows a confirmation state.
+- [ ] RSS link present. Section still hidden when `POSTS` is empty (existing invariant + e2e).
+- [ ] `npm test -- --run` passes with the new parser tests.
+
+## Files
+
+- `scripts/lib/parse-substack-feed.js` — extract `image` (enclosure) and `minutes` (word count of `content:encoded`)
+- `__tests__/parse-substack-feed.test.ts` — tests for the two new fields
+- `scripts/fetch-substack.js` — serialise the new fields
+- `lib/content/writing.ts` — type gains `image?`, `minutes?`
+- `lib/content/writing-links.ts` — new: post slug → project id map
+- `components/landing/Writing.tsx` — featured + archive + subscribe
+- `app/globals.css` — new classes + mobile rules
+- `next.config.ts` — no change needed (` ` is used, not `next/image`)
+
+## Steps
+
+### 1. Parser: cover image and reading time
+
+In `scripts/lib/parse-substack-feed.js`, inside the `items.map(...)`:
+
+```js
+ const image = text(item?.enclosure?.['@_url']) || undefined
+ const body = text(item?.['content:encoded'])
+ const words = body ? stripHtml(body).split(/\s+/).filter(Boolean).length : 0
+ const minutes = words > 0 ? Math.max(1, Math.round(words / 230)) : undefined
+
+ if (!title || !url || !date) return null
+ if (isPlaceholder(title)) return null
+
+ return {
+ title, url, date,
+ ...(subtitle ? { subtitle } : {}),
+ ...(image ? { image } : {}),
+ ...(minutes ? { minutes } : {}),
+ }
+```
+
+`XMLParser` is already constructed with `ignoreAttributes: false`, so `enclosure['@_url']` is available. The existing `text()` helper handles the object shape.
+
+Substack's cover URLs are already CDN-resized on request; store the URL as-is and let the component request a sized variant (step 4).
+
+### 2. Tests (`__tests__/parse-substack-feed.test.ts`)
+
+Extend the `item()` helper to accept optional `extra` XML, then add:
+
+```ts
+ it('extracts the cover image from ', () => {
+ const posts = parseSubstackFeed(
+ feed(item('Post', 'https://x.substack.com/p/a', 'Mon, 06 Jul 2026 12:00:00 GMT', 'Sub',
+ ' '))
+ )
+ expect(posts?.[0].image).toBe('https://cdn.example/cover.png')
+ })
+
+ it('estimates reading time from content:encoded at 230 wpm, minimum 1', () => {
+ const words = Array.from({ length: 690 }, () => 'word').join(' ')
+ const posts = parseSubstackFeed(
+ feed(item('Post', 'https://x.substack.com/p/a', 'Mon, 06 Jul 2026 12:00:00 GMT', '',
+ `${words}
]]>`))
+ )
+ expect(posts?.[0].minutes).toBe(3)
+ })
+
+ it('omits image and minutes when absent', () => {
+ const posts = parseSubstackFeed(feed(item('Post', 'https://x.substack.com/p/a', 'Mon, 06 Jul 2026 12:00:00 GMT')))
+ expect(posts?.[0]).not.toHaveProperty('image')
+ expect(posts?.[0]).not.toHaveProperty('minutes')
+ })
+```
+
+The `feed()` wrapper must declare the namespace for `content:encoded` to parse: add `xmlns:content="http://purl.org/rss/1.0/modules/content/"` to its `` tag (fast-xml-parser doesn't require it, but the real feed has it and the test should match reality).
+
+### 3. Serialiser and type
+
+`scripts/fetch-substack.js` → in `serialize()` add after the subtitle line:
+
+```js
+ if (post.image) fields.push(` image: ${JSON.stringify(post.image)},`)
+ if (post.minutes) fields.push(` minutes: ${post.minutes},`)
+```
+
+`lib/content/writing.ts`:
+
+```ts
+export interface WritingPost {
+ title: string
+ url: string
+ date: string
+ subtitle?: string
+ /** Cover image URL from the feed's . */
+ image?: string
+ /** Estimated reading time. */
+ minutes?: number
+}
+```
+
+Re-run `node scripts/fetch-substack.js` and commit the regenerated `POSTS`.
+
+### 4. Post → project map (`lib/content/writing-links.ts`)
+
+```ts
+// Hand-maintained: which project a post is about, keyed by the post's URL slug.
+// Used by Writing to show an "About → " chip on the featured post.
+export const POST_PROJECT: Record = {
+ 'the-game-had-already-started': '#brkt-0002/05',
+}
+
+export const slugOf = (url: string): string => url.replace(/\/+$/, '').split('/').pop() ?? ''
+```
+
+### 5. Component (`components/landing/Writing.tsx`)
+
+```tsx
+'use client'
+import { useState } from 'react'
+import { POSTS, SUBSTACK_URL, type WritingPost } from '@/lib/content/writing'
+import { POST_PROJECT, slugOf } from '@/lib/content/writing-links'
+import { PROJECTS } from '@/lib/content/projects'
+import { BinaryRule } from './BinaryRule'
+
+const formatDate = /* unchanged */
+
+// Substack CDN accepts sizing directives in the path; request a 900px WebP.
+const sized = (url: string) => url.replace('/image/fetch/', '/image/fetch/w_900,c_limit,f_webp,q_auto:good/')
+
+function Featured({ post }: { post: WritingPost }) {
+ const projectId = POST_PROJECT[slugOf(post.url)]
+ const project = PROJECTS.find((p) => p.id === projectId)
+ return (
+
+ {post.image && (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ )}
+
+
+ )
+}
+
+function Subscribe() {
+ const [done, setDone] = useState(false)
+ return (
+
+
+
Get the next one in your inbox.
+
Build notes, roughly monthly. Unsubscribe is one click.
+
+
+
+
+ )
+}
+
+export function Writing() {
+ const [latest, ...rest] = POSTS
+ return (
+
+
+ {/* unchanged */}
+
+
+
+ {POSTS.map((post) => (
+
+ {formatDate(post.date)}
+ {post.title}
+ Read ↗
+
+ ))}
+
+
+
+ )
+}
+```
+
+`rest` is unused when the archive lists everything; drop the destructure if lint complains and use `POSTS[0]`.
+
+**Subscribe endpoint check (do this before styling):** Substack's own embed posts to `/api/v1/free?nojs=true` with a form field named `email`. Verify with a throwaway address by submitting the built page; if Substack has changed the endpoint, fall back to the official iframe embed: `` inside `.sub-block`. The hidden-iframe target keeps the visitor on the page in the form version.
+
+### 6. CSS (`app/globals.css`, Writing block)
+
+```css
+.featured { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); border: 1px solid var(--rule); margin-top: var(--s-7); }
+.featured-media { border-right: 1px solid var(--rule); background: var(--bg-elevated); }
+.featured-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.featured-text { padding: var(--s-5); display: flex; flex-direction: column; gap: var(--s-3); align-items: flex-start; }
+.featured-title { font-family: var(--font-display); font-size: clamp(24px, 3vw, 36px); line-height: 1; letter-spacing: -0.02em; margin: 0; }
+.featured-sub { font-size: 15px; color: var(--fg-muted); max-width: 48ch; margin: 0; }
+.meta-row { display: flex; gap: 14px; flex-wrap: wrap; font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--fg-low); }
+.meta-row .is-accent, .writing-all.is-accent { color: var(--accent); border-color: var(--accent); }
+.chip { display: inline-flex; gap: 6px; padding: 4px 9px; border: 1px solid var(--rule); font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase; color: var(--fg-muted); text-decoration: none; }
+.chip:hover { border-color: var(--accent); color: var(--accent); }
+.sub-block { margin-top: var(--s-5); display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-5); border: 1px solid var(--rule); padding: var(--s-5); align-items: center; }
+.sub-title { font-size: 19px; font-weight: 700; line-height: 1.15; margin: 0; }
+.sub-copy { color: var(--fg-muted); font-size: 14px; margin: 6px 0 0; max-width: 44ch; }
+.sub-form { display: flex; gap: var(--s-3); align-items: flex-end; flex-wrap: wrap; }
+.sub-form .field { flex: 1 1 200px; }
+.sub-ok { font-family: var(--font-mono); font-size: 12px; color: var(--accent); }
+.archive { list-style: none; padding: 0; margin: var(--s-5) 0 0; border-top: 1px solid var(--rule); }
+.archive li { display: grid; grid-template-columns: 110px 1fr auto; gap: var(--s-4); padding: 12px 0; border-bottom: 1px solid var(--rule); align-items: baseline; font-size: 14px; }
+.archive-date { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; color: var(--fg-low); }
+.writing-foot { display: flex; gap: var(--s-5); flex-wrap: wrap; }
+```
+
+Mobile block additions:
+
+```css
+ .featured, .sub-block { grid-template-columns: 1fr; }
+ .featured-media { border-right: 0; border-bottom: 1px solid var(--rule); aspect-ratio: 16 / 10; }
+ .archive li { grid-template-columns: 1fr; gap: 4px; }
+```
+
+### 7. e2e
+
+Add to `e2e/landing.spec.ts` inside the structure suite (runs only when posts exist):
+
+```ts
+ test("writing leads with a featured post and a subscribe form", async ({ page }) => {
+ test.skip(POSTS.length === 0, "no posts committed");
+ await expect(page.locator("#writing .featured-title")).toHaveText(POSTS[0].title);
+ await expect(page.locator("#writing form.sub-form input[type=email]")).toBeVisible();
+ });
+```
+
+## Verify
+
+```bash
+npm test -- --run && npx tsc --noEmit && npm run lint && npm run build && npx playwright test
+```
+
+Check the featured image loads in all three themes and the form's confirmation state appears.
+
+## Commit
+
+`feat: featured post, reading time and subscribe form in Writing`
diff --git a/docs/plans/04-social-preview-card.md b/docs/plans/04-social-preview-card.md
new file mode 100644
index 0000000..d595a06
--- /dev/null
+++ b/docs/plans/04-social-preview-card.md
@@ -0,0 +1,141 @@
+# Plan 04 — Social preview card (Open Graph image)
+
+**Priority:** P1 · **Effort:** 1–2 hours · **Value:** high — every LinkedIn/X/Slack share gets a picture
+**Depends on:** nothing · **Mock-up:** section 04 of the mock-ups board
+
+## Why
+
+`app/layout.tsx` sets `openGraph` and `twitter` metadata but **no image**, and `twitter.card` is `summary`. Shares of https://dommango.github.io render as a bare title. A single static 1200×630 PNG in the brand system fixes this for every share, forever. GitHub Pages can't generate images at request time, so render once with Playwright (already a dev dependency) and commit the file.
+
+## Done when
+
+- [ ] `public/og.png` exists, 1200×630, ≤ 300 KB, in the Gold theme.
+- [ ] ` `, `og:image:width/height`, `og:image:alt`, `twitter:card=summary_large_image`, `twitter:image` present in the built HTML.
+- [ ] `public/apple-touch-icon.png` (180×180) and ` ` present.
+- [ ] LinkedIn Post Inspector and X Card Validator show the image.
+
+## Files
+
+- `scripts/og/template.html` — new, the card
+- `scripts/render-og.mjs` — new, renders template → `public/og.png` (and the touch icon)
+- `public/og.png`, `public/apple-touch-icon.png` — generated, committed
+- `app/layout.tsx` — metadata
+
+## Steps
+
+### 1. The template (`scripts/og/template.html`)
+
+Self-contained; fonts via Google Fonts (rendering happens on a machine with network). Portrait is referenced relative to the repo.
+
+```html
+
+
+
+
+
+
0 0 1 1 0 0 0 1 1 0 0 1 1 0 1 0 0 1 0 1 1 1 1 0 1 1 0 1 0 1 0 1 1 0 0 0 0 0 1 1 0 1 1 0 0 1 0 1 1
+
+
DM dommango.github.io
+
Dom Mangonon.
+
Builds software with AI — SousIQ, Bracketeer, the Claude Code Placemat. Projects, writing, a travel map.
+
+
+
PROJECTS · WRITING · CAREER · TRAVEL · CONTACT
+
+```
+
+Colors are the literal values of `--oxblood-900`, `--bone-100/400/600`, `--gold-500`, `--oxblood-600` from `app/globals.css`. If those tokens change, change them here too — there's no shared source because the template is not processed by Tailwind/Next.
+
+### 2. The renderer (`scripts/render-og.mjs`)
+
+```js
+// Renders the Open Graph card and the apple-touch-icon once; commit the PNGs.
+// Run: node scripts/render-og.mjs
+import { chromium } from 'playwright'
+import { fileURLToPath } from 'node:url'
+import path from 'node:path'
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+const template = 'file://' + path.join(here, 'og', 'template.html')
+const out = path.join(here, '..', 'public')
+
+const browser = await chromium.launch()
+const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 })
+await page.goto(template, { waitUntil: 'networkidle' })
+await page.evaluate(() => document.fonts.ready)
+await page.screenshot({ path: path.join(out, 'og.png'), clip: { x: 0, y: 0, width: 1200, height: 630 } })
+
+// Touch icon: the DM mark on oxblood, 180×180.
+await page.setViewportSize({ width: 180, height: 180 })
+await page.setContent(`
+ DM
+ `)
+await page.evaluate(() => document.fonts.ready)
+await page.screenshot({ path: path.join(out, 'apple-touch-icon.png') })
+await browser.close()
+console.log('wrote public/og.png and public/apple-touch-icon.png')
+```
+
+Run it: `node scripts/render-og.mjs`. Open `public/og.png` and check the portrait isn't clipped and the headline fits. If the PNG is over 300 KB, run it through `pngquant` or `cwebp` is **not** an option (OG must be PNG/JPEG) — use `pngquant --quality 70-90`.
+
+### 3. Metadata (`app/layout.tsx`)
+
+```ts
+export const metadata: Metadata = {
+ metadataBase: new URL(SITE_URL),
+ // ...existing title/description...
+ openGraph: {
+ title: "Dom Mangonon",
+ description: "Building software with AI. Projects, writing, and a travel map.",
+ url: SITE_URL,
+ siteName: "Dom Mangonon",
+ type: "website",
+ locale: "en_US",
+ images: [{ url: "/og.png", width: 1200, height: 630, alt: "Dom Mangonon — builds software with AI" }],
+ },
+ twitter: {
+ card: "summary_large_image",
+ creator: "@CollapseContext",
+ images: ["/og.png"],
+ },
+ icons: {
+ icon: "/favicon.ico",
+ apple: "/apple-touch-icon.png",
+ },
+ alternates: { canonical: "/" },
+};
+
+export const viewport: Viewport = { themeColor: "#160000" };
+```
+
+Import `Viewport` from `next` alongside `Metadata`. (`themeColor` in `metadata` is deprecated in Next 15+; it belongs on the `viewport` export.)
+
+### 4. Verify
+
+```bash
+npm run build
+grep -o ' ]*og:image[^>]*>' out/index.html
+grep -o ' ]*twitter:card[^>]*>' out/index.html
+grep -o ' ]*>' out/index.html
+```
+
+After deploy: paste https://dommango.github.io into https://www.linkedin.com/post-inspector/ and https://cards-dev.twitter.com/validator (or share in a Slack DM to yourself). Social caches are sticky — use the inspector's "re-scrape" if an old card shows.
+
+## Commit
+
+`feat: Open Graph card, touch icon and theme color`
diff --git a/docs/plans/05-now-strip.md b/docs/plans/05-now-strip.md
new file mode 100644
index 0000000..cc3afa0
--- /dev/null
+++ b/docs/plans/05-now-strip.md
@@ -0,0 +1,252 @@
+# Plan 05 — "Now" strip: dated proof the site is alive
+
+**Priority:** P1 · **Effort:** ~half a day · **Value:** high for anyone deciding whether to reach out
+**Depends on:** plan 01 (Substack posts committed) · **Mock-up:** section 05 of the mock-ups board
+
+## Why
+
+A static portfolio gives no signal that anything happened since it was built. The only date on the page is "© 2026". The Availability strip under the nav already occupies the right slot; replace it with four dated facts:
+
+| Cell | Source | Freshness |
+|---|---|---|
+| Status — "Open to conversations · NYC metro" | hand-set in `lib/content/now.ts` | when Dom changes it |
+| Latest post — title + "27 days ago" | `POSTS[0]` (already fetched at build) | every deploy |
+| Last shipped — repo + "3 days ago" | GitHub API at build, public repos only | every deploy (nightly cron already exists) |
+| Building — one line | hand-set in `lib/content/now.ts` | when Dom changes it |
+
+The deploy workflow already rebuilds nightly (`schedule: '0 11 * * *'`), so the "ago" values stay honest without anyone touching the site.
+
+## Done when
+
+- [ ] The strip under the nav shows the four cells; on phones they stack.
+- [ ] "Latest post" and "Last shipped" show an absolute date server-side and a relative "N days ago" that is computed client-side (no hydration mismatch).
+- [ ] `scripts/fetch-github-activity.js` writes `lib/content/activity.ts` between `GENERATED` markers, never fails the build, and keeps the committed value when GitHub is unreachable.
+- [ ] Unit tests cover the activity transform (pure function) and the relative-time formatter.
+- [ ] The old `Availability` "Get in touch →" CTA survives — in the Status cell.
+
+## Files
+
+- `lib/content/now.ts` — new, hand-set
+- `lib/content/activity.ts` — new, generated between markers (like `writing.ts`)
+- `scripts/lib/pick-latest-activity.js` — new, pure transform (tested)
+- `scripts/fetch-github-activity.js` — new, build-time fetch
+- `lib/format/relative-time.ts` — new (tested)
+- `components/landing/NowStrip.tsx` — new; replaces `Availability.tsx`
+- `components/landing/BrutalistLanding.tsx` — swap the component
+- `app/globals.css` — `.now*` classes + mobile rules
+- `.github/workflows/deploy.yml` — run the fetch before `npm run build`
+- `__tests__/pick-latest-activity.test.ts`, `__tests__/relative-time.test.ts`
+
+## Steps
+
+### 1. Hand-set content (`lib/content/now.ts`)
+
+```ts
+// The two lines on the Now strip that a human maintains. Change them when they change.
+export const NOW = {
+ status: 'Open to conversations · NYC metro',
+ statusNote: 'SVP at Citi by day',
+ building: 'SousIQ · vendor bid comparison',
+ buildingSince: '2026-08',
+} as const
+```
+
+### 2. Generated activity (`lib/content/activity.ts`)
+
+```ts
+// Latest public activity across Dom's GitHub repos, written at build time by
+// scripts/fetch-github-activity.js. Same marker convention as writing.ts.
+export interface Activity {
+ repo: string
+ /** Human label, e.g. "Placemat" */
+ label: string
+ url: string
+ /** ISO date of the newest commit on the default branch. */
+ date: string
+ summary: string
+}
+
+// GENERATED — do not edit by hand. See scripts/fetch-github-activity.js.
+export const LATEST_ACTIVITY: Activity | null = null
+// END GENERATED
+```
+
+### 3. Pure transform (`scripts/lib/pick-latest-activity.js`)
+
+```js
+// Given the GitHub /repos/{owner}/{repo}/commits?per_page=1 responses for
+// several repos, return the newest as an Activity, or null. Never throws.
+const LABELS = {
+ 'claude-code-placemat': 'Placemat',
+ 'modular-mind': 'modular-mind',
+ 'dommango.github.io': 'This site',
+}
+
+function pickLatestActivity(results) {
+ const rows = (results || [])
+ .map(({ repo, commits }) => {
+ const c = Array.isArray(commits) ? commits[0] : null
+ const date = c?.commit?.committer?.date || c?.commit?.author?.date
+ if (!repo || !date || Number.isNaN(new Date(date).getTime())) return null
+ const firstLine = String(c?.commit?.message || '').split('\n')[0].trim()
+ return {
+ repo,
+ label: LABELS[repo] || repo,
+ url: c?.html_url || `https://github.com/dommango/${repo}`,
+ date: new Date(date).toISOString(),
+ summary: firstLine.slice(0, 80),
+ }
+ })
+ .filter(Boolean)
+ .sort((a, b) => b.date.localeCompare(a.date))
+ return rows[0] || null
+}
+
+module.exports = { pickLatestActivity, LABELS }
+```
+
+Tests (`__tests__/pick-latest-activity.test.ts`): newest wins across repos; a repo with an empty array is skipped; a malformed date is skipped; all-empty returns `null`; summary is the first line, truncated to 80 chars; label falls back to the repo name.
+
+### 4. Build-time fetch (`scripts/fetch-github-activity.js`)
+
+Copy the structure of `scripts/fetch-substack.js` exactly (markers, bail-outs, warnings). Differences:
+
+```js
+const REPOS = ['claude-code-placemat', 'modular-mind', 'dommango.github.io']
+const { pickLatestActivity } = require('./lib/pick-latest-activity')
+
+async function fetchCommits(repo) {
+ const res = await fetch(`https://api.github.com/repos/dommango/${repo}/commits?per_page=1`, {
+ headers: {
+ accept: 'application/vnd.github+json',
+ 'user-agent': 'dommango.github.io build',
+ ...(process.env.GITHUB_TOKEN ? { authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}),
+ },
+ signal: AbortSignal.timeout(10000),
+ })
+ if (!res.ok) throw new Error(`${repo}: ${res.status}`)
+ return { repo, commits: await res.json() }
+}
+
+// In main(): Promise.allSettled over REPOS; pass the fulfilled values to
+// pickLatestActivity; if null, warn and return without writing. Serialise as
+// `export const LATEST_ACTIVITY: Activity | null = ${JSON.stringify(activity, null, 2)}`.
+```
+
+Filter out commits whose first line starts with `chore: update dashboard data` (the bot noise) before picking — otherwise "This site" wins every six hours with nothing to show.
+
+`deploy.yml`, before the build step:
+
+```yaml
+ - name: Fetch GitHub activity
+ env:
+ GITHUB_TOKEN: ${{ github.token }} # raises the API rate limit; read-only
+ run: node scripts/fetch-github-activity.js
+```
+
+### 5. Relative time (`lib/format/relative-time.ts`)
+
+```ts
+const DAY = 86_400_000
+
+/** "today", "yesterday", "12 days ago", "3 months ago", "2 years ago". */
+export function relativeTime(iso: string, now: Date = new Date()): string {
+ const days = (now.getTime() - new Date(iso).getTime()) / DAY
+ if (!Number.isFinite(days) || days < 0) return ''
+ if (days < 1) return 'today'
+ if (days < 2) return 'yesterday'
+ if (days < 30) return `${Math.round(days)} days ago`
+ if (days < 365) return `${Math.round(days / 30)} months ago`
+ return `${Math.round(days / 365)} years ago`
+}
+```
+
+Tests: each branch, an invalid ISO returns `''`, a future date returns `''`.
+
+### 6. Component (`components/landing/NowStrip.tsx`)
+
+```tsx
+'use client'
+import { useEffect, useState } from 'react'
+import { NOW } from '@/lib/content/now'
+import { LATEST_ACTIVITY } from '@/lib/content/activity'
+import { POSTS } from '@/lib/content/writing'
+import { relativeTime } from '@/lib/format/relative-time'
+
+const absolute = (iso: string) =>
+ new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
+
+/** Renders the absolute date on the server, swaps in "N days ago" after hydration. */
+function Ago({ iso }: { iso: string }) {
+ const [text, setText] = useState(absolute(iso))
+ useEffect(() => { setText(relativeTime(iso) || absolute(iso)) }, [iso])
+ return {text}
+}
+
+export function NowStrip({ onGetInTouch }: { onGetInTouch: () => void }) {
+ const post = POSTS[0]
+ return (
+
+
+ Status
+ {NOW.status}
+ Get in touch →
+
+ {post && (
+
+ )}
+ {LATEST_ACTIVITY && (
+
+ )}
+
+ Building
+ {NOW.building}
+ since {NOW.buildingSince}
+
+
+ )
+}
+```
+
+In `BrutalistLanding.tsx` replace ` ` with ` navigate('contact')} />`. Delete `Availability.tsx` and the `.availability`/`.avail-sep` CSS; keep `.avail-dot` and `.avail-cta` (reused).
+
+### 7. CSS
+
+```css
+.now { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-bottom: 1px solid var(--rule); }
+.now-cell { padding: 14px 16px 14px 0; display: flex; flex-direction: column; gap: 5px; border-right: 1px solid var(--rule); }
+.now-cell + .now-cell { padding-left: 16px; }
+.now-cell:last-child { border-right: 0; }
+.now-k { font-family: var(--font-mono); font-size: 10px; letter-spacing: .18em; text-transform: uppercase; color: var(--fg-low); display: flex; align-items: center; gap: 8px; }
+.now-v { font-size: 14px; font-weight: 500; line-height: 1.3; }
+.now-v a { color: inherit; text-decoration: none; border-bottom: 1px solid var(--rule); }
+.now-v a:hover { color: var(--accent); border-color: var(--accent); }
+.now-ago { font-family: var(--font-mono); font-size: 11px; letter-spacing: .06em; color: var(--accent); }
+.now .avail-cta { margin-left: 0; align-self: flex-start; }
+```
+
+Mobile block: `.now { grid-template-columns: 1fr; } .now-cell { border-right: 0; border-top: 1px solid var(--rule); padding: 12px 0; } .now-cell:first-child { border-top: 0; } .now-cell + .now-cell { padding-left: 0; }`
+
+### 8. Tests and e2e
+
+- Unit: the two test files above.
+- e2e (`landing.spec.ts`): `await expect(page.locator('.now .now-cell')).toHaveCount(POSTS.length > 0 ? 4 : 3)` — adjust if `LATEST_ACTIVITY` is null in CI (it will be unless the fetch runs in CI; CI doesn't run it, so expect 3 with posts, 2 without: `2 + (POSTS.length > 0 ? 1 : 0)`). Import `LATEST_ACTIVITY` in the spec and compute: `2 + (POSTS.length > 0 ? 1 : 0) + (LATEST_ACTIVITY ? 1 : 0)`.
+
+## Verify
+
+```bash
+node scripts/fetch-github-activity.js && git diff lib/content/activity.ts
+npm test -- --run && npx tsc --noEmit && npm run lint && npm run build && npx playwright test
+```
+
+## Commit
+
+`feat: Now strip with latest post and last shipped, fetched at build`
diff --git a/docs/plans/06-honest-assistant.md b/docs/plans/06-honest-assistant.md
new file mode 100644
index 0000000..a7d4f7c
--- /dev/null
+++ b/docs/plans/06-honest-assistant.md
@@ -0,0 +1,229 @@
+# Plan 06 — An honest assistant (or none)
+
+**Priority:** P1 · **Effort:** ~1 day for path A, ~20 minutes for path B · **Value:** high — removes the one thing on the page that actively misinforms
+**Depends on:** plan 01 step 6 (stopgap hides the widget) · **Mock-up:** section 06 of the mock-ups board
+
+## Why
+
+`components/chat/ChatBot.tsx` presents itself as "Dom's AI assistant". With `NEXT_PUBLIC_CHAT_API_URL` unset (it is, on the live site) every answer comes from `getOfflineResponse()` in `lib/services/chat.ts` — a keyword matcher that tells visitors to "visit the Skills page", "check the Education page" and "request Dom's resume through the Contact page". None of those exist. The widget is also styled with the pre-redesign Tailwind tokens (rounded-2xl, `bg-accent-gold`, gray bubbles) and floats over the brutalist page like a leftover.
+
+Two honest options. **Pick one; do not leave the current state.**
+
+- **Path A — make it real.** A ~60-line Cloudflare Worker holds the API key, rate-limits per IP, and calls Claude with the brief that already exists (`DOM_CONTEXT`). The widget is restyled in the site's tokens and says plainly what it can and cannot see. Cost is capped by the rate limit.
+- **Path B — remove it.** Delete the widget and the service. Twenty minutes, zero risk.
+
+If Dom doesn't want to run a Worker, do path B. A site whose thesis is "Unapologetically AI-pilled" is better off with no assistant than a fake one.
+
+## Done when (path A)
+
+- [ ] `NEXT_PUBLIC_CHAT_API_URL` set in the deploy build env → widget renders; unset → nothing renders (no offline mode remains in the code).
+- [ ] Worker rejects origins other than `https://dommango.github.io` (and `http://localhost:3000` in dev), enforces 20 requests / IP / hour, truncates input to 500 chars, caps history to 6 turns, `max_tokens: 300`.
+- [ ] The widget uses only `.brutalist-root` tokens; square corners; visible focus; `prefers-reduced-motion` honoured; honest sub-label.
+- [ ] Four suggested-question chips; Enter sends; errors are plain sentences.
+- [ ] `lib/services/chat.ts` no longer contains `getOfflineResponse` or the page names.
+
+## Done when (path B)
+
+- [ ] `components/chat/`, `lib/services/chat.ts` deleted; ` ` removed from `app/layout.tsx`; `clsx` stays (dashboard uses it).
+- [ ] `grep -rn "Skills page\|Education page" .` returns nothing outside `docs/`.
+
+---
+
+## Path A
+
+### A1. Worker (`worker/src/index.js`, new directory at repo root)
+
+Create the project:
+
+```bash
+mkdir -p worker && cd worker && npm init -y && npm i -D wrangler && npm i @anthropic-ai/sdk
+```
+
+`worker/wrangler.toml`:
+
+```toml
+name = "dommango-site-assistant"
+main = "src/index.js"
+compatibility_date = "2026-08-01"
+compatibility_flags = ["nodejs_compat"]
+
+[[kv_namespaces]]
+binding = "RATE"
+id = ""
+
+[vars]
+ALLOWED_ORIGINS = "https://dommango.github.io,http://localhost:3000"
+MODEL = "claude-opus-5"
+```
+
+`worker/src/index.js`:
+
+```js
+import Anthropic from '@anthropic-ai/sdk'
+import { SITE_BRIEF } from './brief.js'
+
+const LIMIT_PER_HOUR = 20
+const MAX_TURNS = 6
+const MAX_CHARS = 500
+
+const cors = (origin, allowed) => ({
+ 'access-control-allow-origin': allowed.includes(origin) ? origin : allowed[0],
+ 'access-control-allow-methods': 'POST, OPTIONS',
+ 'access-control-allow-headers': 'content-type',
+ 'content-type': 'application/json',
+})
+
+export default {
+ async fetch(request, env) {
+ const allowed = env.ALLOWED_ORIGINS.split(',')
+ const origin = request.headers.get('origin') || ''
+ const headers = cors(origin, allowed)
+
+ if (request.method === 'OPTIONS') return new Response(null, { headers })
+ if (request.method !== 'POST') return new Response('{"error":"POST only"}', { status: 405, headers })
+ if (!allowed.includes(origin)) return new Response('{"error":"origin not allowed"}', { status: 403, headers })
+
+ // Per-IP hourly counter in KV. Key rolls over each hour, so no cleanup needed.
+ const ip = request.headers.get('cf-connecting-ip') || 'unknown'
+ const hour = Math.floor(Date.now() / 3_600_000)
+ const key = `${ip}:${hour}`
+ const used = Number((await env.RATE.get(key)) || 0)
+ if (used >= LIMIT_PER_HOUR) {
+ return new Response('{"error":"That is enough questions for one hour — the Contact form is always open."}', { status: 429, headers })
+ }
+ await env.RATE.put(key, String(used + 1), { expirationTtl: 3600 })
+
+ let body
+ try { body = await request.json() } catch { return new Response('{"error":"bad json"}', { status: 400, headers }) }
+ const messages = (Array.isArray(body.messages) ? body.messages : [])
+ .filter((m) => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string')
+ .slice(-MAX_TURNS)
+ .map((m) => ({ role: m.role, content: m.content.slice(0, MAX_CHARS) }))
+ if (messages.length === 0 || messages[messages.length - 1].role !== 'user') {
+ return new Response('{"error":"send at least one user message"}', { status: 400, headers })
+ }
+
+ const client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY })
+ try {
+ const response = await client.messages.create({
+ model: env.MODEL,
+ max_tokens: 300,
+ output_config: { effort: 'low' },
+ system: [{ type: 'text', text: SITE_BRIEF, cache_control: { type: 'ephemeral' } }],
+ messages,
+ })
+ if (response.stop_reason === 'refusal') {
+ return new Response(JSON.stringify({ message: "I can't help with that one. The Contact form goes straight to Dom." }), { headers })
+ }
+ const text = response.content.filter((b) => b.type === 'text').map((b) => b.text).join('')
+ return new Response(JSON.stringify({ message: text }), { headers })
+ } catch (error) {
+ if (error instanceof Anthropic.RateLimitError) {
+ return new Response('{"error":"Busy right now — try again in a minute."}', { status: 503, headers })
+ }
+ console.error('assistant error', error)
+ return new Response('{"error":"Something went wrong on my side. The Contact form still works."}', { status: 500, headers })
+ }
+ },
+}
+```
+
+Notes for whoever implements this:
+- `claude-opus-5` at `effort: 'low'` is the default the Claude API guidance recommends; if Dom prefers the cheapest option, set `MODEL = "claude-haiku-4-5"` and **remove** the `output_config` line (Haiku 4.5 rejects `effort`).
+- `cache_control` on the system block means the brief is billed at the cached rate after the first request in a 5-minute window.
+- Move `DOM_CONTEXT` from `lib/services/chat.ts` into `worker/src/brief.js` as `export const SITE_BRIEF = \`...\``. Update its "Response Guidelines" to add: "You only know what is in this brief. If asked about anything else, say so and point to the Contact section." Delete the "Career page/Skills page" remnants — they are only in `getOfflineResponse`, which is deleted.
+- Secrets: `cd worker && npx wrangler secret put ANTHROPIC_API_KEY` (paste at the prompt; never in a file or chat). Deploy: `npx wrangler deploy`. The URL it prints is `NEXT_PUBLIC_CHAT_API_URL`.
+- Add `NEXT_PUBLIC_CHAT_API_URL` to the deploy build env (plan 01's block) and to `.env.local` for dev.
+
+### A2. Frontend (`components/chat/ChatBot.tsx` rewrite)
+
+Replace the Tailwind-styled component with one built on the site's classes. Keep the message state logic; change:
+
+- Delete `getOfflineResponse` and the `if (!apiUrl)` branch in `lib/services/chat.ts`. `sendChatMessage` sends `{ messages }` only (no system prompt from the client). Handle `429`/`503` by surfacing the server's `error` string.
+- Markup:
+
+```tsx
+<>
+ setIsOpen(true)} aria-label="Ask about the work">Ask
+ {isOpen && (
+
+
+
+ Ask about the work
+ Answers come from a short brief Dom wrote for it. It can't see your data, the web, or anything not on this page.
+
+
setIsOpen(false)} aria-label="Close">×
+
+
{/* messages */}
+ {messages.length === 1 && (
+
+ {SUGGESTED.map((q) => send(q)}>{q} )}
+
+ )}
+
+
+ )}
+>
+```
+
+```ts
+const SUGGESTED = ['What is SousIQ?', 'How does Bracketeer score?', 'Is Dom open to roles?', 'What is this site built with?']
+```
+
+Put the widget **inside** `.brutalist-root` so it inherits tokens: render ` ` from `BrutalistLanding.tsx` (after ``) instead of `app/layout.tsx`, gated on `process.env.NEXT_PUBLIC_CHAT_API_URL`. The dashboard doesn't need it.
+
+- CSS (`app/globals.css`, new block before Responsive):
+
+```css
+.chat-fab { position: fixed; right: 24px; bottom: 24px; z-index: 40; width: 52px; height: 52px; background: var(--accent); color: var(--fg-inverse); border: 0; box-shadow: 4px 4px 0 0 var(--accent-press); font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; cursor: pointer; }
+.chat { position: fixed; right: 24px; bottom: 24px; z-index: 50; width: min(420px, calc(100vw - 32px)); height: min(520px, calc(100vh - 48px)); background: var(--bg); border: 1px solid var(--accent); display: flex; flex-direction: column; }
+.chat-head { padding: 12px 14px; border-bottom: 1px solid var(--rule); display: flex; justify-content: space-between; gap: 10px; align-items: flex-start; }
+.chat-head strong { font-family: var(--font-mono); font-size: 11px; letter-spacing: .16em; text-transform: uppercase; display: block; }
+.chat-head small { display: block; font-size: 11.5px; color: var(--fg-muted); margin-top: 3px; line-height: 1.35; }
+.chat-x { background: none; border: 1px solid var(--rule); color: var(--fg); width: 28px; height: 28px; cursor: pointer; }
+.chat-log { flex: 1; overflow: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
+.chat-msg { max-width: 88%; padding: 9px 12px; font-size: 13.5px; line-height: 1.45; border: 1px solid var(--rule); }
+.chat-msg.is-assistant { align-self: flex-start; border-left: 2px solid var(--accent); }
+.chat-msg.is-user { align-self: flex-end; background: var(--bg-elevated); }
+.chat-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 14px 10px; }
+.chat-chips button { background: transparent; border: 1px solid var(--rule); color: var(--fg-muted); font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em; padding: 5px 9px; cursor: pointer; }
+.chat-chips button:hover { border-color: var(--accent); color: var(--accent); }
+.chat-in { display: flex; border-top: 1px solid var(--rule); }
+.chat-in input { flex: 1; min-width: 0; background: transparent; border: 0; padding: 12px 14px; color: var(--fg); font-family: var(--font-sans); font-size: 14px; }
+.chat-in input:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+.chat-in button { background: var(--accent); color: var(--fg-inverse); border: 0; padding: 0 16px; font-family: var(--font-mono); font-size: 11px; letter-spacing: .14em; text-transform: uppercase; cursor: pointer; }
+.chat-in button:disabled { opacity: .5; cursor: not-allowed; }
+```
+
+`--fg-inverse` is white-on-accent today; after plan 08 it becomes the oxblood-on-gold value automatically.
+
+- Remove the `clsx` import from the widget (dashboard keeps the dependency).
+- The "thinking" indicator: three `.chat-dot` spans with a CSS animation wrapped in `@media (prefers-reduced-motion: no-preference)`.
+
+### A3. Tests
+
+- Unit: `__tests__/chat-service.test.ts` — mock `fetch`; `sendChatMessage` returns `{success:false, error}` with the server's message on 429; returns `{success:true, message}` on 200; never throws on malformed JSON.
+- e2e: with `NEXT_PUBLIC_CHAT_API_URL` unset (CI), assert `.chat-fab` count is 0. That guards the "no fake mode" invariant.
+
+### A4. Verify
+
+```bash
+cd worker && npx wrangler dev # then from the site: NEXT_PUBLIC_CHAT_API_URL=http://localhost:8787 npm run dev
+```
+
+Ask the four chips; ask something off-brief ("what's the weather") — expect it to say it doesn't know and point to Contact. Hit it 21 times in a minute from one IP — expect the 429 sentence. Then `npm test -- --run && npx tsc --noEmit && npm run lint && npx playwright test`.
+
+## Path B
+
+```bash
+git rm -r components/chat lib/services/chat.ts
+```
+
+In `app/layout.tsx` remove the `ChatBot` import and ` `. Run `npx tsc --noEmit && npm run lint && npm run build && npx playwright test`. Commit as `chore: remove the placeholder chat assistant`.
+
+## Commit (path A)
+
+`feat: real assistant backed by a rate-limited Worker, restyled in brand tokens`
diff --git a/docs/plans/07-not-found-rescue.md b/docs/plans/07-not-found-rescue.md
new file mode 100644
index 0000000..b6115bc
--- /dev/null
+++ b/docs/plans/07-not-found-rescue.md
@@ -0,0 +1,157 @@
+# Plan 07 — A 404 that rescues old links
+
+**Priority:** P0/P1 boundary · **Effort:** ~1 hour · **Value:** medium–high; every pre-redesign URL is a dead end today
+**Depends on:** nothing · **Mock-up:** section 07 of the mock-ups board
+
+## Why
+
+Before the single-page redesign the site had `/career`, `/skills`, `/education`, `/travel`, `/contact` and `/blog/*`. Those URLs live on in LinkedIn posts, tweets and search results. Today they render Next's default 404 — a white page in a system font with the gold chat bubble still floating in the corner (verified at https://dommango.github.io/career). There is no `app/not-found.tsx`, and `output: 'export'` turns that file into `out/404.html`, which GitHub Pages serves for every unknown path.
+
+## Done when
+
+- [ ] `app/not-found.tsx` exists and renders in the brutalist system (nav, binary rule, display heading).
+- [ ] Known old paths show "That page moved", name the new section, count down 3 seconds, then navigate to `/#`. A "Go there now →" button skips the wait.
+- [ ] Unknown paths show a plain 404 with links to the sections — **no** redirect, no guessing.
+- [ ] Works as `out/404.html` (verify after `npm run build`).
+- [ ] e2e covers both cases.
+
+## Files
+
+- `app/not-found.tsx` — new (client component)
+- `app/globals.css` — `.nf*` classes + mobile rule
+- `e2e/not-found.spec.ts` — new
+
+## Steps
+
+### 1. Redirect map and page (`app/not-found.tsx`)
+
+```tsx
+'use client'
+
+// Custom 404. Old multi-page URLs redirect to their section on the single page;
+// anything else gets a plain 404. Exported as out/404.html by `output: 'export'`,
+// which GitHub Pages serves for every unknown path.
+import { useEffect, useState } from 'react'
+import { BinaryRule } from '@/components/landing/BinaryRule'
+
+const REDIRECTS: Array<{ test: RegExp; to: string; label: string }> = [
+ { test: /^\/(career|skills|education|resume)\/?$/i, to: '/#resume', label: 'Career' },
+ { test: /^\/travel\/?$/i, to: '/#travel', label: 'Travel' },
+ { test: /^\/contact\/?$/i, to: '/#contact', label: 'Contact' },
+ { test: /^\/(blog|writing|posts)(\/.*)?$/i, to: '/#writing', label: 'Writing' },
+ { test: /^\/projects?(\/.*)?$/i, to: '/#projects', label: 'Projects' },
+]
+
+const SECTIONS = [
+ ['Projects', '/#projects'], ['Writing', '/#writing'], ['Career', '/#resume'], ['Travel', '/#travel'], ['Contact', '/#contact'],
+] as const
+
+export default function NotFound() {
+ const [path, setPath] = useState('')
+ const [seconds, setSeconds] = useState(3)
+ const match = REDIRECTS.find((r) => r.test.test(path))
+
+ useEffect(() => { setPath(window.location.pathname) }, [])
+
+ useEffect(() => {
+ if (!match) return
+ if (seconds <= 0) { window.location.replace(match.to); return }
+ const id = setTimeout(() => setSeconds((s) => s - 1), 1000)
+ return () => clearTimeout(id)
+ }, [match, seconds])
+
+ return (
+
+
+
+ DM Dom Mangonon
+
+
+
+
+
+
404
+
{match ? <>That page moved.> : <>Nothing here.>}
+
+ {path && <>You asked for {path}. >}
+ {match
+ ? <>It now lives on the front page, under {match.label} .>
+ : <>There’s nothing at that address — no guessing where you meant.>}
+
+ {match &&
Taking you there in {seconds}…
}
+
+
+
+ {SECTIONS.map(([label, href]) => (
+ {label} →
+ ))}
+
+
+
+
+
+ )
+}
+```
+
+`useEffect` reads `window.location` after mount so the static `404.html` (rendered with no path) hydrates cleanly — the first paint is the generic "Nothing here" and switches to the redirect copy immediately on the client. That's acceptable for a 404; don't try to read the path during render.
+
+Note `BinaryRule seed={404}`: any unused seed is fine; it exists for hydration stability.
+
+### 2. CSS (`app/globals.css`)
+
+```css
+.nf { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: var(--s-6); align-items: start; margin-top: var(--s-7); padding-bottom: var(--s-8); }
+.nf-title { font-family: var(--font-display); font-size: clamp(44px, 8vw, 110px); line-height: .88; letter-spacing: -.04em; margin: 10px 0 0; }
+.nf-lead { font-family: var(--font-mono); font-size: 13px; line-height: 1.6; letter-spacing: .04em; color: var(--fg-muted); max-width: 52ch; margin: var(--s-4) 0 0; }
+.nf-lead code { color: var(--fg); background: var(--bg-elevated); padding: 2px 6px; }
+.nf-count { font-family: var(--font-mono); font-size: 13px; color: var(--accent); letter-spacing: .06em; margin: var(--s-3) 0 0; }
+.nf-actions { margin-top: var(--s-5); }
+.nf-map { list-style: none; margin: 0; padding: var(--s-5); border: 1px solid var(--rule); display: flex; flex-direction: column; gap: var(--s-3); }
+```
+
+Mobile block: `.nf { grid-template-columns: 1fr; }`.
+
+### 3. e2e (`e2e/not-found.spec.ts`)
+
+```ts
+import { test, expect } from "@playwright/test";
+
+test.describe("Custom 404", () => {
+ test("old /career URL redirects to the career section", async ({ page }) => {
+ await page.goto("/career");
+ await expect(page.getByRole("heading", { name: /that page moved/i })).toBeVisible();
+ await expect(page.getByRole("status")).toContainText(/taking you there/i);
+ await page.waitForURL(/\/#resume$/, { timeout: 6000 });
+ });
+
+ test("unknown paths get a plain 404 with no redirect", async ({ page }) => {
+ await page.goto("/definitely-not-a-page");
+ await expect(page.getByRole("heading", { name: /nothing here/i })).toBeVisible();
+ await page.waitForTimeout(4000);
+ expect(page.url()).toContain("/definitely-not-a-page");
+ });
+
+ test("404 is branded, not the Next default", async ({ page }) => {
+ await page.goto("/career");
+ await expect(page.locator(".brutalist-root .brand-mark")).toBeVisible();
+ await expect(page.getByText("This page could not be found.")).toHaveCount(0);
+ });
+});
+```
+
+The dev server serves `not-found.tsx` for unknown routes, so the tests work locally and in CI.
+
+## Verify
+
+```bash
+npx tsc --noEmit && npm run lint && npm run build && ls out/404.html && grep -c "That page" out/404.html && npx playwright test e2e/not-found.spec.ts
+```
+
+After deploy: open https://dommango.github.io/travel — should land on the map within 3 seconds.
+
+## Commit
+
+`feat: branded 404 that redirects pre-redesign URLs to their sections`
diff --git a/docs/plans/08-contrast-and-a11y-pass.md b/docs/plans/08-contrast-and-a11y-pass.md
new file mode 100644
index 0000000..70b26f8
--- /dev/null
+++ b/docs/plans/08-contrast-and-a11y-pass.md
@@ -0,0 +1,208 @@
+# Plan 08 — Contrast, focus, motion and mobile-nav pass
+
+**Priority:** P2 · **Effort:** 2–3 hours · **Value:** medium–high (low-vision readers, phones in daylight, keyboard users)
+**Depends on:** nothing; plans 02/03/05/06 use the new tokens if this lands first · **Mock-up:** section 08 of the mock-ups board
+
+## Why — measured, not guessed
+
+WCAG AA needs 4.5:1 for text under ~18px. Ratios computed from the token values in `app/globals.css` (script in step 6 reproduces them):
+
+| Pair | Where it's used | Ratio | AA |
+|---|---|---|---|
+| `#fff` on `--gold-500 #d4a847` | `.btn-primary` text, `.brand-mark` "DM" (Gold theme) | **2.21** | fail |
+| `--bone-600 #7a7060` on `#160000` | `--fg-low`: `.work-year`, `.work-link`, `.hero-portrait-cap`, `.travel-map-cap`, `.footer-legal`, `.binary-rule` — 10–11px | **4.17** | fail |
+| `--blood-500 #c8102e` on `#160000` | Oxblood theme accent as text: `.work-impact` 14px, `.hero-kicker-alt` 13px, `.hl-num` 11px, `.avail-cta` 12px, `.nav-link.is-active` 12px | **3.45** | fail |
+| `#fff` on `--blood-500` | `.btn-primary` (Oxblood) | 5.88 | pass |
+| `--bone-400 #b8ab94` on `#160000` | `--fg-muted` | 8.98 | pass |
+| `--gold-500` on `#160000` | Gold accent as text | 9.18 | pass |
+
+Other findings in the same area:
+
+- **High Contrast theme hides focus.** `--accent`, `--border-strong`, `--rule` and `--fg` all become `#fff`; inputs use `outline: none` with a border-color change on focus — which is white → white. Keyboard users can't see where they are in the mode built for them.
+- `.contact-tracked` renders `L E T ' S T A L K` as text. Screen readers read it letter by letter. It's decorative.
+- No `prefers-reduced-motion` handling: `.avail-dot::after` pulses forever; `.work-card:hover` translates; `scroll-behavior: smooth` via `scrollIntoView`.
+- `` has no accessible name; no skip link.
+- Mobile nav (`≤900px`) scrolls horizontally with the scrollbar hidden and no affordance — the last link is cut mid-word ("CO"). The availability strip wraps into three lines.
+- `.travel-map-frame` tooltip uses Tailwind `bg-gray-900 border-yellow-600` — yellow in Oxblood/HC themes (plan 09 fixes that; listed here for completeness).
+
+## Done when
+
+- [ ] `__tests__/contrast.test.ts` passes: every listed pair ≥ 4.5.
+- [ ] Focus is visible on every focusable element in all three themes (tab through the page).
+- [ ] `L E T ' S T A L K` is `aria-hidden`.
+- [ ] With "reduce motion" on (DevTools → Rendering), nothing pulses or lifts and section navigation jumps instantly.
+- [ ] Mobile nav shows a fade at the trailing edge and an arrow while more links are off-screen.
+- [ ] Existing e2e (no horizontal overflow at 320–1440) still passes.
+
+## Files
+
+- `app/globals.css` — tokens, focus, motion, nav
+- `components/landing/Contact.tsx` — `aria-hidden` on the tracked line
+- `components/landing/Nav.tsx` — `aria-label`, skip link target
+- `components/landing/BrutalistLanding.tsx` — skip link, reduced-motion scroll
+- `__tests__/contrast.test.ts`, `lib/format/contrast.ts` — new
+
+## Steps
+
+### 1. Tokens (`app/globals.css`, in `.brutalist-root`)
+
+```css
+ --bone-500: #948872; /* new: 5.83:1 on --bg */
+ --fg-low: var(--bone-500); /* was --bone-600 (4.17:1) */
+
+ --accent: var(--blood-500);
+ --accent-text: var(--blood-400); /* new: accent usable for small text. 4.98:1 */
+ --fg-inverse: var(--oxblood-900);
+ --btn-fg: var(--ink-000); /* new: text on an accent-filled surface */
+```
+
+Gold variant:
+
+```css
+.brutalist-root[data-accent="gold"] {
+ --accent: var(--gold-500);
+ --accent-text: var(--gold-500); /* 9.18:1 — fine as text */
+ --accent-hover: var(--gold-400);
+ --accent-press: var(--gold-700);
+ --btn-fg: var(--oxblood-900); /* 9.18:1 instead of white's 2.21:1 */
+}
+```
+
+High contrast:
+
+```css
+.brutalist-root[data-contrast="high"] {
+ /* existing lines … */
+ --accent-text: var(--ink-000);
+ --btn-fg: var(--ink-999);
+}
+```
+
+Keep `--bone-600` defined (the OG template and nothing else references it now) — or delete it if `grep -n "bone-600" app/globals.css` shows only the definition.
+
+### 2. Use `--accent-text` wherever the accent is text at ≤ 16px
+
+Search-and-replace `color: var(--accent)` → `color: var(--accent-text)` on exactly these selectors: `.nav-link:hover`, `.nav-link.is-active`, `.contrast-toggle:hover`, `.avail-cta`, `.binary-rule.is-accent`, `.hero-title-alt` (display size — optional, but consistent), `.hero-kicker-alt`, `.hl-num`, `.work-impact`, `.work-points li::before`, `.work-card:hover .work-name`, `.work-card.is-linked:focus-visible .work-name`, `.work-card.is-linked:hover .work-link`, `.writing-all:hover`, `.contact-tracked`, `.contact-email:hover`, `.contact-socials a:hover`, `.footer-socials a:hover`, `.travel-map-toggle:hover`, `.travel-map-toggle.is-active`, `.resume-sent`, `.btn-text:hover`. Leave `background: var(--accent)`, `border-color: var(--accent)` and `.continent-fill` alone — fills keep the saturated hue.
+
+Buttons and the brand mark:
+
+```css
+.brand-mark { background: var(--accent); color: var(--btn-fg); }
+.btn-primary { background: var(--accent); color: var(--btn-fg); }
+.brutalist-root[data-accent="gold"] .btn-primary:hover { background: var(--gold-400); } /* keep */
+```
+
+Delete the `[data-contrast="high"] .btn-primary { color: #000 }` override — `--btn-fg` handles it now.
+
+### 3. Focus that survives High Contrast
+
+Add once, near the top of the `.brutalist-root` styles:
+
+```css
+.brutalist-root :focus-visible { outline: var(--bw-2) solid var(--accent); outline-offset: 3px; }
+.brutalist-root[data-contrast="high"] :focus-visible { outline: 3px solid #fff; outline-offset: 3px; box-shadow: 0 0 0 6px #000; }
+.field input:focus-visible, .field textarea:focus-visible { outline-offset: 6px; border-bottom-width: 2px; }
+```
+
+Remove `outline: none;` from `.field input, .field textarea`. Remove the per-component `:focus-visible` rules that only restate this (`.work-card.is-linked:focus-visible` may stay — it also sets `border-color`).
+
+### 4. Motion and semantics
+
+```css
+@media (prefers-reduced-motion: reduce) {
+ .brutalist-root *, .brutalist-root *::before, .brutalist-root *::after {
+ animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important;
+ }
+ .work-card:hover { transform: none; }
+ .btn-primary:hover { transform: none; }
+}
+```
+
+`BrutalistLanding.tsx` → `scrollToSection`:
+
+```ts
+const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+document.getElementById(id)?.scrollIntoView({ behavior: reduced ? 'auto' : 'smooth', block: 'start' })
+```
+
+`Contact.tsx`: `L E T ' S …
`.
+
+`Nav.tsx`: ``. Add a skip link as the first child of `.page` in `BrutalistLanding.tsx`:
+
+```tsx
+Skip to projects
+```
+
+```css
+.skip-link { position: absolute; left: -9999px; top: 8px; z-index: 60; padding: 8px 12px; background: var(--accent); color: var(--btn-fg); font-family: var(--font-mono); font-size: 12px; letter-spacing: .12em; text-transform: uppercase; }
+.skip-link:focus { left: var(--gutter); }
+```
+
+Give `main` an `id="main"` if you'd rather target that; `#projects` is where visitors want to be.
+
+### 5. Mobile nav affordance and availability strip (in the `@media (max-width: 900px)` block)
+
+```css
+ .nav-links {
+ /* existing overflow rules … */
+ -webkit-mask-image: linear-gradient(to right, #000 82%, transparent);
+ mask-image: linear-gradient(to right, #000 82%, transparent);
+ padding-right: 32px;
+ }
+ .nav-links.is-scrolled-end { -webkit-mask-image: none; mask-image: none; }
+ .site-nav { position: relative; }
+ .site-nav::after { content: "→"; position: absolute; right: 0; bottom: 10px; font-family: var(--font-mono); font-size: 12px; color: var(--accent-text); pointer-events: none; }
+ .site-nav.is-scrolled-end::after { display: none; }
+ .availability { gap: 10px; }
+ .availability > span:nth-child(2) { display: none; } /* drop "New York metropolitan area." on phones; keep it in the Now strip (plan 05) */
+```
+
+In `Nav.tsx`, add a scroll listener on `.nav-links` that toggles `is-scrolled-end` on the nav when `scrollLeft + clientWidth >= scrollWidth - 2`. Five lines in a `useEffect` with a ref; make `Nav` a client component (it already receives handlers from one).
+
+### 6. Test the tokens
+
+`lib/format/contrast.ts`:
+
+```ts
+const lin = (c: number) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }
+const lum = (hex: string) => { const h = hex.replace('#', ''); const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) }
+export const contrastRatio = (a: string, b: string): number => { const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05) }
+```
+
+`__tests__/contrast.test.ts` reads `app/globals.css`, pulls hex values with a regex per token name, and asserts:
+
+```ts
+import { readFileSync } from 'node:fs'
+import { contrastRatio } from '../lib/format/contrast'
+
+const css = readFileSync('app/globals.css', 'utf8')
+const token = (name: string) => css.match(new RegExp(`--${name}:\\s*(#[0-9a-f]{6})`, 'i'))![1]
+
+const BG = token('oxblood-900')
+const pairs: Array<[string, string, string]> = [
+ ['fg-low on bg', token('bone-500'), BG],
+ ['fg-muted on bg', token('bone-400'), BG],
+ ['gold accent text on bg', token('gold-500'), BG],
+ ['oxblood accent text on bg', token('blood-400'), BG],
+ ['button text on gold', token('oxblood-900'), token('gold-500')],
+ ['button text on blood', '#ffffff', token('blood-500')],
+]
+
+describe('brand token contrast (WCAG AA, 4.5:1)', () => {
+ it.each(pairs)('%s', (_, fg, bg) => { expect(contrastRatio(fg, bg)).toBeGreaterThanOrEqual(4.5) })
+})
+```
+
+This is the guard that stops a future "let's make the meta text a bit dimmer" from quietly failing AA.
+
+## Verify
+
+```bash
+npm test -- --run && npx tsc --noEmit && npm run lint && npm run build && npx playwright test
+```
+
+Manual: keyboard-tab the whole page in each theme; enable reduced motion in DevTools; open at 390px and scroll the nav.
+
+## Commit
+
+`fix: AA contrast tokens, visible focus in high contrast, reduced motion, mobile nav affordance`
diff --git a/docs/plans/09-travel-scrubber-and-touch.md b/docs/plans/09-travel-scrubber-and-touch.md
new file mode 100644
index 0000000..93f994a
--- /dev/null
+++ b/docs/plans/09-travel-scrubber-and-touch.md
@@ -0,0 +1,214 @@
+# Plan 09 — Travel: year scrubber, touch + keyboard on the globe, theme-aware map
+
+**Priority:** P2 (delight) · **Effort:** ~half a day · **Value:** medium; the one section friends and family come for
+**Depends on:** nothing · **Mock-up:** section 09 of the mock-ups board
+
+## Why
+
+- `TravelMap` already accepts `selectedYear` and filters `displayedCountries` by it — nothing passes it. A scrubber turns 52 static countries into a story (first trip 1986, the BNP-era Europe run, the 2023–24 burst).
+- The globe rotates with `onMouseDown/Move/Up` only. On phones you can't rotate it at all; keyboard users can't either. The hint says "drag to rotate" regardless.
+- Colors are hard-coded (`#b8922f`, `#2a2a2a`, sphere `#0a0f1a`) and the tooltip uses Tailwind classes (`bg-gray-900 border-yellow-600`). Oxblood and High Contrast themes show a gold globe with a yellow tooltip.
+- The world atlas TopoJSON loads at runtime from `cdn.jsdelivr.net`. If that CDN hiccups the globe is blank; the site otherwise has no runtime third-party dependency.
+
+## Done when
+
+- [ ] A range input (1986–2024) under the continent bars; bars, counts, the "N countries. M continents." heading and the globe all follow it. A Play button animates through the years; Stop halts it.
+- [ ] Touch drag rotates the globe (`pointer` events + `touch-action: none`); arrow keys rotate it when focused; the hint reads "drag or use arrow keys".
+- [ ] Map colors come from CSS tokens; tooltip uses site classes. All three themes look right.
+- [ ] `countries-110m.json` is served from `public/data/world-110m.json` (no CDN).
+- [ ] `buildContinentBars` lives in `lib/content/travel.ts` and has unit tests.
+- [ ] Existing travel e2e updated and passing.
+
+## Files
+
+- `lib/content/travel.ts` — move `buildContinentBars` here; add `countriesUpTo(year)`
+- `app/page.tsx` — stop precomputing bars; pass raw countries
+- `components/landing/Travel.tsx` — year state, scrubber, play, recompute bars
+- `components/travel/TravelMap.tsx` — pointer events, keyboard, tokens, local atlas
+- `app/globals.css` — `.scrub*`, tooltip, map tokens
+- `public/data/world-110m.json` — new (copied from the CDN once)
+- `__tests__/travel-transforms.test.ts` — new
+- `e2e/travel.spec.ts` — update color assertions
+
+## Steps
+
+### 1. Transforms + tests (`lib/content/travel.ts`)
+
+```ts
+export interface ContinentBar { name: string; count: number; pct: number }
+
+/** Continent bars for the countries first visited on or before `year`. */
+export function buildContinentBars(countries: Country[], year?: number): ContinentBar[] {
+ const visible = year ? countries.filter((c) => c.firstVisited <= year) : countries
+ const counts = visible.reduce>((acc, c) => ({ ...acc, [c.continent]: (acc[c.continent] ?? 0) + 1 }), {})
+ const entries = Object.entries(counts).sort(([, a], [, b]) => b - a)
+ const max = entries.length > 0 ? entries[0][1] : 1
+ return entries.map(([name, count]) => ({ name, count, pct: Math.round((count / max) * 100) }))
+}
+
+export const yearBounds = (countries: Country[]): [number, number] => {
+ const years = countries.map((c) => c.firstVisited)
+ return [Math.min(...years), Math.max(...years)]
+}
+```
+
+Delete `buildContinentBars` from `app/page.tsx`; pass `countries` only (the `Travel` component computes bars). Remove the `ContinentBar` import there.
+
+Tests (`__tests__/travel-transforms.test.ts`) with a 5-country fixture: bars sorted by count desc; `pct` is relative to the largest continent; `year` filter excludes later countries; a year before the first trip returns `[]`; `yearBounds` returns min/max.
+
+### 2. Scrubber in `Travel.tsx`
+
+```tsx
+const [min, max] = yearBounds(countries)
+const [year, setYear] = useState(max)
+const [playing, setPlaying] = useState(false)
+const bars = buildContinentBars(countries, year)
+const visible = countries.filter((c) => c.firstVisited <= year)
+const continentsVisible = new Set(visible.map((c) => c.continent)).size
+const newThisYear = countries.filter((c) => c.firstVisited === year)
+
+useEffect(() => {
+ if (!playing) return
+ if (year >= max) { setPlaying(false); return }
+ const id = setTimeout(() => setYear((y) => y + 1), 260)
+ return () => clearTimeout(id)
+}, [playing, year, max])
+```
+
+Heading becomes `{visible.length}{year === max ? '+' : ''} countries. / {continentsVisible} continents.` Render the scrubber panel in place of (or below) the current `travel-highlights` aside:
+
+```tsx
+
+
Scrub the years
+
{year}
+
{ setPlaying(false); setYear(Number(e.target.value)) }}
+ aria-label="Show countries first visited up to this year" />
+
+ { if (year >= max) setYear(min - 1); setPlaying((p) => !p) }}>
+ {playing ? '■ Stop' : '▶ Play'}
+
+ {visible.length} countries · {continentsVisible} continents · by {year}
+
+
+ {newThisYear.length > 0
+ ? newThisYear.map((c) => {c.name} )
+ : none that year }
+
+
+```
+
+Pass `selectedYear={year}` to ` `. Keep `HIGHLIGHTS` as a small list under the scrubber or drop it — Dom's call; the mock-up drops it.
+
+CSS:
+
+```css
+.scrub { border: 1px solid var(--rule); padding: var(--s-5); display: flex; flex-direction: column; gap: var(--s-4); }
+.scrub-year { font-family: var(--font-display); font-size: 64px; line-height: .9; letter-spacing: -.04em; color: var(--accent); }
+.scrub input[type=range] { width: 100%; accent-color: var(--accent); }
+.scrub-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
+.scrub-stat { font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--fg-muted); }
+.scrub-stat b { color: var(--fg); font-weight: 500; }
+.scrub-new { display: flex; flex-wrap: wrap; gap: 6px; }
+.scrub-chip { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em; padding: 4px 8px; border: 1px solid var(--accent); color: var(--accent); }
+.scrub-chip.is-muted { border-color: var(--rule); color: var(--fg-low); }
+```
+
+### 3. Pointer + keyboard on the globe (`TravelMap.tsx`)
+
+Replace the three mouse handlers with pointer handlers (they cover mouse, touch, pen):
+
+```tsx
+onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); handleMouseDown(e) }}
+onPointerMove={handleMouseMove}
+onPointerUp={handleMouseUp}
+onPointerCancel={handleMouseUp}
+onPointerLeave={() => { hideTooltip(); handleMouseUp() }}
+style={{ cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none' }}
+tabIndex={0}
+role="img"
+aria-label={`Globe showing ${displayedCountries.length} visited countries. Drag or use the arrow keys to rotate.`}
+onKeyDown={(e) => {
+ const step = 10
+ const [lon, lat] = rotation
+ if (e.key === 'ArrowLeft') setRotation([lon - step, lat, 0])
+ else if (e.key === 'ArrowRight') setRotation([lon + step, lat, 0])
+ else if (e.key === 'ArrowUp') setRotation([lon, Math.max(-90, lat - step), 0])
+ else if (e.key === 'ArrowDown') setRotation([lon, Math.min(90, lat + step), 0])
+ else return
+ e.preventDefault()
+}}
+```
+
+The handler signatures change from `React.MouseEvent` to `React.PointerEvent` — `clientX/Y` are the same. While you're here, fix the two lint errors in this file: drop `useCallback` from `showCountryTooltip` (its dependency is recomputed every render anyway) or include `isoToCountryMap` in its deps. Change the hint in `Travel.tsx` to "The map · drag or use arrow keys".
+
+### 4. Theme-aware colors and tooltip
+
+Add tokens in `.brutalist-root`:
+
+```css
+ --map-sphere: var(--oxblood-1000);
+ --map-land: #2a2a2a;
+ --map-land-hover: #3a3a3a;
+ --map-visited: var(--accent-press);
+ --map-visited-hover: var(--accent);
+ --map-stroke: #1a1a1a;
+```
+
+and in `[data-contrast="high"]`: `--map-land: #333; --map-visited: #fff; --map-visited-hover: #ddd; --map-stroke: #000;`.
+
+In `TravelMap.tsx` use `style={{ fill: 'var(--map-visited)' }}` etc. instead of `fill="#b8922f"` (react-simple-maps passes `style` through to the ``; the `default/hover/pressed` style objects accept CSS vars). Sphere: `style={{ fill: 'var(--map-sphere)' }}`. Flight lines: `stroke="var(--accent)"` with `strokeOpacity={0.6}`. Airport markers: `fill: 'var(--accent)'`.
+
+Add `data-visited="true"` / `"false"` to each `` so tests don't depend on colors.
+
+Tooltip: replace the Tailwind classes with `className="map-tip"`:
+
+```css
+.map-tip { position: fixed; z-index: 60; background: var(--bg-elevated); border: 1px solid var(--accent); padding: 6px 10px; pointer-events: none; font-family: var(--font-mono); font-size: 12px; color: var(--fg); }
+.map-tip small { display: block; color: var(--fg-muted); font-size: 11px; }
+```
+
+Also drop `className="relative w-full bg-surface-1 rounded-xl border border-border overflow-hidden"` on the container in favour of `className="map-frame"` with `position: relative; width: 100%; overflow: hidden;` — the `.travel-map-frame :where(.relative)` override in globals.css then becomes dead and can go.
+
+### 5. Local atlas
+
+```bash
+curl -sL https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json -o public/data/world-110m.json
+```
+
+`const geoUrl = '/data/world-110m.json'`. ~110 KB, cached by GitHub Pages like everything else.
+
+### 6. e2e updates (`e2e/travel.spec.ts`)
+
+Replace the `fill === "#b8922f"` / `"#2a2a2a"` checks with `[data-visited="true"]` / `[data-visited="false"]` counts. Add:
+
+```ts
+ test("year scrubber filters the map and the bars", async ({ page }) => {
+ const slider = page.getByRole("slider", { name: /countries first visited/i });
+ await slider.fill("2000");
+ await expect(page.locator("#travel .scrub-year")).toHaveText("2000");
+ const visited = await page.locator('path[data-visited="true"]').count();
+ expect(visited).toBeGreaterThan(0);
+ expect(visited).toBeLessThan(20);
+ });
+
+ test("globe rotates with the keyboard", async ({ page }) => {
+ const globe = page.getByRole("img", { name: /globe showing/i });
+ await globe.focus();
+ const before = await page.locator("#travel svg path").first().getAttribute("d");
+ await page.keyboard.press("ArrowRight");
+ await expect.poll(() => page.locator("#travel svg path").first().getAttribute("d")).not.toBe(before);
+ });
+```
+
+## Verify
+
+```bash
+npm test -- --run && npx tsc --noEmit && npm run lint && npm run build && npx playwright test e2e/travel.spec.ts
+```
+
+Manual on a phone (or DevTools touch emulation): drag the globe; press Play; cycle themes.
+
+## Commit
+
+`feat: travel year scrubber, touch and keyboard rotation, theme-aware globe`
diff --git a/docs/plans/10-repo-hygiene.md b/docs/plans/10-repo-hygiene.md
new file mode 100644
index 0000000..3218d88
--- /dev/null
+++ b/docs/plans/10-repo-hygiene.md
@@ -0,0 +1,158 @@
+# Plan 10 — Repo hygiene: commit noise, dead code, lint in CI, small perf
+
+**Priority:** P3 · **Effort:** ~2 hours · **Value:** maintainability; makes every other plan cheaper to review
+**Depends on:** nothing (do it after 01 so the analytics change lands in one go)
+
+## Why — what's there today
+
+| Finding | Evidence |
+|---|---|
+| **670 of 713 commits are `chore: update dashboard data`** (94%) | `git log --oneline \| grep -c 'update dashboard data'`. The 6-hourly cron writes placeholder JSON with a fresh `lastUpdated` every run, so there is always a diff and always a commit — of zeros, because GoatCounter isn't wired into the build (plan 01). |
+| Lint has 2 errors and CI never runs lint | `npm run lint` → `TravelMap.tsx:199` React-Compiler memoization error; `vitest.setup.ts:34` `no-explicit-any`. `ci.yml` runs typecheck, tests, build, e2e — not lint. |
+| Stale docs describing files that don't exist | `IMPROVEMENTS.md` lists `components/ErrorBoundary.tsx`, `hooks/*`, `app/api/contact/route.ts`, `lib/constants.ts`, `lib/utils.ts` — none exist. `ACCESSIBILITY.md` is a generic WCAG primer with examples from a multi-page site. |
+| Dead assets | `public/logos/*` (8 files, unreferenced since the timeline lost logos), `public/{file,globe,next,vercel,window}.svg` (create-next-app leftovers). |
+| Dead CSS | `.resume-download`, `.resume-form`, `.resume-sent`, `.btn-text`, `.resume-meta*`, `.animate-fade-in`, `.animation-delay-*` — no component references them. |
+| Unused dependency | `@tailwindcss/typography` — no `prose` class anywhere; `@types/react-google-recaptcha` sits in `dependencies` instead of `devDependencies`. |
+| `scripts/fetch-performance.js` tests `/career` | 404 since the redesign. |
+| Theme choice isn't remembered | Reload → back to Gold. |
+| Scroll-spy sets state on every scroll event | `BrutalistLanding.tsx` `onScroll` calls `setSection` unconditionally; React bails out on equal values but the loop still runs `getElementById` ×6 per frame. |
+| Five font families load on the landing | Geist + Geist Mono are only used by the dashboard and the old chat widget; the landing preloads them anyway (5 ` ` woff2). |
+| `.env.example` is gitignored | `.gitignore` has `.env*`; README tells contributors to copy a file that isn't in the repo. |
+
+## Done when
+
+- [ ] `update-dashboard-data.yml` produces **no commit** when the fetched data is unchanged apart from `lastUpdated`, and runs daily, not 6-hourly.
+- [ ] `npm run lint` is clean and runs in `ci.yml`.
+- [ ] Stale docs, dead assets, dead CSS, unused dependency removed; `npm run build` output shrinks accordingly.
+- [ ] Theme persists across reloads without a hydration warning.
+- [ ] Landing preloads only Archivo Black, Space Grotesk and JetBrains Mono.
+- [ ] `.env.example` is tracked.
+
+## Steps
+
+### 1. Stop the placeholder commits
+
+In each of `scripts/fetch-analytics.js`, `fetch-performance.js`, `fetch-uptime.js`: when the required env is missing, **do not write a placeholder** if the output file already exists — log and `return`. (First run on a fresh clone still writes one so the dashboard has a file to read.)
+
+```js
+if (!API_KEY || !SITE) {
+ if (existsSync(OUTPUT_PATH)) { console.log('no credentials; leaving existing analytics.json alone'); return }
+ // …existing placeholder write…
+}
+```
+
+Then make the commit step diff-aware in `.github/workflows/update-dashboard-data.yml`:
+
+```yaml
+ - name: Commit updated data
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add public/data/
+ # Ignore diffs that only touch lastUpdated — those are not data changes.
+ if git diff --staged -U0 | grep '^[+-]' | grep -v '^[+-][+-]' | grep -qv '"lastUpdated"'; then
+ git commit -m "chore: update dashboard data"
+ git push
+ else
+ git reset -q
+ echo "No dashboard data changes."
+ fi
+```
+
+And `cron: '0 6 * * *'` (daily). With plan 01 wiring GoatCounter, real numbers will start changing daily — those commits are legitimate.
+
+In `scripts/fetch-performance.js` change `PAGES_TO_TEST` to `['/']`.
+
+### 2. Lint clean, lint in CI
+
+- `components/travel/TravelMap.tsx` line ~199: remove `useCallback` around `showCountryTooltip` (a plain function is fine; the React Compiler handles memoization) **or** add `[isoToCountryMap]` to its deps. Plan 09 touches this file too — coordinate.
+- `vitest.setup.ts`: replace `as any` with `as unknown as typeof IntersectionObserver`; delete the unused `expect` import.
+- `app/dashboard-m7x9k2/components/AnalyticsPanel.tsx`: drop the unused `clsx` import.
+- `ci.yml`: add after Typecheck:
+
+```yaml
+ - name: Lint
+ run: npm run lint
+```
+
+### 3. Delete what's dead
+
+```bash
+git rm IMPROVEMENTS.md ACCESSIBILITY.md
+git rm -r public/logos public/file.svg public/globe.svg public/next.svg public/vercel.svg public/window.svg
+npm uninstall @tailwindcss/typography
+npm uninstall @types/react-google-recaptcha && npm i -D @types/react-google-recaptcha --legacy-peer-deps
+```
+
+`scripts/process-logos.js` only exists to produce `public/logos` — delete it too.
+
+In `app/globals.css` delete: the `.animate-fade-in` keyframes block and `.animation-delay-*` (lines ~100–121), and the Resume leftovers `.resume-download`, `.resume-form`, `.resume-sent`, `.btn-text`, `.btn-text:hover`, `.resume-meta`, `.resume-meta li`, `.resume-meta li :last-child`. Confirm with `grep -rn "" components app` before each deletion.
+
+Update `CLAUDE.md`'s "All styles live in app/globals.css (~1130 lines)" count after the edit — it's the one doc that must stay accurate.
+
+### 4. Remember the theme
+
+`BrutalistLanding.tsx`:
+
+```ts
+const STORAGE_KEY = 'theme-mode'
+const [mode, setMode] = useState('gold')
+
+// Read after mount so server and first client render agree (avoids a hydration mismatch).
+useEffect(() => {
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY)
+ if (saved === 'gold' || saved === 'oxblood' || saved === 'contrast') setMode(saved)
+ } catch { /* storage unavailable — keep default */ }
+}, [])
+
+const cycleMode = () =>
+ setMode((m) => {
+ const next: ThemeMode = m === 'gold' ? 'oxblood' : m === 'oxblood' ? 'contrast' : 'gold'
+ try { localStorage.setItem(STORAGE_KEY, next) } catch { /* ignore */ }
+ return next
+ })
+```
+
+There will be a one-frame flash from Gold to the saved theme on load; acceptable for a cycler that's a novelty. (A no-flash version needs an inline script in `layout.tsx` setting a `data-` attribute before hydration; not worth it here.)
+
+### 5. Cheaper scroll-spy
+
+```ts
+useEffect(() => {
+ let raf = 0
+ const compute = () => {
+ raf = 0
+ const y = window.scrollY + 140
+ let current: SectionId = 'hero'
+ for (const id of SECTION_IDS) {
+ const el = document.getElementById(id)
+ if (el && el.offsetTop <= y) current = id
+ }
+ setSection((prev) => (prev === current ? prev : current))
+ }
+ const onScroll = () => { if (!raf) raf = requestAnimationFrame(compute) }
+ window.addEventListener('scroll', onScroll, { passive: true })
+ compute()
+ return () => { window.removeEventListener('scroll', onScroll); if (raf) cancelAnimationFrame(raf) }
+}, [])
+```
+
+### 6. Fonts only where used
+
+Move the `Geist` and `Geist_Mono` loaders from `app/layout.tsx` into `app/dashboard-m7x9k2/layout.tsx` and apply their `.variable` classes on that layout's wrapper `div`. Remove them from the root `` className. The `:root` `--font-sans: var(--font-geist-sans)` bridge in `globals.css` now only resolves inside the dashboard, which is the only place Tailwind `font-sans` is used (after plan 06 the chat widget uses brand fonts). Verify: `npm run build && grep -c 'rel="preload"' out/index.html` drops from 5 font preloads to 3 (+ the portrait + the JS chunk).
+
+### 7. Track `.env.example`
+
+`.gitignore`: add `!.env.example` after `.env*`. Trim the example to the keys that exist today (`NEXT_PUBLIC_EMAILJS_*`, `NEXT_PUBLIC_RECAPTCHA_SITE_KEY`, `NEXT_PUBLIC_GOATCOUNTER_SITE`, `NEXT_PUBLIC_CHAT_API_URL`) — drop `NOTION_API_KEY` and `RESUME_SOURCE`, which nothing reads any more. `git add .env.example`.
+
+## Verify
+
+```bash
+npm run lint && npx tsc --noEmit && npm test -- --run && npm run build && npx playwright test
+git log --oneline -5 # after the next cron: no new "update dashboard data" commit unless numbers changed
+```
+
+## Commit
+
+Split into small commits: `chore: stop placeholder dashboard commits`, `chore: lint clean and lint in CI`, `chore: remove dead assets, docs and CSS`, `feat: remember theme choice`, `perf: rAF-throttled scroll spy, dashboard-only Geist fonts`.
diff --git a/docs/plans/README.md b/docs/plans/README.md
new file mode 100644
index 0000000..b1fb344
--- /dev/null
+++ b/docs/plans/README.md
@@ -0,0 +1,87 @@
+# Site audit — 2026-08-31 — and the plans that come out of it
+
+Audit of https://dommango.github.io (this repo at `cc965be`), done by reading every tracked file, the deployed HTML and JS, the GitHub Actions logs, and the live Substack feed. Two companion artifacts:
+
+- **Audit report (readable version of this page):** https://claude.ai/code/artifact/3e90ee8d-9695-4563-9ea4-894b7fb3fda3
+- **Working mock-ups of the top changes, in brand tokens, with the theme cycler:** https://claude.ai/code/artifact/64124f03-8fc4-4cb6-93e7-e2fdf2411a7f
+
+Each numbered plan in this folder is written so a smaller model can execute it without this audit in context: goal, evidence, exact files, code, tests, verification commands, commit message.
+
+## Who the site is for
+
+"Members" of a personal site are its visitors. Four kinds show up here, and the ranking below is by value to them:
+
+| Visitor | Arrives from | Wants |
+|---|---|---|
+| Hiring manager / senior leader | LinkedIn | Who is this, what has he actually shipped, how do I reach him |
+| Fellow builder | X, the Placemat, a Substack post | The how — stack, what broke, code, more writing |
+| Reader | a Substack post | More posts, who writes this, a way to subscribe |
+| Friend / pool member | Bracketeer, word of mouth | The human side: travel, the fun projects |
+
+## What is broken today (P0)
+
+All four verified on the live site on 2026-08-31, not inferred from code:
+
+| # | Finding | Evidence | Plan |
+|---|---|---|---|
+| 1 | **Contact form cannot send.** The deploy build receives no `NEXT_PUBLIC_EMAILJS_*` env, so every submission returns "Email service not configured". | Live JS chunk contains that string and no EmailJS service id; `deploy.yml` passes no env to `npm run build`. | 01 |
+| 2 | **Writing section never appears.** Substack answers GitHub Actions with 403, the committed `POSTS` is `[]`, so the post published Aug 4 is invisible. | Deploy job log: `[substack] feed returned 403; keeping committed posts`. Live HTML has no `id="writing"`. Feed has 1 real post. | 01 |
+| 3 | **Assistant misinforms.** No chat API is configured, so the widget answers with a keyword matcher that points at a "Skills page", an "Education page" and "request Dom's resume through the Contact page". | `lib/services/chat.ts` `getOfflineResponse`; strings present in the live bundle. | 01 (hide), 06 (fix) |
+| 4 | **Old URLs dead-end.** `/career`, `/travel`, `/contact`, `/skills`, `/education`, `/blog` render Next's default white 404 with the gold chat bubble on it. | `curl -o /dev/null -w '%{http_code}' https://dommango.github.io/travel` → 404; screenshot in the report. | 07 |
+
+Also P0-adjacent: analytics have never been collected (`NEXT_PUBLIC_GOATCOUNTER_SITE` isn't in the build), so the private dashboard shows zeros and the 6-hourly cron has committed **670 placeholder commits (94% of history)**. Plans 01 and 10.
+
+## Ranked changes
+
+| # | Change | Who it helps | Effort | Value | Plan | Mock-up |
+|---|---|---|---|---|---|---|
+| 00 | Reconnect the plumbing (secrets → build, commit posts, hide fake chat) | everyone | ½ day | restores conversion | [01](01-reconnect-live-plumbing.md) | — |
+| 02 | Project thumbnails + in-page case studies | hiring managers, builders | 1 day | very high | [02](02-project-case-studies.md) | §02 |
+| 03 | Writing: featured post, reading time, subscribe form | readers | ½ day | high | [03](03-writing-featured-and-subscribe.md) | §03 |
+| 04 | Open Graph card + touch icon + theme color | anyone sharing a link | 1–2 h | high | [04](04-social-preview-card.md) | §04 |
+| 05 | "Now" strip: latest post, last shipped, building — dated at build time | anyone deciding to reach out | ½ day | high | [05](05-now-strip.md) | §05 |
+| 06 | Honest assistant: rate-limited Worker + Claude, restyled — or remove it | curious visitors, credibility | 1 day / 20 min | high (risk removal) | [06](06-honest-assistant.md) | §06 |
+| 07 | Branded 404 that redirects old URLs | anyone on an old link | 1 h | medium–high | [07](07-not-found-rescue.md) | §07 |
+| 08 | Contrast tokens, visible focus in High Contrast, reduced motion, mobile nav | low-vision, keyboard, phone users | 2–3 h | medium–high | [08](08-contrast-and-a11y-pass.md) | §08 |
+| 09 | Travel year scrubber, touch + keyboard globe, theme-aware map | friends, phone users | ½ day | medium (delight) | [09](09-travel-scrubber-and-touch.md) | §09 |
+| 10 | Repo hygiene: commit noise, dead code, lint in CI, theme memory, fonts | maintainers | 2 h | maintainability | [10](10-repo-hygiene.md) | — |
+
+Suggested order: **01 → 07 → 04 → 08 → 02 → 03 → 05 → 06 → 09 → 10.** 01 and 07 fix what's broken; 04 and 08 are cheap and touch tokens the later plans reuse; 02/03/05 are the visible value; 06 needs a decision from Dom (Worker or delete); 09 and 10 whenever.
+
+## Quality findings (P2) — detail in plans 08 and 09
+
+Contrast measured from `app/globals.css` token values:
+
+| Pair | Used for | Ratio | AA (4.5) |
+|---|---|---|---|
+| white on `--gold-500` | primary button text, "DM" brand mark | 2.21 | fail |
+| `--fg-low #7a7060` on `#160000` | 10–11px meta text everywhere | 4.17 | fail |
+| `--blood-500` on `#160000` (Oxblood theme) | accent used as 11–14px text | 3.45 | fail |
+| `--fg-muted`, gold-on-oxblood, white-on-blood | body, accent text, Oxblood button | 5.9–9.2 | pass |
+
+Plus: High Contrast theme makes focus rings invisible (accent = border = bg = white with `outline: none` on inputs); `L E T ' S T A L K` is read letter-by-letter; no `prefers-reduced-motion`; nav has no accessible name; mobile nav clips the last link with no scroll affordance; globe is mouse-only (no touch, no keyboard) and hard-codes gold regardless of theme; world atlas loads from a CDN at runtime.
+
+## Hygiene findings (P3) — detail in plan 10
+
+- 2 lint errors (`TravelMap.tsx` React-Compiler memoization; `vitest.setup.ts` `any`) and CI doesn't run lint.
+- `IMPROVEMENTS.md` and `ACCESSIBILITY.md` describe files that don't exist (`ErrorBoundary`, `hooks/*`, `app/api/contact`).
+- Unused: `public/logos/*` (36 KB), five create-next-app SVGs, ~60 lines of Resume/animation CSS, `@tailwindcss/typography`.
+- `fetch-performance.js` tests `/career` (404). `.env.example` is gitignored though README points at it.
+- Theme choice isn't persisted; scroll-spy sets state on every scroll event; Geist + Geist Mono are preloaded on the landing but only the dashboard uses them.
+- Bundle: ~180 KB gzipped JS on first load; the globe (44 KB gz) is correctly split. Fine — no action beyond the font trim.
+
+## What's good — keep it
+
+- The brutalist token system is a real design decision, scoped cleanly under `.brutalist-root`; three themes work by swapping tokens. The mock-ups reuse it unchanged.
+- The Substack fetch is defensive in exactly the right ways (null vs `[]`, marker-bounded rewrite, never fails the build) and has 13 real tests.
+- CI runs typecheck, unit, build and Playwright on PRs; e2e already guards the two things that broke before (horizontal overflow, Writing visibility invariant).
+- Career content is kept out of the public repo on purpose. Don't undo that.
+- Copy is specific and human ("Unapologetically AI-pilled", "mostly still running"). The redesign didn't produce template slop — the unslop scanner flags only the old dashboard/chat radii and the Geist import.
+
+## How to run a plan with a smaller model
+
+1. `git checkout -b feat/ origin/main`
+2. Give the model the single plan file and this instruction: *"Execute this plan exactly. Run every command under Verify and paste the output. Stop and report if any step's expectation doesn't match."*
+3. Review the diff against the plan's **Done when** list; run `/code-review`; open the PR.
+
+Plans are independent unless their **Depends on** line says otherwise.
From 0d3dab3af1f91da98a4d9007e7ffcce64c5ad2a5 Mon Sep 17 00:00:00 2001
From: dommango-sys <251805093+dommango@users.noreply.github.com>
Date: Mon, 31 Aug 2026 23:05:20 -0400
Subject: [PATCH 2/2] Revert "fix: restore to_email/to_name as an EmailJS
recipient stopgap"
This reverts commit 31acb29da1bf6becfce9ef274c60c1750150ecd5.
---
lib/services/emailjs.ts | 7 -------
1 file changed, 7 deletions(-)
diff --git a/lib/services/emailjs.ts b/lib/services/emailjs.ts
index 780f74e..cdc048c 100644
--- a/lib/services/emailjs.ts
+++ b/lib/services/emailjs.ts
@@ -49,19 +49,12 @@ export async function sendContactEmail({
}
try {
- // Stopgap: the EmailJS template's "To email" field is still {{to_email}},
- // so omitting these makes every send 422 with "recipients address is
- // empty" (confirmed live 2026-08-31). Routes to the sender, not Dom, until
- // the template is hard-coded to his address — see docs/plans/01-reconnect-live-plumbing.md
- // step 3. Remove to_name/to_email again once that's done.
const response = await emailjs.send(
serviceId,
templateId,
{
from_name: fromName,
from_email: fromEmail,
- to_name: fromName,
- to_email: fromEmail,
reply_to: fromEmail,
message
}