Skip to content

Public documentation on dormouse.sh, behind one navigation rail - #532

Merged
nedtwigg merged 38 commits into
mainfrom
launch-marketing
Sep 3, 2026
Merged

Public documentation on dormouse.sh, behind one navigation rail#532
nedtwigg merged 38 commits into
mainfrom
launch-marketing

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Sep 3, 2026

Copy link
Copy Markdown
Member

Publishes Dormouse's documentation on the marketing site, and gives the whole docs section one left navigation rail.

What ships

vscode-ext/README.md becomes the canonical product guide — one authored source that renders on the Marketplace, Open VSX, and GitHub without prose forks. Three reference pages are generated from sources that are already tested elsewhere:

Page Generated from
/docs/dor the CLI help snapshots dor/test/cli-help.test.mjs proves match real output
/docs/agent-skill dor/skill.md, rendered exactly
/docs/self-host the runbook half of SELF_HOST.md, which stays canonical in the repo

The homepage is repositioned around the phone, with a browser-surface section.

The rail

The five docs pages could only reach each other through a footer, and two — the changelog and the supply chain — were reachable from nothing once a reader left the homepage. All five now share a left rail that lists every page and expands the current one's sections beneath it. There is no separate "on this page"; one rail, not two.

The rail is a bounded flex column whose section list is the only part that gives up space, so everything shows when it fits and the page list stays reachable when it does not. Narrow screens get it behind a bar under the header instead. /docs is an entrypoint rather than an index — it redirects to DOCS_DEFAULT_PATH, as a 302 because that target is a judgement call.

Joining the rail put the changelog and the supply chain under the rule the reference pages already followed: they render on the reader's picked theme, so their links moved off brand caramel, which is 5.56:1 on the site's black but 3.43–3.78:1 on every bundled light theme.

What is pinned

scripts/public-docs-lint.mjs (12 checks, in root pnpm test) enforces the contracts this depends on: the guide's required sections read out of the spec's own list, links resolving off the parsed tree, Marketplace media rules, --baseImagesUrl on every packaging invocation, per-page head tags, and the /docs entrypoint. Every inventory it checks is derived from the file that owns it rather than restated — it imports docs-pages.ts directly rather than scraping it, so a reordered field is a non-event instead of a silent miss.

Each rule was mutation-tested: the check goes red when its target breaks.

Two SEO fixes found along the way

root.tsx hardcoded title, canonical, and the social cards in <head>, which React Router emits before <Meta />. Every page with its own meta shipped two <title> elements — crawlers read the first, so each reference page advertised itself as the homepage — and all ten pages claimed canonical=https://dormouse.sh/, asking search engines to treat them as duplicates. Both now come from site-meta.ts, with a canonical on each page's own path.

Verification

pnpm test green (140 website tests, all lints). Pages checked in a browser on both a dark and a light theme, including the rail's truncation behaviour under a constrained height.

One gap worth naming: the mobile drawer was verified functionally and its breakpoints by class, but the harness would not shrink the viewport, so nobody has seen the rail at a real phone width. Worth a device check before merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa

nedtwigg and others added 23 commits August 18, 2026 09:07
Promote Dormouse Pocket from a roadmap footnote to the second act of the
pitch.

Homepage:
- Hero tagline "for mice" -> "for mice and thumbs".
- Merge the tmux/layout and distribution sections into one lead section
  headed "VS Code or Standalone"; the tmux line survives as body copy.
- Promote push notifications to section two, rewritten to carry the whole
  loop rather than half of it: unattended-ring push, thumb-drag gesture
  reply, and self-hosting. Replaces the completion-detection section.
- Add "Terminals that know their ports" for the pane port listing.
- Retitle the copy/paste section and rewrite it around selection, Copy
  Raw/Rewrapped, and smart extension.
- Drop the signup form; it still ships on /playground/pocket.

Copy corrections:
- Pocket relays over a WebSocket, not WebRTC.
- Remove pricing and launch-discount claims for an unreleased product.
- Replace retired sleep/wake vocabulary with minimize/reattach.

Known gaps: /playground/pocket still carries the WebRTC and pricing copy,
the ports section has no video, and video-alert.mp4 is now unreferenced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add "Browsers for you (and your agents)" between the ports and
copy-paste sections: a browser is a pane in the same tiling layout, and
`dor ab open surface:N` aims one at the dev server a terminal owns — the
same command an agent runs, so an agent opens a pane you are already
watching. Grounded in docs/specs/dor-browser.md, so it claims screencast
and pop-out but not iframe (agents cannot drive it), and says plainly
that Dormouse drives your own agent-browser rather than shipping one.
Illustrated with a CSS mock; there is no capture for it yet.

Lay the first section's two paragraphs out in two columns and drop its
max-w-4xl cap so the copy spans the same width as the video beneath it —
previously the text sat 40px inset on each side.

All four grid sections now share md:grid-cols-[2fr_3fr] so their column
edges align down the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 1 of the website-docs scope. Restructure vscode-ext/README.md into
the host-neutral guide that will render at /docs, on the Marketplace, and
on Open VSX, and correct what it claimed.

Accuracy fixes, all verified against the owning specs:

- The alert section claimed "when a pane stops changing for two seconds,
  it marks the task complete... works with any CLI tool, no plugins or
  configuration." alert.md is explicit that WATCHING "applies only to
  command names the user explicitly asked for" with "no process
  heuristics", and the window is the configurable T_USER_ATTENTION, not
  two seconds. Replaced with the three real tracks: protocol
  notifications and command-exit need no setup, quiet-detection is an
  opt-in rule on a command name. Documents the OSC 633/133 shell
  integration requirement that watching depends on.
- The mode-switch gesture listed LCtrl -> RCtrl. handle-dual-tap.ts
  detects Meta or Shift only, so that never worked -- and it was the
  variant Windows and Linux readers would have reached for first.
- Cmd+Arrow was described as "swap pane positions"; it swaps terminal
  sessions between panes. Added the missing a / t / > bindings.

Adds the Get Dormouse, Browsers, Themes, and Automation sections the
scope calls for, and removes all five TODO: placeholders, which the
planned public-doc lint rejects.

package.json: add the bugs.url the listing contract requires, and reword
description so it does not imply zero-config alerting for agents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 2 of the website-docs scope. /docs is a web rendering of
vscode-ext/README.md rather than separately authored product prose.

The Markdown stack is in-repo with no new dependencies:

- docs-parser.js supports a deliberate *subset* of CommonMark and throws
  UnsupportedMarkdownError on anything outside it. A third-party parser
  degrades gracefully on constructs it does not know; a hand-rolled one
  mangles them silently, so the subset boundary is a hard error that
  phase 4's lint turns into a build failure.
- Raw HTML is rejected except a narrow <img> allowlist (src/alt/width/
  height/title, https only), since the guide's 22px alert-state icons
  need sizing and portable Markdown has none.
- The slugger mirrors github-slugger, including replacing each space
  individually rather than collapsing runs, so an em dash between spaces
  yields a double hyphen exactly as on GitHub. That is what keeps /docs
  anchors identical to the same heading's anchor on GitHub.
- Table cells honour backslash-escaped pipes, which the shortcut table
  needs for its `\|` split binding.

generate-docs.js applies the /docs delta as an explicit table rather
than regexes over prose. Today it holds one operation -- drop the
document title, which the page shell supplies -- and it fails the build
when a rule matches zero blocks or more than one.

18 parser tests cover the allowlist boundary, escaped pipes, nested and
ordered lists, and slug dedupe. Generated data is gitignored like
changelog.json and regenerated by predev/pretest/prebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 3 of the website-docs scope.

/docs/dor is generated from the help snapshots that dor's own test already
proves match real output. The parser is deliberately narrow -- it knows
only the column-zero markers the CLI emits and leaves everything else as
ordered prose -- and every node keeps its exact source slice, so
reconstruct() rebuilds each snapshot byte for byte. That is asserted
across all 11 shipped snapshots, and each command section also carries
its raw help in a collapsed disclosure.

Two parser bugs found by writing those tests against real snapshots
rather than invented input:

- A section marker was consuming everything up to the next marker, so
  `dor list`'s description paragraphs were absorbed into its USAGE node
  and would have rendered as usage lines. A marker now owns only its
  indented body.
