Adapted from the repo's docs/ui-ux.md — Part of Internals: the platform's design set, published as content on the platform it describes.
Owner:
frontend-engineer. Status: Accepted, normative for Phase 3 and later. The Design approach (§2A) binds all frontend UI work to thefrontend-designskill (.claude/skills/frontend-design/). Design direction: "Opened File" — SETTLED and user-approved 2026-07-06 (§2A.3, §5, §6). Redesign — "Widen & Enrich" (2026-07-06): the SETTLED identity (palette §5, type §6, signature §2A.3) is kept; the shell around it is widened and the hierarchy enriched (a two-tier width system §4, a richer type scale §6, browse-page rails and covers as first-class §7/§8). This was a chosen evolution, not a from-scratch reinvention — the identity did not change. Date: 2026-07-06. Consumes: contract v0.2.0 (contract/openapi.yaml) — server-side via the generated client; and, for/shelland/search, from the browser via the public API base (§8A, §8). Search (GET /search) consumes contract v0.3.0 (the search-only, additive bump — §8). Cover imagery (coverImageUrlonArticleSummaryFields/Section, resolving throughGET /assets/{path}) consumes contract v0.4.0 (Track 4c, additive — §7 CoverImage, §9). Framework: Next.js 16.2.10 (App Router), React 19.2.4, Tailwind CSS v4 (CSS-first@theme),@tailwindcss/typography. Type: Fraunces (display) + Newsreader (body) + Geist Mono (utility), self-hosted vianext/font/google(§6).
1. Status & scope
This document defines the visual system, information architecture, component contracts, page
specifications, and accessibility bar for the public reader front-end. It is normative: every
Phase-3 implementation task (F-4…F-12) cites the section(s) it implements, and any deviation
must update this document in the same task — doc and code move together. Where this document
and an older design doc (e.g. master-blueprint.md §9) differ on a frontend specific, this
document wins for the frontend.
Design mandate (normative). Every frontend UI task — a new screen or a reshaping of an existing one —
MUST apply the frontend-design skill (.claude/skills/frontend-design/) and the Design approach in
§2A. "Renders correctly and passes ddev frontend npm run verify" is necessary but not sufficient:
the result must look intentional, not templated. A screen that is accessible, deterministic, and green
but visually generic does not meet this document's bar. This binding is also part of the frontend
Definition of Done in the project constitution (CLAUDE.md) and §17.
Product frame. A personal publishing platform (blog). Content is authored out-of-band as files;
directories are sections (a tree), *.md files are articles; path = identity = URL. The site is
read-only, unauthenticated — no accounts, comments, admin, or search UI. The article page is the
product; everything else is wayfinding.
Judgement calls the user may want to override are tagged (default — flag to change).
2. Design principles
- Content first. The article is the hero of the article page; chrome recedes and whitespace does the work. No carousels or ornamental motion. The home/opening may state a thesis (§2A.2) — the most characteristic true thing about this blog — earned through restraint, not decoration.
- Server-rendered, near-zero client JS. Server Components by default. The site ships five client
islands:
error.tsx(framework-forced), the deliberately-scoped/shellterminal (§8A), the/searchexperience (§8 Search), and — on the article page — two fetch-free progressive-enhancement islands: the on-demand Mermaid diagram island (§7 MermaidDiagram, ADR-0012) and the "On this page" scrollspy (§7 OnThisPage/Gutter, §12)./shelland/searchare the two browser-fetching exceptions, each isolated to its own route and each fetching from the browser against the public API base (the same transport & CORS posture, §8A.2). The two article-page islands fetch nothing — they only transform / observe the already server-rendered, server-sanitised body (Mermaid lazily renders its diagrams, the scrollspy highlights the current ToC entry) and are pure enhancement (no JS → the article still reads and the ToC still links); Mermaid is additionally absent (loads zero mermaid JS) on diagram-free pages (§14 JS-budget carve-out). So §8A's "only browser-fetching surfaces" claim (only/shell+/search) still holds. No client-side content fetching on the reading pages (home/article/section/tags), no theme-toggle JS. Search is interactive by nature (a debounced query field), so it is a client island by necessity, not a reading page. The masthead's curated-nav active state adds one more, minimal client boundary —NavLink(§4, contract v0.7.0), ausePathnamereader that fetches nothing and only togglesaria-currenton thesite.naventry for the current route — outside the five content islands and immaterial to the JS budget. - Disciplined, not generic. The system stays small — few components, one content column, a tight token set — in service of a distinct point of view, never as licence for defaults. Minimal is allowed (and probably right here), but minimal directions need precision in spacing, type, and detail; elegance is executing a chosen vision well (§2A). Prefer deleting a component over adding a variant — and spend real boldness in one signature place (§2A.3). A single accent and one type family are acceptable only if chosen for this subject, not inherited from the starter template.
- Deterministic output. ISR-cached pages must render identically regardless of when they render: UTC-fixed date formatting, no relative time, no per-request randomness.
- Resilient to missing data. Every nullable/empty contract field has a defined visual behaviour that never reserves empty space or shifts layout.
- Accessible by construction. WCAG 2.2 AA is a build gate (§11), not a review nicety.
- Honest about the contract. No page NUMBERS (cursor pagination is opaque); cover imagery
(
coverImageUrl, populated as of v0.4.0 — Track 4c) is strictly optional and renders only when the field is non-null, never a reserved placeholder (§7 CoverImage, §9); a 404 never discloses why.
2A. Design approach — the frontend-design skill (NORMATIVE)
This section makes the frontend-design skill (.claude/skills/frontend-design/SKILL.md) a standing,
normative part of this document. The concrete spec below (§3–§16) says what to build and which contract
fields to read; this section governs how the result looks and reads — it must carry a deliberate,
subject-grounded identity, not templated defaults. It runs at the start of any UI polish or redesign
pass, before writing code (process in §2A.5). The prior "bare-minimalist MVP" framing is superseded:
minimalism is retained as discipline in service of a distinct point of view. This process has now run once
and produced the settled "Opened File" direction (user-approved 2026-07-06): the palette (§5), type (§6),
and signature (§2A.3) below are the chosen identity, no longer provisional. §2A.5 remains the required
method for any future redesign.
2A.1 Ground it in the subject
Design from this product's world, not generic "blog" tropes.
- Subject: a personal publishing platform — one writer's engineering-and-writing blog. Content
is authored out-of-band as files on disk; directories are sections,
*.mdfiles are articles, and path = identity = URL. The materials of this world are long-form prose, authored files and folders, a table of contents, a reading measure, code, and a writer's voice. - Audience: readers who came to read — often deep-linked to a single article from search or a share — plus a smaller set browsing by section or tag. They value legibility, calm, and the sense of an authored place over interactivity.
- Each key page's single job:
- Article (
/{…slug}) — the product. Sustained, comfortable reading of one piece; everything else is wayfinding to it. - Home (
/) — orient a first-time reader and surface the latest writing; the opening states what this blog is (§2A.2 thesis). - Section (
/{…slug}) — situate a reader within one branch of the tree and list its direct children. - Tags (
/tags,/tags/{tag}) — a cross-cutting index for readers who arrive by topic. - 404 / error / empty — keep a lost or stalled reader oriented and moving, in the site's voice (§10A).
- Article (
Distinctive choices should come from this world — the file tree, the reading column, the byline, the ToC, the way an article opens — not from stock dashboard/marketing patterns.
2A.2 Design principles (normative)
- Opening as thesis. The home/opening states the most characteristic true thing about this blog, in whatever form fits it — not a template hero. A big number + small label + gradient accent is the default answer; use it only if it is genuinely the best one here (unlikely for a reading site).
- Typography carries the personality. Require a deliberate display + body pairing and an intentional type scale (weights, widths, spacing) chosen for a long-form reading site — not a single neutral system face used as a delivery vehicle for the text. The type treatment is itself a memorable part of the design.
- Structure encodes meaning. Eyebrows, dividers, numbering, and labels appear only when they say
something true about the content. Do not add
01 / 02 / 03markers unless the content really is an ordered sequence — the article list is reverse-chronological, not a ranked process. - Motion is deliberate. Prefer one orchestrated moment (if any) over scattered effects; over-animation
reads as AI-generated and fights a reading site. The §12 near-zero-motion floor stands — any motion added
later must clear that bar and respect
prefers-reduced-motion. - Match complexity to the vision. Minimal is allowed and probably right here — but minimal directions need precision in spacing, type, and detail. Elegance is executing the chosen vision well, not omitting decisions.
- Spend boldness in one place. One signature element (§2A.3) is the memorable thing; everything around it stays quiet and disciplined. Cut any decoration that does not serve the brief.
2A.3 Signature element — "the opened file" (SETTLED)
The blog's world is authored plain-text files where path = identity = URL. The signature renders each post the way a writer-engineer sees their own work — an opened file — in two parts:
- The path line — atop every article (and echoed on cards/sections), the canonical content path in
Geist Mono: directory segments in
--muted, the filename segment carrying the marigold "mark" (a low-alpha--markhighlight behind it). Example:~/systems/files-as-source-of-truth.md. It replaces a decorative breadcrumb with the true address of the piece. Spec: §7 PathLine. - The marginalia gutter — on wide screens a Geist-Mono rail hung in the left margin beside the prose
(author, published, updated, reading time, then the "On this page" ticks), fusing the code-editor's
line-number gutter with a manuscript's margin. Below
lgit collapses to an inline mono meta line under the<h1>. Spec: §7 Gutter.
The one aesthetic risk (deliberate): making the URL/path the hero typographic object of the brand — the thing normally hidden as chrome — because path is identity here. Boldness is spent only here (§2A.2): the marigold mark appears on the file segment, the gutter ticks, links, and active/focus states, and nowhere else; every other surface stays quiet mono/serif on neutral. Do not add a second bold element.
Redesign extension (Widen & Enrich, 2026-07-06) — the margin motif carried site-wide. The signature is
the article's left marginalia gutter (a manuscript margin × a code gutter). The redesign extends that
same idea, not a new one: the browse pages (Home, Section) gain a quiet rail flanking the main column (§4,
§7 SectionCard/rail) — the margin made a wayfinding surface. This is not a second bold element: the rails
are quiet mono/serif on the neutral --surface tint, and the marigold is still spent only on the mark, gutter
ticks, links, active/focus, and the eyebrow-rule tick (§7 SectionLabel — a short marigold segment starting
a hairline divider, a restrained structural use of the same accent). One further, on-brand use of the path:
where a Home featured lead has no cover, its own file path becomes the cover art — a path plate (§7
FeaturedArticle) — because path is identity. It is a designed fallback (pure type on a tint), never a
reserved empty box (§9), and adds no <img>.
2A.4 Avoid the AI defaults
AI-generated design currently clusters on three looks. Each is legitimate only where a brief explicitly calls for it; none may be adopted here by default:
- warm cream background (~
#F4F1EA) + high-contrast serif display + terracotta accent; - near-black background + a single acid-green or vermilion accent;
- broadsheet layout — hairline rules, zero border-radius, dense newspaper columns.
Where this doc pins a direction, follow it exactly. Where it leaves an axis free, do not spend that freedom on one of these defaults — make a choice specific to a personal engineering-and-writing blog and justify it.
2A.5 Process — two passes before code (required at the start of any polish/redesign)
- Brainstorm a compact token plan (in the polish-pass design plan or a fresh design note/ADR):
- Color: 4–6 named hex values — not just one accent.
- Type: ≥2 roles — a characterful display face used with restraint, a complementary body face tuned for long-form reading, and a mono/utility face for code and meta if needed.
- Layout: a concept in one-sentence prose + ASCII wireframes for the key pages.
- Signature: the one element from §2A.3.
- Critique the plan against the brief. Work through what you'd produce for any reading site; wherever the plan matches that generic answer, revise it and record what changed and why. Build only once the plan is demonstrably specific to this blog.
- Build to the plan exactly, deriving every color/type decision from it, keeping the §11 accessibility and §14 performance floors (and the §7/§8 loading-vs-404 and heading-order hard gates).
- Critique again against the shipped result; remove one thing that doesn't earn its place.
This process has run for the current design (§2A.3 "Opened File"); §5/§6 are the settled result. Re-run these four passes for any future redesign, and update §5/§6/§2A.3 in the same task — the identity and the doc move together.
3. Information architecture & URL map
Every public URL, its Next.js route file, the contract operation(s) it calls, and its purpose:
| URL | Route file | Operation(s) | Purpose |
|---|---|---|---|
/ |
app/page.tsx |
listArticles (pageSize N) + getSectionTree |
Home: latest articles + section entry points |
/{...slug} |
app/[...slug]/page.tsx |
getArticle → fallback getSection |
Article or section, resolved by path (see below) |
/tags |
app/tags/page.tsx |
listTags |
Tag catalogue with counts |
/tags/{tag} |
app/tags/[tag]/page.tsx |
listArticlesByTag |
Articles for one tag (unknown tag → empty 200) |
/authors |
app/authors/page.tsx |
listAuthors |
Author catalogue with counts (contract v0.5.0, Track 4b) |
/authors/{author} |
app/authors/[author]/page.tsx |
listArticlesByAuthor |
Articles for one author (unknown author → empty 200, ADR-0013) |
/sitemap.xml |
app/sitemap.ts |
listArticles, getSectionTree, listTags, listAuthors |
XML sitemap (absolute URLs) |
/feed.xml |
app/feed.xml/route.ts |
listArticles, getArticle |
RSS 2.0 feed (full-content items, Track 4e) |
/search |
app/search/page.tsx |
(browser) searchArticles (v0.3.0) |
Full-text search — a 'use client' island (§8 Search); noindex |
/shell |
app/shell/page.tsx |
(browser) getSectionTree, getSection, getArticle, listTags, listArticlesByTag, searchArticles |
Full-screen terminal — a 'use client' island (§8A) |
| (any 404) | app/not-found.tsx |
— | Uniform not-found (§8) |
| (any thrown error) | app/error.tsx (+ nested) |
— | Error boundary (§8) |
There is no /articles or /sections index route: sections are reached through the header nav
and section pages; the Home page is the only global chronological surface.
Catch-all resolution order for app/[...slug]/page.tsx (slug: string[] → path = slug.join('/')):
- Try article:
getArticle(path). On 200 → render Article page (§8). - On 404, try section:
getSection(path). On 200 → render Section page (§8). - On 404 →
notFound()→app/not-found.tsx.
Article-first because article deep-links are the majority of traffic; the section call only runs for
section URLs. Articles and sections never collide (a .md file path vs a directory path are distinct).
Explicit routes (/tags, /tags/[tag], /authors, /authors/[author], /sitemap.xml, /feed.xml)
take precedence over the catch-all by Next.js routing, so those paths never reach step 1. (default —
flag to change: sequential article-then-section; a parallel race is an allowed optimization.)
4. App shell & layout
Defined once in app/layout.tsx (Server Component), wrapping every page. <main> is full-width
(vertical rhythm only); each page owns its width tier (below), so the widened viewport goes to
chrome, rails, margins, and covers — never a longer prose line.
- Width tiers (redesign — normative). EVERY page's OUTER container is
.shell(72rem) so all pages' outer edges line up with the masthead exactly — no page is a narrower, centred, inset box. Defined as@layer componentsutilities inglobals.css:.shell=--shell(72rem) — centred, horizontal padding1.25rem/2rem(sm+). The masthead, footer, Home, Section, Tags, Search, AND the article all use it as their outer frame..article-reading— the reading block INSIDE the article's.shell, left-aligned to the shell's left gutter (NOT centred — centring would inset it and break edge-alignment with/). It caps at--measure(40rem) belowlg(single reading column, ~66ch) and atlg+widens tomax-width: 100%— it fills the shell so its right edge is flush with the masthead (review round 2, fix #1), no longer a narrower 59rem inset block. The reading MEASURE atlg+is held by stepping the body type up (§6, 19px/1.75), never by capping the column. Browse content is left-aligned within the shell like/'s thesis: readable surfaces (Search field + results atmax-w-[48rem], Tag-listing atmax-w-[54rem]) hug the left gutter and leave a right margin; wide surfaces (the Tags pill cloud, the Home two-zone, the full article composition) fill the shell. This resolves the "wide page vs readable measure" tension: the shell widens and every left edge aligns; belowlgthe measure caps the column, and atlg+the type size (not a width cap) holds the line length.
- Reading column — the measure:
--measure= 40rem (≈640px) belowlg— a ~66-character measure at the 18px body (§6). Atlg+the article prose lives in the grid's fluid column and the body type steps up to 19px/1.75 so the wider column holds a comfortable ~68–76ch line (§6). The article gutter (§7 Gutter, §8) is the signature exception: onlg(≥1024px) the article hangs its Geist-Mono marginalia rail in the left margin beside the reading column (a left-alignedgrid: [--rail (16rem) gutter] [minmax(0,1fr) prose],column-gap: 3rem) whose overhang is the signature (§2A.3). The prose track fills the shell (fix #1) and the freed viewport width went to the wider rail and the wider (type-held) reading column. The rail spans every grid row (grid-row: 1 / span 20—1 / -1is broken against an implicit grid; fix #2) and scrolls independently (sticky + capped height +overflow-y:auto) so every ToC entry stays reachable at any article scroll position. PathLine and the CoverImage hero sit above the grid and span the full shell width (flush with the masthead — a real wide hero). Belowlgthe gutter collapses inline and the single reading column returns. - Skip link: first focusable element in
<body>:<a href="#main-content">Skip to content</a>, visually hidden until focused (sr-only focus:not-sr-only), lands on<main id="main-content">. - Masthead (
<header>+<nav aria-label="Primary">) — persistent:sticky top-0 z-40, translucentbg-background/85 backdrop-blur-md, hairlineborder-b border-border; inner.shell,flex items-center justify-between,py-3.5(~--header-h= 3.75rem, the sticky/anchor offset). The translucency is not motion (reduced-motion-safe).- Left — brand lockup: a marigold mono
~/root token (aria-hidden— the path motif, §2A.3) ahead of the site title (site.titlefromGET /site, §13) in the display face (Fraunces,font-display font-medium tracking-tight), the whole lockup linking to/. The~/isaria-hiddenso the link's accessible name stays the bare site title. - Right — the primary nav (TOTAL-NAV model, contract v0.7.0 / ADR-0015): a single text nav, all Geist
Mono (
font-mono text-sm text-muted, hover--accent, no underline §11.6), overflow scrolling horizontally (overflow-x-auto); no hamburger (would need JS). Then a hairline separator and two pinned affordances: the Search affordance (a magnifier-glyph link to/search,aria-label="Search the writing") and the shell affordance (a$link to/shell(§8A), mono,--accent,aria-label="Open the command-line shell"). What the text nav contains depends onsite.nav:- Curated mode —
site.navnon-empty:GET /sitereturnsnav[](NavItem{label,path}); when non-empty it is the entire primary navigation — the author curates the whole masthead from the rootmetadata.json. Entries may point at unlisted pages (e.g.about-me, retrievable by path but absent from every listing surface), sections (e.g.case-studies), or built-in routes (e.g.tags,search) — a path is just a string;label→/{path}. Array order = display order — no per-file position/priority properties. In this mode the derived sections list and the built-in Tags link are not rendered (listtagsin the nav to keep it), and the section-tree fetch is skipped entirely. - Fallback mode —
site.navabsent/empty: the previously-derived nav, unchanged: top-level sections fromgetSectionTree(depth 1 only — no dropdowns) + a built-in Tags link. (default — flag to change: fallback nav depth = 1.) - The Search icon is persistent chrome, NOT part of the curated list — it renders in both modes, so
authors never need to list
search. If they do anyway, it renders as a text link like any entry and the icon stays. - No Authors link by default (contract v0.5.0, Track 4b design judgment): a permanent top-level
slot for author browsing would be nav-crowding on a mostly-single-author blog;
/authorsis reachable from the footer Browse column and from every byline (AuthorLinks). Under the total-nav model an author who disagrees just adds anauthorsentry tosite.nav.
- Curated mode —
- Active-nav highlight (curated entries). Each curated entry renders via
NavLink, a tinyusePathnameclient boundary (the one place the reading site opts into active-nav state) that marks the current entry witharia-current="page"+--accent+ medium weight (not colour-only, §11.6). "Current" = exact path match OR a nested descendant (href/…), so a nav entry at a section (e.g.case-studies) highlights on the article pages beneath it while one at a leaf page (e.g.about-me) highlights only there. The fallback mode's server-rendered section/Tags links keep no active state (that remains carried by the PathLine (§7), the page<h1>; §15). (default — flag to change: extend NavLink to the fallback links too.)
- Left — brand lockup: a marigold mono
- Main:
<main id="main-content">, full-width,py-10 sm:py-16(vertical rhythm only). Every page centres its outer content in.shell(the article adds a left-aligned.article-readinginside it, above); pages set their own inter-block rhythm (space-y-*). - Footer (
<footer>),mt-24 py-12,border-t border-border, Geist-Monotext-xs text-muted, inner.shellas a column grid (sm:grid-cols-2 lg:grid-cols-[1.6fr_1fr_1fr]): (1) a colophon — the~/{site.title}lockup, an authored memo line in the writer's voice, and a rights line — all CONTRACT-sourced (GET /site, contract v0.7.0 / ADR-0015; §13):site.memo(a note like "…a plain file, versioned like code…" — §10A, not framework trivia like "Built with Next.js"; null → the line is omitted entirely, no empty<p>), andsite.copyright(the rights line verbatim when set; null → a build-time© {year}default). (2) a Browse wayfinding nav echo (<nav aria-label="Footer">→ Home / Tags / Authors (contract v0.5.0, Track 4b) / Search /$Shell); (3) a Feeds column (an RSS link to/feed.xmland a "Back to top"<a href="#top">). The column labels are styled eyebrows (<p>), not headings, so the footer adds no headings to the page outline. The default copyright year is a build-time constant, never a per-requestDate(principle 4). - Breakpoints: mobile-first;
sm(640px) for inline spacing/type and the shell padding step, andlg(1024px) for the article marginalia gutter and the browse-page rails (Home/Section two-zone, §8). Belowlg, rails stack below the main column and the article is a single reading column; atlgthe article's mono rail hangs in the left margin and the browse rails move to a sticky side column.
5. Design tokens — "Opened File" (SETTLED)
All tokens are CSS custom properties in app/globals.css, switched for dark mode via prefers-color-scheme
only (no manual toggle on the reading site), and exposed to Tailwind via @theme inline (the inline
keyword makes utilities emit var(--…) so the media-query override wins).
Palette. Two themes — Paper (light, a cool grey, deliberately not cream) and Ink (dark, a deep
cool navy, deliberately not black) — plus one signal, the marigold "editor's mark" (a warm yellow-brown
highlighter/marginalia gesture; not terracotta, not acid). Ratios below are text-on---background.
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root { /* PAPER — light */
--background: #F5F6F7; /* cool paper */
--surface: #ECEEF1; /* raised: code, gutter tint, mark base */
--foreground: #15181D; /* ink — primary text ~16.7:1 */
--muted: #555B66; /* meta / marginalia ~6.1:1 (AA) */
--subtle: #808795; /* faint separators / dir segments */
--border: #D7DBE0; /* hairlines */
--accent: #9A5B00; /* marigold mark: links, active ~4.9:1 (AA) */
--accent-hover:#7A4700; /* deeper marigold on hover */
--mark: rgba(154,91,0,.16); /* low-alpha highlight behind a file */
--code: #ECEEF1; /* = surface */
}
@media (prefers-color-scheme: dark) {
:root { /* INK — dark */
--background: #12141A; /* deep cool navy-ink */
--surface: #1B1E26;
--foreground: #E8E6E1; /* warm paper-white ~14.9:1 */
--muted: #9BA0AB; /* meta / marginalia ~7:1 */
--subtle: #6B7280;
--border: #2A2E38;
--accent: #E7A94B; /* marigold ~8.1:1 */
--accent-hover:#F0BE6B;
--mark: rgba(231,169,75,.17);
--code: #1B1E26;
}
}
@theme inline {
--color-background: var(--background);
--color-surface: var(--surface);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-subtle: var(--subtle);
--color-border: var(--border);
--color-accent: var(--accent);
--color-accent-hover:var(--accent-hover);
--color-mark: var(--mark);
--color-code: var(--code);
--font-display: var(--font-fraunces); /* §6 */
--font-body: var(--font-newsreader); /* §6 */
--font-mono: var(--font-geist-mono); /* §6 */
}
Utilities: bg-background, bg-surface, text-foreground, text-muted, text-subtle, border-border,
text-accent/bg-accent, hover:text-accent-hover, bg-mark, bg-code, and the three font families
font-display / font-body / font-mono. The <body> default is font-body (Newsreader), not a
system sans.
- Accent — the mark. Marigold is the only chromatic accent and is spent narrowly (§2A.3): the file
segment's
--markhighlight, the gutter ticks, links, active/focus, and the eyebrow-rule tick. It is never the sole signal for an in-prose link — those carry a persistent underline (§11.6); UI/nav links carry no underline and shift colour on hover instead (with a focus-visible ring, §11.4/§11.6). Both themes verified ≥4.5:1 for accent-as-text. - Radius:
rounded-md(0.375rem) for code blocks, boxed states, and the mark;rounded-lg/rounded-xlfor cover frames, media bands, rails, and empty states;rounded-fullfor TagPills. Soft, not zero (a deliberate non-broadsheet choice, §2A.4). - Spacing: Tailwind's 0.25rem base scale; the reading rhythm named in §4. (The type scale lives in §6.)
- Layout tokens (redesign — the palette above is UNCHANGED). Widths/offsets, defined once in
:root(they do not vary by theme):--measure: 40rem(the reading line belowlg— atlg+the reading column is fluid and the measure is held by the body type step-up, §6/§4),--rail: 16rem(the article gutter onlg+, widened so ToC entries fit),--shell: 72rem(every page's outer container — chrome, browse, AND the article, review round 2 fix #1),--header-h: 3.75rem(sticky masthead height → the sticky offset and the prosescroll-margin-topclear it). (The former--article-shell59rem cap was removed in round 2 — the article fills the shell.) Depth is built from hairline--border+ the--surfacetint (a.tint=color-mix(--surface 55%, --background)step, used by rails/empty states/pills) — no drop shadows (flat, editorial, still non-broadsheet).
6. Typography & prose
- Fonts (SETTLED — self-hosted via
next/font, no runtime font requests):- Display — Fraunces (
--font-fraunces), variable, used restrained (opsz ~144, weight 440–560, low SOFT/WONK). A crafted old-style serif — not a Didone/Playfair. Roles: home thesis, article & section<h1>, proseh2/h3. - Body — Newsreader (
--font-newsreader), text optical size, weight 400/500 + true italic. A screen-reading serif for long-form. Roles: prose, descriptions, ledes, general UI copy. - Utility — Geist Mono (
--font-geist-mono), weight 400/450/500. Roles: the path line, the marginalia gutter, meta lines, eyebrows, section directory entries, code, PrevNext, and the whole of/shell(§8A). Retained from the repo and kept deliberately quiet so the two serifs carry the personality. - No Geist Sans, no system-sans body. Wire all three in
layout.tsx— Fraunces & Newsreader fromnext/font/google, Geist Mono fromgeist/font/mono(ornext/font/google) — exposing the CSS variables named above. Replace the boilerplatebody { font-family: Arial… }withfont-body.
- Display — Fraunces (
- Type scale (authoritative — size / weight / tracking / line-height):
| Role | Face | Size | Weight | Tracking | Line-height |
|---|---|---|---|---|---|
| Home thesis | Fraunces | clamp(2.4rem, 6vw, 3.6rem) |
500 | −0.02em | 1.03 |
Article h1 |
Fraunces | clamp(2.1rem, 4.6vw, 3rem) |
500 | −0.02em | 1.08 |
Section/tags/search h1 |
Fraunces | clamp(2rem, 4.4vw, 2.8rem) |
500 | −0.02em | 1.1 |
| Featured lead title | Fraunces | 1.3rem — equal to the card title (all list titles share one size; the lead differs by cover/lede/spacing only) | 500 | −0.01em | snug |
Prose h2 |
Fraunces | 1.6rem | 500 | −0.005em | 1.25 |
Prose h3 |
Fraunces | 1.25rem | 540 | 0 | 1.3 |
| Card title (ArticleCard) | Fraunces | 1.3rem | 500 | −0.01em | snug |
Lede (.lede) |
Newsreader | clamp(1.15rem, 1.4vw+0.9rem, 1.4rem) |
400 | 0 | 1.55 |
Body (below lg) |
Newsreader | 18px | 400 | 0 | 1.72 |
Body (lg+) |
Newsreader | 19px (1.1875rem) | 400 | 0 | 1.75 |
| Meta / marginalia | Geist Mono | 13px | 450 | 0.01em | 1.5 |
Eyebrow (LATEST, SECTIONS, ~/) |
Geist Mono | 12px | 500 | 0.16em, UPPERCASE | 1.4 |
| Path line | Geist Mono | 13px | 450 (file seg 550) | 0.01–0.02em | 1.5 |
| Code | Geist Mono | 0.86em | 400 | 0 | 1.6 |
Base body is 18px / line-height 1.72 over the ~66ch reading column below lg. At lg+ the
article's prose column widens to fill the shell (§4, review round 2 fix #1), so the body steps up to
19px / 1.75 to hold a comfortable ~68–76ch line in the wider column — the measure is now held by the
type size, not by capping the column at 40rem. (.prose gets a single @media (min-width:1024px) rule;
the below-lg reading column and every non-article surface keep 18px.) The redesign bumps the display
end of the scale (a bigger, more expressive thesis and h1) and adds the lede (.lede) — a Newsreader
stand-first used under the thesis, the article h1 (the article description), and section/tags/search
headers — for hierarchy. Nav/meta/footer use Geist Mono per the table.
- Article body arrives as server-sanitised HTML (
Article.body, ADR-0007) and is injected viadangerouslySetInnerHTML. The frontend must not parse markdown or re-sanitise client-side. Base class:className="prose dark:prose-invert max-w-none", tuned to the Opened File type system viaprose-*CSS variables — headings →--font-display(Fraunces), body →--font-body(Newsreader) at 18px/1.72, links →--accentwith underline, the--tw-prose-*colours mapped to the §5 tokens, code/pre → Geist Mono on--surface. (Do not ship@tailwindcss/typography's neutral defaults unchanged.) The injected-HTML mechanics below (code/table/image handling, heading anchors) are unchanged. - Measure:
max-w-nonelets prose fill the reading column (--measure40rem, §4) — the measure is the column, ~66ch at the 18px body. - Injected-HTML rules (global CSS scoped to
.prose, because we cannot add wrappers to author HTML):
@layer components {
.prose :where(pre) { overflow-x: auto; background: var(--code); border-radius: .375rem; }
.prose :where(code):not(pre code) { background: var(--code); border-radius: .25rem; padding: .1em .35em; }
.prose :where(code)::before, .prose :where(code)::after { content: none; } /* drop plugin backticks */
.prose :where(table) { display: block; max-width: 100%; overflow-x: auto; } /* horizontal-scroll wrapper */
.prose :where(img) { max-width: 100%; height: auto; border-radius: .375rem; } /* constraint: keep layout */
.prose :where(h1,h2,h3,h4,h5,h6) { scroll-margin-top: 5rem; } /* comfortable ToC anchor landing */
}
- Code blocks: horizontal scroll,
--codebackground, mono font (plugin default). Never wrap/clip. - Tables:
display:block; overflow-x:autogives a horizontal-scroll affordance without a wrapper element (accepted trade-off: loses some native table sizing; assistive tech still reads it). - Images:
max-width:100%; height:autoonly. As of Track 4c / contract v0.4.0 the backend rewrites in-body<img src>to absolute asset-endpoint URLs before sanitising (ADR-0007) — the frontend never parses or rewrites the injected HTML, it only supplies these sizing rules (§9). Should a referenced asset still be unresolvable, the browser's broken-image glyph is accepted: layout must not shift and no space is reserved either way. Nonext/imagefor body imagery — same reasoning as § CoverImage (the frontend cannot add wrappers/attributes to author-controlled injected HTML, and originals are served as-is per ADR-0011). (constraint 3.) - Blockquotes: plugin default, left border
--border, text--muted(restrained, not accent). - Heading anchors: article body headings carry
ids matchingTocEntry.id. The anchor affordance this phase is the "On this page" ToC (§7) linking to those ids — no injected inline hover-#icons (would mutate author HTML / add JS).scroll-margin-topabove keeps anchored headings clear of the top edge. - Author-controlled headings: the article title is the page
<h1>; body prose renders headings as authored (ideally h2–h6). If an author writes a body#h1 it produces a second h1 — a content issue the frontend cannot fix; documented as authoring guidance, out of frontend control.
7. Component inventory
Each component is a Server Component unless noted. "Consumes" lists the exact contract fields read.
ArticleCard
- Purpose: one article in a list (home rows, section, tag, search). Anatomy (redesign — the path
leads): a path eyebrow (the compact PathLine grammar
~/{section}/{file}.md, filename marked — the file identity first, §2A.3) → title (link, Fraunces,~1.3rem) → optional description (Newsreader) → MetaLine (authors · date · reading time) → optional tags → beside an optional cover thumbnail (Track 4c, below). Consumes:ArticleSummary.{path,slug,sectionPath,title,description,authors,publishedAt,updatedAt,readingMinutes,tags,coverImageUrl}. - Behaviour: whole title links to
/{path},font-display font-medium,line-clamp-2; hover underlines in--accent. Descriptionfont-body text-muted line-clamp-2, omitted when null. The path eyebrow segments are plain text, not links (the card carries exactly one link — the title). Tags row optional (TagPills), omitted when empty. Cards are borderless, separated byborder-b border-borderwithpy-7— an editorial index, not boxed tiles. - Cover thumbnail (Track 4c, v0.4.0): when
coverImageUrlis non-null, anaspect-[4/3]image (w-28 sm:w-40,rounded-lg border border-border,object-cover,bg-surfacewhile loading) trails the text column — a quiet accent, not a hero, and carries no marigold/signature styling (boldness stays spent on PathLine/Gutter, §2A.3). Strictly conditional (§9): omitted entirely, no reserved space, when null/undefined, so a mixed list never wobbles the text column — only the trailing thumbnail differs row to row. Plain<img loading="lazy" decoding="async">, alt = the article title (§ CoverImage below has the full plain-<img>-vs-next/imagerationale).
FeaturedArticle (redesign — the Home LATEST lead)
- Purpose: the newest article given editorial prominence at the top of Home's LATEST column (§8 Home) — a
stronger hierarchy moment than the ArticleCard rows and the primary place a cover is first-class.
Consumes: the same
ArticleSummaryfields as ArticleCard. Anatomy: a wide media band → path eyebrow → Fraunces title (link,1.3rem— the SAME size as every card title) → the description (Newsreader1.05rem— the SAME size as every card description; all list titles AND descriptions share one size, and the lead's prominence comes from the media band and spacing, not type scale; the.lederole is reserved for page heroes — Home thesis, article stand-first — never list items) → MetaLine → optional tags. - Media band: when
coverImageUrlis present, a lazy<img>in a framedaspect-[16/9]band (.media-frame). When absent, a path plate — the article's own file path rendered as its cover art (path = identity, §2A.3), pure type on a.tint(aria-hidden, marked filename). The plate is a designed fallback, not a reserved empty box (§9); it renders no<img>, so a coverless lead never shifts layout. The plate is used only here (a lead wants a media band); ArticleCard rows stay text-only when coverless. - a11y: the title carries the single link (its accessible name); path segments are plain text and the media band is not a second link.
SectionLabel (redesign — the eyebrow-rule section divider)
- Purpose: the browse-page section break (Home LATEST/SECTIONS, Section Articles, Tags, Search) — a
Geist-Mono uppercase label, a short marigold "mark" tick, and a hairline rule to the zone edge
(
.eyebrow-rule). A deliberate, restrained use of the accent for hierarchy without a second bold element (the tick is the same mark, §2A.3). Renders a real heading (as, defaulth2) so it joins the page outline — its accessible name is exactly the label text; the trailing rule is an emptyaria-hiddenspan (never pollutes the name). Passidwhen a surrounding<section aria-labelledby>names itself from it. A rail heading may instead use a plain eyebrow (.eyebrow, no rule) inside its tinted box. - a11y: the title link is the accessible name; the card is not a redundant nested link. The thumbnail's
altnames the same story the title link does, so it is not a second distinct accessible name.
MetaLine (article & card meta)
- Purpose: the mono meta atom —
authors · date · reading time(Geist Mono,text-sm text-muted,·=text-subtle). Consumes:authors[],publishedAt,readingMinutes,updatedAt. States: authors omitted when[](no dangling "By"); the published date always renders;updatedAt→ "Updated {date}" only for a meaningful update — present AND its rendered calendar date differs from published (isMeaningfulUpdate, comparingformatDateoutput, not raw instants), so a same-day re-save orupdatedAt == publishedAtshows a single date, never a redundant "Updated Jul 6" beside "Jul 6";readingMinutes→ "N min read", or "< 1 min read" when 0. Dates per §10, wrapped in<time dateTime={iso}>. On the article page these fields render in the Gutter (below) onlg+and inline belowlg; on cards they are the trailing half of the card's path-led meta line (§ ArticleCard). Each author name is individually linked to/authors/{slug}via AuthorLinks (below, contract v0.5.0) — the visible text is unchanged (authors.join(', ')), only each name gains a link.
PathLine (signature — §2A.3)
- Purpose: the canonical content path as a first-class typographic object — the true address of the thing
on screen, doubling as its breadcrumb. A synchronous Server Component (it reads only the given path,
never
getSectionTree), so it nests inline without an async boundary. It replaces the old Breadcrumbs component EVERYWHERE (Breadcrumbs was removed in review round 2). Anatomy: Geist Mono; a leading~/root token (--subtle,aria-hidden); ancestor directory segments as links in--mutedjoined by/(slashesaria-hidden); the final segment in--foreground, non-link, weight 550, with a--markhighlight (rounded-md bg-mark),aria-current="page". Long paths scroll inside their own box (overflow-x:auto; white-space:nowrap) — never widen the page. - The path grammar encodes what each URL is (a subject-grounded structural device, review round 2) —
three variants, one component + two inline echoes:
file(PathLinevariant="file", default — the Article page):~/{dirs…}/{name}.md. EverysectionPathsegment is an ancestor link; the.mdfilename is the marked current leaf.directory(PathLinevariant="directory"— the Section page and the/tags·/authorscatalogues):~/{dirs…}/{name}/. A trailing/denotes a browsable folder; the final segment is the marked current directory. This is the fix for round-2 item 3 —/case-studiesreads~/case-studies/, not "Home / Case Studies". The catalogues are thetags/andauthors/directories.leaf(inline on/tags/{tag}·/authors/{author}listings and/search):~/{parent}/{name}— a marked leaf with no.mdand no trailing slash, because a filtered/query view is neither a file nor a browsable folder. The listings keep a real<Link>to the parent directory;/searchis a decorative (aria-hidden) mark since the<h1>names it. (Not the PathLine component — a lightweight inline<p>.)
- Placements: (1) article header — the
filevariant above the<h1>(<nav aria-label="Breadcrumb">); the filename mark animates in on load (§12). (2) Section header + Tags/Authors catalogues — thedirectoryvariant. (3) Listings + Search — the inlineleafecho. (4) cards — a compact static path eyebrow leading the ArticleCard/FeaturedArticle meta (not a nav; the title link is the accessible name). - Motion (§12): every top-of-page path line carries
.pathline-enter(lift/fade) and its single marked current-location leaf carries.file-mark--enter(one wipe per page load); card path eyebrows stay static. - a11y: decorative
~//arearia-hidden; segment links carry the path slug as their accessible name; the current leaf isaria-current="page"; the mark is presentational (colour is not the sole signal — the leaf also sits at full--foreground, weight 550).
Gutter (signature — §2A.3, article only)
- Purpose: the marginalia rail — the code-editor gutter × the manuscript margin. Consumes: the same
fields as MetaLine plus
Article.tableOfContents[]. Anatomy: Geist Mono,--muted; labelled rows (AUTHOR,PUBLISHED,UPDATED,N MIN READ— labels in--accent, uppercase eyebrow scale) then the "On this page" ticks (each·-prefixed in--accent, indented by(level − minLevel) × 0.75rem). TheAUTHORrow's value is AuthorLinks (below) — same linked-name treatment as MetaLine. Layout: onlg+a--rail(16rem) rail hung in the left margin (grid-column: 1,position: sticky; top: calc(--header-h + 1.5rem)) via the article grid (§4/§8); belowlgit collapses to the inline MetaLine + OnThisPage block under the<h1>. The ToC section is omitted when< 2entries (as OnThisPage). - Rail spans every grid row (fix #2, review round 2): the rail uses
grid-row: 1 / span 20, NOT1 / -1.-1resolves against the explicit grid, and this grid declares no rows, so-1== line 1 and the rail collapsed into row 1 (beside the<h1>) — which (a) inflated row 1 to the rail's ~90vh height, opening a giant<h1>→body gap (worst with a long ToC), and (b) gave the sticky rail zero travel so the independent scroll below never engaged. The generous span covers all implicit rows (there are ≤6 grid children and no row-gap, so the extra auto-rows collapse to 0 height); the number is "comfortably more rows than an article can have," not a real count. - Independent scroll (fix #3): on
lg+the sticky rail is capped atmax-height: calc(100vh − --header-h − 3rem)withoverflow-y:auto(thin, unobtrusive scrollbar), so a long meta+ToC scrolls WITHIN the rail and every entry stays reachable at any article scroll position — it no longer requires scrolling to the end of the article. (This only truly engages once fix #2 gives the rail full-column travel.) - a11y: wrapped in
<nav aria-label="Article details">containing the ToC<nav aria-label="On this page">; fully keyboard-operable; the sticky offset respectsscroll-margin-top(§6).
CoverImage (Track 4c, v0.4.0)
- Purpose: the optional hero cover on the Article and Section pages (
frontend/src/components/ CoverImage.tsx). Consumes:Article.coverImageUrl/Section.coverImageUrl(absolute URL from the asset endpoint,GET /assets/{path}, ADR-0011) plus the page's own title (asalt). - Anatomy: a single
<img>in anaspect-[2/1]frame (rounded-xl border border-border,object-cover,bg-surfaceas a neutral loading tile). Placement (redesign — a real wide hero): Article — after PathLine and above the.article-grid, spanning the full.article-readingwidth, which atlg+now fills the shell (review round 2 fix #1 — the reading block is no longer capped at 59rem), so the hero is flush with the masthead and wider than the prose column, a genuine hero rather than a band pinned to the measure. Section — after the title/description header, before the browse band,max-w-[52rem]. It is not full-bleed/edge-to-edge and carries no accent colour: the "Opened File" signature stays spent on PathLine's marigold mark and the Gutter (§2A.3) — the cover is a quiet, disciplined hero, never competing decoration. <img>, notnext/image(deliberate — §14): the asset endpoint (ADR-0011) serves originals byte-for-byte with a long-lived immutableCache-Control— no resizing/transcoding by design. Routing covers through Next's image optimizer would re-encode those immutable bytes (fighting the contract) and would needimages.remotePatternstracking two hosts across environments (the DDEVapi.subdomain and prodapi.bflo.sh); a plain, lazy<img>needs neither and keeps the reading site's near-zero-JS/ dependency budget intact (§14 JS budget line is updated accordingly, see below).- No layout shift (§9): strictly conditional — omitted entirely, no reserved box, when
coverImageUrlis null. When present, theaspect-[2/1]frame fixes the box's size from CSS alone before the image loads;object-coverfills it regardless of the source's real aspect ratio, so the cover cannot shift layout either. - a11y:
altis the article/section title (a plain<img>, not decorative — it is meaningful content, not chrome);loading="lazy" decoding="async". - Deliberately NOT on SectionCard: the directory-entry list rows (below) keep their text-only,
~/{name}grammar unchanged — a considered omission, not an oversight (§ SectionCard).
MermaidDiagram — the lazy diagram island (client island — ADR-0012)
- Purpose: render
```mermaidfenced blocks in the article body as SVG, client-side and lazily (src/components/MermaidDiagrams.tsx). One of the article page's two fetch-free client islands (§2; the other is the OnThisPage scrollspy) — it transforms already server-rendered, server-sanitised body HTML (ADR-0007), so §8A's "only browser-fetching surfaces" claim (/shell+/search) still holds. Consumes: no contract field directly — it reads the injectedArticle.bodyDOM at#article-body(the scan root the Article page gives it). Rendersnull. - Behaviour (ADR-0012): after hydration it scans
#article-bodyforcode.language-mermaidfences (the class the sanitiser preserves verbatim); only if ≥1 exists does it dynamicallyimport('mermaid'), so a diagram-free article ships and loads zero mermaid JS (§14). Each fence'stextContent(the entity-decoded source) is rendered to SVG and the inert<pre>is replaced in place by a<figure>: a pane titlebar (the diagram type on the left, quiet view controls on the right) + a pan/zoom viewport wrapping arole="img"SVG host + a quiet "Diagram source"<details>disclosure keeping the authored source reachable (an a11y path and an on-brand diagrams-as-code "view source"). - View controls (§7 — dense diagrams): the figure is a file-pane whose titlebar carries the diagram
type (reusing the
role="img"label, e.g.FLOWCHART) and a control cluster; the diagram itself is a pan/zoom surface so a big, detail-dense diagram is legible without squeezing 2-D into the reading column.- In-view controls: zoom out / zoom in / reset (quiet mono icon buttons, muted at rest, marigold on
hover/focus, visible focus ring — the §2A.3 chrome idiom) + pointer-drag panning (
grab/grabbingcursors, mouse/pen only — a touch swipe keeps scrolling the page viatouch-action: pan-y) + double-click to reset + arrow-key panning while the surface is focused (+/−zoom,0resets). The zoom buttons disable at the bounds. - Wheel policy — click to engage (no page-scroll hijack): a plain mouse wheel over an unengaged
in-article figure keeps scrolling the page. The reader clicks (or keyboard-focuses) the diagram
surface to engage it — the pane lights up in the marigold accent and the titlebar hint swaps from
click to zoomtoscroll to zoom · esc to release— and a plain wheel then zooms. Engagement releases on Escape, on focus leaving the figure, or on a pointer-down outside it. Ctrl/Cmd+wheel zooms regardless of engagement (the map convention). The hint is a mouse-only affordance (hidden on touch — there is no wheel there) andaria-hidden(keyboard/SR users have the buttons + arrow keys). Inside the fullscreen dialog a plain wheel zooms freely (no page behind it, so no engagement gate). - Offset 0 — the box size (load / reset state): the viewport's height is derived from the diagram's own
aspect ratio at the column width (
height = width × viewBoxH ÷ viewBoxW) — no fixed generic box — and the diagram exactly fits it. A pathological tall diagram is capped at ~75vh and fit-contained inside the capped box (letterboxed). "Offset 0" is what loads and what reset / double-click returns to. - Zoom mechanism — true layout size (crisp by construction): zooming does not CSS-
scale()the SVG; it sets the SVG host's real px width/height (viewBox aspect × zoom), so the browser lays out and rasterizes the vector at its actual size — crisp at every zoom step. (The earliertransform: scale()blur was a compositor caching a small 1× raster and GPU-scaling it; removingwill-changehelped but the small in-article base still read soft. Driving true layout size removes the dependency on compositor behaviour entirely — the dialog was already crisp precisely because it zoomed from a large laid-out base.) Panning is atranslate()of the centred host inside the clipped viewport (translate never re-rasters). Both the in-article figure and the dialog use this one code path. - Zoom clamps: 1× (fit) – 8× relative to the offset-0 fit (you can't shrink below the fit — zoom-out is disabled there); translate is bounded to the content's overflow so the diagram can't be lost off-canvas. Layout size + translate are applied instantly (no eased animation).
- Clip hardening: the clip is set as an inline
overflow: hiddenon the viewport (in addition to the CSS class) so a dragged diagram can never bleed over the article text even if the stylesheet is stale/missing. - Fullscreen dialog: an expand button (
aria-label="Open diagram fullscreen") opens the same diagram — a lossless clone of the rendered SVG — in a native<dialog>viashowModal()(free focus trap + Escape close). The dialog is centred in the viewport (inset: 0; margin: auto) and the clone's initial state is fit-to-dialog (~92% of the dialog on its constraining axis, centred) — so it opens as a big, crisp diagram, not a tiny one. It has its own independent pan/zoom (buttons + drag + free wheel-zoom), a paper/ink translucent styled::backdrop, body scroll locked while open, a visible Close button, focus returned to the expand button on close, and pan/zoom reset to the fit on close. - Wide diagrams therefore never widen the measure — they are zoomed/panned in place or opened fullscreen.
- In-view controls: zoom out / zoom in / reset (quiet mono icon buttons, muted at rest, marigold on
hover/focus, visible focus ring — the §2A.3 chrome idiom) + pointer-drag panning (
- Zero new dependency (ADR-0012 spirit): the pan/zoom is hand-rolled (SVG layout sizing + a
translate()) and the fullscreen is a native<dialog>— no pan/zoom or modal library is added; the island stays fetch-free and its only third-party code remains the single pinnedmermaid. - Security (ADR-0012): the
mermaiddependency is pinned to an exact version (package.json) and initialised withsecurityLevel: 'strict'(startOnLoad: false) — no raw HTML in labels, noclick/script bindings. The only injected HTML is mermaid's own strict SVG output; the retained source is set viatextContent, neverinnerHTML. - Theme: chosen from
prefers-color-scheme(§5, no manual toggle) —darkon Ink,neutralon Paper (a calm editorial greyscale, diagram text in Geist Mono via the--font-monovar); the diagram carries no marigold, so the accent stays spent on the PathLine/Gutter signature (§2A.3). Because the source is retained, diagrams re-render on an OS theme flip. - Motion: the pan/zoom layout-size change and
translate()are applied instantly (no CSS transition on them), so they are reduced-motion safe by construction; the only animated properties are the control colour transitions, already covered by the global reduced-motion reset (globals.css §12). The island adds no page-load or scroll-triggered animation — it stays within the §12 "one orchestrated moment" floor. - Resilience (§9): on a render error the original code block is left visible and nothing throws.
a11y: the SVG host is a labelled image (type-derived
aria-label, e.g. "Flowchart"); every control is a real<button>with anaria-label(Zoom in/Zoom out/Reset view/Open diagram fullscreen/Close fullscreen); the pan surface is keyboard-focusable (arrow-key pan) with a visible focus ring; the dialog is labelled by the diagram, traps focus, closes on Escape, and returns focus to the expand button; the source disclosure is a real keyboard-operable<summary>with a visible:focus-visiblering (§11.4). Mounted on the article page only (§8 Article); inert/absent everywhere else.
TagPill
- Purpose: a tag chip/link. Consumes: a tag
slug(always available) plus — only where aTagobject exists (the/tagscatalogue fromlistTags) — itslabelandcount. - Slug-vs-label display ruling (default — flag to change):
ArticleSummary.tagsis bare slugs (kebab-case); the humanTag.labelexists only onlistTags. Ruling: display the humanlabelwherelistTagssupplies it (the/tagscatalogue), and display the slug as-is everywhere only a slug is available — TagPills on ArticleCards and the article page, and the/tags/{tag}heading/metadata. No humanising guess (slugs can encodeGraphQL,CI/CD, etc.), no cross-fetch: each surface stays honest to the exact field the contract hands it and TagPill adds zero coupling to card/article rendering (aligns with the §14 budget). Alternative (b): resolve labels everywhere by also fetchinglistTags(3600s-cacheable) and mapping slug→label with a slug fallback — nicer card labels at the cost of alistTagsdependency on those pages; if chosen, state that the label's source islistTagsand keep the/tags/{tag}h1 consistent with it. - Anatomy: links to
/tags/{slug}.rounded-full border border-border px-2.5 py-0.5 text-sm hover:border-accent hover:text-accent. On the catalogue it appends the count ("Systems · 4").
AuthorLinks (byline linking, contract v0.5.0)
- Purpose: links each author name in a rendered byline to
/authors/{slug}— the author analogue of TagPill, used inside MetaLine and Gutter (not standalone in the component tree). Consumes:authors: AuthorRef[]({slug, name}— e.g. fromArticleSummary.authors/Article.authors). - Slugs are server-authoritative (ADR-0013, contract v0.5.0): every byline author arrives as an
AuthorRefcarrying both the displaynameand itsslug, derived server-side by one canonical derivation shared by bylines, the/authorscatalogue (Author.slug), and the by-author filter — a byline'sslugalways equals its matching catalogue item'sslug. AuthorLinks links byAuthorRef.slugverbatim; there is no client-side derivation — the earlierlib/format.tsauthorSlug()guess (which mismatched the by-author filter because the index matched the guess against raw labels) was deleted once the server started publishing the slug on every byline (2026-07-07). - Anatomy: renders the exact same text a caller already showed (
authors.map(a => a.name).join(', ')), individually wrapping each name in a link to/authors/{author.slug}— no visual change beyond the link affordance.
AuthorPill (/authors catalogue only)
- Purpose: an author chip/link for the
/authorscatalogue — mirrors TagPill's catalogue mode. Consumes:Author.{slug,name,count}(listAuthors). Anatomy: identical to TagPill's labelled mode — links to/authors/{slug}, same pill styling, appends the count ("Ada Lovelace · 12"). This is a direct render of the catalogue shape, not a distinct derivation: every other author surface (MetaLine/Gutter, via AuthorLinks) also links by the server-publishedAuthorRef.slug, which is the same slug for the same author (one canonical server-side derivation), so AuthorPill and AuthorLinks always agree.
SectionCard (directory entry)
- Purpose: a section entry point (the Home SECTIONS rail, the Section Subsections rail / directory,
subsection links — §8). Consumes:
SectionNode.{path,title}orSection.{path,title,description}. Anatomy: a directory entry — a leading~/root token (--subtle) then the section name as a link, Geist Mono, echoing the filesystem identity (§2A.3). Optional description infont-body text-muted, omitted when null. Borderless, samepyrhythm as ArticleCard; hover →--accent. - No cover thumbnail (deliberate, Track 4c): unlike ArticleCard, SectionCard does not read
coverImageUrl— a directory listing stays text-only, matching the filesystem grammar it echoes; a section landed on via its own URL shows its cover as the Section page's own header hero (§ CoverImage) instead. This is a considered omission (frontend-design skill: "cut any decoration that does not serve the brief"), not a gap.
Breadcrumbs — REMOVED (review round 2)
- The old
Home / … / This SectionBreadcrumbs component was deleted. A section is a browsable folder in the file metaphor, so it wears the PathLinedirectoryvariant (~/{…}/{section}/, § PathLine) — the same signature the article uses, not a decorative humanised trail. This fixes the round-2 complaint that/case-studieswrongly read "Home / Case Studies" instead of~/case-studies. No page renders a Breadcrumbs component any longer (the article was already on PathLine; sections and catalogues moved to it in round 2), so the asyncgetSectionTreetitle-resolution it needed is gone too. The<nav aria-label="Breadcrumb">landmark and the ancestor-link/aria-currenta11y contract are unchanged — they now live in PathLine.
OnThisPage (ToC)
- Purpose: in-article heading outline. Anatomy:
<nav aria-label="On this page">heading + flat<ol>of#idlinks (each·-prefixed in--accent,data-toc-link, title in a.toc-titlespan). Consumes:Article.tableOfContents[]({id,title,level}, flat). Indent by level:padding-left: (level − minLevel) × 0.75rem. Hidden when fewer than 2 entries. Each entry is aflex items-startrow (tickshrink-0, titlemin-w-0) so a heading that is longer than the (widened, 16rem) rail wraps with a hanging indent under its own text — no ragged multi-line clutter (fix #2). Placement: inside the Gutter (§7) onlg+(sticky, in the left margin, scrolls independently — §7 Gutter); inline above the prose (after the inline MetaLine) belowlg. Geist Mono,--muted, ticks in--accent. (default — flag to change: show threshold ≥ 2.) - Scrollspy — the active/current entry (
TocScrollSpy, §2 island #5, §12). A tiny fetch-free progressive-enhancement client island (mounted on the article page beside the Mermaid island) follows the MermaidDiagrams precedent: after hydration it observes the body headings (#article-body; their ids already matchTocEntry.id) with an IntersectionObserver and setsaria-current="location"on the OnThisPage entry for the chapter being read. Server markup stays the source of truth — no JS (or no IO) → the ToC still works, just without the moving highlight. No libraries.- Which entry is "current": the last heading at/above the reading line — a trip line just below the
sticky masthead (derived from
--header-h+ a small buffer). This tracks both directions (scrolling back up re-selects the earlier chapter) and the edges (top of page → first entry, bottom → last). IO is only a cheap trigger; each crossing schedules a rAF-coalesced recompute from the live heading rects — no scroll-event polling. - Keep-in-view: because the rail scrolls independently (§7 Gutter), on an active change the island
nudges the active entry into view within the rail only (
rail.scrollByon.gutter-rail, never the page) and only when the rail overflows — it never fights the user's own rail scroll, and honoursprefers-reduced-motion(instant, not smooth). - Visual (§12, Opened File language): the active entry reads as the current location — full
--foregroundink, weight 550, and the marigold mark highlight behind the title (the.file-markdevice, §2A.3), its·tick filling into a small solid marigold block; non-active entries stay--muted. Driven by thea[aria-current="location"]attribute so the signal is never colour-only (mark + weight + the a11y attribute), with a quiet colour/background transition disabled by the reduced-motion reset.
- Which entry is "current": the last heading at/above the reading line — a trip line just below the
sticky masthead (derived from
PrevNext
- Purpose: adjacent-article navigation at the article foot. Consumes:
Article.prev,Article.next(ArticleRef{path,title}| null). Anatomy: Geist Mono;flex justify-between gap-4; a← Previousslot (uppercase mono label + title) left, aNext →slot right. Null-safe: a null end renders an empty slot (the other stays aligned); both null → the block is not rendered. Each link's accessible name includes the target title.
Pagination ("Older articles")
- Purpose: forward-only cursor paging. Consumes:
PageMeta.nextCursor. Renders a single "Older articles →" link to?cursor={nextCursor}only whennextCursoris non-null. No page numbers, no infinite scroll. There is no "Newer" link (opaque cursors can't reconstruct the previous page) — the browser Back button serves that; documented in-page copy stays honest about direction.
EmptyState
- Purpose: a populated-but-empty surface (unknown/empty tag, empty section). Neutral heading + one
sentence that is an invitation to act + a link onward (home / all tags). Must never imply
"coming soon" or differentiate causes. Copy per §10A.
text-muted.
ErrorState — error.tsx (the only client island)
'use client'. Props (Next 16):{ error: Error & { digest?: string }, unstable_retry: () => void }. Anatomy: a headline stating the page didn't load + a recovery line (copy per §10A — no apology, no vague "something went wrong", no raw server text) + a Try again button callingunstable_retry(). Minimal, keyboard operable.resetis not used (superseded — see §13/NEXT16 notes). Rootapp/error.tsxplus nested boundaries where a sub-tree should fail independently.
NotFound — app/not-found.tsx
- One uniform design for every 404 (unknown, DRAFT, SCHEDULED are indistinguishable — ADR-0006/0008).
"Page not found" + a one-line recovery invitation + link to
/(copy per §10A). No "coming soon", no cause hints, no differentiation. Server Component; triggered bynotFound()fromnext/navigation.
Header / Nav / Footer
- As specified in §4: the Fraunces masthead, the Geist-Mono primary nav, the
$shell affordance →/shell(§8A), and the footer with its authored colophon (§10A). The footer may echo the~/path motif; any copyright year is a build-time constant (principle 4).
Loading skeletons — loading.tsx (deferred — see the loading-vs-404 constraint)
- Loading-vs-404 constraint (normative, hard rule): a segment-level
loading.tsxcreates a Suspense boundary whose fallback Next streams with a committed HTTP 200 before the async page resolves. If that page then throwsnotFound(), the status is already 200 — a soft-404 (the not-found body renders but the status stays 200). Therefore noloading.tsxmay sit above any route that can callnotFound(). The catch-all[...slug](article→section→404) andtags/[tag](via nothing here, but the app root spans it) are the primary content routes and cannotFound(), so at the app root there must be no rootloading.tsx, and no per-routeloading.tsxon[...slug]ortags/[tag]. - Decision (Phase 3): the root
loading.tsxis removed; loading skeletons are deferred (see §15). A scopedloading.tsxis permitted only on a route that nevernotFound()s — i.e./(home) and/tags(catalogue) — and must stay minimal; none is shipped this phase. Do not reintroduce a root or content-route loading boundary to add a skeleton.
8. Page specifications with acceptance criteria
Home — /
- Layout (
.shell, wide — §4): a thesis opening — a~/mono eyebrow, the thesis<h1>in Fraunces (site.thesisfromGET /site, e.g. "Writing, kept like source.", closed by a static mono caret; whenthesisis null it falls back tosite.titleso there is always exactly one non-empty h1), and a Newsreader lede (site.tagline— omitted entirely when empty, never an empty<p>) — then a two-zone band (lg:grid-cols-[minmax(0,1fr)_17rem]):- LATEST (main column): a
SectionLabeleyebrow-rule (h2), then a prominent FeaturedArticle lead (listArticlesnewest) + the remaining ArticleCard rows + Pagination (listArticlespageSize 10 newest total). (default — flag to change: pageSize 10.) - SECTIONS (browse rail): a sticky
.browse-rail(the margin motif, §2A.3) with a plain eyebrowh2, the SectionCard directory (getSectionTreetop level), and a "Browse all tags →" link. Belowlgthe rail stacks under LATEST. Exactly one<h1>(the thesis).
- LATEST (main column): a
- The
<h1>is the thesis (site.thesis, not the bare site name —site.titlelives in the masthead). Copy authored in the contentmetadata.json(ADR-0014), voiced per §10A. - States: empty (no articles) → EmptyState in the LATEST block; SECTIONS block hidden when the tree is
empty.
?cursor=renders the next page (canonical still/, §13). Nullthesis→ h1 =site.title; emptytagline→ no note line. - Metadata: default title (
site.title); description =site.tagline(both fromGET /site, §13). - Acceptance: exactly one h1 (the thesis); newest-first order preserved from the API (the FeaturedArticle
lead is
data[0], rows are the rest); "Older articles →" iffmeta.nextCursornon-null; the FeaturedArticle lead carries a media band — a cover<img>when present, else a path plate (no<img>, no reserved box, §9); ArticleCard rows carry their own optional trailing thumbnails; no layout shift in any case; the caret + any load motion are §12-compliant.
Article — app/[...slug]/page.tsx (article branch)
- Layout (
.shellouter +.article-reading, §4): the outer container is the full.shell(72rem, so the article's edges line up with the masthead — fix #1); inside,.article-readingis left-aligned to the shell gutter. PathLine (full path, filename segment marked — §7) → optional CoverImage hero (Track 4c, §7 — spans the full.article-readingwidth, wider than the prose) →<article class="article-grid">[<h1>title (Fraunces) → optional lede (the article description,.lede, §6) → onlg+the Gutter rail in the left margin / belowlgthe inline MetaLine + OnThisPage →.prosebody viadangerouslySetInnerHTML(Newsreader, §6) → PrevNext ]. Onlg+the article is a left-aligned grid[--rail (16rem) gutter] [minmax(0,1fr) prose](§4, review round 2) that fills the shell — every non-gutter child lands in the reading column (col 2), the Gutter spans all rows of col 1 (sticky, independently scrollable —grid-row: 1 / span 20, fix #2). The body's first block has its top margin zeroed (.prose > :first-child) so a leading<h2>does not open a big title→body gap, worst in the coverless case (fix #4). The PathLine (filevariant) is the article's breadcrumb — the Breadcrumbs component was removed in round 2; sections now use the PathLinedirectoryvariant too (§7). - Data mapping:
getArticle(path);path→PathLine, title→h1,description→lede (omitted when null),authors/publishedAt/updatedAt/readingMinutes→Gutter/MetaLine,tableOfContents→OnThisPage (in the Gutter onlg+),body→prose,tags→TagPills,prev/next→PrevNext,coverImageUrl→CoverImage (rendered only when non-null, §9). A comment at the body injection site cites ADR-0007 ("sanitised server-side; no client parse/sanitise"). - States: 404 →
notFound(); thrown error →error.tsx. No loading boundary (§7 loading-vs-404). - Diagrams (ADR-0012): the body is scanned client-side by the lazy MermaidDiagram island (§7) mounted
after the prose;
```mermaidfences render to SVG on the client only when present (zero mermaid JS otherwise), and a failed render leaves the original code block visible. The island renders nothing on articles without a diagram. - Metadata (
generateMetadata, §13):title= article title;description=article.description ?? site.tagline(GET /site); canonical =SITE_URL/{path}; OpenGraphtype:'article'withpublishedTime/authorsandsiteName = site.title, plusimages:[coverImageUrl]when non-null (Track 4c — §13). - Acceptance: exactly one h1 (the title); PathLine present with the filename segment marked; body via
dangerouslySetInnerHTMLwith no markdown/sanitiser dependency; ToC ticks resolve to body heading ids; the Gutter onlg+collapses to inline meta belowlg; PrevNext hidden when both null; emptyauthors/tagsand nulldescription/updatedAtdegrade cleanly; a nullcoverImageUrlrenders no cover and no reserved box, a non-null one renders with the title asaltand cannot shift layout (§9); relative body images do not shift layout; the load-in mark animation is §12-compliant (reduced-motion → static).
Section — app/[...slug]/page.tsx (section branch)
- Layout (
.shell, wide — §4): a header (max-w-[52rem]) — the directory PathLine (~/{…}/{section}/, §7, replacing the removed Breadcrumbs) →<h1>section title → optional lede (the description,.lede, omit when null) → optional CoverImage hero (max-w-[52rem], Track 4c, §7) → then a two-zone browse band mirroring Home (lg:grid-cols-[minmax(0,1fr)_17rem]): Articles (SectionLabelh2+ ArticleCard list fromarticles, DIRECT children only + Paginationmeta.nextCursor) as the main column beside a sticky Subsections.browse-rail(plain eyebrowh2+ SectionCard list fromsubsections). When the section has only one of the two, that one takes the full width (no empty rail); an only-subsections section shows the directory as its main content. Belowlgthe rail stacks. - Note the asymmetry:
Section.articlesare direct children only; do not imply subtree contents. - States: empty section (no
articlesand nosubsections) → EmptyState (copy per §10A; no "coming soon"); pagination via?cursor=. The cover (if any) still renders above an empty section. - Metadata: title = section title; description =
section.description ?? site.tagline(GET /site); canonical =SITE_URL/{path}; OpenGraphsiteName = site.title,images:[coverImageUrl]when non-null (Track 4c — §13). - Acceptance: one h1; subsections link one level only; article pagination honors
nextCursor; empty section shows neutral EmptyState (no "coming soon"); a nullcoverImageUrlrenders no cover and no reserved box.
Tags catalogue — /tags
- Layout (
.shell, full width — fix #1): the container fills the shell so its edges line up with the masthead. A header — a~/ tagsmono eyebrow,<h1>"Tags", a Newsreader lede (max-wfor readability) — then a generous TagPill wrap fromlistTags(eachlabel · count,.tintpill) that fills the shell (a tag cloud reaching both gutters). Empty catalogue → EmptyState. Not paginated (contract). - Acceptance: one h1; every tag links to
/tags/{slug}; counts shown; degrades to EmptyState when no tags.
Tag listing — /tags/{tag}
{tag}throughout this page is the URL slug, displayed as-is per the §7 TagPill ruling (onlylistTagscarries a human label, and this page does not fetch it under the default ruling).- Layout (
.shellouter + left-alignedmax-w-[54rem]— fix #1): the container fills the shell (edges line up with the masthead) and the readable listing sits in a left-aligned column (like/'s thesis). A header — a~/ tags / {tag}mono path line (thetagssegment links to/tags, the{tag}segment marked) →<h1>"Tagged: {tag}" (slug) → ansr-only<h2>Articles</h2>(bridges the h1→h3 order since ArticleCard titles are<h3>; mirrors Home/Section, kept visually hidden as the page holds only the list) → ArticleCard list fromlistArticlesByTag(pageSize 10) → Pagination. - Critical state: an unknown tag returns a 200 empty page (never 404) → EmptyState (copy per
§10A). Do not call
notFound(). - Metadata: title = "Tagged: {tag}" (slug); canonical =
SITE_URL/tags/{tag}. - Acceptance: one h1; a single intervening
<h2>before the list so heading order is h1→h2→h3 with noheading-orderskip (axe, unsuppressed); unknown tag renders EmptyState with HTTP 200; pagination honorsnextCursor.
Authors catalogue — /authors (contract v0.5.0, Track 4b)
- Layout (
.shell, full width): identical structure to the Tags catalogue — a~/ authorsmono eyebrow,<h1>"Authors", a Newsreader lede, then an AuthorPill wrap fromlistAuthors(eachname · count). Empty catalogue → EmptyState. Not paginated (contract). - Acceptance: one h1; every author links to
/authors/{slug}; counts shown; degrades to EmptyState when no authors.
Author listing — /authors/{author} (contract v0.5.0, Track 4b, ADR-0013)
{author}throughout this page is the URL slug, displayed as-is — this page does not fetchlistAuthorsto resolve a display name (same "no cross-fetch" ruling as/tags/{tag}, § AuthorLinks).- Layout (
.shellouter + left-alignedmax-w-[54rem]): identical structure to Tag listing — a~/ authors / {author}mono path line (theauthorssegment links to/authors, the{author}segment marked) →<h1>"By {author}" (slug) — a deliberate byline framing, not a literal copy of "Tagged:" (§10A voice: an author archive reads as "by", not "authored:") — → ansr-only<h2>Articles</h2>(same heading-order bridge as Tag listing) → ArticleCard list fromlistArticlesByAuthor(pageSize 10) → Pagination. - Critical state (ADR-0013): an unknown author returns a 200 empty page (never 404) → EmptyState
(copy per §10A). Do not call
notFound()— identical contract to unknown tag. - Metadata: title = "By {author}" (slug); canonical =
SITE_URL/authors/{author}. - Acceptance: one h1; a single intervening
<h2>before the list (h1→h2→h3, no skip); unknown author renders EmptyState with HTTP 200; pagination honorsnextCursor.
Search — /search (app/search/page.tsx + SearchExperience island)
- Consumes contract v0.3.0
GET /search(searchArticles) — the only page that reads v0.3.0.SearchResult=ArticleSummary+ an OPTIONAL, NULLABLEsnippet(server-sanitised<mark>HTML, ADR-0007). Ordering is the ONLY relevance signal — no numeric score is exposed or rendered. - Architecture: the route is a Server Component (
.shellouter + left-alignedmax-w-[48rem]— fix #1: the container fills the shell so its edges line up with the masthead, and the field + results sit in a left-aligned readable column like/'s thesis) that renders the static header (eyebrow~/ search, Fraunces<h1>"Search the writing", a Newsreader lede) and mounts the'use client'SearchExperienceisland (src/components/SearchExperience.tsx). The island is the third sanctioned client-fetching surface (§2 principle 2): it fetches from the browser viabrowserClient.searchArticlesagainstNEXT_PUBLIC_API_BASE— the same transport & CORS posture already confirmed for/shell(§8A.2), so it adds no new backend ask. No content is fetched server-side; the page reads?q=only to seed the island (shareable/search?q=…links), and the island auto-runs that query on mount. - The field — a grep-style
/pattern/(signature tie-in). The query input is framed as a mono/ pattern /— the file/shell world the blog is kept in — with the opening slash in marigold (the accent/ "mark") and a quiet closing slash; the whole frame lifts with the §11.4 accent ring onfocus-within. It reuses the Opened File tokens only (§5), no new palette. - Behaviour: the field is debounced (300ms); a trimmed query shorter than 2 (or longer than 128)
characters is guarded client-side and never issues a request (mirroring the contract's 2–128
qvalidation, so a bad query never round-trips to a 400). Enter runs immediately. Results render relevance-ordered (the API order is preserved verbatim) reusing the ArticleCard tile with its optionalsnippetrendered as trusted HTML (matched terms wear the same marigold--markas the PathLine filename — search literally marks the matches; §5). Forward-only cursor paging via a "More results →" button that appends the next page (meta.nextCursor; hidden when null) — the button state, not a link, because it accumulates client state. Stale-response guard: a monotonic request id +AbortControllerdrop out-of-order responses so fast typing can never paint an older page over a newer one. - States (copy per §10A): idle / <2 chars → a mono hint "Type at least two characters to search."; loading →
"Searching…"; empty (200, no matches) → EmptyState "No matches for “{q}”" (NEVER a 404 — mirrors an unknown
tag); error → one neutral voice line (
429→ the rate-limit line; other → "Search isn't available right now."). - Metadata:
title= "Search";robots: { index: false }(a search-results surface is an app view, not a document — mirrors/shell, §8A.1); canonical =SITE_URL/search(never a?q=variant, per the §13 cursor-canonical rule). Excluded from the sitemap (like/shell). - a11y (§11): a
role="search"form with a labelled input (Search the writing); arole="status" aria-live="polite"line announces state changes; an intervening island<h2>"Results" keeps heading order h1 → h2 → h3 (card titles) with noheading-orderskip; the "More results" control is a real keyboard-operable<button>; the marigold field slash and snippet marks meet AA and are never the sole signal (the marked term also sits at weight 550, §5). Noloading.tsx; the island never callsnotFound(). - Acceptance: one h1; a valid query renders relevance-ordered hits with
<mark>snippets injected as HTML (no client re-sanitise); <2-char guard issues no request; empty query is a 200 EmptyState (no 404); "More results" appends vianextCursorand disappears when null;429/Problem surface as one voice line (no raw JSON);noindex+ sitemap-excluded; keyboard-operable and axe-clean.
404 — app/not-found.tsx
- One uniform design (§7; copy per §10A). Acceptance: identical output for unknown, DRAFT, and SCHEDULED paths; no
cause hint; link home; Next injects
noindexfor 404s automatically. SCHEDULED / DRAFT / unknown content paths return HTTP status 404 — never a soft-200 (permanent gate; proven over real HTTP with a productionnext start, not just the mocked suite — see §7 loading-vs-404 constraint and §11).
Error — app/error.tsx
- Client island (§7; copy per §10A). Acceptance: renders on thrown render/data error; Try again invokes
unstable_retry(); keyboard operable; does not leak raw server error text in production (framework default).
Loading — loading.tsx (deferred)
- Deferred this phase per the §7 loading-vs-404 constraint (a content-route loading boundary forces a
streamed 200 that defeats
notFound()). Noloading.tsxships; the app root has none. Acceptance: noloading.tsxabove anynotFound()-capable route; if a skeleton is ever added it is scoped only to a never-notFound()route (/,/tags), matches the resolved layout, and its pulse is disabled under reduced-motion.
8A. Shell mode — the /shell terminal (bflo.sh)
A full-screen, in-browser command line over the same content — the blog's world (files, paths, a tree) made literally operable. It is the deliberate exception to near-zero-JS (principle 2) and the one place the frontend talks to the API from the browser.
8A.1 Nature & isolation
- Route:
app/shell/page.tsx, a full-viewport'use client'island — the only sanctioned client-fetching surface. The server-rendered reading site (§3–§8) is untouched and keeps its ISR model. - No SSR content: the server renders an empty terminal frame and hydrates; all content is fetched live on
command. It is excluded from the sitemap and set
robots: noindex(an app, not a document). It has noloading.tsxand never callsnotFound(). - JS budget: exempt from the §14 reading-site budget; keep it dependency-light (a controlled input + an output log — no terminal library, no client-state library).
8A.2 Data path (browser → public API) — cross-tier dependency
- The shell calls contract v0.2.0 operations (plus v0.3.0
GET /searchforfind/grep) from the browser against the public API base (NEXT_PUBLIC_API_BASE), not the SSR-internal host used by Server Components (§13/§14). - This requires backend CORS: the production browser origin (
SITE_URL) must be on the API'sAccess-Control-Allow-Originallow-list, and the 120 rpm rate limit applies per browser client — the shell must handle429gracefully (8A.6), debounce autocomplete, and cache the tree for the session. - BLOCKING before build:
platform-engineer+contract-ownermust confirm the production public origin and the CORS / rate-limit posture for browser-origin calls. Raise as a task throughcontract-owner(the frontend does not assume backend internals). Until confirmed,/shellis dev-only against the local DDEV origin.
8A.3 Virtual filesystem model
The content tree is modelled as a POSIX-like read-only filesystem, resolved lazily from the contract:
- Root
~/= the content root. Directories = sections (getSectionTree/getSection); files = articles, shown with a.mdsuffix ({articleSlug}.md). - A session cache of
getSectionTree(cacheable ~1h; held in memory for the session) backsls/cd/tree/tab-completion without re-fetching.cwdis tracked in state and mirrored to the prompt. - Paths are contract
pathvalues;~/a/b↔ content patha/b. No write operations exist.
8A.4 Command set (behaviour → contract operation)
| Command | Behaviour | Contract op |
|---|---|---|
ls [path] |
list children (sub-sections + *.md) of cwd/path |
getSectionTree (cache) / getSection |
cd <path> |
change cwd (. .. ~ / supported); error if not a directory |
getSection (validate) |
pwd |
print cwd as ~/… |
— (state) |
tree [path] |
print the section subtree, indented | getSectionTree |
cat <post.md> |
print the article as readable text in the terminal (8A.5) | getArticle |
open <post.md> |
navigate the browser to the GUI reading view /{path} |
— (router push) |
tags |
list all tags with counts | listTags |
tag <slug> |
list articles for a tag (paged; --more continues via nextCursor) |
listArticlesByTag |
find <terms> (alias grep) |
search the writing; hits as ~/{path}.md {title} + a muted plain-text snippet line (HTML/<mark> stripped, §8A.5); paged with --more via nextCursor |
searchArticles (v0.3.0) |
help [cmd] |
list commands / show one command's usage | — (static) |
about / whoami |
the colophon / the author, in the blog's voice (8A.7) | — (static + SITE_*) |
history |
list this session's commands | — (state) |
clear |
clear the output log | — (state) |
- Tab-completion: completes command names and, positionally, paths (directories &
*.md) from the cached tree and tag slugs fromlistTags; cycles on repeatTab; debounced — never a call per keypress. - Arrow ↑/↓: walk
history. Ctrl-C: abort the current line. Unknown command / bad path: a one-line error in the interface voice (8A.7) — never a stack trace, never a raw HTTP body.
8A.5 cat rendering
getArticle returns sanitised HTML (Article.body, ADR-0007). The shell must not inject that HTML
into the terminal. It renders a readable plain-text projection: a mono header (path, title, authors · date · reading time) then the body as text (a lightweight HTML→text pass keeping headings, paragraphs, list
items, blockquotes, and code blocks as monospace; links rendered as text ⟨/path⟩). Long output scrolls
in the log; a trailing hint offers open {file} to read it in the GUI.
8A.6 Failure & rate limits
- RFC 9457 problem+json from the API is surfaced as a single mono line —
title(+detailwhen safe), never the raw JSON.429→ "Rate limited — the shell allows 120 requests a minute. Try again in a moment." Network / 5xx → "The API didn't answer. Try again." No apology, no internals (§10A voice).
8A.7 Styling, affordances & voice
- Terminal styling in the Opened File system: Geist Mono throughout;
--background/--foregroundfor the screen,--mutedfor secondary output,--accent(marigold) for the prompt, caret, and directory markers;--surfacefor the input row; both themes via the sameprefers-color-schemetokens (§5). No new palette. Prompt readsbflo.sh ~/path $(marigold$). - Entry: the masthead
$link (§4) →/shell. Way back: a persistent←"Back to reading" control in the shell chrome;open <post>(into the GUI); and the browser Back button all return to the reading site. - Voice: the shell is
bflo.sh— a real shell for a.shdomain. Terse, competent, dry;help,about, and errors speak in that voice (plain, active, no emoji, no marketing).whoami→ the author;about→ the colophon.
8A.8 Accessibility
- Full keyboard operability (it is a keyboard app): the input holds focus; every command is typed; no
pointer-only affordance. Focus management: focus returns to the input after each command; the
←back control and any links are tabbable. - Screen readers: the output log is an
aria-live="polite"region (role="log") so each result is announced; the input has a visible label /aria-label(Command input); the prompt's$is decorative (aria-hidden). Respectprefers-reduced-motion(no caret blink under reduce; §12). - Contrast: the marigold prompt/caret meet AA on both screens (§5).
8A.9 Types & reuse
- Reuse the generated contract types (§14 / F-1) through a browser-fetch variant of the client that
targets
NEXT_PUBLIC_API_BASEand forwards no server-only headers — same request/response types, distinct transport. Keep the SSR/ISR client and this browser client as two thin wrappers over one generated core; the shell imports only the browser variant.
8A.10 Acceptance
/shellis a single'use client'route; the reading site ships no other new client island.- Every command (8A.4) works from the browser (v0.2.0, plus v0.3.0
GET /searchforfind/grep);ls/cd/tree/completion use the cached tree;catrenders readable text (no HTML injection);findstrips the snippet HTML to a plain-text line;opendeep-links to the GUI. 429/ problem-details handled as one-line voice errors; no raw JSON, no stack traces.- Full keyboard operability; output announced via
aria-live; marigold-on-screen meets AA in both themes. noindex, excluded from sitemap; the browser client targets the public base and is the only client-fetching surface. The CORS / origin confirmation (8A.2) is recorded before production build.
9. Edge cases & resilience
| Case | Behaviour |
|---|---|
description === null |
Omit the description element entirely (card + meta description). Metadata falls back to site.tagline (GET /site, §13). |
authors === [] |
Omit the authors segment of MetaLine (no "By"). |
tags === [] |
Omit the tag row. |
updatedAt null or same rendered date as published |
Do not render "Updated …" (isMeaningfulUpdate, §10) — a same-day re-save / updatedAt == publishedAt shows a single date, not a redundant pair. |
| Empty section (no articles and no subsections) | EmptyState; section title/description still shown. |
| Unknown tag | 200 + EmptyState (never notFound()). |
| Unknown author (contract v0.5.0, ADR-0013) | 200 + EmptyState (never notFound()) — identical contract to unknown tag; the route slug is a route param, not derived. |
| Long title | Cards line-clamp-2; article <h1> not clamped; the PathLine scrolls inside its own box (overflow-x:auto; white-space:nowrap) rather than truncating or wrapping. (default — flag to change.) |
| Deep nesting | Header nav stays flat (curated site.nav entries, or top-level sections in fallback mode — §4); the PathLine shows the full path and scrolls horizontally in its own box (no depth cap / … collapse — the old Breadcrumbs 3-ancestor cap went away with the component); section shows immediate subsections only. |
| Empty ToC (or 1 entry) | OnThisPage hidden. |
Article at content root (sectionPath === "") |
PathLine = ~/{file}.md only (no section segments). |
prev and next both null (single article) |
PrevNext block not rendered. |
In-body <img> |
The sanitised body HTML (ADR-0007) arrives with absolute src values as of Track 4c/v0.4.0 (backend-rewritten; the frontend never parses or rewrites the injected HTML — §6). .prose img { max-width:100%; height:auto; border-radius:.375rem } still applies unconditionally, so a since-removed/unresolvable asset degrades to the browser's broken-image glyph with no reserved space and no shift, exactly as before. |
coverImageUrl (populated as of v0.4.0 — Track 4c) |
Non-null → renders CoverImage (article/section hero) and, on ArticleCard, the trailing thumbnail; null/absent → nothing rendered, no reserved box, strictly conditional (§7 CoverImage). |
10. Formatting rules
- Dates — deterministic, UTC-fixed (principle 4). A single util:
new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeZone: 'UTC' }).format(new Date(iso))→ "July 6, 2026". Wrap output in<time dateTime={iso}>. Never relative time ("2 days ago"). (localeen-US— user ruling 2026-07-06, not a flag-to-change default;dateStyle:'long'remains a default — flag to change.) - Reading time:
${readingMinutes} min read;readingMinutes === 0→ "< 1 min read". - Updated: phrased "Updated {date}", shown only for a meaningful update —
updatedAtpresent AND its rendered calendar date differs from published (isMeaningfulUpdate(publishedAt, updatedAt)comparesformatDateoutput, not raw instants). The published date always shows; a same-day re-save orupdatedAt == publishedAtrenders a single date (no redundant "Updated"). Applies to MetaLine + Gutter (§7). - Content paths (PathLine §7 / shell §8A): render as
~/{section}/{…}/{file}.md— a leading~/root,/-joined section segments, and the article slug with a.mdsuffix. Deterministic frompath. - Titles (§13):
%s — {site.title}(em dash;site.titlefromGET /site). (default — flag to change: separator.)
10A. Writing as design material (voice & microcopy)
Copy is design material, not decoration (frontend-design skill). The interface's words are held to the same bar as its spacing and type. Rules, applied to every string this document prescribes:
- Name things by what readers control and recognise — "writing", "articles", "sections", "tags", "the home page" — never by how the system is built. No framework/stack trivia in reader-facing copy (the footer colophon is an authored line about the blog, not "Built with Next.js").
- Active voice, sentence case, no filler. A control says exactly what it does and keeps the same verb through the flow ("Try again" retries; "Older articles →" pages older). One element, one job.
- Empty screens are an invitation to act, never a mood or a "coming soon". State plainly that there is nothing to read here and give the next move (home, all tags, another section). Never imply content is pending or differentiate why a surface is empty (the DRAFT/SCHEDULED/unknown indistinguishability gate, §7 EmptyState).
- Errors state what happened and how to recover, in the interface's voice. They never apologise and are never vague ("Something went wrong" fails this), and never leak raw server text (§8 Error).
- 404 stays uniform and cause-neutral (§8): it says the page isn't there and points home, revealing nothing about DRAFT/SCHEDULED/unknown.
Prescribed copy (tightened against the above — supersedes earlier strings in §7/§8):
| Surface | Ship this (heading / sentence) | Not this |
|---|---|---|
| Empty section (§8) | Nothing here to read / "Browse the latest writing on the home page." | "No articles here yet." (implies coming soon) |
| Empty/unknown tag (§8) | No articles tagged "{tag}" / "Browse every tag, or head to the home page." | bare "No articles tagged '{tag}'." |
| Empty tags catalogue (§8) | No tags yet / "Browse the latest writing on the home page." | — |
| Empty/unknown author (§8, contract v0.5.0) | No articles by "{author}" / "Browse every author, or head to the home page." | bare "No articles by '{author}'." |
| Empty authors catalogue (§8) | No authors yet / "Browse the latest writing on the home page." | — |
| 404 (§7/§8) | Page not found / "That link didn't lead anywhere. Head to the home page to keep reading." + link home | any apology or cause hint |
| Error (§7/§8) | This page didn't load / "The problem is on our end, not your link. Try again, or head to the home page." + Try again | "Something went wrong." (vague) |
| Footer colophon (§4) | a short authored line in the writer's voice | "Built with Next.js" (system trivia) |
| Pagination (§7) | "Older articles →" (active, honest about direction) | (keep — already good) |
| Home thesis (§8) | <h1> "Writing, kept like source." (site.thesis from GET /site, authored in metadata.json; null → falls back to site.title) + a site.tagline note |
the bare site name as a heading |
| Article meta (§7) | Ada Lovelace · June 12, 2026 · 6 min read · Updated June 20, 2026 · path ~/systems/files-as-source-of-truth.md · < 1 min read at 0 |
"By …", relative time |
| Shell errors (§8A) | one mono line, active, no internals — e.g. "Rate limited — the shell allows 120 requests a minute. Try again in a moment." | raw JSON / stack traces / apologies |
| Search empty (§8) | No matches for “{q}” / "Try different or fewer words, or browse the latest writing on the home page." | "No results found." (dead end) |
| Search hint / error (§8) | "Type at least two characters to search." · "Search is busy — it allows 120 requests a minute. Try again in a moment." · "Search isn't available right now. Try again in a moment." | "Invalid query" / raw problem+json |
The polish-pass design plan may refine tone to match the chosen visual voice, but must stay within these rules; update this table in the same task if a string changes.
11. Accessibility checklist (WCAG 2.2 AA — F-11 turns each into an axe/RTL assertion)
- Skip link is the first focusable element and targets
#main-content. - Landmarks: exactly one
<header>,<main id="main-content">,<footer>;<nav>s are labelled (Primary— the masthead,Footer— the footer echo,Breadcrumb— the PathLine on the article (filevariant) and the section/catalogue (directoryvariant),On this page,Article details— the Gutter). Browse-page rails are labelled regions (<section aria-labelledby>named "Latest"/"Sections"/"Articles"/"Subsections" — unique per page, solandmark-uniqueholds). On/shell: a labelled command input + arole="log" aria-live="polite"output region (§8A.8). - Exactly one
<h1>per page (article/page title). Body prose headings are h2–h6 as authored. :focus-visiblering on every interactive element:outlineorring-2in--accentwith 2px offset; never remove focus outlines without a replacement.- AA contrast in both themes (Opened File, §5): foreground ~16.7:1 (light) / ~14.9:1 (dark); muted
~6.1:1 / ~7:1; marigold accent-as-text ~4.9:1 (light) / ~8.1:1 (dark). The redesign's
.tintpanels (rails, empty states, pills) are only a small step from--background, so foreground/muted/accent stay AA on them too. Verified by axe (the whole-page a11y suite covers Home/Article/Section/Tags/Search). - Colour is never the sole signal. In-prose/content links (the
.prosebody,.empty-messagecopy) carry a persistent 1px underline (offset 2px, easing to 3px on hover). UI/nav links (cards, header, footer, PathLine, pagination, PrevNext, OnThisPage, AuthorLinks, tag/author pills, search) carry no underline — their non-colour signal is the always-present:focus-visibleaccent ring (item 4) plus weight/marked treatment where present (e.g. the PathLine leaf); hover is a colour shift only. This split is deliberate (review round 2): a reading page should underline the links inside prose and keep chrome quiet. prefers-reduced-motion: reducedisables skeleton pulse, transitions, and smooth scroll (§12).- Full keyboard operability: nav, links, pagination, ToC/gutter ticks, PathLine segment links, the
error.tsxretry button, and the entire/shell(§8A.8). - Link text is descriptive ("Older articles", article titles) — no "click here"; icon-only affordances have accessible names.
<html lang="en">set (already inlayout.tsx).- Images:
altis author-controlled (from markdown) and cannot be injected by the frontend this phase; documented as a content responsibility, not a frontend gate.
12. Motion & interaction
A restrained, CSS-only entrance system (extended in review round 2 from "one moment" to "a quiet system"
— the user asked for more animation in the composition; the bar stays editorial, never bouncy, never
JS-driven, and everything is disabled under prefers-reduced-motion). No animation library. Every animation
below is a one-shot both-filled CSS keyframe that runs at first paint, so it needs no JS and re-plays on
each client navigation.
- The path line, everywhere. Every top-of-page path line (article
filePathLine, section/cataloguedirectoryPathLine, tag/author/search inlineleaf, and the Home~/eyebrow) carries.pathline-enter— a 4px lift + fade (pathline-rise, ~0.5s). Its single marked current-location leaf carries.file-mark--enter— the marigold mark wipes in left→right (scaleX(0→1),transform-origin: left). One marked leaf, one wipe, per page load (card path eyebrows stay static so the wipe never scatters). - Section dividers draw in. The eyebrow-rule (
SectionLabel) hairline+tick draws in from the left (rule-draw,scaleX(0→1), ~0.5s) on page enter. - Card lists settle in. Browse card lists (
.list-enteron Home Latest/Sections, Section Articles/Subsections, tag/author listings) fade + rise 6px with a capped stagger (card-rise, ~0.45s; children step 45ms apart, the 6th onward share the last delay so long lists never cascade for seconds). The Home featured lead uses the single-element form (.rise-in). Search results are excluded (they re-render on every keystroke — animating each would be over-animation). - Static caret. The home thesis carries a non-blinking mono caret (a marigold block) — a plain-text
signal, not an effect. (The
/shellcaret may blink slowly only when motion is allowed; §8A.8.) - Hover:
transition-colors(~150ms) — UI/nav links & card titles shift to--accent(no underline), TagPill & directory entries → accent, gutter ticks brighten; in-prose links additionally ease their underline offset 2px→3px (§11.6); the.prosesmooth-scroll for ToC anchor jumps. - Scrollspy — the reading position. As you read, the OnThisPage current entry (
TocScrollSpy, §7) quietly shifts to the marigold "current chapter" mark (ink + weight 550 + the--markhighlight + a filled tick) with a ~150ms colour/background ease, tracking scroll in both directions. This is the reading site's one stateful motion (position feedback), driven by an IntersectionObserver, still CSS-transitioned and reduced-motion-safe; keep-in-view scrolling inside the rail is instant under reduced motion. - Focus:
:focus-visiblering (§11.4). Active: the OnThisPage current entry (above); no other active state. - Reduced motion:
@media (prefers-reduced-motion: reduce) { *,*::before,*::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } }— the global reset covers the entire system: the mark shows statically (no wipe), path lines don't lift, section rules and card lists appear in place (no draw/rise/fade), prose-link offsets don't ease, and no caret blinks. Any future motion MUST stay CSS-only so this one rule keeps covering it. - No scroll-jacking, parallax, scroll-triggered reveals, or JS-driven motion on the reading site — the entrances are page-load only, not scroll-linked.
13. SEO & metadata patterns
Set in app/layout.tsx (root) + per-page generateMetadata (Server-Component only; params/searchParams
are Promises — await them).
- Site identity, footer lines, and header nav are CONTRACT-sourced (
GET /site, contract v0.6.0→v0.7.0 / ADR-0014/0015), not env/hardcode. The masthead/footer name, the<title>default+template, the default meta description, the home hero (thesis+note), the feed channel, the footer memo + copyright, and the headernav[]all readgetSiteMeta()→{ title, tagline, thesis, memo, copyright, nav }, resolved server-side from the root contentmetadata.jsonsiteblock. The user rebrands, edits the colophon, and curates the header nav by editing that file — no env var, no rebuild.getSiteMeta()is the single seam: one ISR window (300s default; the feed passes its own 3600s) and one fallback — it NEVER throws, degrading toSITE_META_FALLBACK({ title: 'Field Notes', tagline: '', thesis: 'Writing, kept like source.', memo: null, copyright: null, nav: [] }) if the API is unreachable, so a site-meta failure can't 500 the chrome on every page. Null semantics:memo/copyrightnull → footer omits the memo and renders its© {year}default;navempty → no curated links. The formerSITE_NAME/SITE_TAGLINE/SITE_THESISenv tokens were removed (round-2 follow-up). - Root layout:
generateMetadata(async — it fetchesgetSiteMeta):metadataBase: new URL(env.siteUrl);title: { default: site.title, template: '%s — ' + site.title }; defaultdescription = site.tagline; RSS discovery viaalternates: { types: { 'application/rss+xml': '/feed.xml' } }→<link rel="alternate" type="application/rss+xml">. (A staticmetadataexport no longer works — the values are fetched.) - Description fallback chain:
article.description(orsection.description) →site.tagline(fromGET /site; may be""). Never derived frombody. - Canonical: each page sets
alternates.canonicalto itsSITE_URL-based path. Cursor pages (?cursor=…) set canonical to the base path without the cursor (avoids duplicate-content indexing of opaque paginated URLs). - OpenGraph / Twitter: articles →
openGraph.type:'article'(+publishedTimefrompublishedAt,authorsfromauthors); other pages →type:'website'.title,description,url,siteName.og:image(Track 4c / v0.4.0):openGraph.images: [coverImageUrl]when the article/section supplies a non-null cover, omitted entirely (not an empty array) when null —og:imagestays absent exactly as it did pre-v0.4.0 for a coverless piece. Twitter stayscard:'summary'(not'summary_large_image'— kept simple; revisit only if a future pass wants a larger Twitter preview). - Env tokens are now URL/transport only (server-read):
SITE_URL(the public https site origin),API_BASE_URL,API_INTERNAL_BASE_URL, the one client-exposedNEXT_PUBLIC_API_BASE(browser/shell, §8A), andREVALIDATE_SECRET(Track 4d / ADR-0016) — the shared HMAC secret the/api/revalidatewebhook receiver verifies signatures with (§14).REVALIDATE_SECRETis strictly server-only: read viaenv.revalidateSecret(src/lib/env.ts) inside the route handler, never given aNEXT_PUBLIC_prefix, never referenced from client code — leaking it would let anyone forge cache-bust requests. Server-side content fetches use the internal base URL (see §14). The display-identity tokensSITE_NAME/SITE_TAGLINE/SITE_THESISwere removed — that identity is contract-sourced now (the bullet above;GET /site). The home<h1>statement issite.thesis(nullable — falls back tosite.titlewhen the metadata declares none); the home note + default meta description aresite.tagline. - Host-split topology (base URLs). The client and the API are served from separate hosts, and the API
carries no
/apiprefix — the version segment/v1is the full base path:SITE_URL— the bare host the client is served from:https://publishing-platform.ddev.site(prodhttps://bflo.sh). Origin only, no path.API_BASE_URL— the public API on its ownapi.subdomain:https://api.publishing-platform.ddev.site/v1(prodhttps://api.bflo.sh/v1). Used only to build absolute canonical/sitemap/feed URLs, never for server-side fetching.API_INTERNAL_BASE_URL— the container-internal address every server/SSR/build-time fetch uses:http://web/v1(the DDEVwebservice,/v1prefix). The public HTTPS cert is untrusted inside the frontend container (UNABLE_TO_VERIFY_LEAF_SIGNATURE), so content fetching never goes over the public URL server-side.NEXT_PUBLIC_API_BASE— the browser-visible public API base for/shell(§8A.2); points at the sameapi.subdomain (https://api.publishing-platform.ddev.site/v1, prodhttps://api.bflo.sh/v1). It is the one client-exposed token (inlined at build via theNEXT_PUBLIC_prefix). Full token list + prod values:frontend/.env.example.
- Sitemap (
app/sitemap.ts,MetadataRoute.Sitemap): enumerate/, every article path (page through alllistArticlesvianextCursor), every section path (flattengetSectionTree),/tagsand each/tags/{slug},/authorsand each/authors/{slug}(listAuthors, contract v0.5.0), and the curatedsite.navpaths (getSiteMeta, contract v0.7.0), deduped against the above. The nav paths matter because an unlisted page (ADR-0015, e.g./about-me) is gone fromlistArticles— without re-includingsite.navit would silently drop out of the sitemap. Absolute URLs fromSITE_URL;lastModified = updatedAt ?? publishedAtfor articles./shellis excluded (an app;noindex, §8A.1).export const revalidate = 3600. - Feed (
app/feed.xml/route.ts): RSS 2.0 with full-content items (Track 4e) — a frontend-derived artifact of the published API: items fromlistArticles(latest 20), each body from the already-publishedgetArticle(no contract change; the fetches are ISR-cached,content-tagged, and deduped with the article pages). Channel =site.title(GET /site) /SITE_URL/site.tagline, plus<atom:link rel="self" type="application/rss+xml">and<lastBuildDate>(newest itempubDate). Item = title, absolute linkSITE_URL/{path}, guid = the same absolute URL withisPermaLink="true"(changed from the bare path — correct RSS semantics; the one-time re-delivery in subscribed readers was accepted pre-launch),pubDate(RFC-822 frompublishedAt), one<dc:creator>per byline author (display name), one<category>per tag, escapeddescription(article.description ?? ''), and<content:encoded>carrying the rendered, sanitised HTML body in CDATA (literal]]>occurrences split the CDATA section). Namespacescontent/dc/atomon thersselement. If a singlegetArticlefails, that item degrades to summary-only (logged) rather than failing the feed. Mermaid fences degrade to their visible source code block in feed readers (no client island there — ADR-0012); documented behavior, not fought. In-body asset URLs are already absolute (api host, ADR-0011) and are not rewritten. Unlisted pages (ADR-0015) are derived out automatically —listArticlesnever returns them. Atom, JSON Feed, and per-section/per-tag feeds are explicit non-goals.Content-Type: application/rss+xml; charset=utf-8;export const revalidate = 3600with thecontentcache tag, so the Track 4d loop busts it on content changes.
14. Performance budget
- No client-side content fetching — all content is fetched in Server Components (SSR/ISR).
- Site identity fetch (
GET /site→getSiteMeta, contract v0.6.0). A normal ISR-cached server fetch (default 300s window; the feed passes its own 3600s), reached by several chrome consumers (layout metadata, Header, Footer, home hero, per-page metadata, feed). Next's Data Cache dedupes the identical/siteGET, so a whole render/build issues it once and serves the rest from cache — it does not add a per-page network request. It is fault-tolerant by design (unlike content wrappers): any error resolves toSITE_META_FALLBACKrather than throwing, so the shell chrome never 500s on a site-meta blip. - Rebrand freshness. The user edits the root content
metadata.jsonsiteblock to rebrand. The API serves the new values on the next request (mtime-keyed content cache, ADR-0014); the frontend shows them as soon as the next reindex tick relays a change webhook (thesitetag, next bullet) — worst case the ISR window (≤300s pages, ≤3600s feed) as the safety net. - On-demand revalidation (Track 4d, SHIPPED — contract v0.8.0 / ADR-0016). Freshness is event-driven:
file save → backend reindex tick (≤1 min) →
content.*events → relay → signed webhookPOST /api/revalidate→revalidateTag→ next request re-renders fresh (typically <60s, worst ~90s, zero container restarts).- Cache-tag scheme — exactly two broad tags (
frontend/src/lib/api/cacheTags.ts; the names are CONTRACT values, thetagsenum ofContentChangedNotification):contenton every content wrapper inlib/api/client.ts(articles, sections, tags, authors, search) andsiteongetSiteMeta. Every server fetch carriesnext: { revalidate, tags }, so onerevalidateTag('content')busts all pages AND the feed/sitemap route handlers — their internal fetches carry the same tags. Broad-two-tag invalidation is the deliberate starting point at this content scale; per-path precision is a later refinement. - The receiver (
app/api/revalidate/route.ts): verifiesX-Content-Signature: sha256=<hex>— an HMAC-SHA256 of the raw body under the server-onlyREVALIDATE_SECRET(§13) — in constant time (node:cryptotimingSafeEqual) BEFORE parsing; 401 problem+json and no invalidation otherwise. Payload tags are constrained to the known-tag allowlist, then expired immediately viarevalidateTag(tag, { expire: 0 })(the Next 16 webhook form; the deprecated single-arg form and the stale-while-revalidate'max'profile are both wrong here). Idempotent by design — at-least-once delivery from the relay is safe. Non-POST → 405; never cached (force-dynamic).
- Cache-tag scheme — exactly two broad tags (
- ISR (route segment config, non-Cache-Components model):
export const revalidate = 300on article/section/home/tag pages;= 3600on sitemap and feed — unchanged by Track 4d; these windows are the SAFETY NET under the event-driven path above (e.g. relay/webhook outage).dynamicParams = truegives SSR fallback for paths not enumerated bygenerateStaticParams. Do not enablecacheComponentsinnext.configthis phase (it removesrevalidate; see §-NEXT16). A newly-due SCHEDULED article appears via the same loop: the reindex tick that makes it public emitscontent.article-published, which webhooks through within ~90s. - No layout shift: conditional cover (never reserved; a present cover's
aspect-[2/1]/aspect-squareframe is sized by CSS before load, §7 CoverImage), self-hosted Fraunces / Newsreader / Geist Mono vianext/fontwithsize-adjustfallbacks (no FOUT/FOIT, §6). No loading skeletons ship (deferred — §7/§15); SSR content resolves server-side, so the first paint is the final layout with nothing to swap. - JS budget (reading site): framework runtime + the
error.tsxisland on every page — no component/UI library, no client state library, no client markdown/sanitiser, nonext/image(Track 4c covers use a plain lazy<img>by deliberate choice, not omission — § CoverImage has the rationale)./shell(§8A) is a separate, sanctioned client island with its own dependency-light budget and is the only browser-fetching surface; it does not affect the reading pages' budget. - Diagram carve-out (ADR-0012): an article page whose body contains a
```mermaidfence additionally loads a lazily-imported, version-pinnedmermaidchunk on demand — a separate async chunk requested by the Mermaid island only on detection, never in the route's main entry (verifiable in the build's chunk graph /ddev frontend npm run verify). Diagram-free pages ship and load zero mermaid JS. The island itself (§7 MermaidDiagram) is one of the two fetch-free article-page islands (§2), and does not change the near-zero-JS posture of any page without a diagram. The OnThisPage scrollspy (§7, §12) is the other: a dependency-free island whose only cost is a small always-present chunk (an IntersectionObserver + the active-mark logic), which fetches nothing and adds no per-page network request.
15. Out of scope (Phase 4+)
manual theme toggle; next/image (deliberately not adopted for covers either — plain <img>, §
CoverImage/§14); comments; author identity (accounts, bios, avatars, canonical IDs — ADR-0013 keeps
authors a free-label string; a future superseding ADR would be needed to add this); on-demand revalidation
wiring (content.article-published); active-nav highlight on the fallback section/Tags links (only
the curated site.nav entries carry active state, via the NavLink client boundary — §4, contract v0.7.0;
extending it to the fallback mode's server-rendered links stays out of scope);
a default Authors link in the masthead nav (deliberately not added — nav-crowding on a
mostly-single-author blog; /authors is reachable from the footer's Browse column and from every byline,
and under the total-nav model an author can list authors in site.nav themselves, §4); inline heading hover-anchor icons; full-content RSS;
loading skeletons / loading.tsx (deferred by the §7 loading-vs-404 constraint — a content-route
loading boundary streams a 200 that defeats notFound(); may return only scoped to a never-notFound()
route like / or /tags).
No longer out of scope as of Track 4c / contract v0.4.0: cover imagery and the /assets endpoint — see
§7 CoverImage, §8 (Article/Section), §9, §13.
No longer out of scope as of Track 4b / contract v0.5.0: author browsing (/authors,
/authors/{author}) — see §7 (AuthorLinks, AuthorPill), §8 (Authors catalogue, Author listing), §9, §13.
Author identity (above) remains out of scope; browsing free-label authors is not identity (ADR-0013).
16. Traceability — doc sections → Phase 3 tasks
| Task | Scope | Primary sections |
|---|---|---|
| F-4 shell / tokens / prose | layout, globals.css, header/footer/skip, root error/not-found (no root loading.tsx — §7 loading-vs-404 constraint) | §4, §5, §6, §7 (Header/Nav/Footer, ErrorState, NotFound, Loading), §11, §12 |
| F-5 home | / page, ArticleCard, SectionCard, Pagination |
§3, §7 (ArticleCard, SectionCard, Pagination, MetaLine), §8 (Home) |
| F-6 catch-all resolution | [...slug] article→section→404, generateStaticParams, generateMetadata |
§3 (resolution order), §8 (routing), §13, §14 |
| F-7 article | body injection, MetaLine, OnThisPage, PrevNext, PathLine breadcrumb | §6, §7 (MetaLine, OnThisPage, PrevNext, PathLine), §8 (Article), §13 |
| F-8 section | subsections, articles, pagination, empty section | §7 (SectionCard, PathLine, Pagination), §8 (Section), §9 |
| F-9 tags | /tags catalogue, /tags/[tag] listing, empty/unknown |
§7 (TagPill, EmptyState), §8 (Tags, Tag listing), §9 |
| F-10 sitemap / feed | sitemap.ts, feed.xml/route.ts |
§3, §13 (sitemap, feed), §14 |
| F-11 tests | axe/RTL assertions from the a11y + edge-case + acceptance specs | §8 (acceptance), §9, §11 |
| F-12 verify | conformance spot-check of the whole doc + live-API proof | §1 + §2A (binding), all |
§2A (Design approach) and §10A (writing) bind every frontend UI task, not a single row. The settled "Opened File" identity (§2A.3, §5, §6, §7 PathLine + Gutter, §8, §12) and shell mode (§8A) are built by the approved redesign + shell workflow, superseding the Phase-3 provisional visuals. A task that ships correct, accessible, green output that still looks templated is not done (§1, §17,
CLAUDE.md).
17. References & binding
.claude/skills/frontend-design/SKILL.md— the design skill embedded as normative by §2A/§10A.CLAUDE.md— makes applying that skill part of the frontend Definition of Done (frontend design quality).docs/master-blueprint.md§9 — earlier product/design notes; this document wins for frontend specifics (§1).- Binding: this document (§2A, §10A) and
CLAUDE.mdtogether bind all frontend UI work — new screens and reshapes alike — to the frontend-design skill. "Renders correctly + passes verify" is necessary but not sufficient; the result must look intentional, not templated.
Appendix — Next.js 16.2.10 conventions verified against frontend/node_modules/next/dist/docs/
Corrections vs. common (older) Next.js knowledge — implementers must follow these:
error.tsxrecovery prop isunstable_retry(added v16.2.0), notreset. Signature:{ error: Error & { digest?: string }, unstable_retry: () => void }.resetstill exists but the docs recommendunstable_retry; this doc usesunstable_retry.paramsandsearchParamsare Promises in pages, layouts,generateMetadata, and route handlers (Next 15+) — await them (e.g.const { slug } = await params).export const revalidateis valid only under the non-Cache-Components model. Next 16 removes it whencacheComponentsis enabled. Ournext.config.tsdoes not enable it — keep it that way in Phase 3.- GET route handlers default to dynamic (uncached) since v15 — the
/feed.xmlhandler must setexport const revalidate = 3600explicitly to be ISR-cached. sitemap.tsreturnsMetadataRoute.Sitemapand is a cached special route handler; setexport const revalidate = 3600to match the 1-hour policy.app/not-found.tsx(root) doubles as the app-wide unmatched-route 404; trigger it withnotFound()fromnext/navigation.global-not-found.jsis experimental and not used.