From d7169e4d335338a73e97656d288df744c867c23e Mon Sep 17 00:00:00 2001 From: dommango-sys <251805093+dommango@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:50:30 -0400 Subject: [PATCH 1/3] feat: branded 404 that redirects pre-redesign URLs to their sections Pre-redesign URLs (/career, /travel, /contact, /blog, /projects) render Next's default 404 today, verified live. This replaces it with a branded page: recognized old paths show a 3-second countdown then redirect to the matching section; unknown paths get a plain no-guessing 404. Diverges from the plan's given code in two places to satisfy this repo's eslint-config-next (React Compiler) rules: reads window.location via useSyncExternalStore instead of useEffect+setState (avoids react-hooks/set-state-in-effect), and uses next/link for the two links that target "/" (avoids @next/next/no-html-link-for-pages). Behavior is unchanged - static out/404.html still SSRs the generic copy first and swaps to the redirect copy on client hydration, confirmed by the e2e suite running real browser JS. --- app/globals.css | 10 ++++++ app/not-found.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++ e2e/not-found.spec.ts | 23 ++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 app/not-found.tsx create mode 100644 e2e/not-found.spec.ts diff --git a/app/globals.css b/app/globals.css index 4f5f13c..81ef9af 100644 --- a/app/globals.css +++ b/app/globals.css @@ -792,6 +792,15 @@ 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; 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); } + /* ---- Contact --------------------------------------------- */ .contact-grid { display: grid; @@ -1126,6 +1135,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..98caaea --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,81 @@ +'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, useSyncExternalStore } from 'react' +import Link from 'next/link' +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 + +// Reads the real pathname only on the client. The server (and the static +// out/404.html shell) always sees '', so the first paint is the generic +// "Nothing here" copy and the client swap-in to redirect copy happens +// without a hydration mismatch. +function subscribe() { + return () => {} +} +function getPath() { + return window.location.pathname +} +function getServerPath() { + return '' +} + +export default function NotFound() { + const path = useSyncExternalStore(subscribe, getPath, getServerPath) + const [seconds, setSeconds] = useState(3) + const match = REDIRECTS.find((r) => r.test.test(path)) + + 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 ( +
+
+ +
+ +
+
+ 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}…

} +
+ {match ? 'Go there now →' : 'Start at the top →'} +
+
+
    + {SECTIONS.map(([label, href]) => ( +
  • {label} →
  • + ))} +
