AGENTS.md@src · git:20261030.d8fea4e · 2026-10-30 · sha256 1734b3c26ee3a5ff

AGENTS.md@src git:20261030.d8fea4eB

Immutable. This exact content never changes and is served at /api/v1/blob/1734b3c26ee3a5ff.

# src — site + studio app

## Purpose

The dither-ui.com site (landing, docs) and the studio editor, built on the kit
in `../dither-kit`. Feature-Sliced Design (FSD) layering.

## Ownership

- `app/` — entry, global styles/tokens, canonical-path + legacy-hash router (App.vue).
- `pages/` — landing, docs, studio, play (see `pages/AGENTS.md`).
- `widgets/` — the docs previews' `workbench` (inspect, measure,
  annotate, capture; see `widgets/workbench/AGENTS.md`) and `source` (the
  preview's files, edited and run in the page; see
  `widgets/source/AGENTS.md`) and the studio
  panels: toolbar, layer-tree, inspector, canvas,
  chart-renderer, widget-renderer, reel-renderer (a reel's clips on the kit
  clock; a film's camera, callouts and chapter band), data-editor, agent
  (the composer / harness control plane).
- `features/` — user actions: history (undo/redo), keyboard (shortcuts +
  ShortcutsHelp overlay), persistence (localStorage hydrate/autosave),
  export-code, export-image, export-video (a frame as a HyperFrames
  composition: builder, dialog, delivery for the agent path; a film's score
  as its audio track), reel (the reel's and the film's pure timeline, the
  score preview), pan-zoom,
  artboard-transform, agent (the Studio agent protocol, seeded evolution,
  the Studio tools and the ACP client behind the composer).
- `entities/` — domain stores: editor (selection/artboards, single source of
  truth), chart, widget, artboard.
- `shared/fonts/` — the display face: `michroma-latin.woff2` (the Latin
  subset, self-hosted), its license `OFL.txt` (keep them together) and
  `display.css` (its `@font-face`, `font-display: swap`; the mono and
  Michroma set the statement on the same number of lines, so the swap
  shifts nothing).
- `shared/` — ui primitives (Segmented, NumberField, ColorField, CodeBlock,
  ContextMenu, ...; `ToolMark` over `marks.ts` — the real one-colour
  marks of npm, pnpm, Bun, Node, Vue, Svelte, Tailwind, StyleX, CSS
  Modules, CSS, GitHub and the coding agents
  the bridge starts (keyed by its harness ids), from Simple Icons (CC0),
  LobeHub (MIT) and the tools' own site icons, paths verbatim; their
  owners' trademarks, shown only beside the tool's name, never restyled,
  dithered or put in the kit; imported by path, never through the
  `shared/ui` barrel, which would pull every shared component into the
  landing's first load; `Segmented` takes `labels` where an option's
  value is not how a reader writes it), config (CHART_TYPES; `titles.ts`, the routes' titles, a leaf
  the landing can load without the docs IA), lib (theme,
  routes (with `safeUrl`, the ONE test of a URL a document may carry into
  an href or a src: http(s), mailto, a raster `data:` image or a relative
  path, read as the browser reads it), base64, menu,
  `storage.ts` — the ONE path to localStorage (`storage.get/set/remove`,
  `readJson` → `unknown` for the caller to parse, `writeJson`), never
  throwing where storage is blocked or full —
  `sfc.ts` — the ONE way the code generators put a document value into an
  SFC (`attr` for a static attribute, `js` for a literal in script or a
  single-quoted binding, `key`, `objectLiteral`, `text` for template text),
  so exported code carries values as data, never as code —
  `sound.ts` — Kobra's interface sounds as WebAudio voices behind one
  remembered switch (`sounds`, localStorage `dither-sounds`) — `files.ts` —
  `download()` and a stored .zip of text files, the ONE download path for
  the Studio's exports, the workbench and the source panel —
  `restyle.ts` — a snippet in the reader's styling system — and the
  node-side `utility-map.ts` that compiles its vocabulary and
  `stylex-layer.ts` that writes the kit's StyleX layer from its Tailwind
  layer (see `dither-kit/AGENTS.md`); `gif.ts` — the
  ONE GIF encoder (one palette, `keep` colours held exact, optional
  ordered dither, delta frames), import-free because the share-card
  renderer carries its source inline: the workbench's captures and the
  share cards both go through it).
- Untrusted input is parsed ONCE where it enters, into a type nothing past
  it re-checks: documents through `parseDocument` / `normalizeArtboard`
  (each field of several shapes, a glow, a texture, an easing, parsed whole
  so every shape the editors write survives; a `url` prop, an image's or an
  avatar's address through `safeUrl`), stored values through `storage.ts`
  and a parser at the reader, bridge messages through a parser in `acp.ts`
  (`permissionOptions`). Pinned by `tests/security.spec.ts` and
  `tests/codegen-escape.spec.ts` (hostile labels, keys, props and slot text
  through both code generators and Vue's own compiler).
- `entities/widget/model/registry.ts` is the single source for Studio's searchable
  component library, inspector controls, untrusted-prop sanitization, functional
  demo composition, and code export. Every public `Dither*` export is covered
  there or by a bespoke widget kind.

## Local Contracts

- Layer imports flow downward only: pages → widgets → features → entities →
  shared. `@dither-kit` may be imported from any layer; nothing imports back
  into it.
- Canonical routes are `/`, `/docs[/section]`, and `/studio`, plus the
  `noindex` player at `/play/` (not prerendered); legacy
  `#/docs[/section]` and `#/studio[/new/<type>]` links remain supported. Route
  entry HTML files own static crawler-visible metadata (their titles are
  `shared/config/titles.ts` `ROUTE_TITLES`, which App.vue sets as the route
  changes; `tests/titles.spec.ts` holds each page's three copies to it); docs sections refine
  it at runtime per `/docs/<id>` (see `pages/AGENTS.md`). `app/App.vue`
  resolves both path styles. Internal app links and public asset URLs use
  `routePath()` /
  `assetPath()` from `shared/lib/routes.ts` so the same build works at `/` and
  at the GitHub Pages project base. Add vue-router only when route params
  outgrow this.
- `entities/editor` is the sole mutation path for document state. History
  (`features/history`) snapshots `{artboards, groups}` — excludes viewport and
  selection deliberately; new document-shaped state must be added to both the
  snapshot and `features/persistence`.
- Editor mutations must stay undoable: route them through the editor store so
  the deep watcher records them; never mutate artboards from a component.
- New artboards route through `placeArtboard` after their final size is known; it
  centers every insert exactly in the current viewport. Do not restore cascading,
  origin, or right-edge spawn logic. A generation (several variants of one
  parent) routes through `placeGeneration`: one centred row, the batch
  treated as a single insert.
- The Studio is agent-addressable through `features/agent`:
  - `protocol.ts` is ONE command vocabulary (`runCommand`) behind three doors:
    `window.ditherStudio.run()` (main world), `dither-studio:command` /
    `dither-studio:result` DOM events on `document` with JSON-STRING details
    (any script world — browser-extension agents cannot see page globals;
    strings cross worlds, objects do not), and the `<script
    type="application/json" id="dither-studio-document">` mirror in <head>
    (live document, refreshed on the autosave cadence). Every command is
    untrusted input and lands only through the same validators the inspector
    and file imports use (`applyDocument`, `sanitizeComponentProps`,
    `normalizeArtboard`); nothing evaluates anything. New commands are cases
    in `runCommand` + a row in `registrySchema().commands` + the SKILL table
    + a tool in `llm.ts` when the model should have it.
  - `registrySchema()` is the machine-readable registry (every component's
    prop specs, chart/widget kinds, document shape, commands); the prerender
    reads it off the mounted studio and writes `dist/agent/registry.json`,
    and strips the document mirror from the static bytes. The skill that
    teaches harnesses the document shape and the protocol is
    `public/agent/SKILL.md`; both are indexed from `llms.txt` ("Agents").
  - `evolve.ts`: a GENERATION is `count` seeded clones of one parent, mutated
    inside the allowlists (chart seed/texture/colour, registry props within
    their specs, screens cell by cell) and normalized; same parent + seed →
    same generation. `placeGeneration` (editor store) lays a generation out as
    one row centred on the viewport and selects it — the one multi-frame
    placement, a batch insert whose midpoint is where a lone frame lands.
  - `tools.ts`: the Studio as a harness sees it — `STUDIO_TOOLS` (one tool
    per protocol command, with JSON-schema parameters; `callStudioTool` runs
    one through `runCommand`, `finishStudioTool` turns `export_video` into
    `data.files` for the bridge to write), the `AgentEvent` union the
    composer renders (delta, assistant, tool, activity, plan, mode,
    commands, turn, error, done), and the composer's state: `AgentConfig`
    (`bridgeUrl`, the chosen `harness` id or `custom` + `command`, `auto`
    permission answers — nothing secret, always saved) and per-project
    sessions (`dither-agent-session-<projectId>`, entries + usage, capped
    at 160k chars by dropping the oldest entries). There is NO model client
    in the app: the composer never runs a model and never holds a key.
  - `acp.ts`: the composer as an Agent Client Protocol client over a
    loopback websocket to `bridge/dither-bridge.mjs` (see `bridge/AGENTS.md`
    for the wire contract). `connectAcp` sends the Studio tools
    (`bridge/tools`), resolves on the bridge's `bridge/hello` (cwd, the
    known harnesses with their PATH status, the running agent or null) and
    boots a harness that is already running (`initialize` + `session/new`,
    reading modes off the answer); `client.start(id | { command })` asks the
    bridge to spawn one and boots it; `stop()`, `newSession()`,
    `setMode()`. `mapUpdate` turns `session/update` into events (message
    chunks → `delta`, tool calls → `activity` with a status-only update
    keeping its title, plans, `current_mode_update` → `mode`,
    `available_commands_update` → `commands`; mirrors of Studio tools are
    skipped because they render from the `studio/call` path), permission
    requests go through the panel's inline approval, `studio/call` runs
    `callStudioTool` + `finishStudioTool` so the harness's MCP tool calls
    land on the canvas, `prompt()` resolves with the stop reason and the
    turn's text (one assistant entry per prose segment between tool
    calls), abort sends `session/cancel`, `bridge/exit` fails every pending
    request. Pinned by `tests/agent-protocol.spec.ts`,
    `agent-evolve.spec.ts`, `agent-tools.spec.ts`; the bridge chain by
    `tests/bridge.spec.ts`.
- Styling systems: `shared/lib/restyle.ts` is the ONE translator.
  `restyle(code, system, framework)` rewrites the `class` / `:class` /
  `style` attributes of a Vue snippet (or a Svelte one, `class={…}`) into
  named styles for `css` (a `<style scoped>` block; `<style>` under Svelte),
  `modules` (`$style` bindings + `<style module>`; CSS under Svelte) or
  `stylex` (`stylex.create` in the script after its imports, `stylex.attrs`
  on the element — `v-bind` in Vue, a spread in Svelte); `tailwind` returns
  the code untouched. Its data is `virtual-dither-utilities`:
  `utility-map.ts` (node-side only — rstack.config.ts, tests; an Rspack
  virtual module, recompiled in watch mode when a scanned file changes)
  scans `UTILITY_SOURCES` (`pages/docs`, `entities`) with Tailwind's scanner
  and compiles every candidate with Tailwind's compiler (theme inlined; the
  kit's `@theme inline` tokens and dark variant read from
  `dither-kit/tailwind/kit.css`), keeping per class its cascade index, its variant
  chains and declarations, plus the `@property` initials.
  `compileUtilities(tokens, strict, map)` merges a class list in
  Tailwind's order (so `px-2 p-4` cascades as Tailwind does), resolves
  every `--tw-*` input to the end (a ring with an offset nests a dozen),
  folds Tailwind's `@supports (color: color-mix(...))` guard into its rule
  (every supported browser passes it), simplifies `calc()`s and drops
  no-op shadows; `map` defaults to the docs' vocabulary, and the kit
  layer generator passes the kit's (`buildUtilityMap(root,
  ["dither-kit/tailwind"])`). StyleX comes out as its compiler takes it:
  a pseudo-element is a top-level key holding its own properties
  (`"::placeholder": { color }`), vendor properties are spelled
  `WebkitLineClamp`. Unknown classes stay classes (the kit
  stylesheet covers them); for StyleX so does any class whose rule needs a
  selector it cannot express, a custom property or `!important`, and a
  dynamic binding only becomes `attrs()` arguments when its shape allows
  (literal / ternary / `&&` / array / object; a ternary or `&&` takes any
  condition, an empty branch is `null`, an array converts whole or not at
  all). In every target only the literals in class position are named (a
  ternary's arms, the right of `&&`, array items, object keys), so a
  condition's string (`view === 'grid'`) stays a value. Names come from the element
  (`stack`, `row`, `grid`, `layer`, `frame`, `box`, `text`, a component's
  name without `Dither`, a literal's first utility), one per distinct rule
  set, inline `style` folded into the element's rule. The preference
  (`styling` ref, localStorage `dither-styling`) is shared by the docs and
  the Studio. `code.get { id, styling? }` and the `get_code` tool return the
  SFC restyled (`tailwind` default, junk fails); the export dialog's
  `styling` control does the same for the copy and the download. New
  vocabulary is picked up by the next build (dev: on a hot update of a
  scanned file). The docs' Run plays the restyled example in any system,
  StyleX through `widgets/source/model/stylex.ts`. Pinned by
  `tests/restyle.spec.ts`, `tests/stylex-coverage.spec.ts` (every Studio
  export and every docs snippet written as StyleX leaves no Tailwind class;
  only a snippet's own CSS hooks stay classes) and the `code.get` case in
  `tests/agent-protocol.spec.ts`.
- Clock for drivers: `clock.seek { seconds? }` / `clock.release` hold or
  free the kit clock from the protocol (the `seek_clock` tool), and
  `window.ditherStudio.clock` exposes the same four calls (`seek(seconds)`,
  `release()`, `directed()`, `time()`), so a screenshot from any driver —
  CDP, WebDriver, Playwright, a DOM event from an extension world — lands on
  a held moment in any engine.
- Video: `features/export-video` turns ONE frame into a HyperFrames
  composition (hyperframes.dev — HTML that `npx hyperframes render` turns
  into a deterministic MP4). `compositionHtml` writes the root contract
  (`data-composition-id/width/height/duration/fps`; the frame IS the video
  frame), embeds the frame's document as JSON with `<` escaped, and carries
  the player inline (`{ js, css }`, `</script` escaped in the bundle) or by
  reference (`{ jsRef, cssRef }`). The player is `pages/play`; its
  single-file bundle is the `player` environment of `rstack.config.ts` →
  `dist/play/player.js` + `player.css` (one classic script, no async
  chunks, every asset a data URI), built by the same `rs build` as the site
  because the site's entries share chunks; `playerAssets()`
  fetches it from the same origin and on a dev server, where it does not
  exist, the export says so. Delivery: the Studio's video dialog downloads
  the self-contained file; the `video.export` protocol command returns the
  composition referencing `./player.js` + `./player.css` and the assets'
  absolute URLs (sync, for any consumer); the `export_video` tool is
  finished by `finishStudioTool`, which ships `data.files` for the bridge
  to write under the harness's project as `video/<slug>/index.html`.
  Options normalize to seconds 1–600
  (default 6; a reel's default is its length), fps 24|30|60, theme
  dark|light. Pinned by
  `tests/composition.spec.ts`, the `video.export` case in
  `tests/agent-protocol.spec.ts`, and the file case in `tests/bridge.spec.ts`.
- Reel: a frame that plays other frames in order. `ReelModel`
  (`entities/widget`: `{ kind: "reel", clips: [{ id, seconds, transition: {
  kind: cut|dissolve|wipe-right|wipe-left|wipe-down|wipe-up, seconds, cell,
  seed } }] }`) references other artboards by id, so history, persistence
  and `normalizeArtboard` (string ids other than the reel's own, seconds
  0.1–600, transition seconds ≤ the clip's, cell 1–16, kind one of
  `REEL_TRANSITIONS`, junk replaced) cover it; `createClip`/`createReel`
  seed 3s clips coming in over a 0.6s dissolve; `addReelArtboard(ids)`
  (editor) cuts frames in order (reels and unknown ids dropped) into a frame
  sized like its first clip; `removeArtboard`/`removeSelected` prune
  dangling clips; `evolve` leaves a reel alone. `features/reel` is the pure
  timeline on the kit's `planSequence`: `reelAt(clips, t)` → the clip on
  screen, the outgoing clip while its transition runs (none under the first,
  which comes in over the frame's background), the eased progress;
  `reelDuration`, `reelLoop`, `wipeDirectionOf`. `widgets/reel-renderer`
  plays it: one `DitherReveal` layer per clip on screen, keyed by its slot
  so a clip's surfaces survive going from incoming to outgoing, masked by
  the transition's progress; time is the kit clock (free-running it loops
  and `replayToken` restarts it; directed it is the composition's moment,
  held past the end); reduced motion cuts; `data-reel-clip/time/total` and
  `data-reel-layer` are the walk's probes. Clips resolve from the
  `REEL_POOL` injection (the player provides its document's frames) or the
  editor's artboards; `ReelClip` renders a clip through `WidgetRenderer` /
  `ChartRenderer` at its own size, scaled down (never up) to fit — the
  import cycle `WidgetRenderer` → `ReelRenderer` → `ReelClip` →
  `WidgetRenderer` is render-time only. Export: `compositionHtml(…, extra)`
  embeds the reel's clips (`reelClips(a)`) in the composition's document and
  `defaultSeconds(a)` makes a reel's video as long as its cut (the dialog,
  `video.export` and `export_video` default to it). Protocol: `reel.add {
  name?, clips: [id | { id, seconds?, transition? }], frame? }` (unknown ids
  and reels fail loudly, other junk is sanitized), `artboard.update {
  widget: { clips } }` rebuilds the cut, the `add_reel` tool; `code.get`
  answers a comment — a reel has no SFC, it is a video. UI: the toolbar's
  `reel` action cuts the selection in selection order (hidden when only
  reels are selected); the inspector's `ReelPanel` edits length, transition
  kind/seconds/cell per clip, reorders, removes, adds from a picker. Pinned
  by `tests/reel.spec.ts` (timeline, model, protocol, the renderer on a
  seeked clock).
- Film: a reel with a camera, callouts, chapters and a score. Each clip
  carries `camera: [{ t, x, y, zoom, tilt }]` (t 0-1 of the clip; the frame
  point looked at, 0-1; zoom 1 = the frame fitted, 0.25-8; tilt ±30
  degrees; at most 16, sorted), `callouts: [{ x, y, label, note, side, from,
  to }]` (a frame point, a 48/64-character label and note, left|right, up
  inside the clip; at most 8, none without a label) and `chapter: { line,
  spec: [[figure, words]] } | null` (80 characters; up to 4 rows of 12/32;
  a blank line is none); the reel carries `score: { seed } | null`.
  `createClip`/`createReel` start them empty and `normalizeArtboard` fills
  and sanitizes them (control characters out), so reels from before films
  load as still reels. `features/reel` adds the film's pure timeline:
  `cameraAt(keys, t)` (eased like the transitions, the first key held
  before and the last after), `cameraTransform(cam, w, h, lens)` (the CSS
  transform about the stage's centre: `perspective() rotateY() translate()
  scale()`), `cameraPoint(…)` (the same arithmetic for one frame point, so
  a callout tracks it exactly; no DOM measuring, which could not be
  frame-exact under the renderer), `CAMERA_MOVES` + `cameraMoveOf` (the
  inspector's still / push in / pull out / pan right / pan left / drift),
  `calloutsAt(clip, local)` (drawn over 0.9s from `from`, faded over 0.3s
  to `to`), `chapterAt(clips, moment)` (the last chapter at or before the
  clip on screen, the one before it while it comes in over 0.5s),
  `hasChapters`, `scorePlanOf(reel, seconds)` (clip starts, chapter turns,
  callout landings, the seed) and `playScore` (the Studio's preview: the
  kit's samples through Web Audio, once). `ReelRenderer` (opaque, the page
  background) runs each clip layer through its camera, draws the callouts
  over a stage that is a size container (an SVG leader drawn by its dash,
  a ring with an ember core, the label and note in `cqh`), and puts
  `ReelBand` (22% of the frame, a size container: the line and the
  figure-first spec list, a new chapter dissolving in through
  `DitherReveal`) under the stage once any clip sets a chapter; reduced
  motion holds the first key, draws callouts at once and turns chapters
  without the dissolve; `data-reel-camera`, `data-reel-callout` and
  `data-reel-chapter` are the walk's probes. The inspector's `ReelPanel`
  sets a clip's camera move, chapter line and three spec rows, and its
  callouts; the reel's score (on, seed) and "Play with score" (the reel
  replayed with the preview). Protocol: `reel.add` takes `camera`,
  `callouts`, `chapter` per clip and `score` (`true` | `{ seed }`), passed
  raw to `normalizeArtboard`; `artboard.update { widget: { clips?, score? } }`
  patches either; `add_reel` describes them. Export: `scoreWav(a, seconds)`
  synthesizes the cut at the video's length (`scoreCut` + `wavBytes`); the
  self-contained composition (the dialog, the bridge's file) inlines it as
  a data URI and `video.export` references `./score.wav` and returns
  `score: { path, base64 }`; either way `compositionHtml` adds `<audio
  id="dither-score" data-start="0" data-duration data-track-index="1">`,
  which HyperFrames mixes into the MP4. `features/agent/film.ts`
  (`addLaunchFilm`, the Studio's `#new/film`) builds the film's own demo
  through `runCommand`: an area chart, a galaxy, a 640x360 dashboard
  screen, a bar chart and an ASCII "dither-ui", cut into a 21s 960x540 film
  (chapters, camera moves, three callouts, score seed 7), its frames in a
  row under it. Pinned by `tests/reel.spec.ts` (the "film", "launch film"
  and renderer cases) and `tests/score.spec.ts`.
