···11+# SP12 — Playful error pages (404 / 500)
22+33+- **Status:** Built
44+- **Depends on:** SP4 (public renderer), SP6 (brand), SP10 (publication URL model)
55+- **Decisions:** none new (reuses 0007 read-through renderer, 0009 Cloudflare deploy)
66+77+## Goal
88+99+Replace the bare `new Response('Not found', { status: 404 })` plain-text responses on the
1010+reader routes with a **good-looking, playful** error page that stays on brand, and add the
1111+two error pages SkyPress is missing entirely: a global **404** for unmatched URLs and a
1212+**500** for server-side failures (e.g. a PDS fetch that throws).
1313+1414+The visual concept is **"Below the horizon"**: SkyPress's warm paper/ink/**sun** palette with
1515+a setting sun dipping under a thin horizon line and a couple of distant birds. One scene,
1616+copy that adapts to what went wrong, and a single **Back to homepage** button.
1717+1818+## Scope
1919+2020+Five situations, all rendered by the same scene:
2121+2222+| Kind | HTTP | Triggered from |
2323+| ------------------------ | ---- | --------------------------------------------------------- |
2424+| `writer-not-found` | 404 | `resolveAuthor(handle)` returns null (all 3 reader routes)|
2525+| `publication-not-found` | 404 | `resolveReaderPublication` returns null |
2626+| `article-not-found` | 404 | record missing, or `record.value.site` ≠ this publication |
2727+| `not-found` | 404 | unmatched URL (global `404.astro`) + malformed params |
2828+| `server-error` | 500 | any route throws (auto-rendered `500.astro`) |
2929+3030+## Copy (the content model)
3131+3232+No em dashes, plain natural wording. Eyebrow is a mono kicker; headline uses the display
3333+face. All five share the single **Back to homepage** button.
3434+3535+- **`writer-not-found`** — eyebrow `404 · no one by that name`,
3636+ heading `No writer at @{handle}`,
3737+ sub `Nobody on the network goes by that handle yet. It might have a typo.`
3838+- **`publication-not-found`** — eyebrow `404 · publication not found`,
3939+ heading `No publication by that name`,
4040+ sub `@{handle} hasn't published anything under "{slug}".`
4141+- **`article-not-found`** — eyebrow `404 · story not found`,
4242+ heading `This story set below the horizon`,
4343+ sub `It's no longer part of {publicationName}. It may have been unpublished or moved.`
4444+- **`not-found`** — eyebrow `404 · off the map`,
4545+ heading `This page set below the horizon`,
4646+ sub `There's nothing to read at this address.`
4747+- **`server-error`** — eyebrow `500 · overcast`,
4848+ heading `The sky's a bit cloudy right now`,
4949+ sub `Something went wrong on our end. Give it a moment, then try again.`
5050+5151+## Modules
5252+5353+- `src/lib/reader/errors.ts` — **pure, dependency-free** source of truth. A function
5454+ `errorScene(kind, context?)` returns `{ status, eyebrow, heading, subline }`. Context is
5555+ the interpolation values (`handle`, `slug`, `publicationName`). All copy lives here, so it
5656+ is edited in one place and is fully unit-testable. No `@wordpress`, no network.
5757+- `src/components/ErrorScene.astro` — the visual. Props are exactly the four
5858+ `errorScene()` fields. Renders inside `Base` (masthead + `Logo`). Self-contained `<style>`
5959+ for the horizon scene. **No `@wordpress/*` imports** (AGENTS.md #3 — this is a read path).
6060+- `src/pages/404.astro` — `prerender = true`. Static generic 404 for unmatched URLs;
6161+ renders `ErrorScene` with `errorScene('not-found')`.
6262+- `src/pages/500.astro` — `prerender = true`. Astro auto-renders this when a route throws,
6363+ so no per-route try/catch is needed; renders `errorScene('server-error')`.
6464+6565+## Wiring the reader routes
6666+6767+In each of `src/pages/[author]/index.astro`, `[author]/[slug]/index.astro`, and
6868+`[author]/[slug]/[rkey].astro`, replace every early `return new Response('…', { status })`
6969+with: build the matching `errorScene(...)` props, set `Astro.response.status = props.status`,
7070+and render `<ErrorScene {...props} />` from the template instead of the normal content
7171+(`{ error ? <ErrorScene {...error} /> : <…normal…> }`). The real 404 status is preserved for
7272+crawlers; the page is now a rendered HTML body, not plain text.
7373+7474+## Look & accessibility
7575+7676+- Reuses brand tokens from `global.css` (`--paper`, `--ink`, `--sun`, `--sun-tint`, `.btn`,
7777+ `.eyebrow`). Light + dark first-class: a dark-mode sky-gradient variant via
7878+ `@media (prefers-color-scheme: dark)`.
7979+- Setting-sun has a slow glow animation, disabled under `@media (prefers-reduced-motion)`.
8080+- Decorative sun/birds/horizon are `aria-hidden`. Single `<h1>` carries the heading. The
8181+ status is conveyed in visible text, not colour alone. The button is a real `<a href="/">`.
8282+- Every error page emits `<meta name="robots" content="noindex">` so the playful copy is
8383+ never indexed, and opts out of social/canonical tags (`socialMeta={false}` on `Base`).
8484+8585+## Tests (TDD — copy is test-locked)
8686+8787+- `errors.test.ts` — for each kind: correct `status` (404 vs 500), correct eyebrow/heading,
8888+ context interpolation (`handle`, `slug`, `publicationName` land in the strings), and a
8989+ guard asserting **no em dash (`—`)** appears in any produced string.
9090+- Reader-route `.meta.test.ts` files already exist; extend the relevant ones to assert an
9191+ error case sets a 404 status and renders the scene rather than plain text.
9292+9393+## Out of scope
9494+9595+- Per-situation CTAs beyond "Back to homepage" (deliberately one button — decided in design).
9696+- Search / "did you mean" suggestions on 404.
9797+- Illustrated/animated art beyond the CSS horizon scene (no image assets).
9898+- Localisation of error copy (English only for v1, consistent with the rest of the reader).
···11+/**
22+ * Source-level guard for the shared error scene. Page/component rendering through
33+ * astro/container isn't viable in this jsdom-pinned suite (see Base.meta.test.ts),
44+ * so we pin the wiring at the source level.
55+ */
66+import { readFileSync } from 'node:fs';
77+import { dirname, join } from 'node:path';
88+import { fileURLToPath } from 'node:url';
99+import { describe, expect, it } from 'vitest';
1010+1111+const here = dirname( fileURLToPath( import.meta.url ) );
1212+const component = readFileSync( join( here, './ErrorScene.astro' ), 'utf8' );
1313+1414+describe( 'ErrorScene component', () => {
1515+ it( 'renders inside Base and opts out of social meta', () => {
1616+ expect( component ).toMatch( /import Base from '[^']*layouts\/Base.astro'/ );
1717+ expect( component ).toMatch( /socialMeta=\{false\}/ );
1818+ } );
1919+2020+ it( 'marks the page noindex so playful copy is never indexed', () => {
2121+ expect( component ).toMatch(
2222+ /<meta\s+name="robots"\s+content="noindex"\s*\/?>/
2323+ );
2424+ } );
2525+2626+ it( 'renders the eyebrow, heading and subline props', () => {
2727+ expect( component ).toMatch( /\{eyebrow\}/ );
2828+ expect( component ).toMatch( /\{heading\}/ );
2929+ expect( component ).toMatch( /\{subline\}/ );
3030+ } );
3131+3232+ it( 'provides a single Back to homepage link to the site root', () => {
3333+ expect( component ).toMatch( /href="\/"[^>]*>\s*Back to homepage/ );
3434+ } );
3535+3636+ it( 'disables the sun-glow animation under reduced motion', () => {
3737+ expect( component ).toMatch( /prefers-reduced-motion/ );
3838+ } );
3939+4040+ it( 'is dependency-free (no @wordpress imports on the read path)', () => {
4141+ expect( component ).not.toMatch( /@wordpress\// );
4242+ } );
4343+} );
+64
src/lib/reader/errors.test.ts
···11+import { describe, expect, it } from 'vitest';
22+import { errorScene, type ErrorKind } from './errors';
33+44+const ALL_KINDS: ErrorKind[] = [
55+ 'writer-not-found',
66+ 'publication-not-found',
77+ 'article-not-found',
88+ 'not-found',
99+ 'server-error',
1010+];
1111+1212+describe( 'errorScene', () => {
1313+ it( 'returns 404 for every not-found kind and 500 for server-error', () => {
1414+ expect( errorScene( 'writer-not-found' ).status ).toBe( 404 );
1515+ expect( errorScene( 'publication-not-found' ).status ).toBe( 404 );
1616+ expect( errorScene( 'article-not-found' ).status ).toBe( 404 );
1717+ expect( errorScene( 'not-found' ).status ).toBe( 404 );
1818+ expect( errorScene( 'server-error' ).status ).toBe( 500 );
1919+ } );
2020+2121+ it( 'interpolates the handle into the writer-not-found copy', () => {
2222+ const scene = errorScene( 'writer-not-found', { handle: 'jeherve.com' } );
2323+ expect( scene.heading ).toBe( 'No writer at @jeherve.com' );
2424+ } );
2525+2626+ it( 'interpolates handle + slug into the publication-not-found copy', () => {
2727+ const scene = errorScene( 'publication-not-found', {
2828+ handle: 'jeherve.com',
2929+ slug: 'tribulations',
3030+ } );
3131+ expect( scene.subline ).toContain( '@jeherve.com' );
3232+ expect( scene.subline ).toContain( 'tribulations' );
3333+ } );
3434+3535+ it( 'interpolates the publication name into the article-not-found copy', () => {
3636+ const scene = errorScene( 'article-not-found', {
3737+ publicationName: 'Tribulations of a Software Engineer',
3838+ } );
3939+ expect( scene.subline ).toContain( 'Tribulations of a Software Engineer' );
4040+ } );
4141+4242+ it( 'falls back gracefully when the article publication name is missing', () => {
4343+ const scene = errorScene( 'article-not-found' );
4444+ expect( scene.subline ).toContain( 'this publication' );
4545+ } );
4646+4747+ it( 'keeps the horizon metaphor for the generic not-found heading', () => {
4848+ expect( errorScene( 'not-found' ).heading ).toBe(
4949+ 'This page set below the horizon'
5050+ );
5151+ } );
5252+5353+ it( 'never emits an em dash in any copy field (house rule)', () => {
5454+ for ( const kind of ALL_KINDS ) {
5555+ const scene = errorScene( kind, {
5656+ handle: 'x',
5757+ slug: 'y',
5858+ publicationName: 'Z',
5959+ } );
6060+ const text = `${ scene.eyebrow }${ scene.heading }${ scene.subline }`;
6161+ expect( text ).not.toContain( '—' ); // em dash
6262+ }
6363+ } );
6464+} );
+72
src/lib/reader/errors.ts
···11+/**
22+ * Source of truth for reader error-page copy + HTTP status (SP12).
33+ *
44+ * Pure and dependency-free (no @wordpress, no network) so it can run on the read
55+ * path (AGENTS.md #3) and be unit-tested directly. Copy avoids em dashes by house
66+ * rule; the eyebrow uses a middle-dot kicker in SkyPress's editorial voice.
77+ */
88+export type ErrorKind =
99+ | 'writer-not-found'
1010+ | 'publication-not-found'
1111+ | 'article-not-found'
1212+ | 'not-found'
1313+ | 'server-error';
1414+1515+export interface ErrorContext {
1616+ handle?: string;
1717+ slug?: string;
1818+ publicationName?: string;
1919+}
2020+2121+export interface ErrorSceneCopy {
2222+ status: number;
2323+ eyebrow: string;
2424+ heading: string;
2525+ subline: string;
2626+}
2727+2828+export function errorScene(
2929+ kind: ErrorKind,
3030+ context: ErrorContext = {}
3131+): ErrorSceneCopy {
3232+ switch ( kind ) {
3333+ case 'writer-not-found':
3434+ return {
3535+ status: 404,
3636+ eyebrow: '404 · no one by that name',
3737+ heading: `No writer at @${ context.handle ?? '' }`,
3838+ subline:
3939+ 'Nobody on the network goes by that handle yet. It might have a typo.',
4040+ };
4141+ case 'publication-not-found':
4242+ return {
4343+ status: 404,
4444+ eyebrow: '404 · publication not found',
4545+ heading: 'No publication by that name',
4646+ subline: `@${ context.handle ?? '' } hasn't published anything under "${ context.slug ?? '' }".`,
4747+ };
4848+ case 'article-not-found':
4949+ return {
5050+ status: 404,
5151+ eyebrow: '404 · story not found',
5252+ heading: 'This story set below the horizon',
5353+ subline: `It's no longer part of ${ context.publicationName ?? 'this publication' }. It may have been unpublished or moved.`,
5454+ };
5555+ case 'server-error':
5656+ return {
5757+ status: 500,
5858+ eyebrow: '500 · overcast',
5959+ heading: "The sky's a bit cloudy right now",
6060+ subline:
6161+ 'Something went wrong on our end. Give it a moment, then try again.',
6262+ };
6363+ case 'not-found':
6464+ default:
6565+ return {
6666+ status: 404,
6767+ eyebrow: '404 · off the map',
6868+ heading: 'This page set below the horizon',
6969+ subline: "There's nothing to read at this address.",
7070+ };
7171+ }
7272+}
+12
src/pages/404.astro
···11+---
22+import ErrorScene from '../components/ErrorScene.astro';
33+import { errorScene } from '../lib/reader/errors';
44+55+// Generic, data-free: prerender to a static 404.html the adapter serves for
66+// any unmatched URL.
77+export const prerender = true;
88+99+const scene = errorScene( 'not-found' );
1010+---
1111+1212+<ErrorScene eyebrow={scene.eyebrow} heading={scene.heading} subline={scene.subline} />
···11+---
22+import ErrorScene from '../components/ErrorScene.astro';
33+import { errorScene } from '../lib/reader/errors';
44+55+// Astro auto-renders this page when an SSR route throws (e.g. a PDS fetch fails),
66+// so no per-route try/catch is needed. Static copy, so prerender it.
77+export const prerender = true;
88+99+const scene = errorScene( 'server-error' );
1010+---
1111+1212+<ErrorScene eyebrow={scene.eyebrow} heading={scene.heading} subline={scene.subline} />