- Definition rows split on the first 2+ space gap, which truncated
  "-h  --help" to "-h" because the term contains its own gap. Rows now
  split on the block's aligned description column, and a wrapped
  description is one starting nearer that column than the term column --
  which is how `dor split`'s long --left|--right|... flag, wrapping at
  indent 18 against column 19, gets joined. No row across any snapshot
  is left without a description now.

Generation fails on a malformed envelope, duplicate command id, a
snapshot missing from or extra to the root inventory, a skill heading
that is missing or ambiguous, or a reference whose anchor does not exist.

/docs/agent-skill renders dor/skill.md exactly; a test asserts the
generated markdown equals the file byte for byte. Its CLI links live in
the page, never in skill.md, so an older installed CLI stays
self-contained rather than pointing at the latest website.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 4 of the website-docs scope, and its close.

Homepage: the browser section now leads with a terminal-to-browser
transcript and links into /docs, /docs/dor#agent-browser, and
/docs/agent-skill. Per the decision to keep it manual, the transcript is
authored literals with a source comment saying so. Two things bound the
drift that invites: command syntax matches the tested help snapshots, and
the output lines use notation the CLI documents itself -- ensure's
`created surface:N  "<command>"` and dor ab's resolution arrow -- rather
than invented formatting.

Root README: drops the same "quiet for two seconds is marked done, works
with anything, no configuration" claim phase 1 removed from the guide,
and routes durable user documentation to /docs instead of restating it.
Quickstart and folder table audited against the scripts and workspaces
that actually exist.

scripts/public-docs-lint.mjs runs from root `pnpm test` after the spec
lint. Alongside the placeholder, link, image, command, and anchor checks
the scope asks for, it fails when either canonical source leaves the
parser's supported Markdown subset -- the guard that makes hand-rolling
the parser safe instead of a silent rendering hazard. Checks are isolated
so one malformed source cannot hide the others behind a stack trace;
verified by breaking each rule in turn.

website-docs.md is rewritten with the built work above the fold in
present tense with Source of truth pointers, per the spec lifecycle in
AGENTS.md. What remains unbuilt is now a named scope: migrating guide
media off github user-attachments to project-owned URLs, VSIX packaging
and live listing verification, and promoting the public-doc contracts
into dor-cli.md and vscode.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guide hotlinked five images from github.com/user-attachments. That is
worse than it looks at traffic:

- Each URL 302s to a signature-expiring S3 object, so every view costs two
  third-party round trips and nothing downstream can cache it. HEAD 403s
  where GET succeeds, which is the giveaway.
- The copy/paste demo alone was an 8.8 MB animated GIF.
- The URLs belong to a GitHub comment upload, so deleting that comment
  breaks both the site and the Marketplace listing.
- Every visitor's IP reached GitHub, on a site that publishes a supply
  chain page.

Vendor the four alert-state icons into website/public/media (84 KB total)
and reference them as absolute https://dormouse.sh URLs -- absolute
because the same README is the Marketplace listing body, rendered on
Microsoft's servers where relative paths do not resolve. Alt text is now
descriptive rather than the filenames it was.

Drop the 8.8 MB demo from the guide entirely rather than re-hosting it;
the homepage already tells that story with video-copy-paste.mp4, and the
Marketplace cannot play video anyway.

Vendored rather than fetched at build time, so the build stays
reproducible from a clean checkout with no network.

The public-doc lint now fails when a guide image is not served from
dormouse.sh or when a referenced file is missing from website/public,
so this cannot regress. Verified by pointing an icon back at GitHub.

Promotes the media item out of the website-docs-release Future scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Previous commit vendored guide images into website/public/media and made
the README point at absolute https://dormouse.sh URLs. That inverted the
authoring flow: adding an image meant hand-placing a file in the website
package and writing an absolute URL, and dragging one into the README on
GitHub -- the normal workflow -- failed the lint.

Media now lives beside the guide in vscode-ext/media and is referenced
repo-relatively (`media/hero.jpg`), so ordinary GitHub authoring works.
All four renderers resolve those same paths, verified by packaging a
VSIX:

- GitHub renders them natively.
- The VSIX ships media/ inside the package (.vscodeignore already had
  `!media/**`), so the extension details pane renders locally.
- vsce --baseImagesUrl rewrites them to https://dormouse.sh/media/... at
  package time, and it rewrites raw <img src> attributes as well as
  Markdown image syntax. Passed explicitly in package.json and in the
  release workflow rather than letting vsce infer a base, since
  inference uses the repo root and this extension is in a subdirectory.
- generate-docs copies vscode-ext/media into website/public/media and
  rewrites sources to /media/, so /docs serves them same-origin. That
  copy is generated and gitignored.

The parser now accepts relative image sources; the lint requires them,
rejecting every remote URL and any file missing from vscode-ext/media,
and flagging media that nothing references. Verified by trying a
user-attachments URL and a missing file.

Drops the stale `vscode-ext/media/` gitignore rule. It sat among build
artifacts, but nothing generates that directory and .vscodeignore
deliberately retains it -- and if the files are not committed there,
GitHub cannot render the guide at all.

Note: vscode-ext/media/hero.jpg duplicates website/public/og-image.jpg
(84 KB). They serve different consumers -- guide hero and social card --
so they are kept separate rather than coupled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four parallel cleanup passes (reuse, simplification, efficiency,
altitude) converged on the same handful of mechanisms. Fixes, most
significant first.

Every docs route imported one combined docs.json, so Rollup hoisted it
into a single 112 KB shared chunk and a /docs visitor downloaded the CLI
reference and agent skill too. The generator now writes one file per
page; /docs pulls 20.7 KB plus a 5.9 KB shared component chunk. Dropping
the unread raw skill markdown takes another ~10 KB off.

The parser now owns tree traversal (`visit`). The generator had mirrored
the node schema with a hardcoded key list, and that mirror was already
incomplete: a row is an array of cell arrays, not a node, so images in
table body cells were never visited and would have shipped unrewritten
src attributes. Only list items carry images today, so it was latent.

The parser also now marks a standalone image at parse time, where the
answer is known. The renderer had been inferring it from the presence of
a width attribute, which silently reclassified any image that gained one.

The lint had grown into a shadow of the generator: it re-asserted
duplicate ids, snapshot inventory in both directions, and reference
anchor existence, all of which the generator already throws on, and its
skill byte-identity check was a tautology against the generator's own
input. Those are deleted; the generator owns them. What the lint keeps
is source policy the generator cannot see, and it now reads the parsed
tree instead of re-scanning Markdown with regexes -- which also stops it
seeing <img> examples inside fenced code as real images. Its link check
skips fenced blocks, so a link-shaped string in a sample is no longer a
false failure. It reads each source once instead of nine times, and no
longer mutates the working tree: media copying moved out of
generateDocs() into main(), so `pnpm test` stopped deleting and
rebuilding website/public/media as a side effect.

Smaller: the delta is expressed once (headings and TOC derive from the
post-delta tree, not three independent depth filters); /docs/dor now
allocates its anchors through one registry and fails on collision, which
nothing checked before; cli.anchors is emitted rather than rebuilt in
four places; SKILL_REFERENCES derives its prose half from
CLI_INTRO_SECTIONS; two copy-pasted argmax loops became mostCommon();
findExactlyOneHeading replaces two divergent "find or throw" copies;
AgentSkillDocs lost a nullable-node model and 58 wrapper divs in favour
of a renderAfterHeading slot; shared LINK/CODE/PRE class tokens replace
six inline copies; independent file reads and copies run concurrently.
Plus vestiges: an unused `consumed` tally, a `.repeat(3)` whose result
was only ever indexed at [0], a per-line RegExp compile inside fenced
blocks, a redundant `includes('-')`, duplicate heading class entries, and
Home.tsx section comments left numbered 1,2,4,5,3 after the reshuffle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every link from /docs back into the site left the origin. The ten
`https://dormouse.sh/...` URLs in the guide were rendered verbatim, so
following "Complete CLI reference" from a dev server or a preview build
landed on production rather than the page next to it -- and looked like
/docs/dor was 404ing when the route was fine all along.

