CLAUDE.md@docs · diff
git:20260921.13d222a to git:20260921.76c98e6
14 added, 4 removed. Audit B to B.
# docs — the published mkdocs site (product + contributor docs)
> ↑ [root](../CLAUDE.md)
The mkdocs site under `docs/`, deployed to the Pages site. It sells Grove and routes readers to depth. Note: `docs/design-system.md` lives here but is **not part of this site** — it is the **TUI's** visual contract, owned by [tui](../src/grove/tui/CLAUDE.md), and `exclude_docs`'d from the build. Cross-link it, never document it here, and never edit it for a web change: the web front end's contract is [webapp/design-system.md](../webapp/design-system.md).
## Two audiences, physically separated
**Split product docs from contributor docs; never blur them on one page.** The site nav has two halves: **User Guide** (product/user docs) and **Developer Reference**. Operational/contributor detail for a component — the dashboard dev server (`npm run dev`), wire-type codegen, the test pyramid, BFF route internals — lives in that component's README (e.g. `webapp/README.md`), **not** in the site's User Guide. The site links *out* to the README for contributor work; the README banners *back* to the site for product usage.
User-facing webapp / auth / daemon docs live under `docs/` *Use* (`use-webapp.md`, `use-auth.md`, plus the `grove daemon` / `grove auth` blocks in `use-cli.md`). When a feature spans both audiences, write each half in its own surface and cross-link. Internal flags (`--print-port`-style) get a user-facing gloss on the site (what it does); the implementation rationale (LocalTransport auto-discovery) stays in code comments. This is the doc analogue of the engine's "public surface small, internals private" rule.
## Presentation: screenshots are signal, chrome is noise
**Real product screenshots carry the message; cut decorative chrome.** A skimming reader reads the headline, glances at one product screenshot, then scans section headers — they do not read paragraphs first. So icon-tile galleries, status-chip walls, pill rows, and decorative lifecycle strips are noise; remove them. Numbered navigation cards group direct links by the reader's task. Use the theme's `.ms-lifecycle` kit to replace the landing page's install instructions and generic docs grid rather than duplicate them. The landing page sells and routes; depth lives in the User Guide. Erring toward "comprehensive" on the home page reads as bloat — restraint is the quality signal. The README's **support census is the one deliberate exception**: it replaces a factual sentence, names only real integrations (the native agents plus the ticket providers), and is not a feature gallery.
- **Third-party support marks are pinned and attributed, never hotlinked — but the edited PNG is the artwork of record.** `tools/support_icons.py --from-upstream` is only the explicit destructive reset to LobeHub/selfh.st sources; ordinary finishing is `tools/refine_support_icons.py`, whose contract is narrow and testable: outer-alpha cleanup on Codex/Linear/Gitea, Claude Code mark zoom over its existing dark shell, GitHub untouched, and zero RGB changes on the three edge-cleaned files. Source, version/date, licence, modification notice and non-endorsement live beside the outputs in `docs/logos/support/SOURCES.txt`. Keep the visible row ordered **agents first, then ticket providers with Linear first**: Claude Code, Codex, OpenCode, Linear, GitHub, Gitea. A mark supplied by the maintainer carries its own shell, so neither the finishing pass nor the upstream reset enumerates it.
- **The README's `## 🌳 Overview` and `index.md`'s `## What is Grove?` are ONE block of bullets, kept byte-identical apart from link targets.** The README links absolute to the published site, the landing page links relative, and nothing else may differ — a reader who arrives from GitHub and a reader who arrives from search must get the same pitch in the same order. Edit both in the same commit and diff them with the hrefs normalised away; a prose divergence is invisible in either file alone. The first bullet is the problem statement (deciding what to build is the bottleneck, more agents widen it, a shared checkout and a pinned laptop are what stop you), because a pitch that opens with the mechanism has not said why anyone needs it.
+ **The README's `## 🌳 Overview` and `home.md`'s `## What is Grove?` are ONE block of bullets, kept byte-identical apart from link targets.** The README links absolute to the published site, the landing page links relative, and nothing else may differ — a reader who arrives from GitHub and a reader who arrives from search must get the same pitch in the same order. Edit both in the same commit and diff them with the hrefs normalised away; a prose divergence is invisible in either file alone. The first bullet is the problem statement (deciding what to build is the bottleneck, more agents widen it, a shared checkout and a pinned laptop are what stop you), because a pitch that opens with the mechanism has not said why anyone needs it.
## Writing style for every line on the site
(Applies to published prose; `docs/design-system.md` is `exclude_docs`'d and exempt.)
- **No em dashes, no semicolons, no parenthetical asides in prose.** Use a period or a comma, and split the sentence. Colons survive only after a bold lead-in word or in a fence title.
- **Short, well-decomposed sentences with narrative continuity.** Not choppy fragments, and not long dash-chained clauses either.
- **Explain with an analogy a developer already knows.** A workspace is a private workbench; the BFF is a receptionist at a front desk; the config cascade is a team `.editorconfig`.
- **Seed a memorable, repeatable phrase** that recurs to aid recall: "one agent, one worktree, one window"; "your git stays yours".
### The page shape, since the 2026-09-14 compression pass
Every capability and Use page is a product page a reader scans in under a minute, and the shape is the same on all of them.
- **The masthead comes before prose and media.** Every content page opens with an H1 topic label immediately followed by an H2 that names the page's purpose. The theme removes the source H1 and enlarges the H2 only when it is the first content element. A paragraph, figure or carousel before it leaves a small label without the large title. The landing page uses its own hero instead. Keep the generated configuration reference on the same heading contract.
- **Media leads the body.** Put the lead sentence and the page's figure, video or carousel after the H1 and H2 pair. Two or more figures become one `.swiper.ms-shots` carousel; a page never scatters screenshots down its body.
- **Sections are a lead sentence plus 4 to 5 narrated bullets.** Each bullet is one or two full sentences that say what the reader gets or what happens. A section that cannot fill four bullets merges into its neighbour. Command reference pages (`use-cli.md`) are the exception: a `###` per command is one sentence, its fence, and at most three bullets.
- **Configuration sits at the bottom.** Anything whose subject is a config file, an env var, a token, a workflow YAML or a flag table moves after every product section and before `## See also`. Fences survive; the prose around them shrinks to a lead sentence.
- **Tables stay tables** when the content is genuinely tabular (a status enum, a tool census, a command list). Cards (`.ms-grid` of `.ms-card`) carry three to five peers such as channels or providers.
- **Product perspective only.** What the reader can do, what happens, what it costs. Implementation rationale, incident history and test detail live in the nearest `CLAUDE.md`, never on the page.
- **A rewrite preserves every link's text and destination byte for byte, and every inbound anchor.** Take a link census before (`[text](target)`, raw `href`, `src`, `poster`) and diff it after; the strict build checks files, not anchors, so the anchor sweep is by hand against real heading slugs.
- **Measure narrative words** (prose outside fences, tables and HTML) per page and set a budget before delegating; see the fleet lessons below for why a budget without an invariant produces fragments.
## Theme: reuse the landing-page kit, never hand-roll marketing CSS
+ **The site has TWO front doors and they are different pages: `index.md` is the animated landing page at the site root, `home.md` is the documentation entry page the sidebar calls Home.** `index.md` uses the theme's `app.html` front matter, so only that route omits documentation chrome and its art direction stays in `stylesheets/grove-landing.css`; everything else remains on the theme kit. The root has to be the landing page — it is what GitHub Pages serves to an arriving visitor, and a marketing page on any other route is a page nobody reaches. Its two buttons and its nav lead to `home/`, never past it into a mid-guide page.
+
+ Two failures happened here in order, and the second is the one worth remembering. First the landing scene was written *into* `index.md`, which silently deleted the hero, pills, carousel and card grids that are the docs entry page. The repair — restore `index.md`, put the landing page beside it at its own route, and split the deleted narrative into an `overview.md` — fixed the deletion and introduced two new problems: the landing page was now unreachable, and `overview.md` duplicated the homepage it was extracted from. **Restoring a page you overwrote is not the same as restoring the ROUTING, and a "safe" additive route can be a route with no visitors.** The shape that works is a rename, not a new page: the landing page takes the root, the docs home moves down one level, and nothing is duplicated.
+
+ Consequences of `home.md` sitting one level deep: its raw `href`s and `<img src>` all carry `../` (mkdocs rewrites markdown links only — see the raw-HTML rule below), and `theme.header_tabs`' Home entry needs `url: home/` with `match: [home]` rather than the `.` that now resolves to the landing page. The landing page is `not_in_nav`, and the scene's textures resolve against `import.meta.url` rather than the page, so the module works from any route. The legacy `#install` / `#explore-the-docs` anchors stay on the ROOT, because that is where old inbound links point. `test_site_root_is_the_landing_page_and_leads_into_the_docs`, `test_documentation_home_keeps_the_theme_kit_layout` and `test_nav_gives_every_page_in_the_sidebar_an_icon` pin all of this; each was mutation-tested against the exact regression it guards.
+
+ **A nav entry with no `theme.nav_icons` key renders a bare label, and nothing in the build says so** — the four `Agents` children shipped icon-less that way. The map is keyed by the nav TITLE, not the filename, so renaming an entry silently drops its icon; `test_nav_gives_every_page_in_the_sidebar_an_icon` walks the nav tree against the map. **The landing page's typography must name the theme's own roles** (`--font-sans` = Geist, `--font-display` = IBM Plex Serif, `--font-mono`), never a hard-coded family: hard-coding Geist is what drifted the hero off the docs' type system. Note the shipped serif is IBM Plex **Serif**, not IBM Plex Sans — the theme vendors no Plex Sans at all. Its locally vendored Three.js module is a runtime dependency, not a theme fork, and carries its own provenance record beside the files. Vendor the complete relative-import graph; a successful syntax check does not resolve imports. Do not substitute CSS robot artwork during startup: it has different geometry and makes the first paint jump to another scene. Keep the canvas hidden until its textures and first frame are ready, fade in that real frame, then start motion. With JavaScript/WebGL unavailable, retain the hero and links against the page background. Respect reduced motion and stop scheduling offscreen or document-hidden. **The module is `.js`, never `.mjs`** — nginx and most static hosts label `.mjs` `application/octet-stream`, Chrome refuses that as a module script, and the visitor silently gets the flat fallback and reports the page as "static"; the browser console names it, the fallback never does. **To review the scene as a visitor sees it, run a throwaway `selenium/standalone-chrome` container with `--use-angle=swiftshader` on the preview's docker network** — every host browser here fails WebGL, so a captured frame that shows the fallback is a host limitation, not a page defect, and two captures a few seconds apart with different hashes are the proof of motion. SwiftShader cannot finish a 2560-wide frame inside WebDriver's page-load timeout: capture at 1920 or below, and if a run stalls, the grid's slot is still held by that dead session and every later capture times out too — `DELETE /session/<id>` before blaming the page.
+
+ **The scene's lighting is the docs palette, and its glare had one cause.** The warm side of the rig (env card, key point light, near rail neon, underglow, CSS wash) is the clay accent, not the reference's magenta — one hero with a pink rig beside terracotta buttons read as two design systems; `PALETTE.ember*` is the single place that hue lives. The "god ray" flare and the flicker the reviewer circled were not bloom strength: the scanner's point light sat at antenna height, centimetres from the robots' mirror-chrome knobs as each passed, and a point light that close to a mirror is a white hotspot the bloom pass lifts into a star, pulsing with the scan. Raising the lamp above the pane, brushing the chrome and halving the scan pulse took the near-white pixels in the right half of a 1280 frame from 75 to 4; reducing bloom on its own had been tried twice before and never touched it. **Desktop composition is a `setViewOffset` on the split layout, never a camera move**: it slides the projection window toward the top-right corner while the perspective and the diagonal clip's left clearance stay as tuned. The camera itself was moved once already, to clear that clip, and any further move re-opens it.
+
The `mkdocs-shadcn-mewbo` theme (resolved from a flat release index in `pyproject.toml` — see the pipeline section below) ships a full landing-page kit. Build pages from its primitives, not bespoke markup:
- `.ms-hero` (`__eyebrow`/`__title`/`__lede`), `.ms-cta-row` + `.ms-btn--primary|secondary|ghost`
- `.ms-pills`/`.ms-pill`, `.ms-grid--3|4|5` + `.ms-card` (`__icon`/`__title`/`__body`; `<a class="ms-card">` for clickable cards)
- `.ms-lifecycle`/`.ms-step` (auto-numbered via CSS counter), `.ms-chips`/`.ms-chip`
- `.ms-shot` captioned screenshot figure: `<figure class="ms-shot"><div class="ms-shot__frame"><img …></div><figcaption class="ms-shot__body">…</figcaption></figure>` (frame tint via `--ms-shot-frame`)
- `.swiper.ms-shots` image carousel, for a sequence one figure cannot carry (a multi-step flow, or one page shown at two scroll positions). Swiper markup: a `.swiper-wrapper` of `.swiper-slide > figure`, then `.swiper-pagination` and the two `.swiper-button-*` divs. It needs `theme.carousel: true`, which is already set, and the slides are plain `<figure>` without the `.ms-shot` classes since the carousel owns the box. `theme.lightbox: true` makes the whole strip one gallery, so the viewer's arrows walk it.
- `.ms-devices` side-by-side device pair (widths via `--ms-devices-primary|secondary`; caption is a `.ms-devices__caption` child)
**Icons:** the theme loads the Iconify runtime from a CDN in its `main.html`, so `<iconify-icon icon="lucide:NAME"></iconify-icon>` renders anywhere in content. Section headings get an auto-injected icon via `## Heading { .ms-h2-icon data-icon="KEY" }`, where `icon-inject.js` maps KEY ∈ {target, flow, plug, grid, route, book, star} → a lucide glyph (other keys fall back to a circle).
**A `nav_icons`/`header_tabs` icon name is resolved by a LIVE API call at build time, so an invented name is a red gate on `main`, not a cosmetic slip.** Those names go through the theme to `api.iconify.design`, and a name the API reports as absent aborts `--strict` — `lucide:diagram-3` (a Bootstrap Icons name that has no lucide twin) broke every docs deploy from 2026-09-06 until it was corrected. Nothing in review catches this, because a plausible-looking name is indistinguishable from a real one in the diff. **Probe the name before you commit it:** `curl -o /dev/null -w '%{http_code}' 'https://api.iconify.design/lucide/NAME.svg?height=16px'`. The failing step is the *deploy* job, whose log names an icon the branch never touched, so check whether `mkdocs.yml` is even in your diff before believing a docs change caused it.
**Throttling is no longer that gate, and the distinction is the whole point.** Until theme v1.16.0 each icon was one request, so ~45 nav icons meant ~50 sequential calls from the runner's shared egress IP; the API answered `429` and every one of them was an error, so a green-on-main site failed to deploy with `Aborted with 50 errors` naming icons that were all perfectly valid. The theme now resolves through the **batch** endpoint (`/{prefix}.json?icons=…`), one request per icon *set* — two for this site — warmed before any page renders, and caches them under the gitignored `.cache/iconify/` (also an `actions/cache` step in `docs.yml`). **The two endpoints are metered separately**, measured 2026-09-14: the per-icon `.svg` endpoint returned 429 while the batch `.json` returned 200 from the same address in the same second. That is why batching fixes this and retrying does not, and why the mirrors `api.simplesvg.com` / `api.unisvg.com` are not a fallback — both answer `403 Cloudflare` to everything. A throttled build now degrades to an `<iconify-icon>` the browser resolves, while a genuinely unknown name still fails the build, as exactly one error naming the real culprit.
**A missing markdown extension in this theme is SILENT.** The strict build stays clean and the feature just renders as plain text: `++k++` prints literally, a fence `title=` prints as text, an abbreviation gets no underline. The `markdown_extensions` order in `mkdocs.yml` follows the theme's authoring skill, and that skill is the authority whenever you touch that list — check it before anything else when a block "does nothing".
**Abbreviations are defined once in `docs/_glossary.md`**, which `pymdownx.snippets` `auto_append`s onto every page (its `base_path` resolves from the `mkdocs.yml` directory, NOT `docs/`). The glossary must stay in `exclude_docs`, or it publishes as a page of its own.
**`pymdownx.keys` renders every letter capitalised and does not parse `++P++`**, so a case-sensitive uppercase TUI binding is written `++shift+p++`. That is load-bearing here: in the TUI `p` pauses and `P` switches project, so a bare `++p++` twin would be indistinguishable. Use this syntax for keyboard instructions in Markdown prose and tables. Raw HTML captions need explicit `<kbd>` elements because the extension does not parse their contents. Show modifier keys explicitly there too, so Shift+P cannot be mistaken for P. Commands, flags, configuration values and sample input remain code, not keycaps.
**A fence `title=` is for a block that IS a named file.** Most `.grove/config.json` fragments on this site legitimately apply to any cascade layer, and shell transcripts and output samples name no file at all — those stay untitled, because a wrong filename misleads worse than none. Eleven of roughly forty blocks earned one.
**The only Grove-bespoke CSS is the status chip gallery** (`.grove-status-grid`/`.grove-status-chip` in `docs/stylesheets/grove.css`, wired via `extra_css` — bump its `?v=` cache-buster whenever the file changes) — it mirrors the TUI status palette and is deliberately not generalized into the theme. Everything else comes from the theme; do not re-fork it. **The theme's clay accent is two tokens:** `--primary` is a FILL (button backgrounds, washes), `--primary-text` is the same hue re-tuned to be READ. Local CSS that colours text reaches for `--primary-text`; `--primary` measured 2.6-3.0:1 on the light page, under the 4.5:1 bar.
**`theme.show_datetime` renders INSIDE the masthead, so it is off.** From theme v1.12.0 the H1 accent label and the first H2 are one unit spaced at 4px. The updated-date row is a sibling in that same flex column, between them, carrying its own 16px pad and a 20px line: measured 40px label-to-title on `getting-started` with it on, 4px with it off, identical on v1.13.3 and v1.15.0 (so this is config, not a stale pin). If a masthead ever looks blown apart again, measure `title.top − label.bottom` and enumerate what sits between the two in DOM order before touching any CSS.
**Verify a rebuilt site on a NEW port, or the browser hands you the old CSS.** `python3 -m http.server` serves unversioned `/css/mewbo.css`, so after a theme bump the page keeps the cached stylesheet while a direct `fetch()` with a cache-busting query returns the new one. That combination reports the upgrade as landed and the layout as broken at the same time: a rail divider measured 54px short of the fold and a search placeholder from the previous version, both of which were correct in the actual build. `?v=` on the page URL does not help, because the stale asset is the stylesheet, not the document.
**A generated SVG with only a `viewBox` has an aspect ratio but NO intrinsic size, and every consumer that has to size it on its own gets it wrong.** Textual's `export_screenshot` emits exactly that. Inline it never showed, because the page's CSS sizes the image; it showed the day the theme's full-screen viewer (`theme.lightbox`) asked the browser how big the picture was, read 263x150 for a 1629x928 capture, and opened the screenshot THREE TIMES SMALLER than it renders in the column. `tools/screenshots/capture.py` now stamps the viewBox onto `width`/`height` as it writes each file, so the asset describes itself; the same PNG shots were correct all along, which is why the failure looked like a theme bug at first. Anything else that generates an SVG into `docs/img/` owes the same stamp.
- **Figures use raw HTML `<img>` with hand-written relative paths** (`../img/…` from any sub-page, `img/…` from `index.md`), because mkdocs only rewrites paths in markdown image syntax. Keep other component blocks raw HTML WITHOUT the `md_in_html` `markdown` attribute: adding `markdown` makes python-markdown wrap loose inline children in `<p>`, which breaks the flex/grid child structure the kit's CSS expects.
+ **Figures use raw HTML `<img>` with hand-written relative paths** (`../img/…` from every page including `home.md`, since the only page at the site root is the landing page and it carries no figures), because mkdocs only rewrites paths in markdown image syntax. Keep other component blocks raw HTML WITHOUT the `md_in_html` `markdown` attribute: adding `markdown` makes python-markdown wrap loose inline children in `<p>`, which breaks the flex/grid child structure the kit's CSS expects.
**Validate with `make docs-build`** (CI-parity `--strict`), but a clean strict build proves only that the site compiled — it says NOTHING about whether a theme feature actually rendered, since every silent-degradation mode above builds green. Verify a feature against the BUILT HTML (the `<kbd>`, the `class="filename"` strip, the `<abbr title=…>`), and anything driven by theme JavaScript (mermaid, tablesort, video autoplay, the mermaid Expand viewer) against a live headless page, because none of it exists in the HTML at all. For a real visual check, headless-Chrome-screenshot the built `site/`: `python3 -m http.server` in `site/`, then `chrome --headless=new --virtual-time-budget=12000 --screenshot=… http://127.0.0.1:PORT/index.html`. The Playwright MCP does drive the built site fine (navigate, toggle the theme class, screenshot), so reach for it first and keep headless Chrome as the fallback. Playwright itself is only importable from inside `webapp/` (`import { chromium } from '@playwright/test'`), so a probe script has to live there.
- **`mkdocs --strict` validates markdown links and NOTHING inside a raw HTML `href`.** This site builds its landing and routing surfaces out of `.ms-card` grids, whose `href`s are therefore completely unchecked: an `index.md` card pointed at a `use-webapp.md` anchor that never existed, through every green build, until it was swept by hand. **`theme.link_check: true` (v1.13.2+) now does the file half of that sweep** — it resolves every raw `src`, `poster` and `href` in the BUILT site against the directory each page landed in, and raises under `--strict`. Leave it on and fix what it reports. It checks that a target file exists, not that an `#anchor` within it does, so an anchor rename still needs the hand sweep below. Sweep BOTH forms (parse `href="…"` plus `[](…)`, resolve against real heading slugs) after any pass that renames a heading. Two traps in writing that sweep, both of which produced confident nonsense before they were caught: a markdown link already ends in `.md` while a raw href is directory-style, so appending `.md` blindly reports every good link as broken; and heading slugs must be derived after stripping `{ .ms-h2-icon … }` attr-lists.
+ **`mkdocs --strict` validates markdown links and NOTHING inside a raw HTML `href`.** This site builds its landing and routing surfaces out of `.ms-card` grids, whose `href`s are therefore completely unchecked: a home-page card pointed at a `use-webapp.md` anchor that never existed, through every green build, until it was swept by hand. **`theme.link_check: true` (v1.13.2+) now does the file half of that sweep** — it resolves every raw `src`, `poster` and `href` in the BUILT site against the directory each page landed in, and raises under `--strict`. Leave it on and fix what it reports. It checks that a target file exists, not that an `#anchor` within it does, so an anchor rename still needs the hand sweep below. Sweep BOTH forms (parse `href="…"` plus `[](…)`, resolve against real heading slugs) after any pass that renames a heading. Two traps in writing that sweep, both of which produced confident nonsense before they were caught: a markdown link already ends in `.md` while a raw href is directory-style, so appending `.md` blindly reports every good link as broken; and heading slugs must be derived after stripping `{ .ms-h2-icon … }` attr-lists.
**Two console 404s on a local build are expected and neither is a regression.** `versions.json` is written by `mike` at deploy, and `build-info.json` is the first entry in the theme's fallback probe chain, which then succeeds on `../build-info.json`. Do not go hunting.
## A docstring is published copy, and the API reference publishes the CONTRACT
**Four unrelated doors put prose on this site, so the guard belongs on the RENDERED PAGE, not on any one source.** Hand-authored markdown, the JSON-schema config reference (`schema_to_md.py`), mkdocstrings docstrings, and any future generator all land in the same published HTML — a guard on one door is a guard on none. `docs/hooks/no_tracker_refs.py` runs `on_page_content` (page body only, so the theme's nav/footer chrome is out of scope, and every generator has already run) and aborts the strict build on a bare `#<n>`. CI builds the docs on every same-repo PR, so this is a real gate, not a convention.
**`show_source: false` is load-bearing, not a cosmetic trim.** mkdocstrings source blocks render each module *verbatim, including every inline comment* — so with sources on, the published invariant would be "no tracker ref in any comment in `src/grove`", which nothing can enforce. Off, the remaining invariant is docstrings only, which is small enough to be true. The repo is one click away for anyone who wants the implementation.
**A ref inside a code span is a deliberate literal, and a redactor that cannot tell the difference breaks the docs it is protecting.** `#42` is the documented example value in the ticket-ref grammar (`use-cli`, `use-mcp`, `features-ticket-providers`, and the `TicketRef` docstring); fenced blocks additionally carry mermaid hex colours (`#111111`, `#84cc16`) that a naive `#\d+` reads as references. The gate therefore spares inline `<code>` and `<pre>` — except a code block inside a `<details>`, which is the mkdocstrings source-block shape, so re-enabling `show_source` fails the build instead of silently re-leaking. Both arms are mutation-verified.
**Do not "fix" refs at the generation boundary with a griffe extension that mutates `docstring.value` — it is clean on a ref-only parenthetical and MANGLES every ref woven into the grammar.** `since #N: a container` becomes `since: a container`; `the pre-#N behavior` becomes `the pre- behavior`. Worse, the whitespace tidy-up that cleans that debris eats the space before a `:meth:` role, so the Sphinx cross-reference silently stops resolving. It also cannot reach source blocks at all (pygments-highlighted raw source, not docstring text). **A ref woven into a sentence can only be rewritten by hand.** The durable rule is the one the config models already follow: write the behaviour, leave the archaeology to `CLAUDE.md`, which is written for maintainers and is not published.
**Verify with the BUILT HTML, never a regex over sources.** Count refs in rendered *text nodes* (parse the HTML, classify each node as prose / inline `code` / `pre`) — a raw `grep` over `site/**/*.html` massively overcounts, since `href="#…"` attributes and element ids match too. For a build-free fast loop, `griffe.load("grove.core", search_paths=["src"])` over the page's `::: ` targets reproduces the rendered docstring set exactly, which is what makes it a trustworthy pre-build check.
## Branding is `site_name`-driven; no template overrides
The tab `<title>` (`"<Page> · Grove"`), the Ask AI greeting and avatar, and the WebMCP tool name all derive from `site_name`; `theme.custom_dir` is unset. Do not reintroduce template copies for branding. The favicon and header logo both resolve to `docs/logos/grove-logo.png` via `theme.favicon` and `theme.icon`. No branding shims remain: the `og:site_name` hook was deleted once theme v1.9.1 emitted the tag itself, leaving `schema_to_md.py` and `no_tracker_refs.py` as the site's only two hooks.
## The docs pipeline rides the mirror split
`.github/workflows/docs.yml` deploys from `main` on every push and picks its target at runtime from `github.server_url`: github.com publishes versioned GitHub Pages via mike (`main` + `latest` aliases), anything else uploads a static build to the host named by the repo variables `DOCS_HOST`/`DOCS_RESOLVE_IP` (set per-mirror, never committed). PR previews deploy as `pr-<N>` on both sides; `docs-cleanup.yml` removes them on github.com, the `cleanup` job in `docs.yml` removes them on the other side. **The theme is no longer pinned to a version at all — it resolves from a flat INDEX and tracks its own latest release.** We own the theme repo, so a bump commit here bought nothing but a manual step. GitHub serves no PEP 503 index, but `/releases/expanded_assets/<tag>` is a flat page of asset links, which is exactly what uv's `format = "flat"` consumes; `pyproject.toml` declares it as an `explicit` index scoped to this one package, so no other dependency can resolve from a release page. The rotating filename that made a direct `latest-master` URL a trap is the *mechanism* here rather than the hazard, because an index is read rather than fetched by name.
**The consequence to remember: a stale `uv.lock` entry does not merely serve an old theme, it 404s the build.** The moving tag carries exactly one wheel, so the filename the lock names is gone the moment the theme releases. That is why all three docs jobs run `uv lock --upgrade-package mkdocs-shadcn-mewbo` *before* `uv sync` — it is what keeps them green, not just current. `docs-cleanup.yml` needs it too even though it only deletes versions: `mike` parses `mkdocs.yml`, and `theme: shadcn` being unresolvable aborts even `mike list`. Nothing else is unpinned; `--frozen` gates elsewhere are untouched.
## Rewriting these pages with a fleet
**Measure NARRATIVE words, never total, and never table rows.** A concision brief asks agents to turn paragraphs into tables and bullets, so a budget that counts table cells penalises the exact form it is demanding. The tell is an agent that "refuses" to cut its densest section: `use-mcp`'s tool table looked like the worst offender on the site and was already correct. The opposite failure follows immediately, so **check the total as well as the narrative before believing a number** — moving a bullet list into a table books a 40% narrative cut on a page that got 8% shorter. That is still a win when the content really was tabular, but it is reformatting, not deletion, and the commit message should say which one happened.
**A word budget is a licence to break every other rule unless you say otherwise.** Semicolons compress two sentences into one, so an agent optimising a count reaches for them: one reported "merging sentence pairs with semicolons" as its technique, and `configure-agents` went from four to six during a pass whose only job was to tighten it. Nothing catches this. The build is green and the number goes down. State the punctuation bans in the brief itself and sweep for them at integration.
**Give each agent ONE file.** Sonnet handled a single page fine and finished none of its second or third: every page still untouched at the halfway mark was somebody's file number two. The model was not the problem and swapping in a bigger one would not have fixed it.
**A compression agent given a bullet count and a budget delivers fragments, and every fragment is a fact deleted.** Nine Sonnet agents given the page shape above, a link census, and a word budget hit the budget on every page and turned `Runtime is a per workspace choice, so two workspaces in one repo can differ` into `Two workspaces in one repository can differ`, then `Host workspaces use your credentials and PATH`: the shape survived, the reason did not, and the page read as a checklist of assertions with no product in it. What worked was the integrator rewriting every product bullet by hand from the ORIGINAL prose with the agent's structure as the skeleton, which cost about as long as the fleet did. **Brief the bullet as a sentence pair, "what happens, and why the reader cares", and mark the census facts that must survive per section**, or budget the hand pass up front. The link census and the narrative counter were the parts of the delegation that paid: not one link moved across ten pages, and no page came back over budget.
**Verify a subagent's factual claims before acting on them.** One reported that `features-branch-provenance` contradicted `branch_plan.py` on Track remote. It did not, and the agent was blocked from writing that file at the time, so it was reporting on state it could not see. Acting on it would have introduced the bug it claimed to fix.
## The catalog page is the census the landing page refuses to be
- `features-catalog.md` is one row per capability, tables only, each row linking to the page that owns the depth. It exists so `index.md` can stay a routing surface (see the restraint rule above) without the site losing an answer to "does Grove do X". **Adding a capability page means adding its row here in the same change**, and a heading anchor a row points at must survive any rename on the target page — the strict build checks the file, never the `#anchor`, so this page is the first place a heading rename silently breaks. `features-attachments.md` is the user half of the composer attachment and annotation work; the engine reasoning stays in [core](../src/grove/core/CLAUDE.md) and the browser mechanics in [webapp](../webapp/CLAUDE.md).
+ `features-catalog.md` is one row per capability, tables only, each row linking to the page that owns the depth. It exists so `home.md` can stay a routing surface (see the restraint rule above) without the site losing an answer to "does Grove do X". **Adding a capability page means adding its row here in the same change**, and a heading anchor a row points at must survive any rename on the target page — the strict build checks the file, never the `#anchor`, so this page is the first place a heading rename silently breaks. `features-attachments.md` is the user half of the composer attachment and annotation work; the engine reasoning stays in [core](../src/grove/core/CLAUDE.md) and the browser mechanics in [webapp](../webapp/CLAUDE.md).
## Session lessons
- Lessons specific to this site land here. Cross-cutting facts go to [root](../CLAUDE.md); TUI visual decisions to `docs/design-system.md` (owned by [tui](../src/grove/tui/CLAUDE.md)); web visual decisions to [webapp/design-system.md](../webapp/design-system.md) and web engineering to [webapp](../webapp/CLAUDE.md).
- **`exclude_docs` must list both instruction files, `CLAUDE.md` and the external-agent `AGENTS.md` shim.** Neither is product documentation, and strict link checking correctly rejects their maintainer-only relative links when they leak into the site. Keep all substantive guidance here in `CLAUDE.md`; the shim remains untouched.
- **An `on_files` hook replacing a source page must remove the existing `File` before appending its generated one.** MkDocs warns on append-overwrite today and is deprecating it; `schema_to_md.py` owns `configure-reference.md`, so it explicitly removes that entry first instead of relying on implicit replacement.
- **`configure-reference.md` is generated from the Pydantic schema, so its source of truth is config-model docstrings, never a hand edit.** `WorkspaceDefaults` and `AgentSpec.models` are published there through `use_attribute_docstrings`; change their product prose in `src/grove/core/config.py`, then regenerate through the owning schema workflow. Hand-editing the page is overwritten at the next docs build and makes the checked-in reference disagree with the contract.
- **Only a docstring's FIRST paragraph is published, and it is one or two sentences of WHAT.** Since 2026-09-14 `schema_to_md.py` prints `_lead()` of each description, the later paragraphs stay the maintainer's why in the source. Write a new field's first paragraph as the row a reader scans, no dashes, no `:class:` roles, under 35 words, and put the incident behind it in paragraph two. The generator also renders every nested model as its own `###` table under the field that holds it and folds top level scalars into one table, so a field with `properties` needs no description of its own children.
- **Screenshot regeneration has a CI path now (`_docs-screenshots.yml`), and its two hard constraints are the sandbox root and determinism.** `dispatch.yml`'s `docs-screenshots` suite captures on a runner and pushes the PNGs back to a branch, with `target` (where the images land) and `source` (where the capture tooling comes from) as separate inputs — the split is what lets a not-yet-merged capture fix regenerate main's images. Two things bind anyone touching it. **The sandbox cannot live on `/tmp` in CI**: act_runner's job container mounts a RAM-backed `/tmp` under a memory cap, and this tree holds a year of transcripts plus twelve git repos at once, so `Sandbox.rooted()` reads `GROVE_SHOTS_ROOT` and the workflow points it at `$RUNNER_TEMP`. And **every run legitimately changes pixels**, because the demo corpus is anchored to `datetime.now()` and the usage heatmap is laid out relative to the day it is planted — so the workflow's change-detection guards against an EMPTY capture, never against staleness, and a byte-comparison "are the shots current" gate is not a thing that can exist here. The suite is deliberately excluded from `everything`: every other dispatch suite reads the tree and reports, this one writes to a branch.
- **`make docs-images` can never be green, so CI calls the two capture targets directly.** It chains `docs-mockups`, and `mockups.py` hard-exits 2 on two inputs (`tui-list.svg`, `webapp-home-grid.png`) that no current capture produces — confirmed against the source, and a stronger claim than the older bullet below that names only one dead input.
- **Webapp doc screenshots come from a fully synthetic demo fleet, never the live daemon.** The live daemon serves real (private) repos, so screenshot against a sandbox: `XDG_CONFIG_HOME`/`XDG_STATE_HOME`/`CLAUDE_CONFIG_DIR` pointed at a temp dir, fictional repos under `/tmp`, workspaces created via `grove.core.build(...).create(...)` (no auth needed in-process), and Claude Code transcripts hand-planted under `$CLAUDE_CONFIG_DIR/projects/<encoded-cwd>/<minted-session-id>.jsonl` (format: `tests/core/agents/fixtures/basic.jsonl`; tail `stop_reason: tool_use` reads as working, `end_turn` as waiting). A second `grove daemon serve --port <alt>` plus `next start -p <alt>` with `GROVE_DAEMON_URL` reuses the existing `.next` build; pair a headless Playwright (`webapp/node_modules/@playwright/test`) through the real `/login` flow and approve with `grove auth approve` under the sandbox env. Engine `mgr.kill()` per workspace plus removing the temp dir tears it all down; the tmux server is shared with the host, so verify `tmux ls` is clean.
- **The rail-footer identity row leaks the REAL host `user@host` unless you patch the capture daemon.** The footer renders the daemon's `GET /whoami`, which reads `socket.gethostname()` + `getpass.getuser()` (module-qualified in `daemon/app.py::_build_whoami`, at request time) — under the screenshot sandbox that is still the real machine, so an unpatched run bakes the developer's identity into committed PNGs. `webapp_capture.py` launches the daemon via a `python -c` wrapper (`_DAEMON_WRAPPER`) that monkeypatches both to fictional values before importing uvicorn, instead of `grove daemon serve` — every captured identity reads `grove@grove-demo`. Verify every shot's footer and avatar before committing.
- **Webapp screenshots must FORCE the space-black dark theme — the published shots are dark.** next-themes defaults to `system`, so a headless capture inherits Chrome's light preference and silently produces light screenshots. `webapp_shots.mjs` pins dark on every `newContext` (`colorScheme: "dark"` + an `addInitScript` setting the next-themes `localStorage` key `theme=dark`, so first paint is already dark with no light→dark flip). Verify after a regen: a captured shot's header theme toggle shows the sun glyph (= currently dark).
- **`make docs-webapp-screenshots` is a ~12 MINUTE command, and most of that is the usage corpus.** The demo history is a full year of heavy-professional activity, so seeding writes a large body of transcripts and the usage projection over them measured **313s** on this host. Two things follow. **The projection runs IN PROCESS in `webapp_capture.py`, before the daemon starts, and must never go back over HTTP** — it used to be a `POST /api/grove/usage/refresh` from the browser, which died at exactly **300s** with a `502 daemon_unreachable`: that ceiling is the **Next BFF's own fetch timeout**, not Playwright's, so no client-side timeout could ever reach it, and the symptom (a 502 naming the daemon) points at the wrong process entirely. And **the browser waits on projection-derived sections only, never on quota** — `usage-quota-card` renders only when an account produced a reading, so blocking on it turns "no subscription was read" into a hung run that yields no screenshots and names no cause. Report that state and carry on.
- **Every published webapp shot is a rounded window composited onto one 2400x1350 canvas over a committed wallpaper** (`tools/screenshots/frame.py`, backdrop asset in `tools/screenshots/assets/`). Two consequences bind anyone touching this. **Framing is not idempotent** — a second pass frames the frame — so `make docs-webapp-screenshots` chains the framer onto the capture that produces it, and the framed set is a NAMED list in the Makefile rather than a `webapp-*.png` glob; the glob silently swept `webapp-home-mobile.png`, which feeds the phone mockup and supplies its own device shell. And the capture viewport is **1600x900, deliberately 16:9**, so the framer scales the window instead of letterboxing it (it also clears the workspace page's 1024px split breakpoint).
- **A capture driver goes stale on a ROUTE MOVE without a single testid changing, and nothing fails until the twelve-minute run does.** `webapp_shots.mjs` navigated to `/` and waited for `fleet-dashboard` for as long as `/` was the fleet; the composer landing took the root route and moved the fleet to `/fleet`, leaving a selector that is still correct about a page it no longer visits. The failure surfaces as a locator timeout, which reads as slow data. **Before any regeneration, re-derive the route list from `webapp/app/**/page.tsx` rather than trusting this file's own header comment** — the comment is the thing that goes stale first.
- **The canonical webapp shot set is `webapp-home.png`, `webapp-composer.png`, `webapp-sessions.png`, `webapp-workspace.png`, `webapp-annotate.png`, `webapp-diagram-split.png`, `webapp-diagram.png`, `webapp-diagram-palette.png`, `webapp-usage.png`, `webapp-usage-detail.png`, `webapp-pair-device.png`, `webapp-pair-code.png`** — the census is `FRAMED_SHOTS` in the Makefile, not this sentence. Retargeted 2026-08-10 against the current app (post webapp2→webapp rename) and regenerated clean via `make docs-webapp-screenshots`. `webapp-home.png` is the fleet dashboard + rail; `webapp-composer.png` is shot in its OWN context at 960x540 (60% of the desktop width, pulled back one notch from the original 800x450/50% — that read as too tightly zoomed on the control) with `deviceScaleFactor: 4` (3840x2160 capture, the framer still downsamples); `webapp-workspace.png` is `/w/[id]` with the **split** pane clicked open (`data-testid="pane-split"`) so transcript + work panel both show — the default pane is transcript-only, so `work-panel` never mounts without that click (`workspace/index.tsx`'s `paneView === "work" ? workPanel : transcript`). Current testids driving the script: `app-sidebar`, `fleet-dashboard`, `workspace-card`, `workspace-page`, `pane-split`, `work-panel`; the login form now selects by label (`getByLabel("Device name")`, no more placeholder) and the pairing code renders in a bare `<output>` element, not `p.text-3xl`. `webapp-home-mobile.png` is unreferenced and no longer regenerated (stays in `docs/img/screenshots/` in case the phone shot returns) — re-add it to `use-webapp.md` and the capture script if revived.
- **The two diagram shots are the only captures that reach the NETWORK, and the fixture's diagram is opened through the ENGINE rather than written as a record.** `demo.json`'s `arch-diagram` workspace names a `.drawio` beside it (`grove-stack.drawio`, a real architecture diagram with no host-private label); `FleetPlanter._diagrams` seats it under the workspace's `.grove/attachments/arch-diagram/` and calls `open_diagram`, so the daemon serves a real session id and revision and the Diagram tab loads the hosted draw.io embed exactly as production does. Three things bind a regeneration. **The drive waits on the `Saved` badge, never the iframe** — the frame mounts long before the editor has acknowledged the document, and the first paint is draw.io's spinner. **An offline host skips the pair and says so** (`DIAGRAM_WAIT_MS`, a network budget), so the rest of the set still regenerates. **The full-pane shot needs a Fit Window AFTER a Select None**: the tab's own `fit` was computed for the split's frame and the format panel takes canvas after it ran, so the drawing sits at 67% with its lower third clipped; a click to focus the editor also SELECTS the cell under it, and draw.io's Fit Window (Ctrl+Shift+H) fits the selection when there is one — measured as one text cell at 765%. Escape does not clear a selection, Select None (Ctrl+Shift+A) does. It also means the fixture's newest workspace stays `auth-refactor`: the diagram entry is inserted before it, AND the planter pins the record's `updated_at` back to its create time after `open_diagram`, because that call bumps the timestamp and `list()` sorts newest-first within a status tier — without the pin the diagram workspace floated to the top and every TUI shot changed subject, which is only visible by opening the PNGs.
- **A capture that waits on a CONTAINER's testid ships whatever the container held at that instant, and two published runs shipped skeletons.** `webapp-workspace.png` went out twice with grey transcript bars and `No live session to measure`: the driver waited on `work-panel` and clicked Info, while the transcript and the Activity card both gate on the activity snapshot naming a primary session plus `/turns` answering, which lands a beat later. Wait on the CONTENT the caption describes — the first user bubble (`user-message-collapse`, not the overflow toggle, which renders only for a clamped message) and `metric-turns` inside `metrics`. The composer had the twin defect one row down: `launch-controls` mounts with each pill's NOUN as its label and swaps in the resolved default when the cascade answers, so wait on `[data-pill="agent"]`/`[data-pill="runtime"]` carrying a resolved value. And the onboarding tour opens itself for any browser that has never seen it, which a fresh headless context always is — `forceDark` now also pins `grove.onboarding.seen`.
- **`webapp-annotate.png` stages a COMMITTED annotated photo and drags the shell's split to the wider half; nothing about it is seeded.** The asset is the onboarding tour's own `webapp/public/onboarding/tour-sample-annotated-photo.webp` (a stock aerial crosswalk with every pedestrian boxed, no host-private pixels), read into the file chooser under the short name `crosswalk.webp` because the name is the card's caption, picked through the workspace composer's file chooser on `auth-refactor` with the transcript pane alone selected, so the page half is the transcript plus its composer holding the staged card. Three things bind a regeneration. **The group's `data-testid` IS its `id`** — `react-resizable-panels` writes the testid from the id after spreading rest props, so the first driver waited on a selector the app never rendered (the fix is recorded in [webapp](../webapp/CLAUDE.md)). **The pane opens at 45% and the docs promise at least half**, so the driver drags the handle to 40% of the window and THROWS if the pane measures under half rather than shipping a narrower shot. **marker.js opens at 100% and its editor toolbar has no Zoom to Fit** (only the read-only viewer does), so the driver presses Zoom Out in the editor's 0.1 steps until the zoom is under the fit computed against the MARKER AREA's box, not the editor's — the editor's box includes both toolbars, and a fit against it stops one step large with the picture clipped and nothing scrolled.
- **A hand-authored live transcript reads well and MEASURES thin.** Six legible calls a turn is what a hand writes; a real 8-turn session makes ~180. `Turn.burst` names how many synthetic calls the planter prepends from the history corpus (same tool mix as the backfill, seeded by session id), so `auth-refactor` reports 183 calls over 12 turns without the transcript pane turning into noise; the authored steps stay last so an open tail is still the authored one. `ContextWindow.request` was retuned at the same time: fresh input per request is now single digits to tens (the cache carries the conversation), so the Activity card's fresh/cache-read/cache-write ratio matches a real session instead of paying 160-3200 fresh tokens per request.
- **`DemoTmux.install_quiet_sessions` mirrors `tmux.create_session`'s SIGNATURE, and the engine gaining a keyword takes the whole pipeline down as a `failed to set up tmux session: … unexpected keyword argument`.** The double is a monkeypatch over the real function, so it has to accept whatever the manager passes; `size` was added for `detached_size` and the seed died on the first workspace. When `create_session` grows an argument, the double grows it too.
- **The synthetic demo fleet must force `container.enabled: false`, or the whole screenshot pipeline (TUI *and* web) silently produces empty transcripts.** `tools/screenshots/_fleet.py::demo_config()` never set `container`, so it inherited the engine's own default of `container.enabled: True` (the containerized-workspaces epic) — every demo workspace then launched inside a real devcontainer, where the stub agent scripts under `tools/screenshots/agents/` (host paths) don't exist. Symptom: the rail badge reads "agent exited with status 127" and the transcript renders the empty-state greeting instead of the planted turns, because the container's own filesystem has no shell to exec. Diagnose by reading the workspace's `runtime` field off `GET /workspaces` (`"container"` vs `"host"`), not by re-reading the stub script — it runs fine standalone. Forcing `container: {"enabled": False}` in `demo_config()` fixed both pipelines and is also faster (no `devcontainer up` per demo workspace).
- **The demo media are three bare autoplay videos in `docs/videos/`, and each one leads exactly one page** (`1-grove-terminal.mp4` ≈4 MB/7s → `features-activity` (it led `features-peek` until that page merged into activity on 2026-09-14), `2-grove-web.mp4` ≈20 MB/10s → `use-webapp`, `3-grove-devcontainer.mp4` ≈3 MB → `features-containers`; the web clip is heavy for autoplay-on-load, compress if it bites). **One clip, one home: the landing page carries none of them.** It used to open with the terminal clip and repeat two more further down, which made the page's own hero a duplicate of a page it links to; the hero is now the `.swiper.ms-shots` **end-to-end product tour**, not a four-shot surface sampler: composer → CLI → TUI fleet → activity → issue ops → containers → workspace → sessions → usage → MCP → telemetry → web fleet. Authentication stays out because it is setup friction rather than product value, and near-duplicate states stay on their owning pages. Autoplay is the theme's (`carousel.js`: `loop`, 4s delay, pause on hover, off under reduced-motion), so a "slideshow" needs no markup beyond the slides. **If you retire a clip from a page, give it another home or delete the file** — a committed mp4 nothing references is dead weight in every clone. Census it against the BUILT site (`grep -rl '<name>.mp4' site/`), never against sources. **Docs and README differ because GitHub cannot inline-play a committed mp4** (raw serving is blocked; drag-drop attachments cap at 10/100 MB):
- The **docs site** hands playback to the theme, which strips `autoplay`/`muted`/`loop`/`playsinline` off any `article video` and drives an IntersectionObserver instead, so only the on-screen clip plays. Writing those attributes in source claims an ownership you no longer have; write a bare `<video preload="auto" poster=…>` — no `.ms-shot` card, no controls, no caption. **From theme v1.12.5 the box is the theme's**: width, 960px cap, centring, the vertical rhythm and the corner radius all ship in `mewbo.css`, so an inline `style` here is both redundant and uncorrectable from a stylesheet later. The `poster` still matters: it is what prevents a black flash before first paint.
- The **README** embeds each clip as a GitHub **native inline player**: upload the mp4 ONCE through GitHub's web markdown editor (drag-drop), which stores it outside the repo on GitHub's CDN, and put the bare `https://github.com/user-attachments/assets/<uuid>` URL alone on its own line. A committed mp4 will NOT embed (raw serving renders a download link) and GitHub's markdown sanitizer STRIPS `<video>`/`<source>`, so the upload is the only path to an inline player. Wrapping the URL in ANY tag (`<a>`/`<p>`/`<img>`) breaks the auto-`<video>`, and these players have no autoplay/loop. Compress before upload: `ffmpeg -c:v libx264 -pix_fmt yuv420p -crf 30 -movflags +faststart -an` (took the web clip 20 MB → 1.6 MB, under the 10 MB free-tier attachment cap; crf 30 keeps UI text crisp).
- Keep README prose terse and free of em dashes; depth lives on the site.
- **A TUI screenshot is a framed PNG now, and the SVG is a sandbox intermediate that never reaches `docs/`.** `capture.py` exports Textual SVG into the throwaway sandbox, rasterizes it through Chromium (`driver/svg_raster.mjs`), then composites it with the same `frame.py` the web shots use, so a terminal shot and a dashboard shot sit on one wallpaper. Publishing both forms would leave thirteen files no page references, which is the orphan shape this tree keeps re-learning. Four things bind anyone touching it:
- **The markup must be INLINED into a host document, never loaded through `<img>`.** An SVG behind an `<img>` is a sandboxed, resource-restricted document in Chromium and external resources are blocked — and Rich writes an `@font-face` pointing at a **CDN** copy of Fira Code, which is not installed on this host. Through an `<img>` the webfont silently never loads. The fallback is not a *layout* hazard either way, because Rich gives every run an explicit `textLength` that fits glyphs to the cell grid whatever font wins, so the tell is legibility rather than breakage: the raster reports which font it actually got instead of assuming. Rasterizing is also what takes that CDN fetch off the READER's critical path, where it has been all along.
- **ImageMagick's rsvg delegate is still the wrong renderer** (it drops the cell background fills that carry the peek rail, every status colour and the selected row). That warning predates this pipeline and is the reason it shells out to a browser at all.
- **The raster is transparent outside the terminal's own `rx=8` corners, and that is safe rather than lucky.** `convert("RGB")` drops the alpha to black, and the framer's corner radius is far larger than the terminal's own, so every black pixel falls inside what the frame mask cuts. Shrink the frame radius below the terminal's and black notches appear.
- **`tools/screenshots/mockups.py` now has a SECOND dead input** (`tui-list.svg`, alongside the `webapp-home-grid.png` it already required and nothing produces). It could not run before this and still cannot; the delete-vs-revive decision stays open.
- **The framed canvas is 1920x1080 and every published frame is palette-compressed, both measured rather than chosen.** Sources are captured at 2x (a 1600x900 viewport at `deviceScaleFactor: 2`, a 1629x928 terminal rasterized at 2x) so the framer always DOWNsamples, which is the direction that stays sharp. The canvas was 2400x1350 and bought nothing visible, since the theme caps content at 960px. On `tui-list` at 1920x1080: **1066 KB truecolour, 347 KB at 256 colours with Floyd-Steinberg dithering**, mean absolute difference 0.85/255, no banding in the sky (the region that shows it first). `optimize=True` alone was worth 2% and WebP would have been 278 KB — worth revisiting as a whole-set format decision, not as a one-page exception.
- **The README's moving image is GENERATED from the published stills (`slideshow.py`), so it cannot drift from the docs.** `webapp-tour.gif` walks composer → sessions → workspace → annotate → diagram → usage → fleet, each held 800ms with a short crossfade, looping; `TOUR` in `slideshow.py` is the census, and the diagram slide is the full-pane shot because the split's diagram is a thumbnail at 960px. Two things make it affordable. Every framed shot shares one wallpaper at one window placement, so the backdrop is byte-identical across slides *and* through the blends, and GIF's delta encoding pays only for the window interior — which is why the tour is built from FRAMED shots and never from raw captures. And **dithering is off here, for size rather than for looks**: error diffusion runs across the whole frame, so a change confined to the window perturbs the wallpaper's dither pattern after it and destroys exactly that identity. Measured over five slides: **7840 KB dithered against 4205 KB not.** A hold is ONE frame with a long delay, never repeated frames. **The palette is cut from the full-size stills, never a coarse tile of the frames**: a 120px thumbnail is wallpaper and dark chrome, so the median cut spent its entries there and the annotation slide's red and violet marker boxes came out umber and slate. Full-size stills cost 200 KB over seven slides and keep the accents.
- **`docs/img/mockups/banner-deviceframes.png` is an unused asset**, kept only in case the device-frames banner is reinstated. If you ever re-strip its background: floodfill white→transparent seeded from the border corners (`-alpha set -channel RGBA -fuzz 3% -fill none -draw "matte X,Y floodfill"` — the ImageMagick 6 primitive is `matte`, renamed `alpha` in IM7, so `alpha … floodfill` errors under `convert-im6`), then `-trim +repage`. **Keep fuzz LOW (~3%).** The white peripherals (keyboard, mouse, the display's white chin) are the SAME white as the background and separated from it only by a soft-shadow *gradient*, so at ~20% the flood bridges that penumbra and drains them; the damage is invisible on a white page and only shows on a dark background. Flooding from the CORNERS rather than a global `-transparent white` is what protects the fully-enclosed white UI inside the dark screens. Present a transparent banner BARE — it needs no `.ms-shot` card.
- **`tools/screenshots/mockups.py` CANNOT RUN, and has not been able to for a long time** — a stronger statement than the "nothing consumes its outputs" this bullet used to make, and a different decision. Line 239 requires `docs/img/screenshots/webapp-home-grid.png`, a shot name from the old composer-first capture script that no longer produces it and that no page has ever referenced; `hero-laptop.gif` and `webapp-phone-mockup.png` are consumed by nothing either. So it is not a working reference kept warm, it is an orphan that has drifted — precisely the case [core](../src/grove/core/CLAUDE.md) warns about: **ask whether an orphan still WORKS, not just whether it is reachable.** Delete-vs-revive is an open decision; until it is taken, do not cite this module as a working example. Its device-shell mechanics remain the written reference if framed shots return: shells live in `tools/screenshots/device-shells/` (committed mockuphone PNGs — opaque body, transparent screen + exterior); a screenshot is seated BEHIND the shell with `-compose DstOver`, confined to the screen rectangle measured once by flood-filling the alpha exterior and trimming (recipe in the module docstring). Three traps: (1) **`-gravity center` set for the `-extent` LEAKS past the `( … )` and silently offsets the `-geometry` seat from center**, shoving the shot down-right out of the screen into the transparent exterior — reset `-gravity NorthWest` (and `+repage`) before every `-geometry`. (2) **ImageMagick's rsvg delegate renders Textual SVGs poorly** (drops cell backgrounds/peek rail/colors, leaving stray titles) — rasterize a TUI capture with headless Chromium, not `convert svg`. (3) **A phone shot seated edge-to-edge reads as "bleeding"** — `frame(..., inset=(top,right,bottom,left))` reserves a black safe area for the status bar and home indicator. Both GIF frames must be the same pixel size or the GIF jumps.