Skip to content
bflo.sh

The design system — an opened file

The normative UI/UX definition of the reader: paper-and-ink palette, serif voice, the PathLine signature — every state specified.

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 the frontend-design skill (.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 /shell and /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 (coverImageUrl on ArticleSummaryFields/Section, resolving through GET /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 via next/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

  1. 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.
  2. Server-rendered, near-zero client JS. Server Components by default. The site ships five client islands: error.tsx (framework-forced), the deliberately-scoped /shell terminal (§8A), the /search experience (§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). /shell and /search are 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), a usePathname reader that fetches nothing and only toggles aria-current on the site.nav entry for the current route — outside the five content islands and immaterial to the JS budget.
  3. 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.
  4. Deterministic output. ISR-cached pages must render identically regardless of when they render: UTC-fixed date formatting, no relative time, no per-request randomness.
  5. Resilient to missing data. Every nullable/empty contract field has a defined visual behaviour that never reserves empty space or shifts layout.
  6. Accessible by construction. WCAG 2.2 AA is a build gate (§11), not a review nicety.
  7. 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, *.md files 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).

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 / 03 markers 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:

  1. 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 --mark highlight 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.
  2. 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 lg it 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:

  1. warm cream background (~#F4F1EA) + high-contrast serif display + terracotta accent;
  2. near-black background + a single acid-green or vermilion accent;
  3. 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)

  1. 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.
  2. 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.
  3. 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).
  4. 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('/')):

  1. Try article: getArticle(path). On 200 → render Article page (§8).
  2. On 404, try section: getSection(path). On 200 → render Section page (§8).
  3. 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 components utilities in globals.css:
    • .shell = --shell (72rem) — centred, horizontal padding 1.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) below lg (single reading column, ~66ch) and at lg+ widens to max-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 at lg+ 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 at max-w-[48rem], Tag-listing at max-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; below lg the measure caps the column, and at lg+ the type size (not a width cap) holds the line length.
  • Reading column — the measure: --measure = 40rem (≈640px) below lg — a ~66-character measure at the 18px body (§6). At lg+ 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: on lg (≥1024px) the article hangs its Geist-Mono marginalia rail in the left margin beside the reading column (a left-aligned grid: [--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 201 / -1 is 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). Below lg the 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, translucent bg-background/85 backdrop-blur-md, hairline border-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.title from GET /site, §13) in the display face (Fraunces, font-display font-medium tracking-tight), the whole lockup linking to /. The ~/ is aria-hidden so 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 on site.nav:
      • Curated mode — site.nav non-empty: GET /site returns nav[] (NavItem{label,path}); when non-empty it is the entire primary navigation — the author curates the whole masthead from the root metadata.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 (list tags in the nav to keep it), and the section-tree fetch is skipped entirely.
      • Fallback mode — site.nav absent/empty: the previously-derived nav, unchanged: top-level sections from getSectionTree (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; /authors is reachable from the footer Browse column and from every byline (AuthorLinks). Under the total-nav model an author who disagrees just adds an authors entry to site.nav.
    • Active-nav highlight (curated entries). Each curated entry renders via NavLink, a tiny usePathname client boundary (the one place the reading site opts into active-nav state) that marks the current entry with aria-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.)
  • 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-reading inside it, above); pages set their own inter-block rhythm (space-y-*).
  • Footer (<footer>), mt-24 py-12, border-t border-border, Geist-Mono text-xs text-muted, inner .shell as 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>), and site.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.xml and 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-request Date (principle 4).
  • Breakpoints: mobile-first; sm (640px) for inline spacing/type and the shell padding step, and lg (1024px) for the article marginalia gutter and the browse-page rails (Home/Section two-zone, §8). Below lg, rails stack below the main column and the article is a single reading column; at lg the 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 --mark highlight, 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-xl for cover frames, media bands, rails, and empty states; rounded-full for 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 below lg — at lg+ the reading column is fluid and the measure is held by the body type step-up, §6/§4), --rail: 16rem (the article gutter on lg+, 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 prose scroll-margin-top clear it). (The former --article-shell 59rem cap was removed in round 2 — the article fills the shell.) Depth is built from hairline --border + the --surface tint (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>, prose h2/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 from next/font/google, Geist Mono from geist/font/mono (or next/font/google) — exposing the CSS variables named above. Replace the boilerplate body { font-family: Arial… } with font-body.
  • 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 via dangerouslySetInnerHTML. 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 via prose-* CSS variables — headings → --font-display (Fraunces), body → --font-body (Newsreader) at 18px/1.72, links → --accent with 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-none lets prose fill the reading column (--measure 40rem, §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, --code background, mono font (plugin default). Never wrap/clip.
  • Tables: display:block; overflow-x:auto gives 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:auto only. 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. No next/image for 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 matching TocEntry.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-top above 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. Description font-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 by border-b border-border with py-7 — an editorial index, not boxed tiles.
  • Cover thumbnail (Track 4c, v0.4.0): when coverImageUrl is non-null, an aspect-[4/3] image (w-28 sm:w-40, rounded-lg border border-border, object-cover, bg-surface while 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/image rationale).

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 ArticleSummary fields as ArticleCard. Anatomy: a wide media band → path eyebrow → Fraunces title (link, 1.3rem — the SAME size as every card title) → the description (Newsreader 1.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 .lede role is reserved for page heroes — Home thesis, article stand-first — never list items) → MetaLine → optional tags.
  • Media band: when coverImageUrl is present, a lazy <img> in a framed aspect-[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, default h2) so it joins the page outline — its accessible name is exactly the label text; the trailing rule is an empty aria-hidden span (never pollutes the name). Pass id when 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 alt names 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, comparing formatDate output, not raw instants), so a same-day re-save or updatedAt == publishedAt shows 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) on lg+ and inline below lg; 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 --muted joined by / (slashes aria-hidden); the final segment in --foreground, non-link, weight 550, with a --mark highlight (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 (PathLine variant="file", default — the Article page): ~/{dirs…}/{name}.md. Every sectionPath segment is an ancestor link; the .md filename is the marked current leaf.
    • directory (PathLine variant="directory" — the Section page and the /tags · /authors catalogues): ~/{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-studies reads ~/case-studies/, not "Home / Case Studies". The catalogues are the tags/ and authors/ directories.
    • leaf (inline on /tags/{tag} · /authors/{author} listings and /search): ~/{parent}/{name} — a marked leaf with no .md and 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; /search is a decorative (aria-hidden) mark since the <h1> names it. (Not the PathLine component — a lightweight inline <p>.)
  • Placements: (1) article header — the file variant above the <h1> (<nav aria-label="Breadcrumb">); the filename mark animates in on load (§12). (2) Section header + Tags/Authors catalogues — the directory variant. (3) Listings + Search — the inline leaf echo. (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 ~// are aria-hidden; segment links carry the path slug as their accessible name; the current leaf is aria-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). The AUTHOR row's value is AuthorLinks (below) — same linked-name treatment as MetaLine. Layout: on lg+ 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); below lg it collapses to the inline MetaLine + OnThisPage block under the <h1>. The ToC section is omitted when < 2 entries (as OnThisPage).
  • Rail spans every grid row (fix #2, review round 2): the rail uses grid-row: 1 / span 20, NOT 1 / -1. -1 resolves 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 at max-height: calc(100vh − --header-h − 3rem) with overflow-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 respects scroll-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 (as alt).
  • Anatomy: a single <img> in an aspect-[2/1] frame (rounded-xl border border-border, object-cover, bg-surface as a neutral loading tile). Placement (redesign — a real wide hero): Article — after PathLine and above the .article-grid, spanning the full .article-reading width, which at lg+ 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>, not next/image (deliberate — §14): the asset endpoint (ADR-0011) serves originals byte-for-byte with a long-lived immutable Cache-Control — no resizing/transcoding by design. Routing covers through Next's image optimizer would re-encode those immutable bytes (fighting the contract) and would need images.remotePatterns tracking two hosts across environments (the DDEV api. subdomain and prod api.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 coverImageUrl is null. When present, the aspect-[2/1] frame fixes the box's size from CSS alone before the image loads; object-cover fills it regardless of the source's real aspect ratio, so the cover cannot shift layout either.
  • a11y: alt is 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 ```mermaid fenced 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 injected Article.body DOM at #article-body (the scan root the Article page gives it). Renders null.
  • Behaviour (ADR-0012): after hydration it scans #article-body for code.language-mermaid fences (the class the sanitiser preserves verbatim); only if ≥1 exists does it dynamically import('mermaid'), so a diagram-free article ships and loads zero mermaid JS (§14). Each fence's textContent (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 a role="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/grabbing cursors, mouse/pen only — a touch swipe keeps scrolling the page via touch-action: pan-y) + double-click to reset + arrow-key panning while the surface is focused (+/ zoom, 0 resets). 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 zoom to scroll 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) and aria-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 earlier transform: scale() blur was a compositor caching a small 1× raster and GPU-scaling it; removing will-change helped 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 a translate() 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: hidden on 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> via showModal() (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.
  • 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 pinned mermaid.
  • Security (ADR-0012): the mermaid dependency is pinned to an exact version (package.json) and initialised with securityLevel: 'strict' (startOnLoad: false) — no raw HTML in labels, no click/script bindings. The only injected HTML is mermaid's own strict SVG output; the retained source is set via textContent, never innerHTML.
  • Theme: chosen from prefers-color-scheme (§5, no manual toggle) — dark on Ink, neutral on Paper (a calm editorial greyscale, diagram text in Geist Mono via the --font-mono var); 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 an aria-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-visible ring (§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 a Tag object exists (the /tags catalogue from listTags) — its label and count.
  • Slug-vs-label display ruling (default — flag to change): ArticleSummary.tags is bare slugs (kebab-case); the human Tag.label exists only on listTags. Ruling: display the human label where listTags supplies it (the /tags catalogue), 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 encode GraphQL, 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 fetching listTags (3600s-cacheable) and mapping slug→label with a slug fallback — nicer card labels at the cost of a listTags dependency on those pages; if chosen, state that the label's source is listTags and 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").
  • 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. from ArticleSummary.authors / Article.authors).
  • Slugs are server-authoritative (ADR-0013, contract v0.5.0): every byline author arrives as an AuthorRef carrying both the display name and its slug, derived server-side by one canonical derivation shared by bylines, the /authors catalogue (Author.slug), and the by-author filter — a byline's slug always equals its matching catalogue item's slug. AuthorLinks links by AuthorRef.slug verbatim; there is no client-side derivation — the earlier lib/format.ts authorSlug() 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 /authors catalogue — 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-published AuthorRef.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} or Section.{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 in font-body text-muted, omitted when null. Borderless, same py rhythm 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.
  • The old Home / … / This Section Breadcrumbs component was deleted. A section is a browsable folder in the file metaphor, so it wears the PathLine directory variant (~/{…}/{section}/, § PathLine) — the same signature the article uses, not a decorative humanised trail. This fixes the round-2 complaint that /case-studies wrongly 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 async getSectionTree title-resolution it needed is gone too. The <nav aria-label="Breadcrumb"> landmark and the ancestor-link/aria-current a11y 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 #id links (each ·-prefixed in --accent, data-toc-link, title in a .toc-title span). 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 a flex items-start row (tick shrink-0, title min-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) on lg+ (sticky, in the left margin, scrolls independently — §7 Gutter); inline above the prose (after the inline MetaLine) below lg. 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 match TocEntry.id) with an IntersectionObserver and sets aria-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.scrollBy on .gutter-rail, never the page) and only when the rail overflows — it never fights the user's own rail scroll, and honours prefers-reduced-motion (instant, not smooth).
    • Visual (§12, Opened File language): the active entry reads as the current location — full --foreground ink, weight 550, and the marigold mark highlight behind the title (the .file-mark device, §2A.3), its · tick filling into a small solid marigold block; non-active entries stay --muted. Driven by the a[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.

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 ← Previous slot (uppercase mono label + title) left, a Next → 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 when nextCursor is 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 calling unstable_retry(). Minimal, keyboard operable. reset is not used (superseded — see §13/NEXT16 notes). Root app/error.tsx plus 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 by notFound() from next/navigation.
  • 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.tsx creates a Suspense boundary whose fallback Next streams with a committed HTTP 200 before the async page resolves. If that page then throws notFound(), the status is already 200 — a soft-404 (the not-found body renders but the status stays 200). Therefore no loading.tsx may sit above any route that can call notFound(). The catch-all [...slug] (article→section→404) and tags/[tag] (via nothing here, but the app root spans it) are the primary content routes and can notFound(), so at the app root there must be no root loading.tsx, and no per-route loading.tsx on [...slug] or tags/[tag].
  • Decision (Phase 3): the root loading.tsx is removed; loading skeletons are deferred (see §15). A scoped loading.tsx is permitted only on a route that never notFound()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.thesis from GET /site, e.g. "Writing, kept like source.", closed by a static mono caret; when thesis is null it falls back to site.title so there is always exactly one non-empty h1), and a Newsreader lede (site.taglineomitted entirely when empty, never an empty <p>) — then a two-zone band (lg:grid-cols-[minmax(0,1fr)_17rem]):
    • LATEST (main column): a SectionLabel eyebrow-rule (h2), then a prominent FeaturedArticle lead (listArticles newest) + the remaining ArticleCard rows + Pagination (listArticles pageSize 10 newest total). (default — flag to change: pageSize 10.)
    • SECTIONS (browse rail): a sticky .browse-rail (the margin motif, §2A.3) with a plain eyebrow h2, the SectionCard directory (getSectionTree top level), and a "Browse all tags →" link. Below lg the rail stacks under LATEST. Exactly one <h1> (the thesis).
  • The <h1> is the thesis (site.thesis, not the bare site name — site.title lives in the masthead). Copy authored in the content metadata.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). Null thesis → h1 = site.title; empty tagline → no note line.
  • Metadata: default title (site.title); description = site.tagline (both from GET /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 →" iff meta.nextCursor non-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 (.shell outer + .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-reading is left-aligned to the shell gutter. PathLine (full path, filename segment marked — §7) → optional CoverImage hero (Track 4c, §7 — spans the full .article-reading width, wider than the prose) → <article class="article-grid"> [ <h1> title (Fraunces) → optional lede (the article description, .lede, §6) → on lg+ the Gutter rail in the left margin / below lg the inline MetaLine + OnThisPage.prose body via dangerouslySetInnerHTML (Newsreader, §6) → PrevNext ]. On lg+ 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 (file variant) is the article's breadcrumb — the Breadcrumbs component was removed in round 2; sections now use the PathLine directory variant too (§7).
  • Data mapping: getArticle(path); path→PathLine, title→h1, descriptionlede (omitted when null), authors/publishedAt/updatedAt/readingMinutes→Gutter/MetaLine, tableOfContents→OnThisPage (in the Gutter on lg+), body→prose, tags→TagPills, prev/next→PrevNext, coverImageUrlCoverImage (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; ```mermaid fences 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}; OpenGraph type:'article' with publishedTime/authors and siteName = site.title, plus images:[coverImageUrl] when non-null (Track 4c — §13).
  • Acceptance: exactly one h1 (the title); PathLine present with the filename segment marked; body via dangerouslySetInnerHTML with no markdown/sanitiser dependency; ToC ticks resolve to body heading ids; the Gutter on lg+ collapses to inline meta below lg; PrevNext hidden when both null; empty authors/ tags and null description/updatedAt degrade cleanly; a null coverImageUrl renders no cover and no reserved box, a non-null one renders with the title as alt and 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 (SectionLabel h2 + ArticleCard list from articles, DIRECT children only + Pagination meta.nextCursor) as the main column beside a sticky Subsections .browse-rail (plain eyebrow h2 + SectionCard list from subsections). 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. Below lg the rail stacks.
  • Note the asymmetry: Section.articles are direct children only; do not imply subtree contents.
  • States: empty section (no articles and no subsections) → 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}; OpenGraph siteName = 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 null coverImageUrl renders 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 ~/ tags mono eyebrow, <h1> "Tags", a Newsreader lede (max-w for readability) — then a generous TagPill wrap from listTags (each label · count, .tint pill) 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 (only listTags carries a human label, and this page does not fetch it under the default ruling).
  • Layout (.shell outer + left-aligned max-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 (the tags segment links to /tags, the {tag} segment marked) → <h1> "Tagged: {tag}" (slug) → an sr-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 from listArticlesByTag (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 no heading-order skip (axe, unsuppressed); unknown tag renders EmptyState with HTTP 200; pagination honors nextCursor.

Authors catalogue — /authors (contract v0.5.0, Track 4b)

  • Layout (.shell, full width): identical structure to the Tags catalogue — a ~/ authors mono eyebrow, <h1> "Authors", a Newsreader lede, then an AuthorPill wrap from listAuthors (each name · 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 fetch listAuthors to resolve a display name (same "no cross-fetch" ruling as /tags/{tag}, § AuthorLinks).
  • Layout (.shell outer + left-aligned max-w-[54rem]): identical structure to Tag listing — a ~/ authors / {author} mono path line (the authors segment 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:") — → an sr-only <h2>Articles</h2> (same heading-order bridge as Tag listing) → ArticleCard list from listArticlesByAuthor (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 honors nextCursor.

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, NULLABLE snippet (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 (.shell outer + left-aligned max-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' SearchExperience island (src/components/SearchExperience.tsx). The island is the third sanctioned client-fetching surface (§2 principle 2): it fetches from the browser via browserClient.searchArticles against NEXT_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 on focus-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 q validation, 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 optional snippet rendered as trusted HTML (matched terms wear the same marigold --mark as 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 + AbortController drop 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); a role="status" aria-live="polite" line announces state changes; an intervening island <h2> "Results" keeps heading order h1 → h2 → h3 (card titles) with no heading-order skip; 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). No loading.tsx; the island never calls notFound().
  • 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 via nextCursor and 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 noindex for 404s automatically. SCHEDULED / DRAFT / unknown content paths return HTTP status 404 — never a soft-200 (permanent gate; proven over real HTTP with a production next 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()). No loading.tsx ships; the app root has none. Acceptance: no loading.tsx above any notFound()-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 no loading.tsx and never calls notFound().
  • 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 /search for find/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's Access-Control-Allow-Origin allow-list, and the 120 rpm rate limit applies per browser client — the shell must handle 429 gracefully (8A.6), debounce autocomplete, and cache the tree for the session.
  • BLOCKING before build: platform-engineer + contract-owner must confirm the production public origin and the CORS / rate-limit posture for browser-origin calls. Raise as a task through contract-owner (the frontend does not assume backend internals). Until confirmed, /shell is 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 .md suffix ({articleSlug}.md).
  • A session cache of getSectionTree (cacheable ~1h; held in memory for the session) backs ls/cd/tree/tab-completion without re-fetching. cwd is tracked in state and mirrored to the prompt.
  • Paths are contract path values; ~/a/b ↔ content path a/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 from listTags; cycles on repeat Tab; 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 (+ detail when 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/--foreground for the screen, --muted for secondary output, --accent (marigold) for the prompt, caret, and directory markers; --surface for the input row; both themes via the same prefers-color-scheme tokens (§5). No new palette. Prompt reads bflo.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 .sh domain. 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). Respect prefers-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_BASE and 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

  • /shell is 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 /search for find/grep); ls/cd/tree/completion use the cached tree; cat renders readable text (no HTML injection); find strips the snippet HTML to a plain-text line; open deep-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"). (locale en-USuser 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 — updatedAt present AND its rendered calendar date differs from published (isMeaningfulUpdate(publishedAt, updatedAt) compares formatDate output, not raw instants). The published date always shows; a same-day re-save or updatedAt == publishedAt renders 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 .md suffix. Deterministic from path.
  • Titles (§13): %s — {site.title} (em dash; site.title from GET /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)

  1. Skip link is the first focusable element and targets #main-content.
  2. 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 (file variant) and the section/catalogue (directory variant), On this page, Article details — the Gutter). Browse-page rails are labelled regions (<section aria-labelledby> named "Latest"/"Sections"/"Articles"/"Subsections" — unique per page, so landmark-unique holds). On /shell: a labelled command input + a role="log" aria-live="polite" output region (§8A.8).
  3. Exactly one <h1> per page (article/page title). Body prose headings are h2–h6 as authored.
  4. :focus-visible ring on every interactive element: outline or ring-2 in --accent with 2px offset; never remove focus outlines without a replacement.
  5. 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 .tint panels (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).
  6. Colour is never the sole signal. In-prose/content links (the .prose body, .empty-message copy) 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-visible accent 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.
  7. prefers-reduced-motion: reduce disables skeleton pulse, transitions, and smooth scroll (§12).
  8. Full keyboard operability: nav, links, pagination, ToC/gutter ticks, PathLine segment links, the error.tsx retry button, and the entire /shell (§8A.8).
  9. Link text is descriptive ("Older articles", article titles) — no "click here"; icon-only affordances have accessible names.
  10. <html lang="en"> set (already in layout.tsx).
  11. Images: alt is 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 file PathLine, section/catalogue directory PathLine, tag/author/search inline leaf, 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-enter on 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 /shell caret 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 .prose smooth-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 --mark highlight + 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-visible ring (§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 header nav[] all read getSiteMeta(){ title, tagline, thesis, memo, copyright, nav }, resolved server-side from the root content metadata.json site block. 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 to SITE_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/copyright null → footer omits the memo and renders its © {year} default; nav empty → no curated links. The former SITE_NAME/SITE_TAGLINE/SITE_THESIS env tokens were removed (round-2 follow-up).
  • Root layout: generateMetadata (async — it fetches getSiteMeta): metadataBase: new URL(env.siteUrl); title: { default: site.title, template: '%s — ' + site.title }; default description = site.tagline; RSS discovery via alternates: { types: { 'application/rss+xml': '/feed.xml' } }<link rel="alternate" type="application/rss+xml">. (A static metadata export no longer works — the values are fetched.)
  • Description fallback chain: article.description (or section.description) → site.tagline (from GET /site; may be ""). Never derived from body.
  • Canonical: each page sets alternates.canonical to its SITE_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' (+ publishedTime from publishedAt, authors from authors); 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:image stays absent exactly as it did pre-v0.4.0 for a coverless piece. Twitter stays card:'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-exposed NEXT_PUBLIC_API_BASE (browser /shell, §8A), and REVALIDATE_SECRET (Track 4d / ADR-0016) — the shared HMAC secret the /api/revalidate webhook receiver verifies signatures with (§14). REVALIDATE_SECRET is strictly server-only: read via env.revalidateSecret (src/lib/env.ts) inside the route handler, never given a NEXT_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 tokens SITE_NAME/SITE_TAGLINE/SITE_THESIS were removed — that identity is contract-sourced now (the bullet above; GET /site). The home <h1> statement is site.thesis (nullable — falls back to site.title when the metadata declares none); the home note + default meta description are site.tagline.
  • Host-split topology (base URLs). The client and the API are served from separate hosts, and the API carries no /api prefix — the version segment /v1 is the full base path:
    • SITE_URL — the bare host the client is served from: https://publishing-platform.ddev.site (prod https://bflo.sh). Origin only, no path.
    • API_BASE_URL — the public API on its own api. subdomain: https://api.publishing-platform.ddev.site/v1 (prod https://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 DDEV web service, /v1 prefix). 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 same api. subdomain (https://api.publishing-platform.ddev.site/v1, prod https://api.bflo.sh/v1). It is the one client-exposed token (inlined at build via the NEXT_PUBLIC_ prefix). Full token list + prod values: frontend/.env.example.
  • Sitemap (app/sitemap.ts, MetadataRoute.Sitemap): enumerate /, every article path (page through all listArticles via nextCursor), every section path (flatten getSectionTree), /tags and each /tags/{slug}, /authors and each /authors/{slug} (listAuthors, contract v0.5.0), and the curated site.nav paths (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 from listArticles — without re-including site.nav it would silently drop out of the sitemap. Absolute URLs from SITE_URL; lastModified = updatedAt ?? publishedAt for articles. /shell is 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 from listArticles (latest 20), each body from the already-published getArticle (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 item pubDate). Item = title, absolute link SITE_URL/{path}, guid = the same absolute URL with isPermaLink="true" (changed from the bare path — correct RSS semantics; the one-time re-delivery in subscribed readers was accepted pre-launch), pubDate (RFC-822 from publishedAt), one <dc:creator> per byline author (display name), one <category> per tag, escaped description (article.description ?? ''), and <content:encoded> carrying the rendered, sanitised HTML body in CDATA (literal ]]> occurrences split the CDATA section). Namespaces content/dc/atom on the rss element. If a single getArticle fails, 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 — listArticles never 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 = 3600 with the content cache 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 /sitegetSiteMeta, 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 /site GET, 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 to SITE_META_FALLBACK rather than throwing, so the shell chrome never 500s on a site-meta blip.
  • Rebrand freshness. The user edits the root content metadata.json site block 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 (the site tag, 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 webhook POST /api/revalidaterevalidateTag → 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, the tags enum of ContentChangedNotification): content on every content wrapper in lib/api/client.ts (articles, sections, tags, authors, search) and site on getSiteMeta. Every server fetch carries next: { revalidate, tags }, so one revalidateTag('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): verifies X-Content-Signature: sha256=<hex> — an HMAC-SHA256 of the raw body under the server-only REVALIDATE_SECRET (§13) — in constant time (node:crypto timingSafeEqual) BEFORE parsing; 401 problem+json and no invalidation otherwise. Payload tags are constrained to the known-tag allowlist, then expired immediately via revalidateTag(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).
  • ISR (route segment config, non-Cache-Components model): export const revalidate = 300 on article/section/home/tag pages; = 3600 on 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 = true gives SSR fallback for paths not enumerated by generateStaticParams. Do not enable cacheComponents in next.config this phase (it removes revalidate; see §-NEXT16). A newly-due SCHEDULED article appears via the same loop: the reindex tick that makes it public emits content.article-published, which webhooks through within ~90s.
  • No layout shift: conditional cover (never reserved; a present cover's aspect-[2/1]/aspect-square frame is sized by CSS before load, §7 CoverImage), self-hosted Fraunces / Newsreader / Geist Mono via next/font with size-adjust fallbacks (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.tsx island on every page — no component/UI library, no client state library, no client markdown/sanitiser, no next/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 ```mermaid fence additionally loads a lazily-imported, version-pinned mermaid chunk 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.md together 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:

  1. error.tsx recovery prop is unstable_retry (added v16.2.0), not reset. Signature: { error: Error & { digest?: string }, unstable_retry: () => void }. reset still exists but the docs recommend unstable_retry; this doc uses unstable_retry.
  2. params and searchParams are Promises in pages, layouts, generateMetadata, and route handlers (Next 15+) — await them (e.g. const { slug } = await params).
  3. export const revalidate is valid only under the non-Cache-Components model. Next 16 removes it when cacheComponents is enabled. Our next.config.ts does not enable it — keep it that way in Phase 3.
  4. GET route handlers default to dynamic (uncached) since v15 — the /feed.xml handler must set export const revalidate = 3600 explicitly to be ISR-cached.
  5. sitemap.ts returns MetadataRoute.Sitemap and is a cached special route handler; set export const revalidate = 3600 to match the 1-hour policy.
  6. app/not-found.tsx (root) doubles as the app-wide unmatched-route 404; trigger it with notFound() from next/navigation. global-not-found.js is experimental and not used.