The guide cannot simply spell them relatively. It is the packaged
Marketplace listing body and is also read on Open VSX and GitHub, where
a root-relative path resolves against the wrong host or not at all, so
absolute URLs are a stated constraint and public-docs-lint fails the
build if the README stops carrying one.

So the website localizes them where it already localizes media paths.
localizeSiteLinks strips this origin and keeps path, query, and
fragment, leaving /docs/dor#agent-browser a working deep link. It
matches on URL.origin rather than a string prefix, so a lookalike host
is not caught, and it runs over dor/skill.md before /docs/dor lifts its
introduction out of those same blocks -- a no-op today, but the hole is
closed for all three pages at once.

That makes the renderer's external-link test a bare scheme check: a
same-site href is relative by the time it arrives. SITE_ORIGIN had that
one consumer and is gone.

The remaining absolute dormouse.sh URL in the prerendered HTML is
<link rel="canonical">, which has to be absolute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/docs rendered vscode-ext/README.md as a hosted product guide. It is
withdrawn: the route, the page, the header's Docs link, the breadcrumb
the two references hung off, the footer entry, and the homepage link
into the guide's browser section are all gone. /docs is now a 404 and
nothing public points at it.

/docs/dor and /docs/agent-skill are untouched and are now siblings
rather than children, so DocsLayout loses its breadcrumb prop entirely
-- both callers only ever used it to point back at the page that is
gone.

The links that pointed at /docs are dropped rather than repointed. The
guide still reaches readers through the Marketplace, Open VSX, and
GitHub, and does not need the site to advertise it.

The generator is kept whole and still parses the guide on every build.
That is not sentiment: `vsce --baseImagesUrl https://dormouse.sh` turns
the guide's `media/hero.jpg` into `https://dormouse.sh/media/hero.jpg`
on the Marketplace, so the guide's media sync is what keeps the packaged
listing's images alive, page or no page. docs.guide.json is generated
and unconsumed, ready for whatever replaces the page.

The lint check that required both READMEs to link to /docs now requires
the two references that exist, matched as exact URLs -- the old prefix
test would have been satisfied by a link to /docs itself and shipped a
404.

Recorded under a guide-page-return scope in the spec, including the
question anyone reviving the page should answer first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	AGENTS.md
#	package.json
#	vscode-ext/package.json
`vscode-ext/media/` is the Vite outDir for the webview bundle, built with
`emptyOutDir: true` — so `pnpm build:vscode` deleted the guide images that
4626d44 committed there, and `generate-docs` then failed on the missing
`media/hero.jpg`. It also enumerated the bundle's own `assets/` and
`index.html` as guide media.

The guide's images move to `vscode-ext/images/`, published at
`/images/` on the site so `--baseImagesUrl` still resolves the Marketplace
listing. `vscode-ext/media/` is now gitignored, which it should have been
all along: nothing in it was ever tracked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFfnnvHPrrCPPa2P5C9CkC
SELF_HOST.md stays canonical in the repository — an assistant reads it in
a checkout, and scripts/deploy-lint.mjs audits its Installer contract
against deploy/local/ — so the site publishes from it rather than from a
copy that would have to be kept true separately.

The file is two documents in one. SELF_HOST_DELTA withholds the halves a
web reader has no use for: the assistant instructions, the final handoff,
and the maintainer Installer contract with its four subsections. That
needs a second delta operation, `remove-section`, which takes a heading
plus everything under it.

Removing sections orphans the links into them, so `resolveRemovedAnchors`
sends a surviving `#anchor` whose target is gone to the canonical file on
GitHub, and `assertAnchorsResolve` fails the build on any that remain.
Three links in SELF_HOST.md needed it; without this the page would have
shipped three that scroll nowhere.

The homepage and both READMEs now route to all three references, checked
in the lint from both sides: every reference is linked, and no `/docs`
href points anywhere unpublished.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gt1GkqkGLoF3oopvJ3fWE
The reference pages are long-form reading, so unlike the rest of the site
they are not locked to the brand's black. DocsLayout restores a theme and
floats the same `compact` ThemePicker the Pocket playground uses, bottom
right, opening upward. A reader who has never chosen one is prompted
until they do; the homepage is untouched and stays black.

Two things this needed from the picker. `menuSide` positions the compact
menu through style rather than a Tailwind class, because the website
renders against the lib's prebuilt stylesheet where a never-emitted
utility is simply absent — `bottom-full` did nothing and the menu opened
off the bottom of the viewport. `onPick` reports the choice rather than
the change, because `restoreActiveTheme` persists an id of its own, so
storage cannot answer "has this reader chosen yet" and
`subscribeToActiveTheme` stays silent when the active theme is re-picked.

Prose links move off brand caramel to the theme's own link color: caramel
is 5.56:1 on the site's black but 3.43-3.78:1 on every bundled light
theme, so a reader picking one would have dropped the page's links below
WCAG AA. Caramel stays where it cannot be rethemed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gt1GkqkGLoF3oopvJ3fWE
A cleanup pass over the docs pipeline, plus two fixes it surfaced.

Inventories that were restated are now derived from whatever owns them.
The skill-to-CLI reference table was a hardcoded list of eight commands,
so a newly documented command would have gone unlinked with nothing
failing; it is derived from each heading's backticked `dor X` tokens and
throws when one names a command with no section. The guide's required
sections come from this spec's own `text` fence, the reference pages from
`docs-pages.ts` (which the routes, the prerender list, and the footer also
read), and the image base from the generator. `buildGuide` and
`buildSelfHost` were the same eight steps written twice and had already
drifted on ordering, so both call one `buildDocument`.

`--baseImagesUrl` was an unenforced string in three places under a bare
"must stay in sync" claim; `checkImageBaseUrl` now pins every packaging
invocation, exempting a `--packagePath` republish. Generated guide media
moves to `public/guide/`, which the generator owns outright — it was
`rm -rf`ing `public/images/`, the natural home for hand-authored assets,
with the path blanket-gitignored.

The public-doc lint reads the parsed tree rather than regexing Markdown
it had already parsed, so a link inside a code span is no longer a link,
and `README.md` is parsed too. Two checks that could match zero and pass
now fail instead, the WebRTC ban is scoped to remote-api.md's `## Future`
fold so promoting it retires the rule, and `checkSelfHostWithholding` is
gone — `applyDelta` already catches a rename, with a better message.

Shared class strings gain owners: `AnchoredHeading` renders every h2-h6
on every docs page, `site-tokens.ts` holds the recipes that were literal
in five files, and `--docs-accent` spells the theme-link-with-caramel-
fallback chain once. That last one must live on `body`: `applyTheme`
writes `--vscode-*` onto `body.style`, and a var() in a declaration on
`html` cannot read a property set on its child, so on `:root` it silently
collapsed to caramel on every theme — the exact contrast failure the
fallback exists to prevent.

Two fixes found while verifying the above.

The theme prompt now reads "Don't like the colors? Pick a theme." and
carries a close button. Closing counts as answering it, so it does not
return on the next page — a reader who declined has still seen the offer
— which makes the persisted fact a dismissal rather than a choice.

Per-page head tags move out of `root.tsx`'s `<head>` into `site-meta.ts`.
A tag hardcoded there is emitted before `<Meta />`, so each reference page
shipped two `<title>` elements and crawlers read the first — the
homepage's — and every page pinned `canonical` and `og:url` to
`https://dormouse.sh/`, asking search engines to treat all ten as
duplicates of the homepage. Every page now carries exactly one title and a
canonical on its own path, trailing-slashed to match the 308 the host
issues. `checkPageHeadTags` and `checkSiteOrigin` pin both rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2D8V67mkkAbeJCU2kSwJU
…hing

Three fixes and the tests that pin them.

`AnchoredHeading` mapped depth 1 to level 6, because `HEADING_TEXT` began
at 2 and the lookup fell through. `/docs/agent-skill` renders `dor
skill.md` with no delta, so its document title was shipping as an `<h6>`
at `text-base` — wrong size and wrong outline. The refactor that
centralized the heading also lost the tag: the previous code clamped with
`Math.min(depth, 6)`, so only the class fell back.

The theme prompt read `localStorage` in a `useState` initializer. The
prerender runs in Node with no storage, so it answered "not dismissed"
and baked the prompt into the static HTML, which a returning reader's
first client render then contradicted — a hydration mismatch, and a
dismissed prompt flashing before it vanished. Both renders now start
from `null` and reconcile in an effect. A new reader sees the prompt
appear after hydration, which is the better half of the trade.