- Keyboard map lives in `features/keyboard/useShortcuts.ts`; every new
  shortcut also gets a row in `ShortcutsHelp.vue`.
- Pointer transforms use `features/artboard-transform/startDrag`; it filters by
  pointer id and owns pointer-up/cancel/unmount cleanup. Artboard surfaces drag
  only from non-interactive regions so live controls retain pointer ownership.
- Move, nudge, duplicate, delete, lock, and group commands act on the complete
  selection; locked members remain stationary.
- Toolbar hierarchy is contextual: project/insertion/global view controls persist;
  edit, data, and export actions appear in the selection toolbar only while an
  artboard is selected. Project file import/export stays in the project menu.
- Studio lifecycle watchers are singletons while the route is mounted:
  `startAutosave`/`startHistory` replace prior handles and StudioPage stops them
  on unmount. Route revisits must not accumulate deep watchers.
- A11y floor: icon-only buttons carry `aria-label` (+ `aria-pressed` for
  toggles); dialogs use `role="dialog" aria-modal`, close on Escape, focus on
  open and TRAP Tab — app dialogs wrap their panel in the kit's
  `DitherFocusScope` (ExportDialog with `autofocus=false`, its own close
  button takes focus via an `immediate` watch because it mounts lazily with
  `open` already true; ShortcutsHelp with autofocus), which also restores
  focus on close; menus (toolbar project/library, ContextMenu `role="menu"`
  + `menuitem`) dismiss on Escape and on a pointer outside, focus their
  first item on open, and take ArrowUp/Down/Home/End through
  `shared/lib/menu.ts` (`menuKeydown`, `focusFirstMenuItem`); no
  `window.prompt`/`confirm` — the project menu names inline (Enter commits,
  Escape backs out) and deletes through `DitherAlertDialog`; global `:focus-visible` ring is in `app/styles.css` — do not
  suppress. Interactive targets are at least 24px on their short side
  (`h-6` / `min-h-6` / `size-6` on dense chrome). Pressed and selected
  chrome is `.ring-on` and every toggle carries `aria-pressed` (state is
  never colour alone); the accent blue is the CANVAS's selection colour
  only (artboard ring, handles, snap guides, marquee, screen-cell rings).
  The accent blues stay tuned to clear 4.5:1 against white (light
  `#1d63d0`, dark `#1f6fd6`) — do not brighten them back.