+
+
+
+
+ ) +} diff --git a/e2e/not-found.spec.ts b/e2e/not-found.spec.ts new file mode 100644 index 0000000..a209bc6 --- /dev/null +++ b/e2e/not-found.spec.ts @@ -0,0 +1,23 @@ +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); + }); +}); From c62c5d66a4db79ed82badf4543b347b423d29c23 Mon Sep 17 00:00:00 2001 From: dommango-sys <251805093+dommango@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:05:35 -0400 Subject: [PATCH 2/3] fix: address code review on the branded 404 HIGH: /blog, /writing, /posts redirected to /#writing, which doesn't exist while POSTS is empty (hasPosts() false) - visitors landed at the top of the homepage with a dead #writing anchor. Gate the redirect target and the .nf-map Writing link on hasPosts(), same predicate BrutalistLanding/Nav use. Guarded by a new unit test. HIGH: the reflected pathname had no length cap and .nf-lead had no overflow-wrap, so a long/crafted URL blew the page out to 2x viewport width. Cap displayed path length and add overflow-wrap. MEDIUM: split app/not-found.tsx into a server component (owns metadata - was silently inheriting the homepage's and canonical URL) rendering the interactive part from the new components/not-found/NotFoundClient.tsx. Added a "Stay on this page" control and an onClickCapture guard so the 3s auto-redirect can be cancelled (WCAG 2.2.1) and can't override a navigation the visitor already started by clicking a section link. Split the countdown's aria-live announcement from the visual ticker so screen readers get one announcement, not one per second. Fixed .nf-map's dead gap token (.writing-all's own margin-top was overriding it) and removed CSS that duplicated .section's existing spacing. e2e: added a /blog fallback test, a redirect-cancellation race test, a viewport assertion on the redirect target, and a comment on what this suite does and doesn't prove about the static export. --- __tests__/not-found-redirects.test.ts | 40 ++++++++ app/globals.css | 25 ++++- app/not-found.tsx | 86 ++--------------- components/not-found/NotFoundClient.tsx | 122 ++++++++++++++++++++++++ e2e/not-found.spec.ts | 34 ++++++- 5 files changed, 223 insertions(+), 84 deletions(-) create mode 100644 __tests__/not-found-redirects.test.ts create mode 100644 components/not-found/NotFoundClient.tsx diff --git a/__tests__/not-found-redirects.test.ts b/__tests__/not-found-redirects.test.ts new file mode 100644 index 0000000..6cd1c74 --- /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 a Writing link 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'))?.to).toBe('/') + } + }) +}) diff --git a/app/globals.css b/app/globals.css index 81ef9af..b1a9948 100644 --- a/app/globals.css +++ b/app/globals.css @@ -793,13 +793,28 @@ input, .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; margin-top: var(--s-7); padding-bottom: var(--s-8); } +.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; } -.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-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 { diff --git a/app/not-found.tsx b/app/not-found.tsx index 98caaea..12c70c2 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,81 +1,15 @@ -'use client' +import type { Metadata } from 'next' +import { NotFoundClient } from '@/components/not-found/NotFoundClient' -// 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, useSyncExternalStore } from 'react' -import Link from 'next/link' -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 - -// Reads the real pathname only on the client. The server (and the static -// out/404.html shell) always sees '', so the first paint is the generic -// "Nothing here" copy and the client swap-in to redirect copy happens -// without a hydration mismatch. -function subscribe() { - return () => {} -} -function getPath() { - return window.location.pathname -} -function getServerPath() { - return '' +// A server component so it can own its own metadata — without this, a +// 'use client' not-found.tsx silently inherits the homepage's <title> 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() { - const path = useSyncExternalStore(subscribe, getPath, getServerPath) - const [seconds, setSeconds] = useState(3) - const match = REDIRECTS.find((r) => r.test.test(path)) - - 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 ( - <div className="brutalist-root" data-accent="gold" data-contrast="default"> - <div className="page"> - <nav className="site-nav" aria-label="Main"> - <Link href="/" className="brand" aria-label="Home"><span className="brand-mark">DM</span><span className="brand-word">Dom Mangonon</span></Link> - </nav> - <main className="section"> - <BinaryRule seed={404} accent /> - <div className="nf"> - <div> - <span className="ds-eyebrow">404</span> - <h1 className="nf-title">{match ? <>That page<br />moved.</> : <>Nothing<br />here.</>}</h1> - <p className="nf-lead"> - {path && <>You asked for <code>{path}</code>. </>} - {match - ? <>It now lives on the front page, under <strong>{match.label}</strong>.</> - : <>There’s nothing at that address — no guessing where you meant.</>} - </p> - {match && <p className="nf-count" role="status">Taking you there in {seconds}…</p>} - <div className="nf-actions"> - <Link className="btn-primary" href={match ? match.to : '/'}>{match ? 'Go there now →' : 'Start at the top →'}</Link> - </div> - </div> - <ul className="nf-map" aria-label="Sections"> - {SECTIONS.map(([label, href]) => ( - <li key={href}><a className="writing-all" href={href}>{label} →</a></li> - ))} - </ul> - </div> - </main> - </div> - </div> - ) + return <NotFoundClient /> } diff --git a/components/not-found/NotFoundClient.tsx b/components/not-found/NotFoundClient.tsx new file mode 100644 index 0000000..9173294 --- /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. Route +// old blog URLs to the front page instead of a dead anchor until then. +const WRITING_TARGET = hasPosts() ? '/#writing' : '/' +const WRITING_LABEL = hasPosts() ? 'Writing' : 'the front page' + +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' }, + { test: /^\/(blog|writing|posts)(\/.*)?$/i, to: WRITING_TARGET, label: WRITING_LABEL }, + { test: /^\/projects?(\/.*)?$/i, to: '/#projects', label: 'Projects' }, +] + +export const SECTIONS: ReadonlyArray<readonly [string, string]> = [ + ['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 ( + <div className="brutalist-root" data-accent="gold" data-contrast="default"> + <div className="page"> + <nav className="site-nav" aria-label="Main"> + <Link href="/" className="brand" aria-label="Home"><span className="brand-mark">DM</span><span className="brand-word">Dom Mangonon</span></Link> + </nav> + <main className="section"> + <BinaryRule seed={404} accent /> + {/* 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. */} + <div className="nf" onClickCapture={() => setCancelled(true)}> + <div> + <span className="ds-eyebrow">404</span> + <h1 className="nf-title">{match ? <>That page<br />moved.</> : <>Nothing<br />here.</>}</h1> + <p className="nf-lead"> + {path && <>You asked for <code>{displayPath(path)}</code>. </>} + {match + ? <>It now lives on the front page, under <strong>{match.label}</strong>.</> + : <>There’s nothing at that address — no guessing where you meant.</>} + </p> + {redirecting && ( + <p className="nf-count"> + <span role="status">Redirecting to {match!.label}.</span>{' '} + <span aria-hidden="true">Taking you there in {Math.max(seconds, 1)}…</span> + </p> + )} + <div className="nf-actions"> + <Link className="btn-primary" href={match ? match.to : '/'}>{match ? 'Go there now →' : 'Start at the top →'}</Link> + {redirecting && ( + <button type="button" className="nf-stay" onClick={() => setCancelled(true)}> + Stay on this page + </button> + )} + </div> + </div> + <ul className="nf-map" aria-label="Sections"> + {SECTIONS.map(([label, href]) => ( + <li key={href}><a className="writing-all" href={href}>{label} →</a></li> + ))} + </ul> + </div> + </main> + </div> + </div> + ) +} diff --git a/e2e/not-found.spec.ts b/e2e/not-found.spec.ts index a209bc6..ce7fd4e 100644 --- a/e2e/not-found.spec.ts +++ b/e2e/not-found.spec.ts @@ -1,20 +1,48 @@ 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(/taking you there/i); - await page.waitForURL(/\/#resume$/, { timeout: 6000 }); + 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 falls back to the front page when there are no posts yet", async ({ page }) => { + await page.goto("/blog"); + await expect(page.getByRole("heading", { name: /that page moved/i })).toBeVisible(); + await page.waitForURL(/\/$/, { timeout: 10000 }); + // No dangling #writing anchor — there's nothing on the page to scroll to yet. + await expect(page).not.toHaveURL(/#writing/); }); 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); + // 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(); From 768cbb9a9bbc44ef27385dd38dee29ba13e46bd0 Mon Sep 17 00:00:00 2001 From: dommango-sys <251805093+dommango@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:07:22 -0400 Subject: [PATCH 3/3] fix: don't fake a Writing redirect target before any post exists Redirecting /blog to '/' with label "the front page" produced "It now lives on the front page, under the front page." Exclude the Writing entry from REDIRECTS entirely while hasPosts() is false instead, so /blog falls through to the honest plain 404 (no redirect, no guessing) until a real post ships and gives it somewhere to go. --- __tests__/not-found-redirects.test.ts | 4 ++-- components/not-found/NotFoundClient.tsx | 12 ++++++------ e2e/not-found.spec.ts | 13 ++++++++----- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/__tests__/not-found-redirects.test.ts b/__tests__/not-found-redirects.test.ts index 6cd1c74..86a05f3 100644 --- a/__tests__/not-found-redirects.test.ts +++ b/__tests__/not-found-redirects.test.ts @@ -31,10 +31,10 @@ describe('not-found redirect table', () => { } }) - it('does not offer a Writing link when there are no posts to show', () => { + 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'))?.to).toBe('/') + expect(REDIRECTS.find((r) => r.test.test('/blog'))).toBeUndefined() } }) }) diff --git a/components/not-found/NotFoundClient.tsx b/components/not-found/NotFoundClient.tsx index 9173294..24e858f 100644 --- a/components/not-found/NotFoundClient.tsx +++ b/components/not-found/NotFoundClient.tsx @@ -10,16 +10,16 @@ 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. Route -// old blog URLs to the front page instead of a dead anchor until then. -const WRITING_TARGET = hasPosts() ? '/#writing' : '/' -const WRITING_LABEL = hasPosts() ? 'Writing' : 'the front page' - +// 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' }, - { test: /^\/(blog|writing|posts)(\/.*)?$/i, to: WRITING_TARGET, label: WRITING_LABEL }, + ...(hasPosts() + ? [{ test: /^\/(blog|writing|posts)(\/.*)?$/i, to: '/#writing', label: 'Writing' }] + : []), { test: /^\/projects?(\/.*)?$/i, to: '/#projects', label: 'Projects' }, ] diff --git a/e2e/not-found.spec.ts b/e2e/not-found.spec.ts index ce7fd4e..43570bd 100644 --- a/e2e/not-found.spec.ts +++ b/e2e/not-found.spec.ts @@ -16,12 +16,15 @@ test.describe("Custom 404", () => { await expect(page.locator("#resume")).toBeInViewport(); }); - test("old /blog URL falls back to the front page when there are no posts yet", async ({ page }) => { + 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: /that page moved/i })).toBeVisible(); - await page.waitForURL(/\/$/, { timeout: 10000 }); - // No dangling #writing anchor — there's nothing on the page to scroll to yet. - await expect(page).not.toHaveURL(/#writing/); + 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 }) => {