`parseImgTag` allowed `title` and then dropped it, so a `title` on an
allowlisted `<img>` never reached the renderer that already typed and
rendered it.

`ovsx publish` packages from source in the npm script, so it needs
`--baseImagesUrl` exactly as `vsce` does; `ovsx` supports the flag and
ignores it with a warning for a prepackaged extension, so the workflow's
`--packagePath` path is unaffected. `checkImageBaseUrl` now covers both
packagers rather than leaving one unpinned.

The hydration test needed two corrections to pin anything. Its
`prerender` helper ran in jsdom, which always has `localStorage`, so it
never reproduced the storage-less render that causes the mismatch; and
`renderToString` escapes the apostrophe, so asserting `toContain` on the
prompt's prose passed whatever rendered. It now stubs storage away for
the server render and asserts on `role="status"`. Both tests fail
against the pre-fix component, as does the heading test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
The test restated the five allowed attributes, so allowing a sixth that
`parseImgTag` then dropped on the way out — the bug just fixed for
`title` — would have kept passing. It now asserts its sample map equals
`IMG_ALLOWED_ATTRS` before building the tag from it, so a new entry fails
until the test gains a sample for it, and a dropped one fails on the
round trip.

The theme prompt's hydration rule now names the test that pins it, as
AGENTS.md asks of a rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
Groundwork for a left-hand rail across the docs section. The changelog
and the supply chain join `DOCS_PAGES`, so the route table and the
prerender list stop naming them separately and rail order has one owner
to read. `docsNeighbors` derives prev/next from that order.

The obligation the READMEs carry moves onto the page itself. The lint
keyed "must be linked off-site" on the `/docs/` prefix, which was the
same set until now; with two rail entries outside that prefix it would
have started demanding README links for the changelog and the supply
chain. It reads `published` instead, and fails if nothing carries it.

`changelog/after/:version` stays out of the rail: the standalone updater
deep-links it after an update, so it is a parameterized view rather than
a page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
The rail nests the selected page's sections under it, so all five pages
need to produce that inventory in one shape. `TocEntry` moves to
docs-pages.ts, beside the page list the rail reads it with; the three
generated references already emitted it, and the changelog and the
supply chain now derive theirs.

The changelog's comes from changelog.json, capped at the most recent
five: the rail carries four other pages, and one entry per release would
be longer than all of them together. The supply chain's comes from the
same array that titles its sections, which the page now maps over
instead of spelling each one out at the call site, so the rail cannot
name a section that was renamed or dropped. Its anchors are written out
rather than slugged from the title, so rewording a heading does not
break a saved link.

`/docs/dor` gains a `Commands` heading and nests its eleven subcommands
under it, rather than showing fourteen flat entries beside four on every
other page. The commands render as `h3`: a reader on a screen reader
navigates the outline rather than the rail, so the two must agree, and
`DorDocs.test.tsx` pins each command one level below the entry nesting
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
The five docs pages could only reach each other through a footer, and two
of them — the changelog and the supply chain — were reachable from
nothing once a reader left the homepage. `DocsLayout` now renders a left
rail listing all five and expanding the current page's sections beneath
it, so moving between pages and within one is the same control. The
right-hand "on this page" is gone; it restated half the rail.

The rail is a bounded flex column whose section list is the only part
that gives up space. Everything shows when it fits, and when it does not
the five pages stay put while the sections scroll — the top level is what
a reader needs to leave a page they are on.

Narrow screens get the rail on demand instead, behind a bar under the
header: on a phone, five pages plus a page's sections above every article
would bury the article. Escape closes it. `Docs` joins the marketing nav
on desktop only, and `/docs` redirects to `DOCS_DEFAULT_PATH` — an
entrypoint, not an index, so a 302 rather than a 301 we would be stuck
with. `checkDocsEntrypoint` keeps the redirect and the constant agreeing.

The changelog and the supply chain adopt the layout, which puts them
under the rule the reference pages already followed: they render on the
reader's theme, so their links move off brand caramel, which is 5.56:1 on
the site's black but 3.43-3.78:1 on every bundled light theme. The
decorative caramel borders stay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
Cleanup pass over the rail.

The lint parsed `docs-pages.ts` with three regexes. Node strips the types,
so it imports the module and reads the same objects the browser bundle
does. This was not cosmetic: `/path:\s*"([^"]+)"[^}]*published:\s*true/`
silently matched nothing when a field was reordered or a nested object
sat between, and every check only failed on zero matches, so a partial
miss passed. A fourth reference added with its fields in the wrong order
would never have been required to appear in the READMEs.

`published` named a state but encoded an obligation — every page in the
rail is published, and the one exemption lived in the lint as a
`guideExempt` set. It is `linkedFrom` now, naming the documents that must
link each page, so the self-host exemption sits on the entry it applies
to and the union is type-checked.

The rail's one contract — every entry's id is an id its page renders —
was written three times over three pages and left the two generated ones
unchecked. One table-driven test covers all five, and fails if a page
joins the rail without joining the test. Verified by dropping heading ids:
self-host and agent-skill now go red where they had no check at all.

`DocsThemeControl` loads lazily. Nothing needs the floating picker at
first paint and it pulled the theme-picker chunk onto the critical path of
every docs page — including /changelog/after, which the standalone updater
opens and which is SPA-served, so its chunks arrive as a waterfall.

Smaller: `docsRailPosition` returns the entry it already found rather than
making the caller find it again; `title` defaults to the rail label, so
three pages stop repeating their own name; the nav landmark and the muted
link and indent recipes each have one owner; rail targets on the changelog
and the supply chain get the `scroll-mt` every other docs heading has.

The `Docs` nav link never highlighted — `SiteHeader` compares the path
exactly and `/docs` only ever redirects, so it could not match. It names
the paths it covers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
Findings from a review of the rail work.

Moving the commands to `h3` flattened them. Each command section renders
its own `FLAGS` / `Text output:` labels, and those were fixed at `h3`, so
every label became a peer of the command it belongs to — eleven commands
and their labels in one undifferentiated run, worse than the outline the
nesting was meant to fix. Labels now render one level below their
section, and the test checks the whole run rather than only the command
headings, which is why it passed.

The changelog rail listed the newest five releases from a module
constant, but `/changelog/after/:version` renders only releases newer
than the baseline — so on the URL the standalone updater opens, a reader
one version behind saw one article under five rail entries, four of them
scrolling nowhere; on the newest version, all five. The entries are
derived from what the page actually renders, and the rail's contract test
now covers the filtered route.

Two holes in the lint. Homepage coverage keyed on `linkedFrom`, so a
`/docs` page added without that field would ship reachable from the rail
alone — the exact strandedness the check exists to prevent; it is back on
every `/docs` page. And `README_OF[source]` failed open: Node erases the
union at runtime, so a typo skipped the page's check and still printed
"passed".

Below `lg` the docs carry a sticky nav bar under the fixed header, and
`scroll-mt-24` cleared neither — a tapped entry landed 13px (phone) to
29px (md) underneath. One token now owns the offset at every breakpoint.
The drawer also stayed open over the section just jumped to, since a
section link is a same-document hash that navigates nothing.

The lazily-loaded theme control gets an error boundary: a reader holding
cached HTML across a redeploy requests a chunk that no longer exists, and
an unguarded rejection would replace the whole article over a floating
colour picker. `/docs/` joins `/docs` in the redirects, since every other
page is served at a trailing slash. The changelog's accent rule and the
blockquote border were the last caramel left on pages that now follow the
reader's theme. The Code map said the header omits `/docs` and that
docs-pages feeds a footer this work deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 3, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: acc05d2
Status: ✅  Deploy successful!
Preview URL: https://a8365d37.mouseterm.pages.dev
Branch Preview URL: https://launch-marketing.mouseterm.pages.dev

View logs

`ChangelogAfter` re-exported only the component, so the route had no
`meta` — the export is per-module and does not travel with a re-exported
default. `/changelog/after/*` is SPA-served, so the fallback's head
shipped unchanged: every such URL advertised the homepage's title and
`canonical=https://dormouse.sh/`, which is exactly the duplicate-page
signal the rest of the site was fixed to stop sending. It is also the
changelog URL most readers arrive on, since the standalone updater opens
it after every update.