- Layer tree is a `listbox`: every row type is a focusable `role="option"`
  with `aria-selected`, Enter/Space select (`.self`-guarded so rename inputs
  don't retrigger), ↑/↓ move focus and MUST stopPropagation — the same keys
  nudge artboards at window level. A row's lock and hide keys show on hover
  AND while the row holds focus (a key Tabbed to is never invisible); a
  hidden row says so with its eye-off key and an italic name, its text
  kept at AA, never dimmed. New row kinds follow this shape.
- Design tokens: shadcn-style CSS vars in `app/styles.css`; components use
  token utilities (bg-background, text-muted-foreground, border-border), never
  raw hex. The same file holds the site chrome voice (`@layer components`:
  `.nav-a` and `.micro` in sentence case, `.nav-pill` (a machined
  secondary key: 10px radius, the hairline, a narrow reflection on its
  top edge), `.led`, and `.ring-on` — the ONE pressed/selected state: ink
  text, a hairline ring, a breath of fill, never a colour fill; an
  outline in forced colors) and the look's MATERIALS, after the user's Y2K
  design system V5 (root `AGENTS.md`), per theme on `:root` (light) and
  `.dark`: `--chrome` (chrome type: gunmetal on light, its lightest stop
  4.5:1 on white; bright on dark), `--chrome-key` (satin aluminum, both
  themes: chrome `#D4DCE1` with a narrow reflection, no hard band),
  `--chrome-rim`, `--shell-ground` (anodized graphite on dark, bone
  ceramic on light, lit from the top left), `--well`, `--gloss`,
  `--recess` and `--recess-sm` (a surface pressed in, shadowed from the
  top left), `--smoke` (smoked polymer: graphite on dark, bone on light),
  a light `--muted-foreground` a step deeper than the kit's (`#5b6270`,
  AA on the bone grounds, not only on white),
  `--float-shadow`, `--ease-settle`, `--cut` (16px), `--display-face`
  (Michroma, then the mono; the `font-display` utility reads it; its
  `@font-face` is `shared/fonts/display.css`, imported by the landing and
  the docs only, never by the player, whose bundle inlines every asset).
  Classes:
  `.chrome-type` (the ramp clipped to the glyphs, one horizon per line),
  `.spec` (spec label: uppercase, tracked, 10px) and `.speed-tag` (a
  slanted plate), the ONLY uppercase in the site's own chrome;
  `.hardshell` (two corners cut by `clip-path`, which clips what it
  holds, fixed descendants included; a chrome-glint ring along the cut;
  the shell ground; set `--cut` locally for a smaller cut); `.gel` (an
  asymmetric pebble recessed into its shell, `overflow: hidden`, a
  directional highlight over its top 38%; chips in it take z-index 4);
  `.capsule` (a floating smoked-polymer shell: `--smoke` under a light
  8px blur, a narrow reflection, the float shadow; never frosted glass);
  `.rim` (the chrome rim on any positioned element; `.gel` and `.capsule`
  carry it); `.key-chrome` (the primary key: a satin aluminum capsule
  with obsidian ink, lifting 1px on hover except under reduced motion;
  the ONLY capsule key, every other key is machined); `.track` (machined
  segments recessed into the surface: 2px padding, 10px radius, its keys
  7px; the picked one `.ring-on`). Chrome type goes on display type only
  (the landing's statement, section titles and figures, the docs' page
  title), never on body text or every heading. Reduced transparency
  drops the shell ground and the highlight and makes the capsule solid;
  forced colors drop the cuts, rims and chrome type for system colours
  and plain borders (tracks bordered, the pressed segment outlined).
  Shared by the docs and the Studio; none of them grows a voice of its
  own. The landing takes only `.nav-a`, `.chrome-type`, `.key-chrome` and
  the dark theme's tokens from it (always dark), printed on its own
  obsidian (root
  `AGENTS.md`, `pages/landing/chapters.css`): its keys, picked segments,
  cards and panels are fills on one raised ground, never rings or tags;
  its only hairlines are the frame's.
