diff --git a/__tests__/not-found-redirects.test.ts b/__tests__/not-found-redirects.test.ts
new file mode 100644
index 0000000..86a05f3
--- /dev/null
+++ b/__tests__/not-found-redirects.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from 'vitest'
+import { REDIRECTS, SECTIONS } from '../components/not-found/NotFoundClient'
+import { hasPosts } from '../lib/content/writing'
+
+// BrutalistLanding only ever renders these ids, and 'writing' only once
+// hasPosts() is true (see SECTION_IDS in components/landing/BrutalistLanding.tsx).
+// A 404 redirect or section link pointing anywhere else is a dead anchor.
+const RENDERED_SECTION_IDS = ['hero', 'projects', 'resume', 'travel', 'contact', ...(hasPosts() ? ['writing'] : [])]
+
+function targetId(to: string): string | null {
+ if (to === '/') return null
+ const match = to.match(/^\/#([a-z]+)$/)
+ return match ? match[1] : null
+}
+
+describe('not-found redirect table', () => {
+ it('every redirect target is "/" or an id that actually renders on the page', () => {
+ for (const { to, label } of REDIRECTS) {
+ if (to === '/') continue
+ const id = targetId(to)
+ expect(id, `redirect "${label}" -> ${to}`).not.toBeNull()
+ expect(RENDERED_SECTION_IDS, `redirect "${label}" -> ${to}`).toContain(id)
+ }
+ })
+
+ it('every section link points at an id that actually renders on the page', () => {
+ for (const [label, href] of SECTIONS) {
+ const id = targetId(href)
+ expect(id, `section "${label}" -> ${href}`).not.toBeNull()
+ expect(RENDERED_SECTION_IDS, `section "${label}" -> ${href}`).toContain(id)
+ }
+ })
+
+ it('does not offer or redirect to Writing when there are no posts to show', () => {
+ if (!hasPosts()) {
+ expect(SECTIONS.some(([label]) => label === 'Writing')).toBe(false)
+ expect(REDIRECTS.find((r) => r.test.test('/blog'))).toBeUndefined()
+ }
+ })
+})
diff --git a/app/globals.css b/app/globals.css
index 4f5f13c..b1a9948 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -792,6 +792,30 @@ input,
.writing-all:hover,
.writing-all:focus-visible { color: var(--accent); border-color: var(--accent); }
+/* ---- 404 --------------------------------------------------- */
+.nf { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: var(--s-6); align-items: start; }
+.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; overflow-wrap: anywhere; }
+.nf-lead code { display: inline-block; max-width: 100%; overflow-wrap: anywhere; color: var(--fg); background: var(--bg-elevated); padding: 2px 4px; }
+.nf-count { font-family: var(--font-mono); font-size: 13px; color: var(--accent); letter-spacing: .06em; margin: var(--s-3) 0 0; min-height: 1.6em; }
+.nf-actions { margin-top: var(--s-5); display: flex; align-items: center; gap: var(--s-4); flex-wrap: wrap; }
+.nf-stay {
+ background: none;
+ border: none;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--fg-muted);
+ text-decoration: underline;
+ cursor: pointer;
+ padding: 0;
+}
+.nf-stay:hover,
+.nf-stay:focus-visible { color: var(--accent); }
+.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); }
+.nf-map .writing-all { margin-top: 0; }
+
/* ---- Contact --------------------------------------------- */
.contact-grid {
display: grid;
@@ -1126,6 +1150,7 @@ input,
.timeline-row { grid-template-columns: 1fr; gap: 6px; }
.continent-row { grid-template-columns: 120px 1fr 36px; }
.hero-portrait { max-width: 100%; justify-self: stretch; }
+ .nf { grid-template-columns: 1fr; }
}
/* reCAPTCHA renders a fixed 304px iframe that Google gives no way to resize,
diff --git a/app/not-found.tsx b/app/not-found.tsx
new file mode 100644
index 0000000..12c70c2
--- /dev/null
+++ b/app/not-found.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+import { NotFoundClient } from '@/components/not-found/NotFoundClient'
+
+// A server component so it can own its own metadata — without this, a
+// 'use client' not-found.tsx silently inherits the homepage's
and
+// canonical URL from app/layout.tsx.
+export const metadata: Metadata = {
+ title: 'Page not found',
+ robots: { index: false, follow: true },
+ alternates: { canonical: null },
+}
+
+export default function NotFound() {
+ return
+}
diff --git a/components/not-found/NotFoundClient.tsx b/components/not-found/NotFoundClient.tsx
new file mode 100644
index 0000000..24e858f
--- /dev/null
+++ b/components/not-found/NotFoundClient.tsx
@@ -0,0 +1,122 @@
+'use client'
+
+// Custom 404 body. Old multi-page URLs redirect to their section on the
+// single page; anything else gets a plain 404. `app/not-found.tsx` (a
+// server component, for metadata) renders this. Exported as out/404.html
+// by `output: 'export'`, which GitHub Pages serves for every unknown path.
+import { useEffect, useState, useSyncExternalStore } from 'react'
+import Link from 'next/link'
+import { BinaryRule } from '@/components/landing/BinaryRule'
+import { hasPosts } from '@/lib/content/writing'
+
+// Writing only renders on the page (and only gets an #writing anchor) once
+// there's a post to show — same predicate BrutalistLanding/Nav use. Until
+// then there's nowhere to rescue a blog URL to, so it isn't a "match" at
+// all: it falls through to the plain, no-guessing 404 below.
+export 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' },
+ ...(hasPosts()
+ ? [{ test: /^\/(blog|writing|posts)(\/.*)?$/i, to: '/#writing', label: 'Writing' }]
+ : []),
+ { test: /^\/projects?(\/.*)?$/i, to: '/#projects', label: 'Projects' },
+]
+
+export const SECTIONS: ReadonlyArray = [
+ ['Projects', '/#projects'],
+ ...(hasPosts() ? ([['Writing', '/#writing']] as const) : []),
+ ['Career', '/#resume'],
+ ['Travel', '/#travel'],
+ ['Contact', '/#contact'],
+]
+
+const MAX_SHOWN_PATH = 64
+
+// Not a real store: nothing here can change the pathname without unmounting
+// this component (a same-page hash link doesn't unmount, but it can't match
+// a REDIRECTS entry either), so subscribe() is a no-op. useSyncExternalStore
+// (rather than useState+useEffect) because it reads the client value during
+// the post-hydration render instead of setting state in an effect, which
+// react-hooks/set-state-in-effect rejects — and it's the documented pattern
+// for reading a browser-only value without a hydration mismatch.
+function subscribe() {
+ return () => {}
+}
+function getPath() {
+ return window.location.pathname
+}
+function getServerPath() {
+ return ''
+}
+
+function displayPath(path: string) {
+ let decoded = path
+ try {
+ decoded = decodeURIComponent(path)
+ } catch {
+ // Malformed percent-encoding — show it raw rather than throwing.
+ }
+ return decoded.length > MAX_SHOWN_PATH ? `${decoded.slice(0, MAX_SHOWN_PATH)}…` : decoded
+}
+
+export function NotFoundClient() {
+ const path = useSyncExternalStore(subscribe, getPath, getServerPath)
+ const [seconds, setSeconds] = useState(3)
+ const [cancelled, setCancelled] = useState(false)
+ const match = REDIRECTS.find((r) => r.test.test(path))
+ const redirecting = Boolean(match) && !cancelled
+
+ useEffect(() => {
+ if (!redirecting) return
+ if (seconds <= 0) { window.location.replace(match!.to); return }
+ const id = setTimeout(() => setSeconds((s) => s - 1), 1000)
+ return () => clearTimeout(id)
+ }, [redirecting, match, seconds])
+
+ return (
+
+
+
+
+
+ {/* A click on any link below means the visitor chose where to go;
+ stop the pending auto-redirect so it can't override that choice. */}
+
setCancelled(true)}>
+
+ 404
+
{match ? <>That page moved.> : <>Nothing here.>}
+
+ {path && <>You asked for {displayPath(path)}. >}
+ {match
+ ? <>It now lives on the front page, under {match.label}.>
+ : <>There’s nothing at that address — no guessing where you meant.>}
+
+ {redirecting && (
+
+ Redirecting to {match!.label}.{' '}
+ Taking you there in {Math.max(seconds, 1)}…
+
+ )}
+
+ {match ? 'Go there now →' : 'Start at the top →'}
+ {redirecting && (
+
+ )}
+
+ )
+}
diff --git a/e2e/not-found.spec.ts b/e2e/not-found.spec.ts
new file mode 100644
index 0000000..43570bd
--- /dev/null
+++ b/e2e/not-found.spec.ts
@@ -0,0 +1,54 @@
+import { test, expect } from "@playwright/test";
+
+// This suite runs against `npm run dev` (see playwright.config.ts), so it
+// exercises app/not-found.tsx directly, not the static `out/404.html` that
+// GitHub Pages actually serves for an unknown path. `npm run build && ls
+// out/404.html` confirms that file exists; it necessarily contains only the
+// pre-hydration "Nothing here" shell (see NotFoundClient's getServerPath),
+// so don't grep it for redirect copy — the behaviour these tests check only
+// exists after client JS runs, same as it would on the live site.
+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(/redirecting to career/i);
+ await page.waitForURL(/\/#resume$/, { timeout: 10000 });
+ await expect(page.locator("#resume")).toBeInViewport();
+ });
+
+ test("old /blog URL gets a plain 404 when there are no posts yet", async ({ page }) => {
+ // There's no Writing section to rescue this to until a post ships
+ // (see lib/content/writing.ts POSTS), so it must not redirect to a
+ // dangling #writing anchor.
+ await page.goto("/blog");
+ await expect(page.getByRole("heading", { name: /nothing here/i })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Writing →" })).toHaveCount(0);
+ await page.waitForTimeout(1500);
+ expect(page.url()).toContain("/blog");
+ });
+
+ 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();
+ // No match means no timer is ever started, so a short wait is enough to
+ // prove nothing fires — it isn't racing a real 3s countdown.
+ await page.waitForTimeout(1500);
+ expect(page.url()).toContain("/definitely-not-a-page");
+ });
+
+ test("clicking a section link cancels the pending auto-redirect", async ({ page }) => {
+ await page.goto("/career");
+ await page.getByRole("link", { name: "Projects →" }).click();
+ await page.waitForURL(/\/#projects$/);
+ // The /career -> /#resume timer would otherwise fire ~3s after load and
+ // override the user's own navigation.
+ await page.waitForTimeout(3500);
+ await expect(page).toHaveURL(/\/#projects$/);
+ });
+
+ 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);
+ });
+});