Verified against the deployed preview, where `/changelog/after/1.0.1`
served the homepage title and canonical while rendering the changelog.

Canonical points at `/changelog`, not the filtered path: these are views
of one page, and self-canonicalizing them would offer a distinct document
per released version. The lint now fails any page module that re-exports
a component without carrying `meta` with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pnpm test fails at its first step on a clean checkout — scripts/spec-lint.mjs reports 4 problems before anything else runs:

docs/specs/website-docs.md:48: path does not exist -> website/public/guide/images/
docs/specs/website-docs.md:120: path does not exist -> website/public/guide/images/
docs/specs/website-docs.md:146: path does not exist -> website/public/guide/
docs/specs/website-docs.md:520: path does not exist -> website/public/guide/images/

website/public/guide/ is gitignored and created by syncGuideMedia, which only main() in generate-docs.js calls — and that runs from the website package's pretest/prebuild, i.e. inside pnpm -r run test, strictly after spec-lint in the root test script. So the four backticked references resolve on your machine (a previous pnpm dev left the directory behind) and on nobody else's. ci.yml runs pnpm test before pnpm build, so Build & Test should go red on this as soon as it registers.

SKIP_PATH_PREFIXES already exists for exactly this case — its neighbour is the gitignored website/src/data/changelog.json, carrying the comment "lint:specs must pass on a fresh checkout". One line beside it clears all four:

  'website/src/data/changelog.json', // gitignored, generated by website prebuild (deploy.md)
  'website/public/guide', // gitignored, generated by website prebuild (website-docs.md)

I verified it: with that line added, node scripts/spec-lint.mjs is OK (25 specs, 40 files checked) on a tree with no website/public/guide/, and public-docs-lint stays at 12 checks passed. Happy to push this and the two inline fixes below if you'd like — say the word and I'll commit them.

One latent thing, not blocking and nothing in the canonical sources triggers it today: matchLink in website/scripts/docs-parser.js takes the first ) as the link's close (text.indexOf(')', i + 2)), so [x](https://en.wikipedia.org/wiki/Foo_(bar)) parses as an href truncated at Foo_(bar with a stray ) left in the prose. It is a silent mangle, and the module's own header argues that a hand-rolled parser earns its place precisely by making everything outside the subset a hard error instead. The blockquote loop has the same shape — > quote followed immediately by # Heading swallows the heading into the quote rather than rejecting it, and a swallowed heading also leaves the rail.

Comment thread scripts/public-docs-lint.mjs Outdated
Comment thread docs/specs/website-docs.md Outdated
nedtwigg and others added 2 commits September 2, 2026 19:51
Review findings.

The rule added to stop a route losing its head tags tested for the word
`meta`, which its own explanatory comment contains — so deleting the
export and leaving the comment passed. The mutation that "verified" it
replaced the whole file, taking the comment with it, so it passed for the
wrong reason. It now tests for an export in either legitimate spelling,
and is checked against the mutation that defeated it.

The spec's front matter still said `/docs` is not a page and the header
carries no Docs link, contradicting Reference page chrome, the Code map,
and SiteHeader itself. Two neighbouring claims went stale the same way: a
link to `/docs` is no longer a link to nothing, and the homepage's
obligation is every `/docs` page rather than a fixed three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
Verifying the previous commit on the deployed preview showed it half
worked and made one thing worse. `/changelog/after/*` is served by
rewriting to `__spa-fallback.html`, whose static head already carries the
homepage's `canonical` and `og:url` — and React Router's client `<Meta />`
appends its own rather than replacing them. So the route's new meta fixed
the title, which is a text child React reconciles, and left two
conflicting canonicals, which search engines discard together. That is
worse than the single wrong one it replaced.

A per-installed-version view of one page does not belong in an index at
all, so the route now asks not to be indexed and makes no index claim:
`siteMeta` gains `indexable: false`, which emits `robots: noindex, follow`
and omits the canonical and social tags a page nobody should index has no
use for. `/changelog` remains the page to find, prerendered and
self-canonical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier threads are addressed — I re-ran your three lint-regex cases and they behave as you describe. Resolving them.

Two things still open, one blocking.

pnpm test is still red at d19d2435, at its first step — this is the finding from the last review, unchanged. On a checkout without website/public/guide/:

spec-lint: 4 problem(s)

  docs/specs/website-docs.md:49: path does not exist -> website/public/guide/images/
  docs/specs/website-docs.md:121: path does not exist -> website/public/guide/images/
  docs/specs/website-docs.md:147: path does not exist -> website/public/guide/
  docs/specs/website-docs.md:521: path does not exist -> website/public/guide/images/

So "pnpm test green (140 website tests, all lints)" in the description holds only where a previous pnpm dev left the generated directory behind: syncGuideMedia runs from the website package's pretest, inside pnpm -r run test, strictly after spec-lint in the root script. One line in scripts/spec-lint.mjs beside the entry that already carries this comment for changelog.json clears all four — verified at this head, spec-lint: OK (25 specs, 40 files checked), public-docs-lint: 12 checks passed:

  'website/src/data/changelog.json', // gitignored, generated by website prebuild (deploy.md)
  'website/public/guide', // gitignored, generated by website prebuild (website-docs.md)

It is outside the diff so I can't suggest it inline — say the word and I'll push it.

Per-page head tags now contradicts the code it describes — inline suggestion below. d19d2435 gives /changelog/after/:version no canonical at all, which is the right call, but the spec still says Must give every page one.

Not blocking, and the durable version of a bug this PR has now found twice on the same route: nothing pins the correspondence between prerender() in react-router.config.ts and indexable: false. Today changelog/after/:version is the only route in routes.ts outside that list, and it is correct — but the next one added inherits the fallback's homepage canonical silently, which is exactly the failure d19d2435 fixed. checkPageHeadTags already reads the route modules and checkDocsEntrypoint already reads _redirects, so the pair is in reach of a rule.

One more, explicitly not for this PR

public-docs-lint.mjs is now the fifth sibling lint in root pnpm test, and the only one carrying novel structural rules without a self-test — AGENTS.md pins that requirement to deploy-lint and e2e-lint alone, so nothing asked for one here. But the \bmeta\b rule was exactly the failure AGENTS.md describes ("a claim that something is checked"), and what let it through was that the mutation lived in a session rather than in a script: replacing the whole file took the comment with it, so the check passed for the wrong reason and no later run could re-derive that. A public-docs-lint-selftest.mjs on the scripts/lint-kit.mjs plumbing would pin each rule's mutation the way the other two do. Its own weight is real, so it is a maintainer call, not a review finding.

Comment thread docs/specs/website-docs.md Outdated
The spec's **Must** covered every page, which stopped being true when
`/changelog/after/:version` gave up its canonical. What a future
fallback-served route needs is not that exemption but the reason for it:
the client `<Meta />` reconciles `<title>` text and appends everything
else, so a second canonical joins the fallback's homepage one and both
are discarded. Deleting that sentence lets the next such route
reintroduce the pair, which is AGENTS.md's test for what belongs here.

No lint pins the new rule — `ChangelogAfter.tsx` is the only route it
governs — so the paragraph says which checks cover what rather than
implying all three are enforced.

Budget 4550 -> 4600. The section gained an invariant, and the alternative
was shaving single words off working prose to fit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
The offsets were derived from Tailwind class names; measured against the
deployed page they are right, but each step clears its chrome by only 3px.
A comment claiming the rule without saying how little room it has invites
someone to grow the bar's padding and silently break it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a64a828c addresses the head-tags thread — the paragraph now carries the append behaviour as its own Never, and it matches site-meta.ts's comment and ChangelogAfter.tsx. public-docs-lint: 12 checks passed, and the file lands at 4554 words against the new 4600 budget. Resolving that thread.

One correction to what I told you last time, and one on f1d6b19a inline below.

ci.yml has never run on this PR — zero CI runs across all six pushes, while CI fired normally on generic-vpn in the same window. The cause is that #532 conflicts with main: mergeable is CONFLICTING / DIRTY, in package.json and scripts/spec-word-budgets.json. ci.yml triggers on pull_request, which runs against refs/pull/532/merge, and GitHub does not queue those runs when it cannot compute the merge commit. tend-review keeps firing only because it is pull_request_target, which runs against the base.