- Entry (`app/main.ts`) is `createApp` — client render, deliberately NOT
  hydration: the route prerender (`scripts/prerender.mjs`) is a crawler
  surface, and hydrating it corrupts DOM because kit components gate on
  measured state (chart `ctx.ready`, mount flags), so first-render vnodes
  never match the settled snapshot. Don't retry hydration without first
  making first render equal settled state.
  Because the client renders fresh over those bytes, one-shot entrance
  latches are runtime-only: a figure marks itself `data-live`/`data-done`,
  and the prerender strips both before serializing, so the static page
  ships the figure un-latched and the client plays the entrance exactly
  once (a snapshot taken after it played flashed the finished art, hid it
  on mount, then replayed — measured on a 4x-throttled CPU).
- Docs sections are DIRECT children of `.docs-flow` and carry
  `content-visibility: auto` + `contain-intrinsic-size: auto 700px` — below-
  fold sections skip layout/paint until they approach the viewport (the page
  is 220 sections / ~180k px; first layout of everything cost seconds). New
  section wrappers must stay inside `.docs-flow` to keep the rule applying.
- Page transitions: the routes (`/`, `/docs`, `/studio` and the section
  pages) opt into cross-document view transitions in `app/styles.css`
  (`@view-transition`, none under reduced motion), and the arriving page
  dithers from the old one through the kit's `ditherViewTransition`: a
  right wipe (left going back through history) with a band of the signal
  ink. `app/page-reveal.ts` owns the hand-off: `PAGE_REVEAL_SCRIPT`,
  inlined first in each route's head by `rstack.config.ts` (`html.tags`,
  with the `PAGE_END_ID` marker it appends to each body), parks the
  `pagereveal` transition and holds the old page on screen until the app
  takes it; arriving from one of the pages it also holds the first frame
  to the marker (`<link rel=expect blocking=render>`), since Chromium drops
  a transition into a page it paints mid-parse (the 1.4 MB docs);
  `takePageTransition` hands it to main.ts in either order (that hold lets
  the app's deferred script run before the reveal). `tests/page-reveal.spec.ts`
  runs the inline script both ways. The theme toggle (`useTheme`'s
  `revealToggle`) dithers the new theme in as an iris from its control.
- Engine gate: `app/main.ts` adds `html.engine-webkit` when the UA is WebKit
  by ENGINE (`AppleWebKit` without `Chrome`/`Android` — so iOS skins like
  CriOS/FxiOS/EdGiOS count), and the docs sections then opt OUT of
  content-visibility there, because WebKit's reveal path keeps stale geometry
  until a forced flush — blank chunks under real scroll (bug 321501) and
  re-entry jank (318216), both unfixed as of Safari/iOS 26. Chrome/Gecko/Edge
  keep the skip. Remove the gate when those bugs ship fixes.
- `shared/ui/AdSlot.vue` never evaluates the EthicalAds client on the
  critical path: the slot loads only when it approaches the viewport AND the
  document is loaded plus a beat (8s-after-load safety net), and the aside it
  lives in is `display: none` under lg — phones never fetch it. The gate is
  IntersectionObserver on purpose: Safari has no `requestIdleCallback`.

## Verification

- `npx vue-tsc --noEmit` and `npx rs build` green before commit.
- Interactive checks in a real browser (agent-browser + screenshots) for
  anything visual or stateful (undo/redo walks, dialog focus, deep links).
- The video path on the built site (`rs preview`): the player seeked by
  synthetic `hf-seek` events gives identical bytes for identical times, the
  dialog's download opened from disk matches the player at the same time,
  and `npx hyperframes lint`/`render` accept the file. A reel seeked the
  same way shows the right clip, layers and mask state per time, requests
  no frames while directed, and its composition renders to an MP4 as long
  as the cut whose frames differ across the transitions.
- Other engines: when Playwright's WebKit/Firefox builds cannot be fetched,
  WebKitGTK's own WebDriver (`apt install webkit2gtk-driver xvfb`, then
  `xvfb-run WebKitWebDriver --port=4447` and a W3C session on
  `webkit2gtk-4.1/MiniBrowser --automation`) drives the built site over
  plain HTTP: the same checks — container units resolved in a layer's
  computed transform, the world's canvas and WebGL engines, the Studio
  protocol and `clock.seek`, the player's `ditherClock` determinism — pass
  there, which is Safari's engine.
- Perf probes use the canonical trailing-slash URLs (`/docs/`, `/studio/`)
  — the directories the build writes; `rs dev`/`rs preview` also rewrite
  the slashless forms to them (`server.historyApiFallback`), static hosts
  may not.

## Child DOX Index

- `pages/AGENTS.md` — landing, docs, studio, play page contracts
- `widgets/workbench/AGENTS.md` — the docs previews' workbench: tools and
  keys, the stage coordinate system, the pointer surface, keyboard scope,
  the sanitized per-preview workspace, captures, the agent hand-off
- `widgets/source/AGENTS.md` — the docs previews' source files: the
  example, the kit's own files, tokens and prompt as tabs; edit, Run in
  the page (compiler-sfc, types stripped, linked to the page's Vue and the
  kit), download as a zip