diff --git a/docs/design.md b/docs/design.md
index 4728b40..70bb905 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -205,6 +205,13 @@ Deprecated until WP10: `.card` and `.cards` (now flat `--ground-2` panels; move
`.rows`), `.pill` and `.pill.accent` (aliases of `.chip` and `.chip.signal`) and
`.button.primary` (a plain `.button` is the filled one).
+The page head of `/explorer/`, `/breaches/` and every spec page carries a decorative drawing
+(`Plate.astro`, WP8): a small delegation chain drawn from the page id and title by
+`src/lib/plate.ts`, so a page always gets the same one. It is the first child of
+`header.page-head`, floats beside the title from 64rem up and is left out below that. It has no
+frame and uses the existing inks only: `--signal` for the path, `--boundary` for the trust
+boundary dash, hairlines for the rest. It never goes on a page with a data view above the fold.
+
The header scrolls away with the page, so `--header-h` (what sticky content must clear) is 0.
Product surfaces go in `product`. The matrix (`MatrixTable`, `StatusCell`, `ObservationDetail`)
diff --git a/public/og-breaches.png b/public/og-breaches.png
new file mode 100644
index 0000000..f199d74
Binary files /dev/null and b/public/og-breaches.png differ
diff --git a/public/og-explorer.png b/public/og-explorer.png
new file mode 100644
index 0000000..99f6ce1
Binary files /dev/null and b/public/og-explorer.png differ
diff --git a/scripts/generate-brand-assets.ts b/scripts/generate-brand-assets.ts
index 761c226..4d4523c 100644
--- a/scripts/generate-brand-assets.ts
+++ b/scripts/generate-brand-assets.ts
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
import { chromium } from "@playwright/test";
import { cellKey, currentRevision, matrixFor } from "../src/lib/atlas";
import { getDataset } from "../src/lib/data";
+import { plate, plateSvg, SECTION_PLATES, sectionCard } from "../src/lib/plate";
// Run with `pnpm generate:brand` after changing public/brand, the tokens or the data. No network
// requests are made.
@@ -95,6 +96,40 @@ try {
letsauth.eu
`);
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: "public/og.png" });
+
+ // The section roots that carry a decorative plate (WP8) get a card of their own: the same layout
+ // as the home card, with the page's drawing beside the headline as it sits in the page head.
+ for (const [id, section] of Object.entries(SECTION_PLATES)) {
+ await page.setContent(`
+
+
+
${section.eyebrow}
+
${section.title}
+
+ ${plateSvg(plate(id, section.title))}
+
letsauth.eu/${id}/
`);
+ await page.evaluate(() => document.fonts.ready);
+ await page.screenshot({ path: `public${sectionCard(id as keyof typeof SECTION_PLATES)}` });
+ }
} finally {
await browser.close();
}
diff --git a/specs/016-visual-redesign/plan.md b/specs/016-visual-redesign/plan.md
index b866429..5b6a875 100644
--- a/specs/016-visual-redesign/plan.md
+++ b/specs/016-visual-redesign/plan.md
@@ -616,7 +616,17 @@ per page; the draw-in (M4) runs once and is static under reduced motion; the dra
appears on a page that already has a data view above the fold (matrix, map, chain, report); a
`plate: false` prop removes it.
-Decision for Jan: ship this, or keep letsauth's visuals strictly data-derived. The plan
+Decided (22 October 2026, review follow-up 10): Jan chose to ship it on `/explorer/`,
+`/breaches/` and the spec pages only. `/mcp/` and `/standards/` keep their data view as the one
+visual, so the drawing stays off them; the cards are per section root (explorer and breaches),
+and spec pages keep the home card. As built, the drawing departs from the file list above: a
+first version (a network on its own plate under the head, one of eight hues) read as added on
+top, so it is a miniature delegation chain in the explorer's vocabulary and existing inks
+(`--signal` path, `--boundary` dash, hairlines), floated beside the title inside the page head
+on wide screens and left out below 64rem. A page leaves it out by not placing ``, so no
+`plate: false` prop is needed.
+
+Original decision for Jan: ship this, or keep letsauth's visuals strictly data-derived. The plan
recommends shipping it on the four section roots and spec pages only, because those pages have
no diagram of their own and the drawing is what makes the two sites read as a family. Skipping
it costs nothing else in the plan.
@@ -673,7 +683,7 @@ Decisions for Jan before WP1 starts
Grotesque for h1 only.
2. **Neutral hue**: cool graphite like the blog (recommended, hue 250 to 255) or keep the warm
paper of today (hue 85) with the new flat treatment.
-3. **WP8 plate**: ship or skip (section WP8).
+3. **WP8 plate**: ship or skip (section WP8). Decided: ship, on the explorer, breach and spec pages.
4. **Search palette**: in WP10 scope or not at all.
If no answer arrives, the packages proceed with the recommended option in each case.
diff --git a/specs/016-visual-redesign/tasks.md b/specs/016-visual-redesign/tasks.md
index 2d5b980..adb1f03 100644
--- a/specs/016-visual-redesign/tasks.md
+++ b/specs/016-visual-redesign/tasks.md
@@ -170,7 +170,24 @@ Taken as review follow-up 9. Legal content keeps its substance; only the wording
- [x] W9-T04 `README.md` and `docs/*.md` pass `check:prose`; `FAIL` is true in `scripts/check-prose.ts`, which also bans "game-changer" and "holistic" from the review's list; `docs/development.md` says the check fails
- [x] W9-T05 `/about/`, `/contribute/`, `/made-in-europe/`: one idea per sentence, no semicolons, no "X, not Y" principle names, no "yours to download"
- [x] W9-T06 `/privacy/`, `/imprint/`, `/disclaimer/`, `/404`: wording only, no semicolons in body copy, headings without the colon-and-triad pattern; legal content unchanged
-- [ ] W9-T07 Screenshot baselines for the pages whose head changed, through the `refresh-screenshots` label
+- [x] W9-T07 Screenshot baselines for the pages whose head changed, through the `refresh-screenshots` label (on the WP8 pull request, which refreshes the same pages)
+
+## WP8: Decorative plate
+
+One calm, label-free drawing in the head of the pages that have no diagram of their own, as the
+blog does. Jan chose to ship it on `/explorer/`, `/breaches/` and the spec pages only (review
+follow-up 10); `/mcp/` and `/standards/` keep their data view as the one visual. A first version
+(a network on its own framed plate under the head, in one of eight hues) read as something added
+on top, so the drawing now speaks the explorer's language and sits in the head itself.
+
+- [x] W8-T01 `src/lib/plate.ts`: the pure generator. An FNV-1a hash of the page id and title seeds a small generator; a miniature delegation chain of 3 to 5 actor heads on lifelines and 4 to 6 messages, of which a chain (each message sent by the lane the previous one reached) is the highlighted path, and one dashed trust boundary around one or two lifelines. `plateSvg()` writes the SVG with classes only, no colours, `aria-hidden`
+- [x] W8-T02 Colours from the existing tokens only: lifelines `--rule`, heads `--ground-2` on `--rule-strong`, quiet messages `--rule-strong`, the path `--signal`, the boundary dash `--boundary` (plan 3.2: "trust boundary dashes"); no new tokens
+- [x] W8-T03 `src/components/Plate.astro`: the first child of `header.page-head`, floated beside the title at 18rem from 64rem up, no frame, hidden below 64rem; the path draws in once on load with a halo that fades after and its ends appear when it reaches them (M4, `--dur-draw`, `--ease-draw`), only under `prefers-reduced-motion: no-preference`
+- [x] W8-T04 Placed in the page head of `/explorer/`, `/breaches/` and every `/standards//` page
+- [x] W8-T05 `scripts/generate-brand-assets.ts`: `public/og-explorer.png` and `public/og-breaches.png` from the same generator and seed, the drawing beside the headline as in the page head; `Base.astro` takes an `image` prop for the card
+- [x] W8-T06 `tests/unit/plate.test.ts` (same input, same SVG, as a snapshot; different pages, different drawings; every plate in bounds, its path a chain and under 4 kB) and `tests/e2e/plate.spec.ts` (in the head and hidden from assistive technology on the three page types, absent on home, `/mcp/`, `/standards/` and a chain, beside the title on wide screens and hidden on phones, the section cards, axe, the reduced-motion end state)
+- [x] W8-T07 `src/pages/accessibility.astro` lists the draw-in and its reduced-motion form; the plan records the decision
+- [x] W8-T08 Screenshot baselines for the pages it changes: refreshed through the `refresh-screenshots` label on the pull request
## Review follow-ups (21 October 2026)
@@ -186,3 +203,4 @@ Items from the redesign review, one pull request each, in order.
- [x] R-07 Home figures and copy: the facts read Servers, Requirements, Cells observed and Specifications. "Cells observed" counts the current matrix cells that have an observation, beside the total (611 of 897), in place of the 1,664 entries of the append-only history, which counted every scan of the same cell again; `Facts.astro` takes an optional `of` total that stands still while the figure counts up. "Latest observations" shows the six newest observations of the current revision with one row per requirement and per server, so one scan run no longer fills the panel with six rows of the same feature. The seed-data sentence under the list is gone (the server page and `/contribute/` no longer carried one). No baseline changes: the home screenshot stops above the facts
- [x] R-08 The explorer as a scene: one Web Animations API timeline (a target-less scene clock) holds the playhead for Play, Pause, Previous, Next, a scrub bar in the stepper, the arrow keys, a click on a step and the decision log (M6). Auto-advance starts once the chain has sat in view for 1 s with no input, stops for good on hover, touch, focus, scroll or a key, never restarts and never runs under reduced motion; M6 in the plan is amended. Per step the sender and receiver light up in the actor head, the message draws in over 300ms, earlier steps keep a quieter arrow and later ones a hairline (colour, not opacity, so axe passes while stepping) (M5). The changed claims get their rail 40ms apart once the message lands (M7), now through the Web Animations API instead of an `is-fresh` class. A toggle swaps the chain in place through a same-document view transition of 220ms and pushes its URL; back and forward swap again, the analytics count the swap as a pageview (M17). A quiet log under the chain gains one line per step (M18). Durations come from the motion tokens (`--dur-message`, `--stagger` and `--dur-step` are new) through `durationToken()` in `src/lib/motion.ts`. Baselines through the `refresh-screenshots` label
- [x] R-09 Content pass (WP9 above): section-root headlines under 24 characters, the title suffix and footer statement, an accessibility statement that lists every motion with its reduced-motion form, `check:prose` as a gate, and the about, contribute, made-in-Europe, legal and 404 pages in plain English
+- [x] R-10 Decorative plate (WP8 above): a deterministic miniature delegation chain in the head of the explorer and breach indexes and every spec page, beside the title on wide screens, drawn in once (M4) and complete under reduced motion, with social cards for the two section roots from the same generator
diff --git a/src/components/Plate.astro b/src/components/Plate.astro
new file mode 100644
index 0000000..f67d619
--- /dev/null
+++ b/src/components/Plate.astro
@@ -0,0 +1,110 @@
+---
+import { plate, plateSvg } from "@/lib/plate";
+
+// The decorative plate (plan WP8): a small label-free delegation chain in the explorer's own
+// vocabulary (actor heads, lifelines, messages, the trust boundary dash), set into the page head
+// beside the title on wide screens. Derived from the page's id and title, so it never changes
+// between builds. Hidden from assistive technology and on narrow screens. The highlighted path
+// draws in once on load (M4); under reduced motion it is drawn complete. A page that shows data
+// above the fold does not place it. Place it as the first child of `header.page-head`.
+interface Props {
+ id: string;
+ title: string;
+}
+
+const { id, title } = Astro.props;
+---
+
+
+
+
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
index dc19314..2b15c30 100644
--- a/src/layouts/Base.astro
+++ b/src/layouts/Base.astro
@@ -15,9 +15,11 @@ interface Props {
path?: string;
/** When set, the page is excluded from search indexing and the sitemap hint. */
noindex?: boolean;
+ /** The social card, a path under public/. Defaults to the home card. */
+ image?: string;
}
-const { title, description, path, noindex = false } = Astro.props;
+const { title, description, path, noindex = false, image = "/og.png" } = Astro.props;
const site = Astro.site ?? new URL("https://letsauth.eu");
const canonical = new URL(path ?? Astro.url.pathname, site);
const plausibleDomain = import.meta.env.PUBLIC_PLAUSIBLE_DOMAIN as string | undefined;
@@ -101,7 +103,7 @@ const nav = [
-
+
{/* --ground in sRGB: oklch(98.5% 0.003 250) and oklch(21% 0.008 255) (pnpm check:contrast --hex) */}
diff --git a/src/lib/plate.ts b/src/lib/plate.ts
new file mode 100644
index 0000000..0ab951a
--- /dev/null
+++ b/src/lib/plate.ts
@@ -0,0 +1,156 @@
+/**
+ * The decorative plate (specs/016-visual-redesign plan, WP8): a small label-free delegation chain
+ * in the explorer's own vocabulary, derived from a page's id and title so that the same page
+ * always gets the same drawing and two pages rarely share one. Three to five actors as heads on
+ * lifelines, four to six messages between them, one of which chain is the highlighted path, and
+ * one dashed trust boundary around one or two lifelines. Pure: no randomness, no clock, no DOM.
+ */
+
+export const PLATE_WIDTH = 320;
+export const PLATE_HEIGHT = 200;
+
+const PAD_X = 28;
+const HEAD_Y = 6;
+const HEAD_W = 34;
+const HEAD_H = 12;
+const FIRST_ROW = 46;
+const LAST_ROW = PLATE_HEIGHT - 18;
+
+export interface PlateMessage {
+ from: number;
+ to: number;
+ y: number;
+ /** On the highlighted path. */
+ path: boolean;
+}
+
+export interface Plate {
+ /** The x of each lifeline, left to right. */
+ lanes: number[];
+ messages: PlateMessage[];
+ /** The dashed trust boundary, a rounded rectangle around one or two lifelines. */
+ boundary: { x: number; y: number; width: number; height: number };
+}
+
+/** FNV-1a, 32 bit. */
+function hash(text: string): number {
+ let h = 0x811c9dc5;
+ for (let i = 0; i < text.length; i++) {
+ h ^= text.charCodeAt(i);
+ h = Math.imul(h, 0x01000193);
+ }
+ return h >>> 0;
+}
+
+/** mulberry32: a small seeded generator, so the drawing depends on the seed alone. */
+function random(seed: number): (n: number) => number {
+ let a = seed;
+ return (n) => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return Math.floor((((t ^ (t >>> 14)) >>> 0) / 4294967296) * n);
+ };
+}
+
+export function plate(id: string, title: string): Plate {
+ const pick = random(hash(`${id}\n${title}`));
+ const laneCount = 3 + pick(3);
+ const step = (PLATE_WIDTH - 2 * PAD_X) / (laneCount - 1);
+ const lanes = Array.from({ length: laneCount }, (_, i) => Math.round(PAD_X + i * step));
+
+ // The path is a chain of messages, each sent by the lane the previous one reached, so it reads
+ // as authority handed on. Two or three quieter messages sit between its steps.
+ const rowCount = 4 + pick(3);
+ const gap = (LAST_ROW - FIRST_ROW) / (rowCount - 1);
+ const pathRows = new Set([0, rowCount - 1]);
+ while (pathRows.size < Math.min(rowCount - 2, 3 + pick(2))) pathRows.add(1 + pick(rowCount - 2));
+ const other = (lane: number) => {
+ const next = pick(laneCount - 1);
+ return next >= lane ? next + 1 : next;
+ };
+ const messages: PlateMessage[] = [];
+ let at = pick(laneCount);
+ for (let r = 0; r < rowCount; r++) {
+ const y = Math.round(FIRST_ROW + r * gap);
+ if (pathRows.has(r)) {
+ const to = other(at);
+ messages.push({ from: at, to, y, path: true });
+ at = to;
+ } else {
+ const from = pick(laneCount);
+ messages.push({ from, to: other(from), y, path: false });
+ }
+ }
+
+ const span = laneCount > 3 && pick(2) === 0 ? 2 : 1;
+ const first = pick(laneCount - span + 1);
+ const left = lanes[first] as number;
+ const right = lanes[first + span - 1] as number;
+ const boundary = {
+ x: left - HEAD_W / 2 - 6,
+ y: HEAD_Y - 4,
+ width: right - left + HEAD_W + 12,
+ height: PLATE_HEIGHT - HEAD_Y,
+ };
+
+ return { lanes, messages, boundary };
+}
+
+/**
+ * The drawing as SVG markup. Classes, not colours: `.pb` boundary, `.pa` actor heads, `.pl`
+ * lifelines, `.pm` quiet messages, `.ph` the halo under the path, `.pp` the highlighted path
+ * (`pathLength` 1, for the draw-in), `.po` its ends. The page or the social card generator styles them with tokens.
+ */
+export function plateSvg(p: Plate): string {
+ const lane = (i: number) => p.lanes[i] as number;
+ const arrow = (m: PlateMessage) => {
+ const dir = m.to > m.from ? 1 : -1;
+ const x = lane(m.to) - dir * 4;
+ return `M${lane(m.from)} ${m.y}H${x}M${x - dir * 5} ${m.y - 3}L${x} ${m.y}L${x - dir * 5} ${m.y + 3}`;
+ };
+ const route = p.messages
+ .filter((m) => m.path)
+ .map((m, k) => `${k === 0 ? "M" : "V"}${k === 0 ? `${lane(m.from)} ` : ""}${m.y}H${lane(m.to)}`)
+ .join("");
+ const ends = p.messages
+ .filter((m) => m.path)
+ .map((m) => ``)
+ .join("");
+ const heads = p.lanes
+ .map(
+ (x) =>
+ ``,
+ )
+ .join("");
+ const lifelines = p.lanes.map((x) => `M${x} ${HEAD_Y + HEAD_H}V${PLATE_HEIGHT}`).join("");
+ const quiet = p.messages
+ .filter((m) => !m.path)
+ .map(arrow)
+ .join("");
+ const { x, y, width, height } = p.boundary;
+ return (
+ ``
+ );
+}
+
+/**
+ * The section roots that carry a plate, with what their social card says. The page and the card
+ * generator (`scripts/generate-brand-assets.ts`) seed the drawing from the same id and title, so
+ * the card shows the page's drawing.
+ */
+export const SECTION_PLATES = {
+ explorer: { title: "Delegation chains", eyebrow: "Delegation chain explorer" },
+ breaches: { title: "Agent auth failures", eyebrow: "Breach anatomy" },
+} as const;
+
+/** The social card of a section root, written by `pnpm generate:brand`. */
+export const sectionCard = (id: keyof typeof SECTION_PLATES) => `/og-${id}.png`;
diff --git a/src/pages/accessibility.astro b/src/pages/accessibility.astro
index 31800ff..4aaa67d 100644
--- a/src/pages/accessibility.astro
+++ b/src/pages/accessibility.astro
@@ -19,6 +19,11 @@ const motions = [
what: "The longest builds-on path draws itself in once, with a soft halo that fades after.",
reduced: "The path is drawn complete, with no halo.",
},
+ {
+ where: "Explorer, breaches and standard pages",
+ what: "On wide screens, a small drawing beside the page title draws its path in once, with a soft halo that fades after. It is decoration and screen readers skip it.",
+ reduced: "The drawing shows complete, with no halo.",
+ },
{
where: "Explorer: steps",
what: "On each step the sender and receiver light up and the message arrow draws in over 300 ms. The changed token claims get a rail that fades over 480 ms.",
diff --git a/src/pages/breaches/index.astro b/src/pages/breaches/index.astro
index 3ae63ad..a121ea5 100644
--- a/src/pages/breaches/index.astro
+++ b/src/pages/breaches/index.astro
@@ -1,8 +1,10 @@
---
+import Plate from "@/components/Plate.astro";
import Base from "@/layouts/Base.astro";
import { breachList } from "@/lib/breaches";
import { getDataset } from "@/lib/data";
import { PARTY_LABEL, TOGGLE_KIND_LABEL, VULNERABILITY_CLASS_LABEL } from "@/lib/labels";
+import { SECTION_PLATES, sectionCard } from "@/lib/plate";
import { REPO } from "@/lib/site";
const data = getDataset();
@@ -21,9 +23,11 @@ const date = (d: string) =>
title="Breach anatomy"
description={`${breaches.length} published vulnerabilities and incidents in how agents were authorized, each traced to the control that was missing and the atlas requirements that name it.`}
path="/breaches/"
+ image={sectionCard("breaches")}
>
+
Breach anatomy
Agent auth failures
diff --git a/src/pages/explorer/index.astro b/src/pages/explorer/index.astro
index 2f4c7fe..47f35a6 100644
--- a/src/pages/explorer/index.astro
+++ b/src/pages/explorer/index.astro
@@ -1,8 +1,10 @@
---
+import Plate from "@/components/Plate.astro";
import Base from "@/layouts/Base.astro";
import { getDataset } from "@/lib/data";
import { scenarioList } from "@/lib/explorer";
import { TOGGLE_KIND_LABEL } from "@/lib/labels";
+import { SECTION_PLATES, sectionCard } from "@/lib/plate";
const data = getDataset();
const scenarios = scenarioList(data);
@@ -23,9 +25,11 @@ for (const { scenario } of scenarios) {
title="Delegation chain explorer"
description={`${scenarios.length} scenarios of how authority moves through agent systems: ${stepCount} steps, each with the decoded token and the specification section it follows, and ${toggleCount} attack and misconfiguration toggles.`}
path="/explorer/"
+ image={sectionCard("explorer")}
>
+
Delegation chain explorer
Delegation chains
diff --git a/src/pages/standards/[id].astro b/src/pages/standards/[id].astro
index a719897..19be7fd 100644
--- a/src/pages/standards/[id].astro
+++ b/src/pages/standards/[id].astro
@@ -2,6 +2,7 @@
import Citation from "@/components/Citation.astro";
import Crumbs from "@/components/Crumbs.astro";
import MaturityTag from "@/components/MaturityTag.astro";
+import Plate from "@/components/Plate.astro";
import RelationList from "@/components/RelationList.astro";
import Base from "@/layouts/Base.astro";
import { ATLAS_SPEC } from "@/lib/atlas";
@@ -52,6 +53,7 @@ const repoFile = `https://github.com/letsauthlab/letsauth/blob/main/data/specs/$