So "Build & Test should go red on this as soon as it registers" from my earlier review was wrong — it will not register at all until the conflict is resolved, and the only check on the PR right now is Cloudflare Pages. Nothing has run pnpm test, pnpm lint:specs, or the website's 140 tests against this branch.

That also means the spec-lint finding is still open and still invisible. At f1d6b19a, on a checkout with no website/public/guide/:

spec-lint: 4 problem(s)

  docs/specs/website-docs.md:49: path does not exist -> website/public/guide/images/
  docs/specs/website-docs.md:121: path does not exist -> website/public/guide/images/
  docs/specs/website-docs.md:147: path does not exist -> website/public/guide/
  docs/specs/website-docs.md:526: path does not exist -> website/public/guide/images/

The one-line fix in scripts/spec-lint.mjs is unchanged from the last two reviews:

  'website/src/data/changelog.json', // gitignored, generated by website prebuild (deploy.md)
  'website/public/guide', // gitignored, generated by website prebuild (website-docs.md)

Happy to push that plus a merge of main into the branch — both conflicts are in additive-list files, so the resolution is mechanical — which would get Build & Test to register for the first time. Say the word.

How I checked
  • Runs by workflow on this branch: gh api "repos/diffplug/dormouse/actions/runs?branch=launch-marketing&per_page=100" → 6 tend-review, 0 CI, 0 Chromatic. Chromatic's absence is separately explained by its paths: lib/** filter; ci.yml has no path filter.
  • Conflict set: git merge-tree --write-tree f1d6b19a 76e958f8CONFLICT (content) in package.json and scripts/spec-word-budgets.json, everything else auto-merging. Merge base is 62dc27c5. Note that the review checkout is shallow, so git merge-base reports "unrelated histories" until you git fetch --unshallow — the branches are not actually unrelated.
  • spec-lint run in a clean worktree at f1d6b19a, so the generated website/public/guide/ a prior pnpm dev would leave behind is absent.
  • Word count is spec-lint's own measure (split(/\s+/).filter(Boolean)): 4554.
  • Scroll-offset arithmetic: SiteHeader.tsx is h-16 ... md:h-20 with no lg step, and DocsLayout.tsx's bar is sticky top-16 ... md:top-20 lg:hidden, so at lg the header alone is 80px against scroll-mt-24's 96px.

Comment thread website/src/components/docs-tokens.ts Outdated
nedtwigg and others added 4 commits September 2, 2026 20:10
"Each step clears its chrome by 3px" was true of the two steps sitting
under both the header and the docs bar, and wrong about `lg`, where the
bar is hidden and the header has no `lg` size — 96px against 80px is 16px
of room, not 3. The sentence that follows is the one an editor acts on,
so a uniform figure would send someone hunting for tightness that is not
there, or leave them thinking the loose step is as constrained as the
others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
`/docs` exists only as a redirect, and `_redirects` is a deploy artifact
Vite copies but never reads — so the entrypoint answered 404 on the dev
server while working on the host. The site header's own Docs link points
at it, which made production the only place the entrypoint could be
tried.

A dev-only middleware applies the file's 3xx rules, leaving `_redirects`
the single owner rather than restating the rule in a route. Rules are
read per request, so editing one takes effect without a restart, and the
`200` rewrite is skipped — that is the SPA fallback the dev server
already does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
Narrow screens already have a sticky bar naming the section, so the
picker sits at the end of that line rather than floating a second control
over a small viewport. It opens downward there, and its prompt hangs
below with the arrow pointing back up at it.

Doing that meant giving up the lazy import. The control now renders in
two places, and a second `Suspense` boundary over the same `lazy()`
component never resolved — the bar's picker stayed a `<template>` even
once the bar was visible. That put ~6KB gzip back on the docs pages'
critical path, which is the cheaper half of the trade against a picker
that never appears. The error boundary that guarded the lazy chunk goes
with it; a static import has no chunk to fail.

The prompt needed an explicit width when inline: an absolutely-positioned
box shrinks to fit its containing block, which here is the picker, so the
prose wrapped one word per line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
Links looked the same whichever theme was picked, because
`--vscode-textLink-foreground` was doing no work: no bundled theme
defines `textLink.foreground`, so the variable always resolved to the
colour registry's default for the theme's *kind* — one blue on every dark
theme, one on every light one.

Every theme does carry an `accent`, which is the colour a reader
recognises it by, so links derive from that instead. It cannot be used
raw: seven of the eleven bundled accents fall below WCAG AA against their
own background and four carry alpha, so taking them as-is would undo the
contrast work that moved these links off brand caramel. `docsAccentFor`
flattens any alpha against the page, then walks the accent toward white
or black — whichever the background is not — stopping at the first step
that clears 4.5:1, so a theme keeps as much of its accent as contrast
allows. A theme already clearing it is returned untouched.

`docs-accent.test.ts` checks every bundled theme clears AA and that more
than two distinct colours come out, which is what the old behaviour could
not manage. Verified in the browser across four dark themes that
previously shared one blue: #1a8cd8, #99947c, #6a7da3, and Kimbie's tan.

Left unset, `--docs-accent` keeps the value index.css gives it, so a
reader without JS still gets a legible link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three findings on d0cd62e1 and the three commits before it. One is a real contrast bug in the new correction; the other two are prose that this increment falsified.

docsAccentFor ramps the wrong way on a mid-tone background, and returns a colour below the AA it promises. The direction split is luminance(bg) < 0.5, but luminance here is WCAG relative luminance, where the white/black crossover is ≈0.179, not 0.5 — everything from a dark grey up to a fairly light one is classified as "dark" and ramped toward white. I ran it against the module: docsAccentFor("#007fd4", "#808080") returns #ffffff at 3.95:1, while black would have reached 5.32:1. Nothing on the ramp clears 4.5, so the loop falls through and the fallback hands back the same failing white. No bundled theme triggers it, but the picker on these pages carries the theme store, so an installed OpenVSX theme with a mid-tone editor.background reaches it — and the module's own docstring is what promises this cannot happen. Suggestion inline; it passes all six existing docs-accent.test.ts cases plus #808080/#949494/#767676/#6b6b6b.

Worth noting where the < 0.5 shape is right: pickAlarmColor in lib/src/lib/themes/dynamic-palette.ts splits on OKLab L, which is perceptual, so 0.5 really is mid-grey there (#808080 is L 0.600 → black, correct). The same constant against WCAG luminance is not the same test.

The link-colour change left three descriptions asserting the mechanism it removed. docs-accent.ts opens with "Never take it from --vscode-textLink-foreground", while website-docs.md -> "Prose links take the theme's link color" and docs-tokens.ts's LINK_CLASS docstring ("The registry default behind this var is chosen per theme kind and clears it") both still describe taking it from exactly there. docs-tokens.ts also claims --docs-accent "is defined once in website/src/index.css so the decision has one owner", which DocsLayout's new effect now overwrites. Suggestions inline for all four spots, plus the ThemePicker's new second placement and a code-map row for docs-accent.ts — the map is the exhaustive kind, so a new module belongs in it. Measured: the spec lands at 4599 words against its 4600 budget with all three of its suggestions applied, and public-docs-lint: 12 checks passed.

getActiveThemeId() ?? DOCS_THEME_ID — the ?? cannot fire. getActiveThemeId falls back to getBundledThemes()[0]?.id ?? '', so it is never nullish. What makes the line correct today is that getBundledThemes()[0].id happens to equal DOCS_THEME_ID, which nothing states and nothing pins. getAppliedThemeSnapshot() is exported for this and answers the actual question — which theme is on the page — including when localStorage is unavailable and setActiveThemeId silently no-ops. Two paired suggestions inline (import + call site); apply them together, since the first shifts the second's line.

Smaller, not worth a suggestion

redirectsInDev skips a rule with no status column, but Cloudflare treats /from /to with no code as a 301. Since the plugin exists precisely so a rule behaves the same in dev as in production, a status-less rule would be the one shape that still diverges. Nothing in _redirects has that shape today.

Comment thread website/src/lib/docs-accent.ts Outdated
Comment thread website/src/components/DocsLayout.tsx
Comment thread website/src/components/DocsLayout.tsx Outdated
Comment thread docs/specs/website-docs.md Outdated
Comment thread docs/specs/website-docs.md Outdated
Comment thread docs/specs/website-docs.md Outdated
Comment thread website/src/components/docs-tokens.ts Outdated
Comment thread website/src/components/docs-tokens.ts Outdated
`/docs/self-host` scrolled sideways on a phone. Ten space-free tokens sit
in its paragraphs — the longest, `~/Library/LaunchAgents/sh.dormouse
.server.plist`, is 47 characters, about 404px of monospace against the
343px a 375px phone leaves after padding. One unbreakable word to the
line breaker, so it pushed the whole article rather than only itself.
The same tokens inside tables were already contained by the table's own
scroller, which is why only this page showed it.

Inline code now offers a break after each `/`, `.`, `-` and `_`, so a
path splits where a reader expects rather than mid-segment. `<wbr>`
contributes nothing to `textContent`, so selecting the span still yields
a string that pastes into a shell — pinned, along with the breaks
themselves, by MarkdownDocument.test.tsx. `break-words` on the class is
the backstop for a token with no separator at all, such as a hash.

Measured at a 343px column: nothing outside the table and code-block
scrollers exceeds it on any of the five rail pages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One finding on b399a71f, in the new wrapping itself.

The break is offered inside a run of separators, so a --flag parts at its dashes. split(/(?<=[/._-])/) fires after every separator including one that is itself followed by a separator, so --watch renders -<wbr/>-<wbr/>watch and https://<laptop> renders https:/<wbr/>/<wbr/>&lt;laptop. When the line actually breaks there the reader sees - at the end of one line and -watch at the start of the next — indistinguishable from a hyphenation artifact, and on a page whose whole job is commands a reader retypes. Across the four generated documents, 85 of the 381 inline-code break sites are inside a separator run (46 distinct spans), and /docs/agent-skill alone ships 76 of them. ~/.config/... gets the same treatment between its / and ..

A negative lookahead is the whole fix — suggestion inline, plus one adding the case that pins it and one on the doc comment above it. With them applied: Tests 149 passed, pnpm build clean, and grep -o -- '-<wbr/>-' dist/docs/*/index.html and https:/<wbr/>/ both drop to 0. Against the current regex the new case fails with Received: ...-<wbr/>-<wbr/>watch, so it pins the bug rather than the fix.

The rest of the increment holds up. I re-derived the split against ten shapes (trailing separator, bare /, empty string, a separator-free hash) — no empty parts, no stray <wbr>; <wbr> is zero-width in textContent, so the copy claim is real; and the lookbehind is inside the build's target, which Vite 8 resolves to safari16.4/ios16.4 — the exact release that shipped lookbehind assertions, so nothing here needs a lower floor.

One spec line is missing. The renderer's guarantee in website-docs.md -> Markdown rendering contract reads "mobile-width media without horizontal overflow", which this commit widens to prose, and the new constraint — a wrapping hint must not change what a code span's textContent yields — is stated only in the component comment. AGENTS.md: "When updating code covered by a spec, update the spec to match." Suggestion inline; measured, the file lands at 4588 words against its 4600 budget with it applied, and public-docs-lint: 12 checks passed.

Happy to push all four, plus the spec-lint line and a main merge from the earlier reviews — say the word.

How I checked
  • corepack pnpm install --frozen-lockfile, then website$ pnpm test → 18 files, 149 tests, green at b399a71f and again with the fix applied.
  • Break-site census: walked the code inline nodes of website/src/data/docs.{cli,guide,selfhost,skill}.json, splitting each value with the current regex and with (?<=[/._-])(?![/._-]), and counted the difference — 381 sites, 85 removed, 46 spans affected.
  • Rendered census: pnpm build, then grep -o -- '-<wbr/>-' dist/docs/*/index.html | wc -l → 76 before, 0 after; https:/<wbr/>/ → present before, 0 after.
  • Vite target: ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET in the installed vite@8.2.2 is chrome111, edge111, firefox114, safari16.4, ios16.4, and vite.config.ts sets no build.target.
  • Word count is spec-lint's own measure: 4554 at b399a71f, 4588 with the spec suggestion applied.
  • spec-lint reports two problems in this checkout that are artifacts, not defects: the review runner restores AGENTS.md from the base branch, so the copy on disk does not index docs/specs/website-docs.md, and it cites scripts/installer-verify-test.mjs, which is on main at 76e958f8 but not on this branch. Both clear against git show HEAD:AGENTS.md.

Comment thread website/src/components/MarkdownDocument.tsx Outdated
Comment thread website/src/components/MarkdownDocument.tsx Outdated
Comment thread website/src/components/MarkdownDocument.test.tsx
Comment thread docs/specs/website-docs.md
nedtwigg and others added 3 commits September 2, 2026 22:05
Review findings.

The break opportunity went after every separator rather than after each
run, so `--watch` rendered as `-<wbr>-<wbr>watch` and `https://host` as
`https:/<wbr>/<wbr>host` — a hyphenated word break and a typo. The test
asserted a path, which has no runs, so it passed over the bug; it now
pins both cases.

`docsAccentFor` chose its direction by whether the background's luminance
was under 0.5, which is not where the contrast crossover sits and leaves
a mid-tone background neither light nor dark. It measures both ends and
walks toward the one that actually contrasts more; `#808080` now darkens
where it used to lighten. The trailing fallback went with it — the loop's
last step is that end, so there was nothing left to fall back to.

`DocsLayout` read the theme through `getActiveThemeId`, which never
returns nullish: it already falls back to the first bundled theme, so the
`?? DOCS_THEME_ID` was unreachable and a reader without storage would get
the page painted in one theme and its links derived from another's accent
the moment those two stop coinciding. It reads the applied snapshot.

The rest is prose that described the behaviour these changes replaced:
the picker has two placements now, links come from the theme's accent
rather than a per-kind default, `index.css` is no longer the only writer
of `--docs-accent`, and the code map gained the module that computes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
main compressed every spec into house form and rewrote the spec lint, so
the conflicts were all "their compression versus our additions" — resolved
by taking theirs and re-adding ours in the same tighter voice.

- AGENTS.md: main's compressed spec index and its lint table, with a
  website-docs.md entry and a public-docs-lint row written to match, and
  the count corrected to five.
- SELF_HOST.md, docs/specs/theme.md: main's prose throughout; theme.md
  keeps the three picker invariants this branch added — the docs pages'
  two placements, `menuSide` taking its offset from style, and `onPick`
  reporting the choice rather than the change — rewritten to fit.
- package.json: main's script list with public-docs-lint restored to it.
- scripts/spec-word-budgets.json: main's re-baseline wholesale, then
  --ratchet for the three files this branch grows.

main's lint is stricter than the one website-docs.md was written against,
and it found three real things. A spec over 2500 words now needs a paired
rationale file, so the evidence moved into website-docs.rationale.md —
the contrast measurements, the two-title and duplicate-canonical
findings, the phone-width overflow arithmetic — leaving one-line rules
marked `(rationale)`. A quoted `->` citation must name a heading or a
bolded phrase, so public-docs-lint's cites the section rather than a
sentence. And compressing that sentence moved the prose
`checkImageBaseUrl` greps for, which the lint caught and which is exactly
what it is for; the claim now anchors on the imperative, re-verified by
mutation.

`pnpm test` green: 25 specs, 50 files, every lint and self-test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
CI failed where `pnpm test` was green locally, and the difference was my
working tree: `website/public/guide/images/` is generated and gitignored,
so it exists here only because I had built, while spec-lint runs before
the website's `pretest` on a fresh checkout. Four backticked references
to it therefore passed for me and failed there — the local green was an
artifact of a dirty tree, not evidence.

The destination is named in prose now, with backticks kept for
`vscode-ext/images/`, which is tracked. Verified by removing every
generated artifact — the guide tree, `dist`, and the generated data
files — and running the suite from that state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7bac256d clears the spec-lint finding — verified on a clean worktree at this head, spec-lint: OK (25 specs, 50 files checked) and public-docs-lint: 12 checks passed, so Build & Test should go green at its first step for the first time on this branch. Two things.

The spec no longer names the path it describes, and it did not have to. Four sites now say "the generated guide/images tree under website/public/" where syncGuideMedia writes website/public/guide/images/ — a reader can't grep for it, and the un-backticking is what pushed docs/specs/website-docs.md from 4350 to 4400 in scripts/spec-word-budgets.json. checkablePath only verifies a token beginning with a top-level dir, so the website/-relative form already used one line away in website-docs.rationale.md (`public/guide/`) and in generate-docs.js's own comment passes untouched while keeping the literal path. Suggestions inline for all four. With them applied: spec-lint: OK (25 specs, 50 files checked), spec-lint-selftest: OK (12 load-bearing checks), public-docs-lint: 12 checks passed, and countWords puts the file at 4335, so the budget line can go back to 4350 — which I verified passes.

docsAccentFor can return a colour that clears AA before rounding and misses it after. The loop measures the unrounded mix, but toHex rounds what it hands back, so the two disagree by up to half a channel. Sweeping ten accents across the 256-step grey ramp, 36 of 2560 pairs land below 4.5:1#007fd4 on #c1c1c1 returns #00538a at 4.479:1, worst case #888888 on #fafafa at 4.478:1. Small, and no bundled theme reaches it, but it is the one claim the module exists to make: **Must** correct that accent for contrast, MIN_CONTRAST = 4.5, and the rationale's "stops at the first step that clears the threshold". Rounding inside mix is the whole fix and covers the alpha flatten on line 76 as well as the ramp — with it, 0 of 3072 pairs fall below AA, Tests 150 passed, and docsAccentFor("#99947c", "#272822") still comes back untouched. Suggestion inline, plus one adding the case that pins it: against the current code that case fails at 4.479, so it pins the bug rather than the fix.

Small, and not something this commit touched: line 150's - The same content renders usefully in Open VSX and GitHub Markdown. is the last survivor of a bullet list this section rewrote into prose — it had four siblings at bdeb4d7d — so it now dangles as a one-item list after a paragraph about public/guide/ writes. Where that claim belongs is an editorial call, which is why I have not suggested it inline.

How I checked
  • corepack pnpm install --frozen-lockfile, then website$ pnpm test -> 18 files, 150 tests, green at b9d4828d and again with the mix fix applied. website/src/ is byte-identical from b9d4828d through 7bac256d, so those runs stand at this head.
  • Sweep: docsAccentFor(accent, "#vvvvvv") for v in 0..255 across #007fd4 #99947c #ff0000 #00ff00 #0000ff #888888 #f0c674 #569cd6 #4ec9b0 #c586c0, measuring contrastRatio on the returned hex. 36 below 4.5 before, 0 after, with #ffffff80 and #00000040 added to the accent list for the second run.
  • Why AA is always reachable: max(contrast(white, bg), contrast(black, bg)) bottoms out at sqrt(21) = 4.583 when L(bg) + 0.05 = sqrt(0.0525), so after this fix the loop always returns before the trailing toHex(toward).
  • Spec measurements ran in a git worktree at 7bac256d, so the generated website/public/guide/ is absent rather than left over from a prior pnpm dev. countWords is scripts/spec-md.mjs's, not a naive whitespace split: 4355 as committed, 4335 with the four suggestions.
  • Wrapping census re-verified at b9d4828d: pnpm build, then grep -o -- '-<wbr/>-' dist/docs/*/index.html and https:/<wbr/>/ are both 0, and /docs/dor renders --<wbr/>watch, --<wbr/>json, --<wbr/>port.
  • docs/specs/theme.md's picker bullet was wrong at b9d4828d — "free-floating" only, and it undercounted the pages carrying the picker. The base merge rewrote it correctly, so there is nothing to raise.

Comment thread website/src/lib/docs-accent.ts
Comment thread website/src/lib/docs-accent.test.ts
Comment thread docs/specs/website-docs.md Outdated
Comment thread docs/specs/website-docs.md Outdated
Comment thread docs/specs/website-docs.md Outdated
Comment thread docs/specs/website-docs.md Outdated
nedtwigg and others added 2 commits September 2, 2026 22:25
`toHex` rounds, but the contrast was measured on the unrounded candidate,
so `docsAccentFor` could clear 4.5:1 and hand back a colour that does
not: `#007fd4` on `#c1c1c1` returned `#00538a` at 4.479:1. That is the
one guarantee the module exists to make. `mix` rounds now, so the colour
measured is the colour returned, and the alpha flatten rides the same
path. Every bundled theme clears with room, which is why the existing
cases passed over it; the new one is that pair.

The four spec references to the generated media tree go back to the
literal `public/guide/images/`. Avoiding it was unnecessary: spec-lint
only path-checks tokens starting with a top-level directory, which is why
the rationale file could already say `public/guide/`, and the
`website/`-relative frame is the one the neighbouring sentence uses.

Verified from a tree with every generated artifact removed, which is the
state CI starts in and the one my earlier local runs were not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa
The guarantee named media, but the wrapping change extended it to prose:
a long path in a paragraph widened the page just as an oversized image
would. And the constraint that makes the fix safe — a break hint must not
change what `textContent` yields, or a path stops pasting into a shell —
lived only in a component comment, so deleting it would let the next
editor reach for `break-all` or a real inserted character and quietly
break copying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfBvkERwzAaarAZYJoxNsa

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc54aec2 closes the rounding finding. I re-swept with the fix in: 0 of 3,072 greyscale pairs and 0 of 200,000 random accent/background pairs fall below 4.5:1 on the returned hex, and the new case fails at 4.479 against the unrounded mix, so it pins the bug rather than the fix. website$ pnpm test is 151 passed, and spec-lint: OK (25 specs, 50 files checked) from a worktree with every generated artifact removed — Build & Test should clear its first step at this head.

One correction, and it is mine: the sentence acc05d2d applied is the suggestion I wrote, and I got the mechanism backwards. CodeSpan splits on /(?<=[/._-])(?![/._-])/ — a break after each run of separators, never inside one, which is exactly what b399a71f changed and what MarkdownDocument.test.tsx pins with >--<wbr/>watch<. "at each of its separators" describes the code before that fix, so a reader implementing the spec writes split(/(?<=[/._-])/) and reintroduces --<wbr/>-<wbr/>watch. Suggestion inline. With it applied: spec-lint: OK, public-docs-lint: 12 checks passed, and the file measures 4370 words against its 4400 budget.

Nothing else in the increment. mix is module-private with two call sites, both feeding toHex or contrastRatio, so rounding at the source makes measured and returned the same colour on the ramp and the alpha flatten alike; the early return takes integer channels either way.

How I checked
  • git worktree at acc05d2d, corepack pnpm install --frozen-lockfile, website$ pnpm test → 18 files, 151 tests.
  • Sweep: docsAccentFor(a, b) measured with contrastRatio on the returned hex — 12 accents (including #ffffff80 and #00000040) across the 256-step grey ramp, plus 200,000 seeded random RGB pairs. 0 below AA in both.
  • Bug pinned: reverting mix to the unrounded form fails the new case with expected 4.4793364798107485 to be greater than or equal to 4.5.
  • countWords is scripts/spec-md.mjs's: 4369 as committed, 4370 with the suggestion.
  • spec-lint reports AGENTS.md: spec not indexed in the review checkout only — the runner restores AGENTS.md from the base branch. It clears in the worktree.

Comment on lines +223 to +224
without horizontal overflow — an inline code span offers a break at each of its
separators. **Never** let such a hint change what the span's `textContent`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeSpan breaks after each run of separators, never inside one — /(?<=[/._-])(?![/._-])/, with the negative lookahead b399a71f added and MarkdownDocument.test.tsx pinning as >--<wbr/>watch<. This wording is the pre-fix behaviour, and it is my own suggestion's error, not yours.

Suggested change
without horizontal overflow — an inline code span offers a break at each of its
separators. **Never** let such a hint change what the span's `textContent`
without horizontal overflow — an inline code span offers a break after each run
of its separators. **Never** let such a hint change what the span's `textContent`

@nedtwigg
nedtwigg merged commit 0ba3d54 into main Sep 3, 2026
19 checks passed
@nedtwigg
nedtwigg deleted the launch-marketing branch September 3, 2026 06:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants