From d58c31409a869ea524e06903fab37a7b77a1bd20 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 00:51:37 -0700 Subject: [PATCH 1/5] docs: correct and condense the website, theme, tutorial, deploy and fork specs Fixes claims that no longer match the code and moves evidence to the rationale files so five specs fit under smaller budgets. Corrections: - theme.md scopes the "every path resolves consumed tokens" claim to shipping hosts and names `pnpm dev:lib` as the exception; the same-theme applyTheme no-op is keyed on object identity, not the id. - deploy.md drops the `/latest/download/` story (the homepage reads the committed versioned manifest), names the four files bump-version.sh touches, says Stage 1 also uploads the .vsix, and records that the release environment gates are GitHub settings no lint can see. - website-docs.md spells out the trailing slash `localizeSiteLinks` adds, drops a per-command rail count that rots, rewords the README-link rule to follow `linkedFrom`, and moves shipped behavior out from under `## Future`. - tutorial.md names the layout test, stops claiming the detector reads `tut-items.ts`, and narrows the `q` shortcut, which is buffer input on the reset screen. - webgl-text.md carries the fork version canopy actually ships. - AGENTS.md: only two of the sibling lints fail on a missing spec line, the e2e self-test mutates in both directions, and loopback-lint has a self-test too. The Door pair tie-break and the rail's command nesting gain comments at the code that decides them. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 13 +-- docs/specs/deploy.md | 27 +++-- docs/specs/deploy.rationale.md | 4 - docs/specs/theme.md | 52 +++++----- docs/specs/theme.rationale.md | 2 - docs/specs/tutorial.md | 22 ++--- docs/specs/tutorial.rationale.md | 2 + docs/specs/webgl-text.md | 18 ++-- docs/specs/website-docs.md | 136 ++++++++++---------------- docs/specs/website-docs.rationale.md | 22 ++++- lib/src/lib/themes/dynamic-palette.ts | 3 + scripts/spec-word-budgets.json | 8 +- website/scripts/generate-docs.js | 4 +- 13 files changed, 135 insertions(+), 178 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 07a6e8e6b..e5a504330 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,7 +110,7 @@ Specs are written ahead of the code: a new component's spec starts as a full des `scripts/spec-lint.mjs` (`pnpm lint:specs`, the first step of the root `pnpm test`) enforces the mechanically checkable conventions above — its header comment lists the checks — and ratchets size: every spec, this file, `SECURITY.md`, and `SELF_HOST.md` carry a word budget in `scripts/spec-word-budgets.json`, its size rounded up to the nearest 50. Rationale files carry none; evidence may grow without limit. Over budget: cut to fit, or re-baseline with `node scripts/spec-lint.mjs --ratchet ` in the same PR. `SECURITY.md` and `SELF_HOST.md` ride the same checks. Advisory prose reviews follow `docs/prose-audit.md` (`pnpm audit:prose`). -Six sibling lints run in `pnpm test`. Five enforce one invariant a spec states in prose, each naming the line it enforces and failing if that line is gone; `ps1-cmdlet-lint` guards the one shipped file nothing else can parse: +Six sibling lints run in `pnpm test`. Five enforce one invariant a spec states in prose and name the line they enforce; only `public-docs-lint` and `e2e-lint` read that prose and fail when the line is gone. `ps1-cmdlet-lint` guards the one shipped file nothing else can parse: | Lint | Enforces | |---|---| @@ -118,10 +118,10 @@ Six sibling lints run in `pnpm test`. Five enforce one invariant a spec states i | `scripts/xterm-lint.mjs` (`pnpm lint:xterm`) | The `@xterm/*` version lockstep in `docs/specs/webgl-text.md`. | | `scripts/loopback-lint.mjs` (`pnpm lint:loopback`) | `docs/specs/security-local.md` -> "Loopback Listeners": a loopback bind is not an access control — a new listener references a guard module or is allowlisted with a reason. | | `scripts/deploy-lint.mjs` (`pnpm lint:deploy`) | `docs/specs/security-remote.md` -> "Credentials at rest" and "Network posture (self-hosted)": the installer controls binding all three of `deploy/local/install-{macos,windows,linux}`. | -| `scripts/ps1-cmdlet-lint.mjs` (`pnpm lint:deploy`) | Every `Verb-Noun` call in `deploy/local/install-windows.ps1` uses an approved verb and a noun that is not this project's vocabulary. No job has a PowerShell, so this is the Windows installer's only syntax gate — a repo-wide rename once turned all 147 `Write-Host` calls into `Write-Burrow`. | +| `scripts/ps1-cmdlet-lint.mjs` (`pnpm lint:deploy`) | Every `Verb-Noun` call in `deploy/local/install-windows.ps1` uses an approved verb and a noun that is not this project's vocabulary. No job has a PowerShell, so this is the Windows installer's only syntax gate. | | `scripts/e2e-lint.mjs` (`pnpm lint:e2e`) | The structural half of `docs/specs/security-remote.md` -> "Remote Control": one Noise suite with no selector, no JavaScript curve, no legacy relay discriminant, no Relay-side protocol-v1 type, no checked-in service worker, no optional field on a ciphertext or transcript. | -`scripts/spec-lint-selftest.mjs` plants one defect per finding check in the spec lint. The `deploy` and `e2e` lints carry self-tests that mutate each rule in whichever direction it points: a present-control rule has its control deleted (and, for exact-count rules, a copy added), a `forbidden` rule has the banned text appended. `scripts/e2e-lint-selftest.mjs` is wholly the second kind; `scripts/deploy-lint-selftest.mjs` is mostly the first. Either way the lint must go red. **A rule added to one of these lints without its self-test case is not enforced** — it is a claim that something is checked. They share plumbing, and only that, through `scripts/lint-kit.mjs`. `scripts/installer-verify-test.mjs` (also `pnpm lint:deploy`) runs the installer shell helpers lint can only read, extracted from the shipped files; `scripts/ps1-cmdlet-lint-selftest.mjs` carries the `ps1-cmdlet` lint's mutations. `pnpm test` also runs `scripts/clamp-issue-body-selftest.mjs`, the test for `scripts/clamp-issue-body.mjs` (the helper the audit workflows use to keep an issue body postable); it lives at the repo root because its callers do. +`scripts/spec-lint-selftest.mjs` plants one defect per finding check in the spec lint. The `deploy`, `e2e`, and `loopback` lints carry self-tests that mutate each rule in whichever direction it points: a present-control rule has its control deleted (and, for exact-count rules, a copy added), a `forbidden` rule has the banned text appended. `scripts/e2e-lint-selftest.mjs` is mostly the second kind; `scripts/deploy-lint-selftest.mjs` mostly the first. Either way the lint must go red. **A rule added to one of these lints without its self-test case is not enforced** — it is a claim that something is checked. They share plumbing, and only that, through `scripts/lint-kit.mjs`. `scripts/installer-verify-test.mjs` (also `pnpm lint:deploy`) runs the installer shell helpers lint can only read, extracted from the shipped files; `scripts/ps1-cmdlet-lint-selftest.mjs` carries the `ps1-cmdlet` lint's mutations. `pnpm test` also runs `scripts/clamp-issue-body-selftest.mjs`, the test for `scripts/clamp-issue-body.mjs` (the helper the audit workflows use to keep an issue body postable); it lives at the repo root because its callers do. ## Design @@ -137,11 +137,8 @@ See [PRODUCT.md](PRODUCT.md) for users, brand personality, and aesthetic directi The concrete type scale, color strategy (surfaces, foregrounds, header palette, dynamic door bg, selection ring), and shared chrome constants live in [`lib/src/components/design.tsx`](lib/src/components/design.tsx) — read it before adding or changing any `text-*`, `bg-*`, `text-color-*`, or border -class anywhere in `lib/src/`. The `@theme` token definitions are split: colors -in [`lib/src/theme-colors.css`](lib/src/theme-colors.css), which a host can -import on its own, and the type scale, fonts, and animation tokens in -[`lib/src/theme.css`](lib/src/theme.css). When adding or removing a color -token, update `theme-colors.css` and `design.tsx` together. +class anywhere in `lib/src/`. `docs/specs/theme.md` → "Runtime model" owns how +the token files are split; `DESIGN.md` → "Don't" owns what adding one costs. ## Running inside Dormouse diff --git a/docs/specs/deploy.md b/docs/specs/deploy.md index 4f26a19d5..5d8036e9d 100644 --- a/docs/specs/deploy.md +++ b/docs/specs/deploy.md @@ -22,7 +22,7 @@ Source of truth: `create_release` in `scripts/sign-and-deploy.sh`; `publish-vsco Human-driven, in order: 1. **Update dependency snapshots** — run `node website/scripts/generate-deps.js`, review the diffs in `website/src/data/dependencies-{npm,cargo,runtime}.json`, commit if changed. -2. **Draft release notes and bump version** — run `/release-notes` at the repo root ([.claude/commands/release-notes.md](../../.claude/commands/release-notes.md)): it recommends a `breaking.added.bugfix` bump from the merge commits and squash-merged PRs since the last tag, runs `./scripts/bump-version.sh X.Y.Z`, and edits `CHANGELOG.md` for that version. Review the diff. +2. **Draft release notes and bump version** — run `/release-notes` at the repo root ([.claude/commands/release-notes.md](../../.claude/commands/release-notes.md)), which owns the steps. Review the diff. 3. **Commit and tag** — `git commit -am "Release vX.Y.Z"` then `git tag vX.Y.Z`. 4. **Push** — `git push && git push origin vX.Y.Z`, which triggers CI (Stage 1). 5. **Run local signing** — plug in the PIV USB key, then `./scripts/sign-and-deploy.sh all X.Y.Z`: it waits for CI, verifies and signs artifacts, writes the website update manifest, and creates the GitHub Release. Secrets follow [Environment / secrets](#environment--secrets). **Must reject a dirty tree, untracked files, or commits ahead of the configured upstream**; without an upstream the script warns that push status is unknown. @@ -36,7 +36,7 @@ Human-driven, in order: ## Versioning -**Must synchronize the version files and Cargo.lock's `dormouse` entry with `scripts/bump-version.sh`** (`cargo check --offline`). +**Must synchronize the four version files — `lib/package.json`, `vscode-ext/package.json`, `standalone/src-tauri/Cargo.toml`, `standalone/src-tauri/tauri.conf.json` — and Cargo.lock's `dormouse` entry with `scripts/bump-version.sh`** (`cargo check --offline`). **A release is triggered by pushing one tag (`v0.1.0`)** — never separate `vscode-ext/v*` and `standalone/v*` tags, because one changelog entry covers both. @@ -46,8 +46,8 @@ Source of truth: `scripts/bump-version.sh`; `on.push.tags` in `.github/workflows **Both signing steps must run locally** — Windows code signing requires a physical USB hardware key (EV cert via PIV), macOS a local Developer ID cert. -- **Stage 1 (CI)** — build the unsigned Tauri apps (win, mac, linux) and the VSCode extension, generate and attest their artifact manifests, upload the unsigned Tauri artifacts, publish the extension after protected-environment approval. -- **Stage 2 (local, `sign-and-deploy.sh`)** — download the CI artifacts → verify attestations and hashes → sign macOS (codesign + notarize) → sign Windows (jsign + PIV hardware key) → generate the Tauri update manifest with signatures → upload the signed artifacts to the GitHub Release. +- **Stage 1 (CI)** — build, attest, and upload the unsigned artifacts: the three Tauri bundles and the `.vsix`, which Stage 2 verifies but never signs. +- **Stage 2 (local, `sign-and-deploy.sh`)** — verify, sign, and release. ## Stage 1: CI workflow @@ -55,11 +55,11 @@ Triggered by tag push `v*`: `build-standalone`, `build-vscode`, and `security-au Environment protection, secret placement, and token permissions follow `docs/specs/security-ci.md` → "GitHub Actions Policies", "Automated Maintainer (tend)", and "VS Code Extension Releases". -**Must create `release-attest` before referencing it in both build jobs**, with no required reviewer (rationale); GitHub otherwise auto-creates an unprotected environment. +**Must create `release-attest` before referencing it in both build jobs**, with no required reviewer (rationale). **Never use `tauri-action`'s built-in GitHub Release creation** — the release is created locally, after signing. -**The `build-standalone` artifact upload must set `include-hidden-files: true`** — `actions/upload-artifact` v4.4+ silently drops dotfiles, and the zsh shell integration ships as ZDOTDIR dotfiles (rationale). The `vscode-extension` upload keeps the safer default — only `*.vsix` and the manifest. +**The `build-standalone` artifact upload must set `include-hidden-files: true`** (rationale). The `vscode-extension` upload keeps the safer default — only `*.vsix` and the manifest. **Must hash `artifact-executables.txt` into each standalone artifact manifest and upload it alongside the files**, recording their executable paths before ZIP transport loses permissions (rationale). @@ -75,6 +75,8 @@ Dispatches `security-audit.yaml` on the release tag (`gh workflow run`), polls f Runs in CI because Marketplace publishing uses PAT tokens, no hardware key. **The `vscode-extension-publish` environment must require reviewer approval and admit deployments only from `v*` tags**, and **`VSCE_PAT` / `OVSX_PAT` must be environment secrets there**, never broad repository secrets. +Those three, and the `Tag operations` ruleset behind `v*`, are GitHub repository settings: nothing in the tree carries them and no lint can read them, so a checkout cannot prove the release path is gated. Verify them in the repository's settings when auditing it. + ## Stage 2: Local script **Never mutate `release-signed/downloads/`.** `all` and `resume` rebuild `release-signed/work/`; `sign-mac` and `sign-win` refresh only their platform, and `notarize` preserves the signed Mac copy. `--help` lists subcommands. @@ -91,7 +93,7 @@ Downloaded CI artifacts must pass three checks before any signing step: **Never select release artifacts with a broad `find | head`** — use strict expected paths or exactly-one matching. Release upload rejects unexpected local files or existing remote asset names. -When rebuilding the Windows installer locally, the script rewrites the CI-runner absolute paths baked into the Tauri-generated `.nsi` (via `scripts/patch-nsis-paths.pl`) and repoints the `ADDITIONALPLUGINSPATH` and `OUTFILE` defines at the local plugin directory and installer path before `makensis`. +**Must repoint the Tauri-generated `.nsi` at local paths before `makensis`** — it is baked with CI-runner absolutes; `rebuild_windows_installer` documents the rewrite. **Runs on macOS only** — it uses `codesign` / `xcrun notarytool` / `ditto`, and its in-place `sed -i ''` edits are BSD form. @@ -118,10 +120,7 @@ pnpm --dir standalone exec tauri signer generate # creates the Tauri update sig **Must OS-sign the inner executable, package it, then Tauri-sign the final bundle.** Embed the generated `.sig` in the website manifest and remove the sidecar signature file before upload. -Two macOS packaging edge cases the script enforces; each would ship a release that fails only on the user's machine: - -- **Never `--deep`-sign the outer `.app`** — it would re-sign the Node sidecar and drop the hardened-runtime entitlements it needs. Nested binaries (the Node sidecar, the node-pty and node-datachannel prebuilds, `spawn-helper`) are signed individually first, and the script then launches the signed sidecar and requires both native addons from it. -- **Build the `.tar.gz` with `COPYFILE_DISABLE=1`** and re-scan the result for `._*` entries — AppleDouble resource-fork files make the Tauri updater's extraction fail with `failed to unpack ._Dormouse.app`. +Two macOS packaging edge cases the script enforces, each of which would ship a release that fails only on the user's machine: **never `--deep`-sign the outer `.app`** — nested binaries (the Node sidecar, the node-pty and node-datachannel prebuilds, `spawn-helper`) are signed individually first, and the script then launches the signed sidecar and requires both native addons from it — and **build the `.tar.gz` with `COPYFILE_DISABLE=1`**, re-scanning the result for `._*`. Both carry their reasoning at `sign_macos_app` and `notarize_macos` in the script. ### Packaged app logging @@ -129,7 +128,7 @@ Packaged-app log paths and `DORMOUSE_LOG_FILE` follow `docs/specs/standalone.md` ## Artifact filenames -**All release assets use stable filenames** (no version in the name), so dormouse.sh can hotlink through GitHub's `/latest/download/` redirect with no server-side logic — e.g. `https://github.com/diffplug/dormouse/releases/latest/download/Dormouse-macos-aarch64.tar.gz`. The stable names are the `FNAME_*` constants in `scripts/sign-and-deploy.sh`. +**All release assets use stable filenames** (no version in the name) — the `FNAME_*` constants in `scripts/sign-and-deploy.sh` — so a bundle URL differs only in its version path segment. ## Tauri auto-updater @@ -146,7 +145,7 @@ Config lives in [tauri.conf.json](../../standalone/src-tauri/tauri.conf.json) (` Shape: `version`, `notes` (a link to the GitHub release tag, not the changelog body), `pub_date`, and a `platforms` map keyed `darwin-aarch64` / `windows-x86_64` / `linux-x86_64`, each with `url` and `signature` (that bundle's `.sig`, verbatim). **The script fails rather than emit a platform with an empty signature.** -Manifest URLs carry the version in the *path* (`/v0.1.0/`) while the *filenames* stay stable (rationale). +Manifest URLs carry the version in the *path* (`/v0.1.0/`) while the *filenames* stay stable. **The homepage's download buttons read their URLs from this same committed manifest**, so a release is only downloadable once step 6 has deployed it (`website/src/pages/Home.tsx`). Source of truth: `sign_updates` in `scripts/sign-and-deploy.sh`; `plugins.updater` in `standalone/src-tauri/tauri.conf.json`. @@ -180,4 +179,4 @@ See `docs/specs/hosted.md` -> "Production releases" for the Hosted pipeline and ## Future -**Analytics-backed download URLs.** The `/latest/download/` hotlinks could move to `dormouse.sh/download/...` behind Cloudflare R2. Changing website links and manifest bundle URLs needs no app update while the manifest endpoint remains stable. +**Analytics-backed download URLs.** The GitHub release URLs could move to `dormouse.sh/download/...` behind Cloudflare R2. Changing website links and manifest bundle URLs needs no app update while the manifest endpoint remains stable. diff --git a/docs/specs/deploy.rationale.md b/docs/specs/deploy.rationale.md index 0325aef35..9e02954e1 100644 --- a/docs/specs/deploy.rationale.md +++ b/docs/specs/deploy.rationale.md @@ -23,7 +23,3 @@ The 2026-09-05 audit found that standalone resume commands reset every working a ## Two signing layers **What each layer actually proves.** OS signing proves the executable is from DiffPlug; Tauri signing proves the update bundle was not tampered with in transit. - -## Update manifest (`standalone-latest.json`) - -**Why two URL schemes name the same asset.** The manifest points at versioned release paths (`/v0.1.0/`) while the website hotlinks the `/latest/download/` redirect; with no version in the filenames, both resolve to the same file. diff --git a/docs/specs/theme.md b/docs/specs/theme.md index e69186548..0bfa5f294 100644 --- a/docs/specs/theme.md +++ b/docs/specs/theme.md @@ -6,8 +6,11 @@ VS Code supplies `--vscode-*`; standalone, website, and Pocket use `applyTheme()` with bundled or installed themes — Pocket before first paint, including auth ([pocket-app.md](./pocket-app.md#design-system-and-theming) owns its -browser-chrome sync). **Every path runs the same consumed-token resolver** -(`lib/src/lib/themes/vscode-color-resolver.ts`) before rendering. +browser-chrome sync). **Every shipping host runs the same consumed-token +resolver** (`lib/src/lib/themes/vscode-color-resolver.ts`) before rendering. +`pnpm dev:lib` is the one exception: `lib/src/main.tsx` installs the resolver +only for a real webview and nothing on that path applies a theme, so the dev +server renders with no `--vscode-*` at all. ## Surface hierarchy @@ -22,11 +25,10 @@ foreground/background pairs and nothing else (rationale): unfocused headers. **Hierarchy is the background swap between pairs**; secondary text is alpha on -the same pair's own foreground (`text-app-fg/70`), never a separate token. The -rest is `DESIGN.md`'s: bg-only chrome, no pass-through `--mt-*` layer or one-off -tokens, no `text-muted` inside a header — **use `hover:bg-current/10`** there, -with the `text-alarm-vs-*` ringing tint and destructive-action error styling as -the semantic exceptions. +the same pair's own foreground (`text-app-fg/70`), never a separate token. +`DESIGN.md`'s Do/Don't lists own the rest — bg-only chrome, no pass-through +`--mt-*` layer or one-off tokens, header hover — with the `text-alarm-vs-*` +ringing tint and destructive-action error styling as the semantic exceptions. **Never carry resting structure** with `surface-raised`, `border` (panel.border), `input-border`, or `muted` (descriptionForeground): themes may leave those unset, @@ -91,9 +93,10 @@ cancel queued resolution on disposal.** Pinned by Dormouse uses them as solid header and Workspace-tab fills, so `applyTheme()` composites them over `sideBar.background` first (rationale). -**A same-theme `applyTheme()` call is a no-op only while the expected inline +**A same-*object* `applyTheme()` call is a no-op only while the expected inline `--vscode-*` variables, `color-scheme`, and the `vscode-light` / `vscode-dark` class are still on -`document.body`**, and **ThemePicker re-restores in a layout effect after mount** +`document.body`** — a fresh object for the same id re-applies, and the id +comparison gates only the listener notification — and **ThemePicker re-restores in a layout effect after mount** — React Router document hydration can reconcile those writes away (rationale). Each layer declares its theme-dependent tokens twice: at document level @@ -207,15 +210,12 @@ playground navbar — carries none**. - **The picker renders the bundled default through hydration, then reconciles stored themes and selection in a layout effect** (rationale). - **Every candidate previews in its own palette**, resolved through - `resolveThemeVars`, the path `applyTheme` paints, so no preview shows a color - the app would not: the entry takes that theme's terminal foreground/background, - its swatch the active-header fill and the runtime's focus-ring pick - (rationale). Omissions resolve from the candidate's polarity, never the - document's, and **previewing neither applies a theme nor writes storage**. + `resolveThemeVars` — the path `applyTheme` paints, so no preview shows a color + the app would not (rationale). Omissions resolve from the candidate's + polarity, never the document's, and **previewing neither applies a theme nor + writes storage**. - **Must underline hover without visually marking selection**, preserving the candidate's palette. -- **Fades appear only toward a direction still holding entries**, painted in the - active terminal background, updating on scroll, content, and resize. - **Chrome outside the previews styles itself in `--color-*` utilities.** A host rendering library JSX scans `lib/src` and imports `theme-colors.css`, or none of those utilities reach it (rationale). Controls *inside* one take @@ -230,19 +230,15 @@ playground navbar — carries none**. every path re-resolving the active theme gets the same answer (rationale). **`useRestoredTheme()` latches it before its first restore and ahead of any child render** (rationale). -- **Never use `window.confirm`** — no native dialog in app chrome at all - (`DESIGN.md` → "Don't"; rationale). Uninstalling is a single click, matching - `WatchedCommandList`'s remove control in the same dialog, and **the picker - row's `X` keeps a gap from the row's select target** (rationale). +- Uninstalling is a single click (`DESIGN.md` → "Don't" bans `window.confirm`), + matching `WatchedCommandList`'s remove control in the same dialog, and **the + picker row's `X` keeps a gap from the row's select target** (rationale). - **`useAnchoredMenu` returns a dropdown's whole geometry; a caller never - re-implements placement beside it.** Dialog dropdowns take its measured, - viewport-clamped `fixed` strategy; `compact` takes `absolute`, which measures - only the trigger (rationale). Both prefer the requested `side`, flip to the roomier - side, and recompute their cap when the trigger, menu, or viewport changes. - **Must clamp and cap against the visual viewport when the browser exposes - it**, so mobile browser chrome and the on-screen keyboard stay outside the - menu's usable area. - They close on ancestor scroll and share dismissal with the Shell row. **The dialog owns + re-implements placement beside it** — dialog dropdowns on its `fixed` + strategy, `compact` on `absolute` (rationale). **Must clamp and cap against the visual + viewport when the browser exposes it**, so mobile browser chrome and the + on-screen keyboard stay outside the menu's usable area. Menus close on + ancestor scroll and share dismissal with the Shell row. **The dialog owns the open state** so `Escape` closes the menu first, which `ModalFrame`'s capture-phase handler would otherwise swallow. - **Heights follow the viewport, never a fixed pixel budget**: both surfaces cap diff --git a/docs/specs/theme.rationale.md b/docs/specs/theme.rationale.md index a35806caf..173b4a702 100644 --- a/docs/specs/theme.rationale.md +++ b/docs/specs/theme.rationale.md @@ -42,8 +42,6 @@ showed the stored theme. **Why `useRestoredTheme()` latches the fallback ahead of any child render.** On the desktop Pocket page the header's picker mounts before the component that calls the hook, so a latch deferred to an effect would let the picker re-resolve against no fallback at all. -**Why `window.confirm` cannot gate the uninstall.** Uninstall was gated on `confirm`; on the desktop app the call returned without ever showing a dialog, so uninstalling silently did nothing. - **Why the picker row's `X` keeps a gap from the select target.** The two paths above do not recover symmetrically: `Remove` leaves the extension row on screen to re-install, while the `X` means re-finding the extension through an OpenVSX search. The gap prices in the harder undo. **Why a story, not only a unit test, pins the short-viewport cap.** A unit test can stub the trigger and menu rectangles to pin side selection and resize recomputation, including a visual viewport shrinking while the layout viewport stays fixed, but it cannot prove that the real list yields height while the footer survives. `lib/src/components/design.test.ts` pins the viewport inset, `lib/src/components/use-anchored-menu.test.tsx` the geometry, and the Chromatic story the rendered result. diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 0aef00f63..c8db46180 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -25,15 +25,15 @@ Both `tut` profiles open inside their `initialSectionId`: Browser-side xterm alt-screen behind `FakePtyAdapter`, **never Node `terminal-kit`**: - **`tut-runner.ts`** (`TutRunner`) — profile-aware alt-screen TUI; subscribes to `TutorialState`, re-renders on progress, takes input from `TutorialShell`. -- **`tut-detector.ts`** (`TutDetector`) — wires app events to `TutorialState.markComplete(id)` and **must never touch the tiling engine**. `start()` seeds its prev-state maps and subscribes to `subscribeToActivity` + `subscribeToWatchedCommands` (`dormouse-lib/lib/terminal-registry`), `subscribeToMouseSelection` (`dormouse-lib/lib/mouse-selection`), `subscribeToActiveTheme` (`dormouse-lib/lib/themes`); everything else arrives on the `WallEvent` stream (`handleWallEvent`). **A keyboard split is credited before the split's automatic passthrough transition** (rationale), and **the `kb-arrows` hint that follows must tell the user to re-enter command mode** — the split left them in passthrough. **`kb-arrows` is credited from `selectionChange`** — to a distinct pane, in command mode — so an arrow key *or* a click counts. **Must credit `al-spreads` only when newly enabled WATCHING shares a command key with another live pane.** Transition guards live in that file’s comments, pinned by `website/src/lib/tut-detector.test.ts`. +- **`tut-detector.ts`** (`TutDetector`) — wires app events to `TutorialState.markComplete(id)` and **must never touch the tiling engine**. `start()` seeds its prev-state maps and subscribes to `subscribeToActivity` + `subscribeToWatchedCommands` (`dormouse-lib/lib/terminal-registry`), `subscribeToMouseSelection` (`dormouse-lib/lib/mouse-selection`), `subscribeToActiveTheme` (`dormouse-lib/lib/themes`); everything else arrives on the `WallEvent` stream (`handleWallEvent`). **A keyboard split is credited before the split's automatic passthrough transition** (rationale), and **the `kb-arrows` hint that follows must tell the user to re-enter command mode** — the split left them in passthrough. **`kb-arrows` is credited from `selectionChange`** — to a distinct pane, in command mode — so an arrow key *or* a click counts. **Must credit `al-spreads` only when newly enabled WATCHING shares a command key with another live pane.** Each transition guard is commented where it lives, pinned by `website/src/lib/tut-detector.test.ts`. - **`tutorial-state.ts`** (`TutorialState`) — in-memory progress store ([Storage](#storage)); profile totals come from the section list handed to the constructor. -- **`tut-items.ts`** — sections, items, and both profiles; shared by runner and detector. +- **`tut-items.ts`** — sections, items, and both profiles; read by the runner and by `TutorialState` (the detector reaches ids only through `ItemId`). ## Layout - Desktop `SiteHeader` at top, `themeAware` so `--vscode-*` variables drive its chrome, **carrying no controls**: **the page must restore its own theme** with `useRestoredTheme(POCKET_THEME_ID)` (`lib/src/lib/themes/use-restored-theme.ts`), which also declares the host fallback the Settings picker re-resolves through (rationale). `th-theme` walks the user to the Wall's Settings dialog (`docs/specs/theme.md` → "Where the user picks a theme"); Pocket renders the `compact` picker over the mobile terminal or in the desktop marketing header. - `
` is a flex container so Wall's `flex-1 min-h-0` root gets a real height. -- `/playground/desktop` runs `Wall` (`FakePtyAdapter`, `initialMode="passthrough"`). **Must seed its three-pane L-shape as an explicit Lath snapshot** — `restoredLathLayout` from `DESKTOP_PLAYGROUND_LAYOUT` (`website/src/lib/playground-desktop-layout.ts`) — never the synchronous `initialPaneIds` path (rationale). Seeds: **`tut-main`** (left ~50%, "tutorial", `TutRunner`); **`tut-boxed`** (right-top ~25%, "changelog", `ChangelogRunner`, and the Copy Rewrapped + `cp-override` target); **`tut-splash`** (right-bottom ~25%, "ascii-splash", `AsciiSplashRunner`). **Titles are seeded as pending shell opts** (`setPendingShellOpts(id, { title })`) before the Wall mounts; the lib pins each at first spawn, after the pane's state reset, and a user-pin outranks the engine fallback (`docs/specs/terminal-state.md` → "Header Derivation"). +- `/playground/desktop` runs `Wall` (`FakePtyAdapter`, `initialMode="passthrough"`). **Must seed its three-pane L-shape as an explicit Lath snapshot** — `restoredLathLayout` from `DESKTOP_PLAYGROUND_LAYOUT` — never the synchronous `initialPaneIds` path (rationale); `website/src/lib/playground-desktop-layout.test.ts` pins it. `DESKTOP_PANES` in the same file owns each seed's id, command, and title; **`tut-boxed` is the Copy Rewrapped + `cp-override` target** (rationale). **Titles are seeded as pending shell opts** (`setPendingShellOpts(id, { title })`) before the Wall mounts; the lib pins each at first spawn, after the pane's state reset, and a user-pin outranks the engine fallback (`docs/specs/terminal-state.md` → "Header Derivation"). Every visible pane gets a `TutorialShell` via `PlaygroundShellRegistry`. **`ensureShell` must stay idempotent** — `paneAdded` covers every pane that becomes visible, and `FakePtyAdapter.onPtySpawn` covers the seed panes again, auto-launching each seed's command exactly once (rationale). The page’s `startProgram` factory dispatches: `tut` → `TutRunner`, `ascii-splash`/`splash` → `AsciiSplashRunner`, `changelog` → `ChangelogRunner`. **Spawned terminals use `SCENARIO_SHELL_PROMPT`; seed panes get an empty scenario**, so no delayed `user@dormouse:~$` write lands inside a runner's alt-screen. @@ -41,7 +41,7 @@ Every visible pane gets a `TutorialShell` via `PlaygroundShellRegistry`. **`ensu ## Menu and navigation behavior -Esc / `q` pop back one screen (section → menu → exit); Ctrl+C exits the runner from any screen; re-running `tut` re-enters. **Must consume unsupported CSI/SS3 key sequences without treating their prefix as Esc**; arrows accept CSI and application-mode SS3. Pinned by `website/src/lib/tut-runner.test.ts`. The menu shows `[N/M complete]` per section; drilling in lists that section's items, each `✓` complete, `●` active, or `·` later. **`Reset progress` requires the user type `reset`**, then clears all three storage keys and returns to the profile's initial screen. +Esc pops back one screen (section → menu → exit), and so does `q` everywhere the screen is not consuming typed characters — on the reset screen `q` is confirm-buffer input. Ctrl+C exits the runner from any screen; re-running `tut` re-enters. **Must consume unsupported CSI/SS3 key sequences without treating their prefix as Esc**; arrows accept CSI and application-mode SS3. Pinned by `website/src/lib/tut-runner.test.ts`. The menu shows `[N/M complete]` per section; drilling in lists that section's items, each `✓` complete, `●` active, or `·` later. **`Reset progress` requires the user type `reset`**, then clears all three storage keys and returns to the profile's initial screen. Extras: `Starred on GitHub` (persisted separately, `onOpenGithub`), `🐭 FlappyTerm 🐭`, `Reset progress` — **none of the three ever counts toward `N/M`**. Flappy stays `[LOCKED N/M]` until every section checklist item is complete, then shows `[High score: N]` and unlocks a runner-local mini-game whose game-over screen cross-links the other surface (desktop `p` → `onOpenPocket`; Pocket `n` → `onNotifyPocket` → `/hosted/#remote-control`, wired by the pages). @@ -87,7 +87,7 @@ Hooks in `dormouse-lib` / `MobileTerminalUi` that exist for tutorial observabili - **`WallEvent.kill` / `move` / `paneAdded`** — discriminants on the `WallEvent` union. `kill` fires from `killPaneImmediately`, so every kill path (confirm dialog, tmux `x`, door kill, `dor kill`) credits `kb-kill`. **`move` must fire from both** the Cmd/Ctrl-Arrow swap in `lib/src/components/wall/keyboard/handle-pane-shortcuts.ts` **and** the center-drop swap in `Wall.onProposeMove` (rationale). **`paneAdded` fires once per pane that becomes visible** — seed ids, splits, dor surfaces, restores, auto-spawn — via Lath’s leaf-id diff, with seeds announced explicitly. - **`FakePtyAdapter.pumpActivity(id, durationMs, intervalMs)`** — drives the alert manager for a fixed duration with no data output (the `s` demo). Returns a cancel handle; stops on its own if the pty dies mid-duration. -- **`FakePtyAdapter.sendOutput(id, data, { skipActivity })`** — pushes data through the real protocol parser as if the PTY produced it — `alertManager.onData()` for visible bytes, the notification/semantic-event paths for OSCs (rationale). **Unlike `writePty` it is not suppressed while a scenario is playing.** `TutRunner` passes `skipActivity: true` for every frame, so redrawing the TUI never tilts its own pane's bell. +- **`FakePtyAdapter.sendOutput(id, data, { skipActivity })`** — pushes data through the real protocol parser as if the PTY produced it (rationale). **Unlike `writePty` it is not suppressed while a scenario is playing.** `TutRunner` passes `skipActivity: true` for every frame. - **`FakePtyAdapter.onPtySpawn`** — fires synchronously inside `spawnPty`, before the scenario plays, so a page attaches a shell without racing `TerminalPane`'s mount. - **`subscribeToWatchedCommands` / `getWatchedCommands`** (`lib/src/lib/watched-commands.ts`, re-exported from `terminal-registry`) — the WATCHING rule set, watched to credit `al-watch-cmd`. - **`MobileTerminalUi.onGestureInput(input, data)`** — optional, fired only for radial-menu actions, so Pocket credits gesture items without mistaking native keyboard input for a gesture. @@ -95,21 +95,11 @@ Hooks in `dormouse-lib` / `MobileTerminalUi` that exist for tutorial observabili ## Mouse and Clipboard Feature Coverage -Primary dogfood surface for `docs/specs/mouse-and-clipboard.md`; the three-pane layout covers: - -| Status | Spec coverage | -|---|---| -| ✅ Exercisable | §§1–2 (mouse reporting + override), §§3.1–3.3 (drag, block shape, block hint), §§3.6–3.7 (drag keys + popup), §§4.1–4.3 (raw/rewrapped copy, shortcuts, dismissal). | -| ⚠️ Partial | §3.4 exposes change/resize cancellation but not pure scroll; §3.5 lacks enough scrollback; §8.2 writes paste chords to the fake PTY, whose shell ignores bracket markers. | -| ❌ Missing | §§3.3 and 5 lack smart tokens and therefore `e` extension; §8.5 lacks a scenario that enables bracketed paste. | - -Auto-scroll during a drag and right-click paste are deferred in the implementation ([§9. Future](mouse-and-clipboard.md#9-future)), not Playground gaps. +Primary dogfood surface for `docs/specs/mouse-and-clipboard.md`. What the three-pane layout exercises, partly exercises, and cannot reach today is audited in the rationale; the two gaps worth closing are the `## Future` scenarios below. ## Files - Routes + pages — `website/src/routes.ts`, `website/src/pages/Playground.tsx`, `website/src/pages/PlaygroundDesktop.tsx`, `website/src/pages/PocketPlayground.tsx`, `website/src/pages/Pocket.tsx` -- Pocket composition + modal — `website/src/components/PocketTerminalExperience.tsx`, `website/src/components/PlaceToPaste.tsx` -- Tutorial engine — `website/src/lib/tut-items.ts`, `website/src/lib/tut-runner.ts`, `website/src/lib/tut-detector.ts`, `website/src/lib/tutorial-state.ts` - Playground plumbing — `website/src/lib/playground-routing.ts`, `website/src/lib/playground-desktop-layout.ts`, `website/src/lib/playground-shells.ts`, `website/src/lib/tutorial-shell.ts` - Fake programs — `website/src/lib/ascii-splash-runner.ts`, `website/src/lib/changelog-runner.ts` - Lib contracts this spec owns — `WallEvent` in `lib/src/components/wall/wall-types.ts`; `sendOutput` / `pumpActivity` / `onPtySpawn` in `lib/src/lib/platform/fake-adapter.ts` diff --git a/docs/specs/tutorial.rationale.md b/docs/specs/tutorial.rationale.md index b23a301ac..54350edbe 100644 --- a/docs/specs/tutorial.rationale.md +++ b/docs/specs/tutorial.rationale.md @@ -43,3 +43,5 @@ ## Mouse and Clipboard Feature Coverage **What supplies the mouse-capturing text.** Both neighbor panes, `ascii-splash` and `changelog`; why `changelog` is also the copy target: [Layout](#layout). + +**Coverage audit, against `mouse-and-clipboard.md`'s section numbers as of 2026-09.** Exercisable: §§1–2 (mouse reporting + override), §§3.1–3.3 (drag, block shape, block hint), §§3.6–3.7 (drag keys + popup), §§4.1–4.3 (raw/rewrapped copy, shortcuts, dismissal). Partial: §3.4 exposes change/resize cancellation but not pure scroll; §3.5 lacks enough scrollback; §8.2 writes paste chords to the fake PTY, whose shell ignores bracket markers. Missing: §§3.3 and 5 lack smart tokens and therefore `e` extension; §8.5 lacks a scenario that enables bracketed paste. Auto-scroll during a drag and right-click paste are deferred in the implementation ([§9. Future](mouse-and-clipboard.md#9-future)), not Playground gaps. diff --git a/docs/specs/webgl-text.md b/docs/specs/webgl-text.md index b4dd45f4f..f68496ce3 100644 --- a/docs/specs/webgl-text.md +++ b/docs/specs/webgl-text.md @@ -19,10 +19,11 @@ `sdf` (the default) carries our changes; upstreamable fixes branch off `master` and cherry-pick into `sdf`. - **Versioning**: `@diffplug/xterm-addon-webgl-sdf`, versions shaped - `-sdf.` (`0.20.0-sdf301.1` ⇒ - `@xterm/xterm@6.1.0-beta.301`, iteration 1). **Consumers must pin the exact - core beta named by the tarball's peer dependency**, not the `-sdfNNN` - counter — the addon bundles core internals (rationale). + `-sdf.` (`0.20.0-sdf304.0` ⇒ + `@xterm/xterm@6.1.0-beta.304`, iteration 0). **Consumers must pin the exact + core beta named by the tarball's peer dependency** — the addon bundles core + internals (rationale); the `-sdfNNN` counter is a convenience the lint holds + to that same pin. - **Distribution**: a pnpm tarball-URL dependency on GitHub Release assets, never an npm registry (rationale). **Never replace a published asset**; the lockfile records a sha512 integrity hash, so cut a new iteration. @@ -35,11 +36,10 @@ behavior and standalone drift repair. - **Every pin must be exact, and every addon's core peer must equal its workspace's core pin** — the first-party `@xterm/*` packages share a repo but carry - independent beta counters (rationale). `scripts/xterm-lint.mjs` also requires - `lib` ≡ `standalone` and checks the canopy tarball's tag, filename, counter - and peer as one set; `scripts/xterm-bump.mjs` (`pnpm bump:xterm`) writes the - newest coherent per-commit set for both `lib` and `standalone`, even when only - one has drifted. + independent beta counters (rationale). `scripts/xterm-lint.mjs` owns the full + check list in its header comment; `scripts/xterm-bump.mjs` (`pnpm bump:xterm`) + writes the newest coherent per-commit set for both `lib` and `standalone`, + even when only one has drifted. - **Releases are hand-cut today** per FORK.md; automating this is staged in `## Future`. - **Dev loop**: `pnpm link ~/projects/xterm.js/addons/addon-webgl` from diff --git a/docs/specs/website-docs.md b/docs/specs/website-docs.md index 6118972ab..d272d9e89 100644 --- a/docs/specs/website-docs.md +++ b/docs/specs/website-docs.md @@ -46,13 +46,9 @@ GitHub. The guide is not served by this site. It was rendered at `/docs`; that page and every link to it were removed, and the guide is now read where it is published. -The generator still parses it on every build, for two reasons that outlive the -page: the pass validates the guide's media against the Marketplace rules and -copies `vscode-ext/images/` to `public/guide/images/`, which the -packaged listing resolves its images against, and the parsed guide is what a -replacement page would render. Its data file is generated and unconsumed. The -lint's guide checks still run, because they constrain the guide as a -*Marketplace listing*, not as a website page. +The generator still parses it on every build, and the lint's guide checks still +run, because both constrain the guide as a *Marketplace listing* rather than as +a website page (rationale). The guide is host-neutral at the top level; VS Code and standalone instructions live under explicit subsections rather than relying on the website to rewrite @@ -104,11 +100,8 @@ stays within Marketplace-compatible Markdown: `images/` and never `media/`: `vscode-ext/media/` is the webview bundle's Vite output directory, emptied on every extension build, so anything committed there is deleted by the next `pnpm build:vscode`. - Remote media is rejected outright. `github.com/user-attachments` URLs in - particular 302 to a signature-expiring S3 object (so `HEAD` 403s where `GET` - succeeds), cannot be cached downstream, leak every visitor's IP to a third - party, and disappear with the comment they were uploaded to — taking the - listing's images with them. + **Never** reference remote media, `github.com/user-attachments` URLs least of + all (rationale). Each renderer resolves those relative paths differently, and all four are verified: @@ -120,15 +113,10 @@ verified: | Marketplace / Open VSX | `vsce --baseImagesUrl https://dormouse.sh/guide` rewrites both Markdown images **and** raw `` attributes at package time | | `dormouse.sh` | The generator copies `vscode-ext/images/` to `public/guide/images/`, which is what `--baseImagesUrl` above resolves against | -Links back to this site take the same shape of treatment. The guide spells them -absolutely (`https://dormouse.sh/docs/dor`) because the Marketplace, Open VSX, -and GitHub all render it away from this origin, where a root-relative path -resolves against the wrong host or not at all. On the site those same URLs must -be root-relative: an absolute one leaves the origin on every click, so a link -followed from a dev server or a preview build lands on production instead of -the page next to it. The generator therefore strips its own origin and keeps -path, query, and fragment, so `/docs/dor#agent-browser` survives as a deep -link. Only exact-origin matches are rewritten; every other host is untouched. +**The guide spells site links absolutely** (`https://dormouse.sh/docs/dor`), +because every channel that publishes it renders it away from this origin; +`localizeSiteLinks` turns them back into served paths on the site +([rendering contract](#markdown-rendering-contract) op 5). Reserved: because the generator guarantees it, same-site hrefs reach `MarkdownDocument` root-relative, and the renderer's external-link test is a @@ -196,8 +184,11 @@ delta is structural: 4. Resolve links into the repository: to the page that publishes the file where one exists (`SITE_ROUTES`), otherwise to the canonical file on GitHub, keeping the fragment. -5. Rewrite links pointing back at this site to root-relative paths, dropping - only the origin. +5. Rewrite links pointing back at this site to the **served** root-relative + path: origin dropped, `sitePath`'s trailing slash added unless the path + already carries one or an extension, query and fragment verbatim — so + `https://dormouse.sh/docs/dor#agent-browser` becomes + `/docs/dor/#agent-browser`. Only exact-origin matches are rewritten. 6. Render the subset using the marketing website's typography, spacing, links, code blocks, tables, and responsive raster-media treatment. 7. Add the shared site header and footer. @@ -285,7 +276,7 @@ sections. 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. `/docs/dor` nests its subcommands under one `Commands` heading rather than -listing fourteen entries beside four elsewhere. A reader on a screen reader +listing every command beside the handful of entries elsewhere. A reader on a screen reader navigates the outline rather than the rail, so **must** keep the two agreeing: the commands render a level below that heading, and their own labels a level below them again (`website/src/pages/DorDocs.test.tsx`). @@ -306,8 +297,6 @@ class redefines the site's own `--color-*` tokens from the applied `--vscode-*`, and only `DocsLayout` adds it, so the homepage keeps its black. The changelog and the supply chain joined that rule when they joined the rail, which is why their links moved off caramel. -`applyTheme` writes to `body.style`, which `html` cannot read, so `html` gives -up the canvas and lets body's background propagate. **Prose links take the picked theme's `accent`, contrast-corrected — never brand caramel, never `--vscode-textLink-foreground`** (rationale). Caramel @@ -516,18 +505,14 @@ The **Browsers for you (and your agents)** section in a browser Surface preview, and links to `/docs/dor#agent-browser` and `/docs/agent-skill`. -The transcript is **authored literals in `Home.tsx`, not generated or tested.** -Proving it end to end would need a live Burrow and a real `agent-browser` in CI, -and a captured dev-server port is not stable enough to commit — a busy 5173 -silently becomes 5174. The accepted cost is that the transcript can drift from -real output with no test to catch it. - -Two mitigations bound that drift. Command *syntax* matches -`dor/test/snapshots/help/`, which is tested against the real CLI, so only the -output lines are unverified. And output uses notation the CLI itself documents — -`created surface:N ""` from `dor ensure`'s text output, and the -resolution arrow from `dor ab`'s own examples — rather than invented -formatting. A source comment marks the block as authored and untested. +The transcript is **authored literals in `Home.tsx`, not generated or tested**, +so it can drift from real output with nothing to catch it (rationale). Two +mitigations bound that drift: command *syntax* matches +`dor/test/snapshots/help/`, which is tested against the real CLI, and output +uses notation the CLI itself documents — `created surface:N ""` from +`dor ensure`'s text output, the resolution arrow from `dor ab`'s own examples — +rather than invented formatting. **Must mark the block authored and untested in +a source comment.** Desktop and mobile presentations keep the terminal and browser relationship legible, selectable, and accessible without requiring animation. @@ -552,45 +537,26 @@ as shipped behavior. `tsc --noEmit` before Vitest, including the playground adapters and generated-doc consumers. -`scripts/public-docs-lint.mjs`, invoked by root `pnpm test` after the spec lint, -verifies: +`scripts/public-docs-lint.mjs`, invoked by root `pnpm test` after the spec +lint, checks the rules above mechanically; each rule names its own check, and +the lint's header comment is the inventory. The rules with no other home: -- the canonical guide carries every section listed in the `text` fence above, - read out of this spec rather than restated in the lint; -- neither public README nor `SELF_HOST.md` nor `docs/specs/security.md` - contains `TODO:` placeholders; -- every canonical Markdown source stays inside the parser's supported subset; -- public links use canonical HTTPS URLs, and a local link resolves — read off - the parsed tree, so a link-shaped string in a code span is not a link. +- **No public source carries a `TODO:` placeholder** — the two READMEs, + `SELF_HOST.md`, and `docs/specs/security.md`. +- **Public links use canonical HTTPS URLs, and a local link resolves** — read + off the parsed tree, so a link-shaped string in a code span is not a link. `SELF_HOST.md` and the security spec get only the HTTPS half; spec-lint - already resolves their relative links and validates their fragments; -- the security spec carries no `## Future` heading and no `Reserved:` - paragraph, because it is published whole (`checkSecurityFold`); -- guide images are repo-relative files that exist under `vscode-ext/images/`, - with no remote URLs and no SVG, and every file there is referenced; -- VS Code commands named by the guide exist in `vscode-ext/package.json`, and - the listing metadata fields are present; -- guide heading ids are stable and unique; -- every agent-skill reference target exists in `/docs/dor`; -- generated command inventory matches the snapshot set exactly; -- both READMEs link to every reference page in `docs-pages.ts`, except that - the root README alone owns `/docs/self-host` and `/docs/security` — checked - as exact URLs, so the `/docs` entrypoint cannot stand in for a page under a - prefix test. The guide carries neither obligation: it is a Marketplace - listing for the editor extension, and neither running a Relay nor - auditing the repository is part of installing one; -- the homepage links every `/docs` page root-relatively, and every `/docs` href - on it resolves to one — both directions, because a rewritten section can - strand a page's only link or leave one aimed at the entrypoint; -- every `vsce` or `ovsx` invocation that packages the extension from source - passes the site image base; -- no per-page head tag is hardcoded in the root route, every route that - exports `meta` builds it with `siteMeta`, and the two spellings of the site - origin agree; -- `/docs` redirects to a page the rail actually lists, with the status the - entrypoint's own constant expects; -- public copy does not present staged WebRTC as shipped, for as long as WebRTC - is still under `## Future` in [remote-api.md](remote-api.md). + already resolves their relative links and validates their fragments. +- **Every page whose `linkedFrom` names a README is linked from it**, as an + exact URL, so the `/docs` entrypoint cannot stand in for a page under a + prefix test. The guide owes no link to `/docs/self-host` or `/docs/security`: + it is a Marketplace listing for the editor extension, and neither running a + Relay nor auditing the repository is part of installing one. +- **The homepage links every `/docs` page root-relatively, and every `/docs` + href on it resolves to one** — both directions, because a rewritten section + can strand a page's only link or leave one aimed at the entrypoint. +- **Public copy does not present staged WebRTC as shipped**, for as long as + WebRTC is still under `## Future` in [remote-api.md](remote-api.md). Each check is isolated, so one malformed source reports its own failure instead of aborting the run and hiding every other problem behind a stack trace. @@ -613,17 +579,16 @@ spec. | `dor/test/snapshots/help/` | Tested CLI help, the source for `/docs/dor` | | `website/src/lib/site-meta.ts` | Every page's title, description, canonical, and social cards | | `website/src/lib/docs-pages.ts` | The rail's pages and their order; routes, prerender, rail, and lint all read it | -| `website/src/lib/docs-rail.test.tsx` | Every entry anchors on an id its page renders | | `website/src/pages/Changelog.tsx`, `website/src/pages/SupplyChain.tsx` | Rail pages deriving their own sections | | `website/public/_redirects` | The `/docs` entrypoint and the changelog SPA fallback | | `website/src/routes.ts`, `website/src/components/SiteHeader.tsx` | The published routes and the marketing nav, which carries `Docs` on desktop | -| `website/scripts/docs-parser.js` (+ `.test.js`) | Markdown subset parser, slugger, `` allowlist | -| `website/scripts/help-parser.js` (+ `.test.js`) | Narrow CLI-help parser with losslessness | +| `website/scripts/docs-parser.js` | Markdown subset parser, slugger, `` allowlist | +| `website/scripts/help-parser.js` | Narrow CLI-help parser with losslessness | | `website/scripts/generate-docs.js` | Codegen: the delta tables, `buildDocument`, `localizeSiteLinks`, `resolveRemovedAnchors`, `resolveRepoLinks` and `SITE_ROUTES`, `assertRouteFragments`, `securityAudiences` and `audienceBlocks`, `linkSkillHeadings` | | `website/src/components/MarkdownDocument.tsx` | Renders parsed Markdown blocks | | `website/src/components/DocsLayout.tsx` | Docs chrome: header, the rail and its mobile drawer, prev/next, theme restore | | `website/src/components/DocsThemeControl.tsx` | The picker's two placements and its first-visit prompt | -| `website/src/lib/docs-accent.ts` (+ `.test.ts`) | The themed text colors, contrast-corrected per rendered surface | +| `website/src/lib/docs-accent.ts` | The themed text colors, contrast-corrected per rendered surface | | `website/src/lib/docs-theme.ts` | Default docs theme, and whether the reader has chosen | | `website/src/components/DorCommandReference.tsx` | One CLI command section | | `website/src/pages/DorDocs.tsx` | `/docs/dor` | @@ -638,10 +603,11 @@ spec. Remaining work, in staged order: -1. **VSIX packaging verification.** Package the extension and inspect its - README and media inventory as part of release, so a listing cannot ship with - a broken image or an unretained local asset. `vscode-ext/.vscodeignore` - already retains `README.md`, `icon.png`, and `images/`. +1. **VSIX packaging verification.** Inspect the packaged README and media + inventory as part of release, so a listing cannot ship with a broken image + or an unretained local asset. Packaging already passes `--baseImagesUrl`, + and `vscode-ext/.vscodeignore` already retains `README.md`, `icon.png`, and + `images/`; only the inspection is missing. 2. **Live listing verification.** After publication, inspect the rendered Marketplace and Open VSX pages, and preview the root README under GitHub Markdown. If packaged or live README inspection becomes a release step, @@ -664,7 +630,3 @@ Reviving it needs a page component and an entry in `docs-pages.ts` — not new pipeline work. Whoever does it should first answer the question that removed the page: what this rendering gives a reader that the Marketplace and GitHub renderings do not. - -The lint checks reference URLs exactly rather than by prefix, so a link to a -`/docs/...` page that does not exist is caught rather than satisfied by the -entrypoint redirect. diff --git a/docs/specs/website-docs.rationale.md b/docs/specs/website-docs.rationale.md index e6a1be8dd..d454e23ff 100644 --- a/docs/specs/website-docs.rationale.md +++ b/docs/specs/website-docs.rationale.md @@ -13,6 +13,11 @@ nothing in CI or a local build notices — only a human opening the live listing does. An invocation with `--packagePath` republishes an already-built VSIX, whose URLs were rewritten when it was packaged. +**Why the generator still parses a page nobody serves.** The pass validates the +guide's media against the Marketplace rules and copies `vscode-ext/images/` to +`public/guide/images/`, which is what the packaged listing resolves its images +against; the parsed guide is also what a replacement page would render. + Generated guide media gets `public/guide/` to itself; `syncGuideMedia` replaces its `images/` directory on every build. It previously wrote to `public/images/`, which is the natural home for hand-authored site assets, and @@ -75,10 +80,19 @@ The signup action originally reused the corrected link color on 10% and 20% tints of itself. Those composites pulled every bundled theme below AA, so its foreground is corrected against the stronger hover tint and checked on both. -Anchor offsets are tight by measurement, not by estimate. The mobile bar is -45px and the site header 64px, 80px from `md` up with no `lg` step, so the two -steps that clear both have 3px in hand and `lg` clears the header alone by -16px. +## Marketplace and Open VSX constraints + +`github.com/user-attachments` URLs are the tempting remote media, and the worst +kind: they 302 to a signature-expiring S3 object (so `HEAD` 403s where `GET` +succeeds), cannot be cached downstream, leak every visitor's IP to a third +party, and disappear with the comment they were uploaded to — taking the +listing's images with them. + +## Homepage browser proof + +Proving the transcript end to end would need a live Burrow and a real +`agent-browser` in CI, and a captured dev-server port is not stable enough to +commit — a busy 5173 silently becomes 5174. ## Markdown rendering contract diff --git a/lib/src/lib/themes/dynamic-palette.ts b/lib/src/lib/themes/dynamic-palette.ts index 5b14b77e9..61ef73c84 100644 --- a/lib/src/lib/themes/dynamic-palette.ts +++ b/lib/src/lib/themes/dynamic-palette.ts @@ -56,6 +56,9 @@ export interface DoorChoice { export function pickDoorPair(panelLab: Lab, terminalLab: Lab, appLab: Lab): DoorChoice { const panelDist = deltaEOklab(panelLab, appLab); const termDist = deltaEOklab(terminalLab, appLab); + // `>=`, not `>`: an exact tie takes the panel, because doors are chrome and + // should anchor to the chrome palette when neither pair reads as further + // from the page. Pinned by dynamic-palette.picks.test.ts. return panelDist >= termDist ? { bg: '--color-header-inactive-bg', fg: '--color-header-inactive-fg' } : { bg: '--color-terminal-bg', fg: '--color-terminal-fg' }; diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 5708c5379..96ba3b5b9 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -4,7 +4,7 @@ "SELF_HOST.md": 6200, "docs/specs/alert.md": 7250, "docs/specs/auto-update.md": 1200, - "docs/specs/deploy.md": 1900, + "docs/specs/deploy.md": 1850, "docs/specs/dor-browser.md": 4700, "docs/specs/dor-cli.md": 6250, "docs/specs/dor-tool.md": 4050, @@ -30,11 +30,11 @@ "docs/specs/terminal-context.md": 1050, "docs/specs/terminal-escapes.md": 3850, "docs/specs/terminal-state.md": 2400, - "docs/specs/theme.md": 2200, + "docs/specs/theme.md": 2150, "docs/specs/tiling-engine.md": 4500, "docs/specs/transport.md": 6250, - "docs/specs/tutorial.md": 1900, + "docs/specs/tutorial.md": 1850, "docs/specs/vscode.md": 7550, "docs/specs/webgl-text.md": 1200, - "docs/specs/website-docs.md": 5050 + "docs/specs/website-docs.md": 4650 } diff --git a/website/scripts/generate-docs.js b/website/scripts/generate-docs.js index 08f3aaf46..4dafa39f8 100644 --- a/website/scripts/generate-docs.js +++ b/website/scripts/generate-docs.js @@ -730,8 +730,8 @@ async function buildCli(skill) { // Emitted, not assembled in the page: every docs page reads `toc` off its own // data file, so the table of contents has one owner for all three. The - // commands nest one level down, so the rail shows this page as four entries - // rather than fourteen. + // commands nest one level down, so the rail shows this page as a handful of + // top-level entries rather than one per command. const entry = ({ id, title }) => ({ id, text: title, children: [] }); const toc = [ ...intro.map(entry), From b0200bcdfc587aa92efd4b1383f040564053fd9c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 01:09:11 -0700 Subject: [PATCH 2/5] Fix the tutorial's cross-profile credit, and cut four duplicated tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs: - `TutorialState.markComplete` now rejects an id outside its own profile's sections. Both profiles share `dormouse-tut-v3`, and Pocket's Select mode credits `cp-override` on every mouse-capturing session, so a Pocket-first visitor opened the desktop tutorial with that item already checked and Flappy one item nearer unlock. - `bump-version.sh` accepted `X.Y.Z-prerelease`, which `sign-and-deploy.sh` rejects — after the bump, commit, tag, push and a full CI build. - The reset-confirm screen promised to clear the checkmarks and the star prompt; it also clears the FlappyTerm high score, and now says so. - `xterm-lint` check 4 now reads the UpstreamVsFork baseline triple out of `GlTerminal.stories.tsx` and `canopy/README.md` and holds it against canopy's pins, with self-test cases in `xterm-bump.test.mjs`. It was a hand-copy with nothing but a printed reminder behind it. Simplifications, each removing a second owner of something: - `getTerminalTheme()`'s 20 hex fallbacks duplicated `REGISTRY_DEFAULTS` and had already drifted from it. An unset key is now omitted; the background/foreground pair stays as a last resort for `pnpm dev:lib` and is rostered in DESIGN.md, and the cursor derives from the foreground the way the registry does. - `--mt-font-size` / `--mt-font-family` aliased two `--vscode-*` vars for one consumer, which now reads them directly. - The generated docs JSON shipped four fields no page reads, and a `docs.guide.json` no page imports. Both stop at the write; the in-memory result the tests and the lint read is unchanged. - The generator rewrote site links in `dor/skill.md`, where the spec says there are none — it now asserts that instead of silently repairing it. - `TutRunner`'s `s` guard now spans the whole fake command, so the page's own cancel of a live pump and exit timer is gone. - Dead `tut-items` exports, `standalone/package.json`'s unread `version`, a `POCKET_THEME_ID` re-export used as the desktop playground's fallback, and a redundant `invalidate_updates`. Co-Authored-By: Claude Fable 5.1 --- DESIGN.md | 1 + canopy/src/GlTerminal.stories.tsx | 5 +- docs/specs/deploy.md | 2 +- docs/specs/theme.md | 11 ++- docs/specs/tutorial.md | 8 +- docs/specs/tutorial.rationale.md | 2 +- docs/specs/webgl-text.md | 23 +++--- docs/specs/website-docs.md | 38 +++++---- lib/src/index.css | 4 +- lib/src/lib/terminal-theme.ts | 77 +++++++++++++------ lib/src/theme.css | 8 -- scripts/bump-version.sh | 6 +- scripts/sign-and-deploy.sh | 5 +- scripts/spec-word-budgets.json | 6 +- scripts/xterm-bump.mjs | 9 +-- scripts/xterm-bump.test.mjs | 72 +++++++++++++++++ scripts/xterm-lint.mjs | 60 ++++++++++++++- standalone/package.json | 1 - website/scripts/generate-docs.js | 56 ++++++++++---- .../components/PocketTerminalExperience.tsx | 8 +- website/src/lib/tut-detector.test.ts | 3 +- website/src/lib/tut-items.ts | 11 --- website/src/lib/tut-runner.test.ts | 21 ++++- website/src/lib/tut-runner.ts | 19 ++++- website/src/lib/tutorial-state.test.ts | 25 ++++-- website/src/lib/tutorial-state.ts | 16 +++- website/src/lib/website-theme.ts | 13 ++++ website/src/pages/Playground.test.tsx | 2 +- website/src/pages/PlaygroundDesktop.tsx | 15 ++-- website/src/pages/PocketPlayground.tsx | 10 +-- 30 files changed, 393 insertions(+), 144 deletions(-) create mode 100644 website/src/lib/website-theme.ts diff --git a/DESIGN.md b/DESIGN.md index 108a33f65..4e4b81c38 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -143,6 +143,7 @@ This system has no "primary" accent in the brand sense. The closest analogue is Every literal color the Host-Theme-Only Rule below permits, in full. Each is here because the surface it paints is not read as part of the theme; a literal anywhere else is a bug. - **Window Close Hover** (`#b92a1b`): native OS close-button hover on Windows/Linux chrome buttons; matches the platform convention across themes. - **Setup QR** (`#ffffff` ground, `#000000` modules, in `lib/src/components/QrCode.tsx`): a phone camera reads this control, not a person. Scanners expect dark-on-light and many refuse an inverted code, and no theme token promises either the polarity or the contrast ratio in both light and dark. +- **Terminal last resort** (`#1e1e1e` background, `#cccccc` foreground, in `lib/src/lib/terminal-theme.ts`): xterm.js needs two readable colors to render at all, and `pnpm dev:lib` runs with no resolver and no applied theme (`docs/specs/theme.md` front matter). The rest of the terminal palette carries no literal — an unset key is omitted and xterm uses its own default, except the cursor, which derives from the resolved foreground. ### Named Rules **The Host-Theme-Only Rule.** Never write a hex value or `oklch()` literal into `theme-colors.css`, `theme.css`, or a component. Never use `var(..., fallback)` chains. Every color must resolve through `--vscode-*` or one of the body-published runtime picks (`--color-door-*`, `--color-focus-ring`, `--color-alarm-vs-*`). The only exceptions are the ones rostered under Fixed Exceptions above, and adding one means adding it there. diff --git a/canopy/src/GlTerminal.stories.tsx b/canopy/src/GlTerminal.stories.tsx index c3348eb04..d4a6bb5f0 100644 --- a/canopy/src/GlTerminal.stories.tsx +++ b/canopy/src/GlTerminal.stories.tsx @@ -3,8 +3,9 @@ import { useEffect, useRef, useState } from 'react'; import { Terminal } from '@xterm/xterm'; import { WebglAddon } from '@diffplug/xterm-addon-webgl-sdf'; // The pristine upstream addon, pinned to the exact commit the fork's sdf branch is based on -// (addon 0.20.0-beta.300 and core 6.1.0-beta.304 share gitHead c58ea363) — the regression -// baseline for the UpstreamVsFork story. canopy/README.md records the same triple. +// (addon 0.20.0-beta.300 == core 6.1.0-beta.304 == commit c58ea363) — the regression +// baseline for the UpstreamVsFork story. canopy/README.md records the same triple, and +// scripts/xterm-lint.mjs check 4 holds both against canopy/package.json's pins. import { WebglAddon as UpstreamWebglAddon } from '@xterm/addon-webgl'; // Read the two versions rather than restating them, so the on-screen labels cannot drift from // the pins the way a hand-typed version does. diff --git a/docs/specs/deploy.md b/docs/specs/deploy.md index 5d8036e9d..a3033c7bc 100644 --- a/docs/specs/deploy.md +++ b/docs/specs/deploy.md @@ -36,7 +36,7 @@ Human-driven, in order: ## Versioning -**Must synchronize the four version files — `lib/package.json`, `vscode-ext/package.json`, `standalone/src-tauri/Cargo.toml`, `standalone/src-tauri/tauri.conf.json` — and Cargo.lock's `dormouse` entry with `scripts/bump-version.sh`** (`cargo check --offline`). +**Must synchronize the four version files — `lib/package.json`, `vscode-ext/package.json`, `standalone/src-tauri/Cargo.toml`, `standalone/src-tauri/tauri.conf.json` — and Cargo.lock's `dormouse` entry with `scripts/bump-version.sh`** (`cargo check --offline`). **A version is `X.Y.Z`**: both the bump script and `sign-and-deploy.sh` reject a prerelease suffix, rather than one of them discovering it after the tag is pushed. **A release is triggered by pushing one tag (`v0.1.0`)** — never separate `vscode-ext/v*` and `standalone/v*` tags, because one changelog entry covers both. diff --git a/docs/specs/theme.md b/docs/specs/theme.md index 0bfa5f294..8f0063788 100644 --- a/docs/specs/theme.md +++ b/docs/specs/theme.md @@ -26,8 +26,8 @@ foreground/background pairs and nothing else (rationale): **Hierarchy is the background swap between pairs**; secondary text is alpha on the same pair's own foreground (`text-app-fg/70`), never a separate token. -`DESIGN.md`'s Do/Don't lists own the rest — bg-only chrome, no pass-through -`--mt-*` layer or one-off tokens, header hover — with the `text-alarm-vs-*` +`DESIGN.md`'s Do/Don't lists own the rest — bg-only chrome, **no pass-through +`--mt-*` layer** or one-off tokens, header hover — with the `text-alarm-vs-*` ringing tint and destructive-action error styling as the semantic exceptions. **Never carry resting structure** with `surface-raised`, `border` (panel.border), @@ -140,6 +140,13 @@ inherits `terminal.foreground`, `terminal.selectionBackground` inherits `editor.selectionBackground`, and `terminal.foreground` takes VSCode's terminal foreground registry default. +**`getTerminalTheme()` carries no per-key default** — `REGISTRY_DEFAULTS` is the +one such table, and every shipping host materializes these keys first; an unset +key is omitted so xterm.js applies its own. Two exceptions: the +background/foreground pair, rostered under `DESIGN.md` → "Fixed Exceptions", and +`cursor`, which falls back to the resolved foreground because **the three colors +pushed to a DOM-less host must all be present** or the push is dropped whole. + A `MutationObserver` re-reads these on class or style mutations of `body` or `html`, so applying a theme updates existing terminals. **Adapters must use the `terminal-theme.ts` API directly** — it is not re-exported through the diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index c8db46180..f7a3c1387 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -31,7 +31,7 @@ Browser-side xterm alt-screen behind `FakePtyAdapter`, **never Node `terminal-ki ## Layout -- Desktop `SiteHeader` at top, `themeAware` so `--vscode-*` variables drive its chrome, **carrying no controls**: **the page must restore its own theme** with `useRestoredTheme(POCKET_THEME_ID)` (`lib/src/lib/themes/use-restored-theme.ts`), which also declares the host fallback the Settings picker re-resolves through (rationale). `th-theme` walks the user to the Wall's Settings dialog (`docs/specs/theme.md` → "Where the user picks a theme"); Pocket renders the `compact` picker over the mobile terminal or in the desktop marketing header. +- Desktop `SiteHeader` at top, `themeAware` so `--vscode-*` variables drive its chrome, **carrying no controls**: **the page must restore its own theme** with `useRestoredTheme(WEBSITE_DEFAULT_THEME_ID)` (`website/src/lib/website-theme.ts`), which also declares the host fallback the Settings picker re-resolves through (rationale). `th-theme` walks the user to the Wall's Settings dialog (`docs/specs/theme.md` → "Where the user picks a theme"); Pocket renders the `compact` picker over the mobile terminal or in the desktop marketing header. - `
` is a flex container so Wall's `flex-1 min-h-0` root gets a real height. - `/playground/desktop` runs `Wall` (`FakePtyAdapter`, `initialMode="passthrough"`). **Must seed its three-pane L-shape as an explicit Lath snapshot** — `restoredLathLayout` from `DESKTOP_PLAYGROUND_LAYOUT` — never the synchronous `initialPaneIds` path (rationale); `website/src/lib/playground-desktop-layout.test.ts` pins it. `DESKTOP_PANES` in the same file owns each seed's id, command, and title; **`tut-boxed` is the Copy Rewrapped + `cp-override` target** (rationale). **Titles are seeded as pending shell opts** (`setPendingShellOpts(id, { title })`) before the Wall mounts; the lib pins each at first spawn, after the pane's state reset, and a user-pin outranks the engine fallback (`docs/specs/terminal-state.md` → "Header Derivation"). @@ -47,9 +47,9 @@ Extras: `Starred on GitHub` (persisted separately, `onOpenGithub`), `🐭 Flappy ### Runner-local intercepts -**`TutRunner` intercepts four keys while a specific section is open; they are not real Dormouse shortcuts.** The three alert demos report fake commands as `OSC 633 ; E / C / D` through `FakePtyAdapter.sendOutput`, which the real `TerminalProtocolParser` strips from visible output (rationale). **Must snapshot the live inactivity timeout at demo launch; the run outlasts it and the BUSY-confirm floor.** Countdown and page timers share that duration, pinned by `website/src/lib/tut-runner.test.ts`. +**`TutRunner` intercepts four keys while a specific section is open; they are not real Dormouse shortcuts.** The three alert demos report fake commands as `OSC 633 ; E / C / D` through `FakePtyAdapter.sendOutput`, which the real `TerminalProtocolParser` strips from visible output (rationale). **Must snapshot the live inactivity timeout at demo launch; the run outlasts it and the BUSY-confirm floor.** Each demo's countdown, page timer, and re-press guard run the same snapshotted duration — longer for `s`, whose fake command must outlive WATCHING's silence chain. Pinned by `website/src/lib/tut-runner.test.ts`. -- **`s`** (Alerts) — reports `longtask` on both alert panes so command-keyed WATCHING demonstrates `al-spreads`, pumping only the quiet `tut-boxed` (rationale), keeping the command alive through WATCHING’s silence chain. **A replay cancels the prior delayed exit**, so presses during the countdown cannot stack pumps; afterwards `TutorialShell.reportRunningCommand()` restores each pane's real command. +- **`s`** (Alerts) — reports `longtask` on both alert panes so command-keyed WATCHING demonstrates `al-spreads`, pumping only the quiet `tut-boxed` (rationale), keeping the command alive through WATCHING’s silence chain. **A press while that command is still running is ignored**, so pumps cannot stack and the page needs no cancel of its own; on exit `TutorialShell.reportRunningCommand()` restores each pane's real command. - **`n`** (Alerts) — writes a raw `OSC 777` notification to `tut-boxed`, exercising the terminal-report track, which needs no WATCHING rule. - **`x`** (Alerts) — starts a fake `slowbuild` on `tut-splash` and reports its exit after the captured duration. **The command name must stay unwatched**, so the command-exit track rather than WATCHING owns the bell (rationale). - **`p`** (Copy paste) — toggles the **Place To Paste** scratch modal (`website/src/components/PlaceToPaste.tsx`) via `onTogglePlaceToPaste`. Desktop only — Pocket omits the callback, and the runner hides the prompt line without it. @@ -71,7 +71,7 @@ Pocket reuses `cp-select` / `cp-raw` / `cp-rewrap` but drops `cp-override`: Sele ## Storage -`TutorialState` persists to `localStorage`. **Unknown ids in a stored payload are filtered on load**, so renaming an id is a one-way reset. **Both profiles share the completion key**; totals count only the active profile's items, so an id completed under one is kept but uncounted under the other. +`TutorialState` persists to `localStorage`. **Unknown ids in a stored payload are filtered on load**, so renaming an id is a one-way reset. **Both profiles share the completion key**, so **`markComplete` must reject an id outside the profile's own sections** — a Pocket detection that names a desktop-only item would otherwise arrive pre-checked. An id completed under one profile loads under the other, kept but uncounted. **Must keep progress and reset working without storage**, pinned by `website/src/lib/tutorial-state.test.ts`. diff --git a/docs/specs/tutorial.rationale.md b/docs/specs/tutorial.rationale.md index 54350edbe..2f2b354fe 100644 --- a/docs/specs/tutorial.rationale.md +++ b/docs/specs/tutorial.rationale.md @@ -12,7 +12,7 @@ ## Layout -**Why the page restores its own theme.** Theme selection moved out of `SiteHeader` into the Wall's Settings dialog, so nothing on the page guarantees a picker ever mounts. `useRestoredTheme(POCKET_THEME_ID)` makes the restore unconditional, and declares the host fallback the Settings picker later re-resolves through. +**Why the page restores its own theme.** Theme selection moved out of `SiteHeader` into the Wall's Settings dialog, so nothing on the page guarantees a picker ever mounts. `useRestoredTheme(WEBSITE_DEFAULT_THEME_ID)` makes the restore unconditional, and declares the host fallback the Settings picker later re-resolves through. **Why the desktop layout is an explicit Lath seed.** The synchronous `initialPaneIds` path creates its leaves before the later ones have measured geometry, so it cannot reliably choose alternating split axes — the L-shape comes out however the measurements land. A valid Lath snapshot fixes the shape, and with it the one vertical and one horizontal divider. diff --git a/docs/specs/webgl-text.md b/docs/specs/webgl-text.md index f68496ce3..f7422a463 100644 --- a/docs/specs/webgl-text.md +++ b/docs/specs/webgl-text.md @@ -22,8 +22,7 @@ `-sdf.` (`0.20.0-sdf304.0` ⇒ `@xterm/xterm@6.1.0-beta.304`, iteration 0). **Consumers must pin the exact core beta named by the tarball's peer dependency** — the addon bundles core - internals (rationale); the `-sdfNNN` counter is a convenience the lint holds - to that same pin. + internals (rationale); `xterm-lint.mjs` holds the `-sdfNNN` counter to it. - **Distribution**: a pnpm tarball-URL dependency on GitHub Release assets, never an npm registry (rationale). **Never replace a published asset**; the lockfile records a sha512 integrity hash, so cut a new iteration. @@ -32,16 +31,13 @@ `node scripts/xterm-bump.mjs --canopy `. - **Must derive canopy's core from the released tarball's peer**, verifying its package name, version and counter before writing pins; select the upstream - addon by matching commit and peer. `scripts/xterm-bump.test.mjs` pins this - behavior and standalone drift repair. + addon by matching commit and peer. `scripts/xterm-bump.test.mjs` pins that, + standalone drift repair, and the lint's canopy checks. - **Every pin must be exact, and every addon's core peer must equal its - workspace's core pin** — the first-party `@xterm/*` packages share a repo but carry + workspace's core pin** — the `@xterm/*` packages share a repo but carry independent beta counters (rationale). `scripts/xterm-lint.mjs` owns the full check list in its header comment; `scripts/xterm-bump.mjs` (`pnpm bump:xterm`) - writes the newest coherent per-commit set for both `lib` and `standalone`, - even when only one has drifted. -- **Releases are hand-cut today** per FORK.md; automating this is staged in - `## Future`. + writes the newest coherent per-commit set for `lib` and `standalone` alike. - **Dev loop**: `pnpm link ~/projects/xterm.js/addons/addon-webgl` from `canopy/`. **Revert its root `package.json` / `pnpm-workspace.yaml` residue and reinstall before verifying a tarball** (rationale). @@ -65,7 +61,7 @@ PR: FORK.md's `Merging upstream` — **a conflict-free merge is not a correct one** (rationale). 3. **After rebasing, bump `canopy/package.json`** with `--canopy ` - and update its recorded triple ("Canopy lab"). + and update its recorded triple ("Canopy lab"), which the lint requires. **Must land any required fork rebase with the `@xterm/*` bump in one PR.** @@ -128,9 +124,10 @@ its `test` (a `tsc` typecheck) runs under `pnpm test`. Stories: | `UpstreamVsFork` | regression harness: identical content through pristine upstream `@xterm/addon-webgl`, fork `sdf: false`, fork `sdf: true`, stacked | **`UpstreamVsFork`'s upstream addon must come from the fork base commit**, its -addon/core/commit triple recorded in both `canopy/src/GlTerminal.stories.tsx` -and `canopy/README.md`. The harness owns its discriminating `chevronGauntlet` -rows. +addon/core/commit triple recorded as `addon == core == commit ` in +both `canopy/src/GlTerminal.stories.tsx` and `canopy/README.md` — +`xterm-lint.mjs` check 4 holds each against canopy's pins, and the two commits +against each other. The harness owns its discriminating `chevronGauntlet` rows. **Never write PUA glyphs as literals; use `\uE0BX` escapes** — the literals vanish silently in a rewrite (rationale). diff --git a/docs/specs/website-docs.md b/docs/specs/website-docs.md index d272d9e89..4969e78e5 100644 --- a/docs/specs/website-docs.md +++ b/docs/specs/website-docs.md @@ -195,11 +195,10 @@ delta is structural: 8. Mark same-site and external navigation appropriately. Operations 1–5 live in the generator; 6–8 live in the page components. -Operations 1–4 run in `buildDocument`, so they apply to the guide, which has no -page today, the self-host runbook, and the security spec; operation 5 runs over -those three and `dor/skill.md`, the last before `/docs/dor` lifts its -introduction out of those same blocks, so every published page inherits one -rewrite. +Operations 1–5 run in `buildDocument`, so they apply to the guide, which has no +page today, the self-host runbook, and the security spec. `dor/skill.md` is +exempt from operation 5 and asserted instead +([`/docs/agent-skill` guide](#docsagent-skill-guide)). **Never** publish a relative repository link as-is; `resolveRepoLinks` sends it to the publishing page or the canonical file and fails the build when the @@ -393,10 +392,13 @@ use the first token with a matching CLI section and label it with the first authored spelling. Targeting and Surface handles match by heading prefix and link to the corresponding CLI introductions. -These links are presentation adjacent to the skill body. Website URLs are never -injected into `dor/skill.md`: an older installed CLI must remain self-contained -and version-matched rather than directing its instructions to the latest -website reference. +These links are presentation adjacent to the skill body. **Website URLs are +never injected into `dor/skill.md`** — an older installed CLI must remain +self-contained and version-matched rather than directing its instructions to +the latest website reference — and **the generator asserts the skill names no +site URL rather than rewriting one**, which would repair the violation instead +of reporting it. `buildCli` lifts the intro sections out of these same block +objects, so a site URL here would reach `/docs/dor` too. Generation fails when an introduction heading is missing or ambiguous, or a command heading names no anchor in the generated CLI reference. @@ -468,13 +470,19 @@ website data module: website/scripts/generate-docs.js website/scripts/docs-parser.js website/scripts/help-parser.js -website/src/data/docs.guide.json generated, no page consumes it today website/src/data/docs.selfhost.json website/src/data/docs.security.json website/src/data/docs.cli.json website/src/data/docs.skill.json ``` +**Only a document with a page is written.** The guide is parsed and validated +on every build, and its media synced, but writing its data file shipped 48 KB +nothing imports; `Scope: guide-page-return` restores the write. **The fields +the generator derives for its own assertions — the applied delta and the three +rewrite logs — are stripped at the write** and kept on the in-memory result, +which is what the tests and the public-doc lint read. + One file per document rather than one combined module: a shared import made every docs route pull the others' content into one chunk. @@ -488,7 +496,7 @@ dor/test/snapshots/help/*.md dor/skill.md ``` -The generated data contains the canonical product-guide blocks and heading +The generated data contains each published document's blocks and heading inventory with the explicit fixed delta applied, ordered semantic CLI nodes plus exact raw help, and the skill blocks plus validated heading-to-reference links. The raw skill Markdown is deliberately not emitted. @@ -623,10 +631,10 @@ Remaining work, in staged order: A hosted rendering of the general product guide was built, shipped at `/docs`, and then withdrawn — the guide reads well enough where it is already published, and the page did not earn its place in the site's navigation. The pipeline is -whole, not a stub: `docs.guide.json` is written on every build with no -consumer. +whole, not a stub: `buildGuide` runs on every build, and only the write of its +data file was dropped once nothing imported it. -Reviving it needs a page component and an entry in `docs-pages.ts` — not new -pipeline work. Whoever does it should first answer the question that removed +Reviving it needs a page component, an entry in `docs-pages.ts`, and +`PUBLISHED_PAGES` gaining `guide` — not new pipeline work. Whoever does it should first answer the question that removed the page: what this rendering gives a reader that the Marketplace and GitHub renderings do not. diff --git a/lib/src/index.css b/lib/src/index.css index 171eb6f9c..5a57bed98 100644 --- a/lib/src/index.css +++ b/lib/src/index.css @@ -6,8 +6,8 @@ body { margin: 0; padding: 0; overflow: hidden; - font-size: var(--mt-font-size); - font-family: var(--mt-font-family); + font-size: var(--vscode-font-size); + font-family: var(--vscode-editor-font-family); } #root { diff --git a/lib/src/lib/terminal-theme.ts b/lib/src/lib/terminal-theme.ts index 7e8a7225f..e5037acd7 100644 --- a/lib/src/lib/terminal-theme.ts +++ b/lib/src/lib/terminal-theme.ts @@ -2,31 +2,64 @@ import { Terminal } from '@xterm/xterm'; import { registry } from './terminal-store'; import type { TerminalColorProvider } from './terminal-protocol'; +/** + * The xterm `ITheme`, read straight off `body`'s resolved `--vscode-*`. + * + * No per-key hex defaults: `REGISTRY_DEFAULTS` in `themes/vscode-color-registry.ts` + * is the one table of what an unset VSCode color id resolves to, and every + * shipping host materializes these keys before a terminal renders + * (docs/specs/theme.md -> Terminal color contract). A second table here drifted + * from it — `#cccccc` against the registry's `#BBBBBB` for dark + * `editor-foreground` — and would have painted the terminal with the stale one. + * + * Background and foreground keep a last-resort pair anyway, because xterm.js + * needs two readable colors even in the one path that has no resolver behind it + * (`pnpm dev:lib`); the rest of the palette degrades to xterm's own defaults. + */ +const FALLBACK_BACKGROUND = '#1e1e1e'; +const FALLBACK_FOREGROUND = '#cccccc'; + +/** xterm `ITheme` key -> the `--vscode-*` variable that fills it. */ +const ANSI_VARS: Record = { + selectionBackground: '--vscode-terminal-selectionBackground', + black: '--vscode-terminal-ansiBlack', + red: '--vscode-terminal-ansiRed', + green: '--vscode-terminal-ansiGreen', + yellow: '--vscode-terminal-ansiYellow', + blue: '--vscode-terminal-ansiBlue', + magenta: '--vscode-terminal-ansiMagenta', + cyan: '--vscode-terminal-ansiCyan', + white: '--vscode-terminal-ansiWhite', + brightBlack: '--vscode-terminal-ansiBrightBlack', + brightRed: '--vscode-terminal-ansiBrightRed', + brightGreen: '--vscode-terminal-ansiBrightGreen', + brightYellow: '--vscode-terminal-ansiBrightYellow', + brightBlue: '--vscode-terminal-ansiBrightBlue', + brightMagenta: '--vscode-terminal-ansiBrightMagenta', + brightCyan: '--vscode-terminal-ansiBrightCyan', + brightWhite: '--vscode-terminal-ansiBrightWhite', +}; + export function getTerminalTheme(): Record { const style = getComputedStyle(document.body); - const v = (prop: string, fallback: string) => style.getPropertyValue(prop).trim() || fallback; - return { - background: v('--vscode-terminal-background', v('--vscode-editor-background', '#1e1e1e')), - foreground: v('--vscode-terminal-foreground', v('--vscode-editor-foreground', '#cccccc')), - cursor: v('--vscode-terminalCursor-foreground', '#aeafad'), - selectionBackground: v('--vscode-terminal-selectionBackground', '#264f7840'), - black: v('--vscode-terminal-ansiBlack', '#000000'), - red: v('--vscode-terminal-ansiRed', '#cd3131'), - green: v('--vscode-terminal-ansiGreen', '#0dbc79'), - yellow: v('--vscode-terminal-ansiYellow', '#e5e510'), - blue: v('--vscode-terminal-ansiBlue', '#2472c8'), - magenta: v('--vscode-terminal-ansiMagenta', '#bc3fbc'), - cyan: v('--vscode-terminal-ansiCyan', '#11a8cd'), - white: v('--vscode-terminal-ansiWhite', '#e5e5e5'), - brightBlack: v('--vscode-terminal-ansiBrightBlack', '#666666'), - brightRed: v('--vscode-terminal-ansiBrightRed', '#f14c4c'), - brightGreen: v('--vscode-terminal-ansiBrightGreen', '#23d18b'), - brightYellow: v('--vscode-terminal-ansiBrightYellow', '#f5f543'), - brightBlue: v('--vscode-terminal-ansiBrightBlue', '#3b8eea'), - brightMagenta: v('--vscode-terminal-ansiBrightMagenta', '#d670d6'), - brightCyan: v('--vscode-terminal-ansiBrightCyan', '#29b8db'), - brightWhite: v('--vscode-terminal-ansiBrightWhite', '#e5e5e5'), + const v = (prop: string, fallback = '') => style.getPropertyValue(prop).trim() || fallback; + const foreground = v('--vscode-terminal-foreground', v('--vscode-editor-foreground', FALLBACK_FOREGROUND)); + const theme: Record = { + background: v('--vscode-terminal-background', v('--vscode-editor-background', FALLBACK_BACKGROUND)), + foreground, + // Derived, not defaulted: `RESOLUTION_RULES` inherits the cursor from the + // terminal foreground, and the three keys a DOM-less host is pushed + // (`setThemeColors` in lib/src/host/remote/sidecar-entry.ts) must all be + // strings or the whole push is dropped. + cursor: v('--vscode-terminalCursor-foreground', foreground), }; + // Omitted rather than emptied: an unset key leaves xterm.js on its own + // default, and leaves `themeColorProvider` answering `null` to an OSC query. + for (const [key, prop] of Object.entries(ANSI_VARS)) { + const value = v(prop); + if (value) theme[key] = value; + } + return theme; } /** diff --git a/lib/src/theme.css b/lib/src/theme.css index e41952987..da0f55501 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -19,12 +19,6 @@ @import "./theme-colors.css"; -/* --- Font tokens (not Tailwind colors) --- */ -:root { - --mt-font-size: var(--vscode-font-size); - --mt-font-family: var(--vscode-editor-font-family); -} - /* --- Tailwind token declarations (app layer; colours are next door) --- */ @theme { /* Fonts */ @@ -52,8 +46,6 @@ * this per file; rationale in docs/specs/theme.md). The colour tokens carry * their own mirror in ./theme-colors.css. */ body { - --mt-font-size: var(--vscode-font-size); - --mt-font-family: var(--vscode-editor-font-family); --font-sans: var(--vscode-editor-font-family); --font-mono: var(--vscode-editor-font-family); } diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 92715e1ab..51fe302de 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -18,8 +18,10 @@ if [[ -z "$VERSION" ]]; then exit 2 fi -if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then - echo "Error: '$VERSION' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)" >&2 +# X.Y.Z only: sign-and-deploy.sh's validate_version rejects anything else, and +# it does so after the bump, commit, tag, push and a full CI build. +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: '$VERSION' is not a release version (X.Y.Z)" >&2 exit 2 fi diff --git a/scripts/sign-and-deploy.sh b/scripts/sign-and-deploy.sh index 12774cb8e..0d147bdab 100755 --- a/scripts/sign-and-deploy.sh +++ b/scripts/sign-and-deploy.sh @@ -295,8 +295,9 @@ prepare_sign_dir() { log "Preparing working copies from downloaded artifacts..." rm -rf "$SIGN_DIR" mkdir -p "$SIGN_DIR" - # Copy only the artifact directories (not marker files) - invalidate_updates + # Copy only the artifact directories (not marker files). `prepare_artifact` + # invalidates the updater output itself, because it is also called on its + # own to refresh one platform. for name in "${ARTIFACT_NAMES[@]}"; do prepare_artifact "$name" done diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 96ba3b5b9..02493eb4f 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -30,11 +30,11 @@ "docs/specs/terminal-context.md": 1050, "docs/specs/terminal-escapes.md": 3850, "docs/specs/terminal-state.md": 2400, - "docs/specs/theme.md": 2150, + "docs/specs/theme.md": 2200, "docs/specs/tiling-engine.md": 4500, "docs/specs/transport.md": 6250, - "docs/specs/tutorial.md": 1850, + "docs/specs/tutorial.md": 1900, "docs/specs/vscode.md": 7550, "docs/specs/webgl-text.md": 1200, - "docs/specs/website-docs.md": 4650 + "docs/specs/website-docs.md": 4800 } diff --git a/scripts/xterm-bump.mjs b/scripts/xterm-bump.mjs index 223047109..ee46fb05c 100644 --- a/scripts/xterm-bump.mjs +++ b/scripts/xterm-bump.mjs @@ -137,11 +137,10 @@ if (canopyFlag !== -1) { (dryRun ? ' (--dry-run: no files written)' : ''), ); console.log(`upstream base commit: ${head}`); - console.log( - '\nAlso update the version-correspondence comment at the UpstreamWebglAddon import in\n' + - 'canopy/src/GlTerminal.stories.tsx and the same triple in canopy/README.md, then run ' + - '`pnpm install`.', - ); + // No reminder to update the recorded triple in canopy/src/GlTerminal.stories.tsx + // and canopy/README.md: `scripts/xterm-lint.mjs` check 4 fails until both agree + // with the pins written above. + console.log('\nRun `pnpm install`.'); process.exit(0); } diff --git a/scripts/xterm-bump.test.mjs b/scripts/xterm-bump.test.mjs index a17ee9c24..cc5f149ec 100644 --- a/scripts/xterm-bump.test.mjs +++ b/scripts/xterm-bump.test.mjs @@ -100,3 +100,75 @@ test('default dry-run preserves standalone drift', () => { assert.equal(result.packages.standalone[CORE], oldCore); assert.match(result.stdout, /no files written/); }); + +// --- xterm-lint check 4: the hand-copied UpstreamVsFork triple --------------- +// +// A self-test, not a second lint: the rule's whole job is to catch a hand-copy +// left behind by a rebase, and a finding check that never goes red is a claim +// rather than a control (AGENTS.md -> Specs). Each case mutates one field of +// the recorded triple and requires the lint to report it. + +const lintCore = '6.1.0-beta.304'; +const lintAddon = '0.20.0-beta.300'; +const lintFork = '0.20.0-sdf304.0'; +const lintCommit = 'c58ea363'; +const forkUrl = (v) => + `https://github.com/diffplug/xterm.js/releases/download/sdf-v${v}/diffplug-xterm-addon-webgl-sdf-${v}.tgz`; + +/** Run xterm-lint against a fixture root holding only canopy. */ +function runLint({ addon = lintAddon, core = lintCore, commit = lintCommit, readmeCommit = lintCommit } = {}) { + const root = mkdtempSync(join(tmpdir(), 'xterm-lint-')); + try { + mkdirSync(join(root, 'scripts')); + mkdirSync(join(root, 'canopy/src'), { recursive: true }); + copyFileSync(new URL('./xterm-lint.mjs', import.meta.url), join(root, 'scripts/xterm-lint.mjs')); + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - canopy\n'); + writeFileSync(join(root, 'pnpm-lock.yaml'), [ + 'packages:', + '', + ` '@xterm/addon-webgl@${lintAddon}':`, + ' peerDependencies:', + ` '@xterm/xterm': ^${lintCore}`, + '', + ` '@diffplug/xterm-addon-webgl-sdf@${forkUrl(lintFork)}':`, + ' peerDependencies:', + ` '@xterm/xterm': ^${lintCore}`, + '', + 'snapshots:', + '', + ].join('\n')); + writeFileSync(join(root, 'canopy/package.json'), JSON.stringify({ + dependencies: { + [FORK]: forkUrl(lintFork), + '@xterm/addon-webgl': lintAddon, + [CORE]: lintCore, + }, + }, null, 2)); + writeFileSync( + join(root, 'canopy/src/GlTerminal.stories.tsx'), + `// (addon ${addon} == core ${core} == commit ${commit}) — the regression baseline\n`, + ); + writeFileSync( + join(root, 'canopy/README.md'), + `(addon \`${lintAddon}\` == core \`${lintCore}\` == commit \`${readmeCommit}\`)\n`, + ); + return spawnSync(process.execPath, [join(root, 'scripts/xterm-lint.mjs')], { encoding: 'utf8' }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +test('xterm-lint accepts a canopy whose recorded triple matches its pins', () => { + const result = runLint(); + assert.equal(result.status, 0, result.stderr); +}); +for (const [label, mutation, expected] of [ + ['a stale upstream addon', { addon: '0.20.0-beta.299' }, /records upstream addon 0\.20\.0-beta\.299/], + ['a stale core', { core: '6.1.0-beta.303' }, /records core 6\.1\.0-beta\.303/], + ['two disagreeing commits', { commit: 'deadbee' }, /records fork-base commit/], + ['a triple that is gone', { commit: 'not-a-sha' }, /no "addon == core /], +]) test(`xterm-lint rejects ${label} in the recorded triple`, () => { + const result = runLint(mutation); + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, expected); +}); diff --git a/scripts/xterm-lint.mjs b/scripts/xterm-lint.mjs index 1f0d3b98c..aebc6af70 100644 --- a/scripts/xterm-lint.mjs +++ b/scripts/xterm-lint.mjs @@ -34,7 +34,13 @@ * release line (5.6.0-beta.1..143, then 6.1.0-beta.1..302), so a `-sdfNNN` * counter does not say which line it came from. Check 1 is what closes * that, via the fork's declared peer range; this check catches the cheaper - * mistake of a URL whose tag, filename and counter disagree. + * mistake of a URL whose tag, filename and counter disagree. The same + * check reads the UpstreamVsFork baseline triple, which is hand-copied + * into canopy/src/GlTerminal.stories.tsx and canopy/README.md, and holds + * its addon and core against canopy's pins and its commit against itself + * — the story compares the fork with an upstream addon that must come + * from the fork base, and a stale hand-copy makes the harness compare + * against something we no longer ship. * 5. Format sanity: finding no `@xterm/*` entries at all is a failure, not a * pass — it means the lockfile format moved. */ @@ -182,6 +188,33 @@ if (lib && standalone) { } } +/** + * The recorded UpstreamVsFork baseline: `addon == core == commit `. + * + * One spelling in both files so this has one grammar to parse; backticks are + * optional because the README writes the versions as code spans. + */ +const TRIPLE = /addon\s+`?([\w.-]+)`?\s*==\s*core\s+`?([\w.-]+)`?\s*==\s*commit\s+`?([0-9a-f]{7,40})`?/; +const TRIPLE_FILES = ['canopy/src/GlTerminal.stories.tsx', 'canopy/README.md']; + +/** @returns {{rel: string, addon: string, core: string, commit: string}[]} */ +function canopyTriples() { + const found = []; + for (const rel of TRIPLE_FILES) { + if (!existsSync(join(ROOT, rel))) continue; + const m = TRIPLE.exec(read(rel)); + if (!m) { + problems.push( + `${rel}: no "addon == core == commit " line — that triple is ` + + 'the UpstreamVsFork baseline, and without it nothing checks the hand-copy against the pins', + ); + continue; + } + found.push({ rel, addon: m[1], core: m[2], commit: m[3] }); + } + return found; +} + // --- Check 4: canopy fork lockstep ------------------------------------------- const canopy = workspaces.find((w) => w.dir === 'canopy'); if (canopy) { @@ -221,6 +254,31 @@ if (canopy) { ); } } + + // The recorded triple, against the pins it claims to describe. + const upstreamAddon = canopy.pins['@xterm/addon-webgl']; + const triples = canopyTriples(); + for (const { rel, addon, core: recordedCore } of triples) { + if (upstreamAddon && addon !== upstreamAddon) { + problems.push( + `${rel}: records upstream addon ${addon}, but canopy/package.json pins ${upstreamAddon} ` + + '(docs/specs/webgl-text.md → Canopy lab)', + ); + } + if (core && recordedCore !== core) { + problems.push( + `${rel}: records core ${recordedCore}, but canopy/package.json pins ${core} ` + + '(docs/specs/webgl-text.md → Canopy lab)', + ); + } + } + // No pin records the commit, so the two copies are each other's only check. + if (triples.length === TRIPLE_FILES.length && triples[0].commit !== triples[1].commit) { + problems.push( + `${triples[0].rel} records fork-base commit ${triples[0].commit} but ${triples[1].rel} ` + + `records ${triples[1].commit} — one of them was left behind by a rebase`, + ); + } } // ----------------------------------------------------------------------------- diff --git a/standalone/package.json b/standalone/package.json index 036d1aa39..02fbe23d9 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -1,7 +1,6 @@ { "name": "dormouse-standalone", "private": true, - "version": "0.1.0", "license": "FSL-1.1-MIT", "type": "module", "scripts": { diff --git a/website/scripts/generate-docs.js b/website/scripts/generate-docs.js index 4dafa39f8..1aafe5878 100644 --- a/website/scripts/generate-docs.js +++ b/website/scripts/generate-docs.js @@ -8,11 +8,10 @@ * /docs/dor does not ship the agent skill along with it. * * The guide half of this pipeline has no page of its own right now (see - * docs/specs/website-docs.md -> Canonical product guide). It is kept whole and - * still runs on every build, because the guide's media sync is what puts + * docs/specs/website-docs.md -> Canonical product guide), so it is parsed and + * validated on every build but not written: the media sync is what puts * vscode-ext/images/ on dormouse.sh, where the packaged Marketplace listing - * loads its images from, and because the guide data is what a future page - * would render. + * loads its images from, and the lint reads the parsed guide in memory. * * Wired into website `predev` / `pretest` / `prebuild`, mirroring * generate-changelog.js. @@ -753,10 +752,25 @@ async function buildCli(skill) { async function buildSkill() { const markdown = await readFile(join(repoRoot, 'dor', 'skill.md'), 'utf8'); const parsed = parseMarkdown(markdown, { slug: createSlugger() }); - // Before buildCli lifts its intro sections out of these same block objects, - // so /docs/dor inherits the rewrite rather than needing its own. - const localizedLinks = localizeSiteLinks(parsed.blocks); - return { source: 'dor/skill.md', blocks: parsed.blocks, headings: parsed.headings, localizedLinks }; + // An assertion, not a rewrite: the skill ships inside an installed CLI and + // must stay self-contained, so it names no website URL at all + // (docs/specs/website-docs.md -> `/docs/agent-skill` guide). Running + // `localizeSiteLinks` over it would quietly repair a violation instead of + // reporting it — and buildCli lifts these same block objects, so a site URL + // here would reach /docs/dor too. + const siteLinks = []; + visit(parsed.blocks, (node) => { + if (node.type === 'link' && node.href && node.href.startsWith(`${SITE_ORIGIN}/`)) { + siteLinks.push(node.href); + } + }); + if (siteLinks.length > 0) { + throw new Error( + `dor/skill.md links to ${SITE_ORIGIN}: ${siteLinks.join(', ')} — the bundled skill must ` + + 'stay version-matched to its own CLI rather than pointing at the latest website', + ); + } + return { source: 'dor/skill.md', blocks: parsed.blocks, headings: parsed.headings }; } /** @@ -823,35 +837,51 @@ export async function generateDocs() { source: skill.source, blocks: skill.blocks, headings: skill.headings, - localizedLinks: skill.localizedLinks, toc: buildToc(skill.headings.filter((h) => h.depth > 1)), references, }, }; } +/** + * What the generator derives for its own assertions and for the summary below, + * and no page reads: `docs.security.json` alone shipped ~190 KB of it to the + * browser. Stripped at the write; the in-memory result the tests and + * `scripts/public-docs-lint.mjs` consume keeps every field. + */ +const BUILD_ONLY_FIELDS = ['delta', 'withheldLinks', 'repoLinks', 'localizedLinks']; + +/** The pages with a component; the guide has none (`Scope: guide-page-return`). */ +const PUBLISHED_PAGES = ['selfhost', 'security', 'cli', 'skill']; + +function publishable(value) { + const out = { ...value }; + for (const field of BUILD_ONLY_FIELDS) delete out[field]; + return out; +} + async function main() { const data = await generateDocs(); await mkdir(dataDir, { recursive: true }); // One file per page: importing a single combined module made every docs // route ship all three documents in one shared chunk. await Promise.all([ - ...Object.entries(data).map(([name, value]) => - writeFile(join(dataDir, `docs.${name}.json`), `${JSON.stringify(value, null, 2)}\n`, 'utf8'), + ...PUBLISHED_PAGES.map((name) => + writeFile(join(dataDir, `docs.${name}.json`), `${JSON.stringify(publishable(data[name]), null, 2)}\n`, 'utf8'), ), syncGuideMedia(data.guide.media.available), ]); const { guide, selfhost, security, cli, skill } = data; const markdownPages = [guide, selfhost, security]; console.log( - `Wrote docs data: guide ${guide.headings.length} headings, ` + + `Wrote docs data: guide ${guide.headings.length} headings (parsed, not written), ` + `self-host ${selfhost.headings.length} headings (${selfhost.delta.length} delta rules), ` + `security ${security.headings.length} headings (${security.delta.length} delta rules), ` + `cli ${cli.commands.length} commands + ${cli.intro.length} intro sections, ` + `skill ${Object.keys(skill.references).length} reference links, ` + `${guide.media.available.length} media file(s), ` + `${markdownPages.reduce((n, page) => n + page.repoLinks.length, 0)} link(s) sent to the repository, ` + - `${markdownPages.reduce((n, page) => n + page.localizedLinks.length, 0) + skill.localizedLinks.length} link(s) localized`, + `${markdownPages.reduce((n, page) => n + page.localizedLinks.length, 0)} link(s) localized`, ); } diff --git a/website/src/components/PocketTerminalExperience.tsx b/website/src/components/PocketTerminalExperience.tsx index 283bb88f9..1db5eb61f 100644 --- a/website/src/components/PocketTerminalExperience.tsx +++ b/website/src/components/PocketTerminalExperience.tsx @@ -14,11 +14,7 @@ import { POCKET_TUTORIAL_PROFILE, type ItemId } from "../lib/tut-items"; import { ChangelogRunner } from "../lib/changelog-runner"; import { useRestoredTheme } from "dormouse-lib/lib/themes"; -// The default theme is defined by the real Pocket app so this playground — -// whose whole purpose is proving out that experience — cannot drift from it. -import { POCKET_THEME_ID } from "dormouse-lib/remote/pocket-app/pocket-theme"; - -export { POCKET_THEME_ID }; +import { WEBSITE_DEFAULT_THEME_ID } from "../lib/website-theme"; type FakePtyAdapter = import("dormouse-lib/lib/platform/fake-adapter").FakePtyAdapter; type MobileGestureInputId = import("dormouse-lib/lib/mobile-gesture-menu").MobileGestureInputId; @@ -50,7 +46,7 @@ export function PocketTerminalExperience({ interactive: boolean; fillViewport?: boolean; }) { - useRestoredTheme(POCKET_THEME_ID); + useRestoredTheme(WEBSITE_DEFAULT_THEME_ID); const [terminalReady, setTerminalReady] = useState(false); const adapterRef = useRef(null); const shellRegistryRef = useRef(null); diff --git a/website/src/lib/tut-detector.test.ts b/website/src/lib/tut-detector.test.ts index 13a99b09d..bac31cd33 100644 --- a/website/src/lib/tut-detector.test.ts +++ b/website/src/lib/tut-detector.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_MOUSE_SELECTION_STATE, type MouseSelectionState } from "dormouse-lib/lib/mouse-selection"; import type { ActivityState } from "dormouse-lib/lib/terminal-registry"; +import { DESKTOP_SECTIONS } from "./tut-items"; import { TutDetector } from "./tut-detector"; import { TutorialState } from "./tutorial-state"; @@ -22,7 +23,7 @@ function makeDetectorHarness(initialActivitySnapshot = new Map(); let themeListener: (() => void) | null = null; let activeThemeId = "vscode.theme-defaults.dark_vs"; - const state = new TutorialState(); + const state = new TutorialState(DESKTOP_SECTIONS); const detector = new TutDetector({ state, activityStore: { diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index 1aeba1670..6b787fd18 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -267,14 +267,3 @@ export const POCKET_TUTORIAL_PROFILE: TutorialProfile = { sections: POCKET_SECTIONS, initialSectionId: "gesture", }; - -export const SECTIONS = DESKTOP_SECTIONS; - -export const ALL_ITEM_IDS: readonly ItemId[] = ITEM_IDS; - -export function itemSection( - id: ItemId, - sections: readonly Section[] = SECTIONS, -): Section | undefined { - return sections.find((s) => s.items.some((i) => i.id === id)); -} diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index b13d90eae..9aac83bb3 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -3,8 +3,8 @@ import { FakePtyAdapter } from "dormouse-lib/lib/platform/fake-adapter"; import * as alertSettings from "dormouse-lib/lib/alert-settings"; import type { AlertStateDetail } from "dormouse-lib/lib/platform/types"; import { + DESKTOP_SECTIONS as SECTIONS, POCKET_TUTORIAL_PROFILE, - SECTIONS, type ItemId, type TutorialProfile, } from "./tut-items"; @@ -52,7 +52,7 @@ function mountRunner( adapter.onPtyData(({ data }) => frames.push(data)); const profile = options.profile; - const state = new TutorialState(profile?.sections); + const state = new TutorialState(profile?.sections ?? SECTIONS); for (const itemId of completedIds) state.markComplete(itemId); let pocketTouchMode = options.pocketTouchMode ?? "gestures"; const pocketTouchModeListeners = new Set<() => void>(); @@ -112,12 +112,16 @@ describe("TutRunner snapshots", () => { const demoId = "demo-target"; let currentTimeoutMs = alertSettings.DEFAULT_ALERT_SETTINGS.inactivityTimeoutMs; let durationMs = 0; + let commandMs = 0; + let busyDemoLaunches = 0; let finishTimer: ReturnType | undefined; let cancelPump: (() => void) | undefined; const { adapter, sendKeys, lastFrame, dispose } = mountRunner([], { getInactivityTimeoutMs: () => currentTimeoutMs, onTriggerBusyDemo: (duration, commandDuration) => { durationMs = duration; + commandMs = commandDuration; + busyDemoLaunches += 1; adapter.sendOutput(demoId, "\x1b]633;E;longtask\x07\x1b]633;C\x07"); cancelPump = adapter.pumpActivity(demoId, duration, BUSY_DEMO_INTERVAL_MS); finishTimer = setTimeout(() => adapter.sendOutput(demoId, "\x1b]633;D;0\x07"), commandDuration); @@ -148,9 +152,20 @@ describe("TutRunner snapshots", () => { adapter.alertSetCommandWatched("longtask", true); events.length = 0; sendKeys("s"); - vi.advanceTimersByTime(durationMs + 7_000); + // `s` counts down its fake command, which outlives the pump so WATCHING's + // silence chain has something to ring against. + expect(commandMs).toBeGreaterThan(durationMs); + expect(lastFrame()).toContain(`${Math.ceil(commandMs / 1000)}\x1b[0m seconds`); + // One guard, in the runner: a replay anywhere inside the command is + // ignored, so the page never has to cancel a live pump or exit timer. + vi.advanceTimersByTime(durationMs + 1); + sendKeys("s"); + expect(busyDemoLaunches).toBe(1); + vi.advanceTimersByTime(commandMs - durationMs + 7_000); expect(events.some((event) => event.status === "BUSY")).toBe(true); expect(events.some((event) => event.status === "ALERT_RINGING")).toBe(true); + sendKeys("s"); + expect(busyDemoLaunches).toBe(2); } finally { clearTimeout(finishTimer); cancelPump?.(); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 561e7b938..c6ead7244 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -167,6 +167,9 @@ export class TutRunner implements InteractiveProgram { private resizeUnsub: (() => void) | null = null; private busyDemoStart: number | null = null; private busyDemoDurationMs = 0; + /** How long the fake `longtask` runs — the countdown and the re-press guard. + * Longer than the pump, which stops so the pane can go silent and ring. */ + private busyDemoCommandMs = 0; private commandExitDemoStart: number | null = null; private commandExitDemoDurationMs = 0; private disposed = false; @@ -584,15 +587,18 @@ export class TutRunner implements InteractiveProgram { this.cleanup(true); } + /** Spans the whole fake command, not just the countdown, so the page needs no + * second guard: a replay cannot arrive while its pump or exit timer is live. */ private busyDemoInProgress(): boolean { if (this.busyDemoStart === null) return false; - return Date.now() - this.busyDemoStart < this.busyDemoDurationMs; + return Date.now() - this.busyDemoStart < this.busyDemoCommandMs; } private startBusyDemo(): void { this.busyDemoStart = Date.now(); this.busyDemoDurationMs = getDemoDurationMs(this.getInactivityTimeoutMs()); - this.onTriggerBusyDemo?.(this.busyDemoDurationMs, getWatchCommandDurationMs(this.busyDemoDurationMs)); + this.busyDemoCommandMs = getWatchCommandDurationMs(this.busyDemoDurationMs); + this.onTriggerBusyDemo?.(this.busyDemoDurationMs, this.busyDemoCommandMs); this.startSpinnerTicks(); this.render(); } @@ -823,7 +829,10 @@ export class TutRunner implements InteractiveProgram { lines.push(` ${DIM}\`Esc\` to cancel${RESET}`); lines.push(""); lines.push( - ` This will clear all checkmarks and the GitHub star prompt.`, + ` This will clear all checkmarks, the GitHub star prompt, and your`, + ); + lines.push( + ` FlappyTerm high score.`, ); lines.push( ` ${DIM}Type \`reset\` and press \`Enter\` to confirm.${RESET}`, @@ -908,7 +917,9 @@ export class TutRunner implements InteractiveProgram { private renderBusyDemoLines(): string[] { return [ - this.renderDemoLine("s", "longtask", "Fake task", this.busyDemoStart, this.busyDemoDurationMs), + // Counts down the fake command, not the pump: `longtask` keeps running + // after `tut-boxed` goes quiet, which is the whole point of the demo. + this.renderDemoLine("s", "longtask", "Fake task", this.busyDemoStart, this.busyDemoCommandMs), ` ${DIM}Press \`n\` for a program that rings the bell itself.${RESET}`, this.renderDemoLine("x", "slowbuild", "Slow build", this.commandExitDemoStart, this.commandExitDemoDurationMs), ]; diff --git a/website/src/lib/tutorial-state.test.ts b/website/src/lib/tutorial-state.test.ts index a2f8ebbaf..55896dd40 100644 --- a/website/src/lib/tutorial-state.test.ts +++ b/website/src/lib/tutorial-state.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { DESKTOP_SECTIONS, POCKET_TUTORIAL_PROFILE } from "./tut-items"; import { TutorialState } from "./tutorial-state"; afterEach(() => vi.unstubAllGlobals()); @@ -34,7 +35,7 @@ describe("tutorial persistence", () => { ["dormouse-tut-star-v1", "true"], ["dormouse-flappy-high-v1", "12"], ])); - const state = new TutorialState(); + const state = new TutorialState(DESKTOP_SECTIONS); expect(state.totalProgress().done).toBe(1); expect(state.isComplete("th-theme")).toBe(true); expect(state.isStarPromptResolved()).toBe(true); @@ -51,7 +52,7 @@ describe("tutorial persistence", () => { configurable: true, get: () => { throw new Error("storage denied"); }, }); - completeAndReset(new TutorialState()); + completeAndReset(new TutorialState(DESKTOP_SECTIONS)); }); it("reset removes rejected storage values even when progress is already empty", () => { @@ -60,7 +61,7 @@ describe("tutorial persistence", () => { ["dormouse-tut-star-v1", '"true"'], ["dormouse-flappy-high-v1", "-12"], ])); - const state = new TutorialState(); + const state = new TutorialState(DESKTOP_SECTIONS); const listener = vi.fn(); state.subscribe(listener); state.reset(); @@ -73,7 +74,7 @@ describe("tutorial persistence", () => { for (const method of ["getItem", "setItem", "removeItem"] as const) { vi.spyOn(storage, method).mockImplementation(() => { throw new Error("storage denied"); }); } - completeAndReset(new TutorialState()); + completeAndReset(new TutorialState(DESKTOP_SECTIONS)); }); it("starts fresh after malformed stored values", () => { @@ -82,6 +83,20 @@ describe("tutorial persistence", () => { ["dormouse-tut-star-v1", '"true"'], ["dormouse-flappy-high-v1", "-12"], ])); - completeAndReset(new TutorialState()); + completeAndReset(new TutorialState(DESKTOP_SECTIONS)); + }); + + it("does not let one profile pre-check the other's checklist", () => { + const { values } = installStorage(); + // Pocket's Select mode credits `cp-override` on every mouse-capturing + // session, but Pocket's own checklist does not carry the item — the + // desktop one does, and both profiles share this storage key. + const pocket = new TutorialState(POCKET_TUTORIAL_PROFILE.sections); + expect(pocket.markComplete("cp-override")).toBe(false); + expect(pocket.isComplete("cp-override")).toBe(false); + expect(values.get("dormouse-tut-v3")).toBeUndefined(); + + const desktop = new TutorialState(DESKTOP_SECTIONS); + expect(desktop.isComplete("cp-override")).toBe(false); }); }); diff --git a/website/src/lib/tutorial-state.ts b/website/src/lib/tutorial-state.ts index 20e333b7d..8f00d0fbb 100644 --- a/website/src/lib/tutorial-state.ts +++ b/website/src/lib/tutorial-state.ts @@ -1,5 +1,5 @@ import { loadJson, removeJson, saveJson } from "dormouse-lib/lib/local-json-store"; -import { ITEM_IDS, SECTIONS, type ItemId, type Section } from "./tut-items"; +import { ITEM_IDS, type ItemId, type Section } from "./tut-items"; const STORAGE_KEY = "dormouse-tut-v3"; const STAR_STORAGE_KEY = "dormouse-tut-star-v1"; @@ -12,9 +12,12 @@ export class TutorialState { private flappyHighScore = 0; private listeners = new Set<() => void>(); private sections: readonly Section[]; + /** The ids `markComplete` will accept — this profile's own, and no more. */ + private ownIds: ReadonlySet; - constructor(sections: readonly Section[] = SECTIONS) { + constructor(sections: readonly Section[]) { this.sections = sections; + this.ownIds = new Set(sections.flatMap((s) => s.items.map((i) => i.id))); this.starPromptResolved = loadJson(STAR_STORAGE_KEY, false) === true; const high = loadJson(FLAPPY_HIGH_SCORE_KEY, 0); if (typeof high === "number" && Number.isFinite(high) && high >= 0) { @@ -51,7 +54,16 @@ export class TutorialState { return true; } + /** + * Credit an item, if this profile has one. + * + * Both profiles share `dormouse-tut-v3`, so a detection that fires under one + * and names an item only the other lists would pre-check that other + * tutorial. Pocket's Select mode does exactly that with `cp-override`, which + * Pocket's checklist does not carry. + */ markComplete(id: ItemId): boolean { + if (!this.ownIds.has(id)) return false; if (this.completed.has(id)) return false; this.completed.add(id); this.notify(); diff --git a/website/src/lib/website-theme.ts b/website/src/lib/website-theme.ts new file mode 100644 index 000000000..767cc9c77 --- /dev/null +++ b/website/src/lib/website-theme.ts @@ -0,0 +1,13 @@ +/** + * The theme every playground surface falls back to before the reader picks one. + * + * Taken from the real Pocket app so the playground — whose whole purpose is + * proving out that experience — cannot drift from it. Named for the website + * rather than for Pocket because the desktop playground restores it too, and + * importing `POCKET_THEME_ID` there read as a bug. + * + * The reference pages have their own pre-choice default (`DOCS_THEME_ID` in + * `./docs-theme`); `docs/specs/theme.md` -> "Where the user picks a theme" + * owns how a host declares a fallback. + */ +export { POCKET_THEME_ID as WEBSITE_DEFAULT_THEME_ID } from "dormouse-lib/remote/pocket-app/pocket-theme"; diff --git a/website/src/pages/Playground.test.tsx b/website/src/pages/Playground.test.tsx index 422e8ef86..770198507 100644 --- a/website/src/pages/Playground.test.tsx +++ b/website/src/pages/Playground.test.tsx @@ -22,8 +22,8 @@ vi.mock("react-router", async (importOriginal) => { const previewLifecycle = vi.hoisted(() => ({ mounted: [] as boolean[], unmounted: [] as boolean[] })); vi.mock("../components/SiteHeader", () => ({ default: () =>
, STATIC_PAGE_HEADER_STYLE: {} })); +vi.mock("../lib/website-theme", () => ({ WEBSITE_DEFAULT_THEME_ID: "test-theme" })); vi.mock("../components/PocketTerminalExperience", () => ({ - POCKET_THEME_ID: "test-theme", PocketTerminalExperience: ({ interactive }: { interactive: boolean }) => { useEffect(() => { previewLifecycle.mounted.push(interactive); diff --git a/website/src/pages/PlaygroundDesktop.tsx b/website/src/pages/PlaygroundDesktop.tsx index 08c9faafb..947500e31 100644 --- a/website/src/pages/PlaygroundDesktop.tsx +++ b/website/src/pages/PlaygroundDesktop.tsx @@ -2,9 +2,9 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { Link } from "react-router"; import SiteHeader, { STATIC_PAGE_HEADER_STYLE } from "../components/SiteHeader"; import { PlaceToPaste } from "../components/PlaceToPaste"; -import { POCKET_THEME_ID } from "../components/PocketTerminalExperience"; import { useRestoredTheme } from "dormouse-lib/lib/themes"; import { PlaygroundShellRegistry } from "../lib/playground-shells"; +import { DESKTOP_TUTORIAL_PROFILE } from "../lib/tut-items"; import { TutorialState } from "../lib/tutorial-state"; import { TutDetector } from "../lib/tut-detector"; import { @@ -21,6 +21,7 @@ import { type DesktopPaneSpec, } from "../lib/playground-desktop-layout"; import { SITE_LINK_CLASS } from "../components/site-tokens"; +import { WEBSITE_DEFAULT_THEME_ID } from "../lib/website-theme"; type FakePtyAdapter = import("dormouse-lib/lib/platform/fake-adapter").FakePtyAdapter; type WallEvent = import("dormouse-lib/components/Wall").WallEvent; @@ -86,7 +87,7 @@ function PlaygroundDesktopExperience() { // The navbar picker used to theme this page as a side effect of its own // render-time restore. The picker now lives in the Settings dialog and only // mounts when opened, so the page restores its own theme. - useRestoredTheme(POCKET_THEME_ID); + useRestoredTheme(WEBSITE_DEFAULT_THEME_ID); const [WallModule, setWallModule] = useState<{ Wall: React.ComponentType; @@ -159,7 +160,7 @@ function PlaygroundDesktopExperience() { adapter.setScenario(pane.id, { name: "none", chunks: [] }); } - const tutorialState = new TutorialState(); + const tutorialState = new TutorialState(DESKTOP_TUTORIAL_PROFILE.sections); stateRef.current = tutorialState; const detector = new TutDetector({ state: tutorialState, @@ -184,12 +185,10 @@ function PlaygroundDesktopExperience() { // report one through shell integration. Both alert panes run the // same fake `longtask`, which is what lets one bell click light // up the other pane (docs/specs/alert.md). + // No cancel of a prior run here: TutRunner ignores `s` until the + // fake command's whole `commandMs` has elapsed, so a second call + // cannot arrive while this pump or timer is live. onTriggerBusyDemo: (durationMs, commandMs) => { - busyDemoDisposeRef.current?.(); - if (busyDemoFinishTimerRef.current !== null) { - window.clearTimeout(busyDemoFinishTimerRef.current); - busyDemoFinishTimerRef.current = null; - } for (const paneId of ALERT_DEMO_PANES) { startFakeCommand(adapter, paneId, "longtask"); } diff --git a/website/src/pages/PocketPlayground.tsx b/website/src/pages/PocketPlayground.tsx index 3bf9883bc..bb0b9b03b 100644 --- a/website/src/pages/PocketPlayground.tsx +++ b/website/src/pages/PocketPlayground.tsx @@ -1,9 +1,7 @@ import { useEffect } from "react"; import SiteHeader, { STATIC_PAGE_HEADER_STYLE } from "../components/SiteHeader"; -import { - POCKET_THEME_ID, - PocketTerminalExperience, -} from "../components/PocketTerminalExperience"; +import { PocketTerminalExperience } from "../components/PocketTerminalExperience"; +import { WEBSITE_DEFAULT_THEME_ID } from "../lib/website-theme"; import { NotifySignupForm } from "../components/NotifySignupForm"; import { ShareUrlButton } from "../components/ShareUrlButton"; import { ThemePicker } from "dormouse-lib/components/ThemePicker"; @@ -13,7 +11,7 @@ import { sitePath } from "../lib/site-meta"; import { SITE_LINK_CLASS } from "../components/site-tokens"; function MobilePocketPlaygroundPage() { - useRestoredTheme(POCKET_THEME_ID); + useRestoredTheme(WEBSITE_DEFAULT_THEME_ID); return (
@@ -27,7 +25,7 @@ function MobilePocketPlaygroundPage() { function DesktopPocketPlaygroundPage() { // Declared here rather than left to PocketTerminalExperience below: the // header's picker renders first and re-resolves through this fallback. - useRestoredTheme(POCKET_THEME_ID); + useRestoredTheme(WEBSITE_DEFAULT_THEME_ID); return (
Date: Sun, 20 Sep 2026 01:58:04 -0700 Subject: [PATCH 3/5] fix(tutorial): cancel a stale busy demo across TutRunner instances The `s` alert demo's re-press guard lives on the TutRunner, but the playground page's pump handle and finish timer are per page. Exiting `tut` and re-running it, or running `tut` in a second pane, builds a fresh runner whose cleared guard lets a second demo start while the first demo's pump and `commandMs` timeout are still live, so the stale timer finishes the fake command mid-countdown. The page cancels both again before starting a demo. Also tighten bump-version.sh's version check to the exact regex sign-and-deploy.sh validates with, so a leading-zero component such as `01.2.3` fails at the bump instead of after the tag, push and CI build. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/tutorial.md | 2 +- scripts/bump-version.sh | 2 +- website/src/lib/tut-runner.test.ts | 4 ++-- website/src/pages/PlaygroundDesktop.tsx | 12 +++++++++--- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index f7a3c1387..9fb818ec2 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -49,7 +49,7 @@ Extras: `Starred on GitHub` (persisted separately, `onOpenGithub`), `🐭 Flappy **`TutRunner` intercepts four keys while a specific section is open; they are not real Dormouse shortcuts.** The three alert demos report fake commands as `OSC 633 ; E / C / D` through `FakePtyAdapter.sendOutput`, which the real `TerminalProtocolParser` strips from visible output (rationale). **Must snapshot the live inactivity timeout at demo launch; the run outlasts it and the BUSY-confirm floor.** Each demo's countdown, page timer, and re-press guard run the same snapshotted duration — longer for `s`, whose fake command must outlive WATCHING's silence chain. Pinned by `website/src/lib/tut-runner.test.ts`. -- **`s`** (Alerts) — reports `longtask` on both alert panes so command-keyed WATCHING demonstrates `al-spreads`, pumping only the quiet `tut-boxed` (rationale), keeping the command alive through WATCHING’s silence chain. **A press while that command is still running is ignored**, so pumps cannot stack and the page needs no cancel of its own; on exit `TutorialShell.reportRunningCommand()` restores each pane's real command. +- **`s`** (Alerts) — reports `longtask` on both alert panes so command-keyed WATCHING demonstrates `al-spreads`, pumping only the quiet `tut-boxed` (rationale), keeping the command alive through WATCHING’s silence chain. **A press while that command is still running is ignored**, and **the page cancels any prior pump and exit timer** — the runner's guard is per instance, so a re-run `tut` would otherwise stack them; on exit `TutorialShell.reportRunningCommand()` restores each pane's real command. - **`n`** (Alerts) — writes a raw `OSC 777` notification to `tut-boxed`, exercising the terminal-report track, which needs no WATCHING rule. - **`x`** (Alerts) — starts a fake `slowbuild` on `tut-splash` and reports its exit after the captured duration. **The command name must stay unwatched**, so the command-exit track rather than WATCHING owns the bell (rationale). - **`p`** (Copy paste) — toggles the **Place To Paste** scratch modal (`website/src/components/PlaceToPaste.tsx`) via `onTogglePlaceToPaste`. Desktop only — Pocket omits the callback, and the runner hides the prompt line without it. diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 51fe302de..25802929c 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -20,7 +20,7 @@ fi # X.Y.Z only: sign-and-deploy.sh's validate_version rejects anything else, and # it does so after the bump, commit, tag, push and a full CI build. -if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then +if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "Error: '$VERSION' is not a release version (X.Y.Z)" >&2 exit 2 fi diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index 9aac83bb3..8dfcf20fd 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -156,8 +156,8 @@ describe("TutRunner snapshots", () => { // silence chain has something to ring against. expect(commandMs).toBeGreaterThan(durationMs); expect(lastFrame()).toContain(`${Math.ceil(commandMs / 1000)}\x1b[0m seconds`); - // One guard, in the runner: a replay anywhere inside the command is - // ignored, so the page never has to cancel a live pump or exit timer. + // The runner's guard covers the whole command, so a replay anywhere + // inside it is ignored (the page cancels across runner instances). vi.advanceTimersByTime(durationMs + 1); sendKeys("s"); expect(busyDemoLaunches).toBe(1); diff --git a/website/src/pages/PlaygroundDesktop.tsx b/website/src/pages/PlaygroundDesktop.tsx index 947500e31..667b0b4d6 100644 --- a/website/src/pages/PlaygroundDesktop.tsx +++ b/website/src/pages/PlaygroundDesktop.tsx @@ -185,10 +185,16 @@ function PlaygroundDesktopExperience() { // report one through shell integration. Both alert panes run the // same fake `longtask`, which is what lets one bell click light // up the other pane (docs/specs/alert.md). - // No cancel of a prior run here: TutRunner ignores `s` until the - // fake command's whole `commandMs` has elapsed, so a second call - // cannot arrive while this pump or timer is live. onTriggerBusyDemo: (durationMs, commandMs) => { + // TutRunner ignores `s` for the whole `commandMs`, but that + // guard is per runner while these refs are per page: exiting + // `tut` and re-running it, or running it in a second pane, + // builds a fresh runner that cannot see this pump or timer. + busyDemoDisposeRef.current?.(); + if (busyDemoFinishTimerRef.current !== null) { + window.clearTimeout(busyDemoFinishTimerRef.current); + busyDemoFinishTimerRef.current = null; + } for (const paneId of ALERT_DEMO_PANES) { startFakeCommand(adapter, paneId, "longtask"); } From baeebb767761365432fd0977b2759f0e8b94d905 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 07:54:18 -0700 Subject: [PATCH 4/5] test(website): prove the bundled skill rejects site links --- docs/specs/website-docs.md | 3 ++- website/scripts/generate-docs.js | 31 +++++++++++++++++---------- website/scripts/generate-docs.test.js | 12 +++++++++++ 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/docs/specs/website-docs.md b/docs/specs/website-docs.md index 4969e78e5..4d1050c8c 100644 --- a/docs/specs/website-docs.md +++ b/docs/specs/website-docs.md @@ -398,7 +398,8 @@ self-contained and version-matched rather than directing its instructions to the latest website reference — and **the generator asserts the skill names no site URL rather than rewriting one**, which would repair the violation instead of reporting it. `buildCli` lifts the intro sections out of these same block -objects, so a site URL here would reach `/docs/dor` too. +objects, so a site URL here would reach `/docs/dor` too. Pinned by +`website/scripts/generate-docs.test.js`. Generation fails when an introduction heading is missing or ambiguous, or a command heading names no anchor in the generated CLI reference. diff --git a/website/scripts/generate-docs.js b/website/scripts/generate-docs.js index 1aafe5878..0884c7b3e 100644 --- a/website/scripts/generate-docs.js +++ b/website/scripts/generate-docs.js @@ -749,27 +749,36 @@ async function buildCli(skill) { }; } -async function buildSkill() { - const markdown = await readFile(join(repoRoot, 'dor', 'skill.md'), 'utf8'); - const parsed = parseMarkdown(markdown, { slug: createSlugger() }); - // An assertion, not a rewrite: the skill ships inside an installed CLI and - // must stay self-contained, so it names no website URL at all - // (docs/specs/website-docs.md -> `/docs/agent-skill` guide). Running - // `localizeSiteLinks` over it would quietly repair a violation instead of - // reporting it — and buildCli lifts these same block objects, so a site URL - // here would reach /docs/dor too. +/** + * An assertion, not a rewrite: the skill ships inside an installed CLI and must + * stay self-contained, so it names no website URL at all + * (docs/specs/website-docs.md -> `/docs/agent-skill` guide). Running + * `localizeSiteLinks` over it would quietly repair a violation instead of + * reporting it — and buildCli lifts these same block objects, so a site URL + * here would reach /docs/dor too. + * + * Exported so a test can feed it a violating block tree: `dor/skill.md` is + * clean, so nothing else ever drives this check red. + */ +export function assertNoSiteLinks(blocks, source) { const siteLinks = []; - visit(parsed.blocks, (node) => { + visit(blocks, (node) => { if (node.type === 'link' && node.href && node.href.startsWith(`${SITE_ORIGIN}/`)) { siteLinks.push(node.href); } }); if (siteLinks.length > 0) { throw new Error( - `dor/skill.md links to ${SITE_ORIGIN}: ${siteLinks.join(', ')} — the bundled skill must ` + + `${source} links to ${SITE_ORIGIN}: ${siteLinks.join(', ')} — the bundled skill must ` + 'stay version-matched to its own CLI rather than pointing at the latest website', ); } +} + +async function buildSkill() { + const markdown = await readFile(join(repoRoot, 'dor', 'skill.md'), 'utf8'); + const parsed = parseMarkdown(markdown, { slug: createSlugger() }); + assertNoSiteLinks(parsed.blocks, 'dor/skill.md'); return { source: 'dor/skill.md', blocks: parsed.blocks, headings: parsed.headings }; } diff --git a/website/scripts/generate-docs.test.js b/website/scripts/generate-docs.test.js index d1e5f3ae5..cd221db1f 100644 --- a/website/scripts/generate-docs.test.js +++ b/website/scripts/generate-docs.test.js @@ -2,8 +2,10 @@ import { describe, it, expect } from 'vitest'; import { REPO_BLOB_BASE, SECURITY_AUDIENCES, + SITE_ORIGIN, SITE_ROUTES, applyDelta, + assertNoSiteLinks, assertRouteFragments, generateDocs, resolveRepoLinks, @@ -359,6 +361,16 @@ describe('agent skill', () => { expect(data.skill.markdown).toBeUndefined(); }); + it('rejects a site link in the skill', () => { + // dor/skill.md is clean, so the build never drives this check red; a + // finding check that cannot fail is a claim rather than a control. + const offending = [ + { type: 'paragraph', children: [{ type: 'link', href: `${SITE_ORIGIN}/docs/dor/` }] }, + ]; + expect(() => assertNoSiteLinks(offending, 'dor/skill.md')).toThrow(SITE_ORIGIN); + expect(() => assertNoSiteLinks(data.skill.blocks, 'dor/skill.md')).not.toThrow(); + }); + it('resolves every reference into an existing CLI anchor', () => { const anchors = new Set(data.cli.anchors); const refs = Object.values(data.skill.references); From ec83937398c9e6f0b2f6419a4b4fd33afefce0b2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 08:18:07 -0700 Subject: [PATCH 5/5] fix tutorial command-exit replay --- docs/specs/tutorial.md | 2 +- website/src/lib/tut-runner.ts | 4 ++-- website/src/pages/PlaygroundDesktop.tsx | 22 +++++++++++++--------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 9fb818ec2..79dc984d8 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -51,7 +51,7 @@ Extras: `Starred on GitHub` (persisted separately, `onOpenGithub`), `🐭 Flappy - **`s`** (Alerts) — reports `longtask` on both alert panes so command-keyed WATCHING demonstrates `al-spreads`, pumping only the quiet `tut-boxed` (rationale), keeping the command alive through WATCHING’s silence chain. **A press while that command is still running is ignored**, and **the page cancels any prior pump and exit timer** — the runner's guard is per instance, so a re-run `tut` would otherwise stack them; on exit `TutorialShell.reportRunningCommand()` restores each pane's real command. - **`n`** (Alerts) — writes a raw `OSC 777` notification to `tut-boxed`, exercising the terminal-report track, which needs no WATCHING rule. -- **`x`** (Alerts) — starts a fake `slowbuild` on `tut-splash` and reports its exit after the captured duration. **The command name must stay unwatched**, so the command-exit track rather than WATCHING owns the bell (rationale). +- **`x`** (Alerts) — starts a fake `slowbuild` on `tut-splash` and reports its exit after the captured duration. **The command name must stay unwatched**, so the command-exit track rather than WATCHING owns the bell (rationale). **The page must cancel the prior exit timer across runner instances.** - **`p`** (Copy paste) — toggles the **Place To Paste** scratch modal (`website/src/components/PlaceToPaste.tsx`) via `onTogglePlaceToPaste`. Desktop only — Pocket omits the callback, and the runner hides the prompt line without it. ### Pocket Copy paste specifics diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index c6ead7244..e3e51473f 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -587,8 +587,8 @@ export class TutRunner implements InteractiveProgram { this.cleanup(true); } - /** Spans the whole fake command, not just the countdown, so the page needs no - * second guard: a replay cannot arrive while its pump or exit timer is live. */ + /** Spans the whole fake command, not just the countdown, so a replay anywhere + * inside it is ignored. Per instance — the page cancels across runners. */ private busyDemoInProgress(): boolean { if (this.busyDemoStart === null) return false; return Date.now() - this.busyDemoStart < this.busyDemoCommandMs; diff --git a/website/src/pages/PlaygroundDesktop.tsx b/website/src/pages/PlaygroundDesktop.tsx index 667b0b4d6..758ccef8b 100644 --- a/website/src/pages/PlaygroundDesktop.tsx +++ b/website/src/pages/PlaygroundDesktop.tsx @@ -102,7 +102,7 @@ function PlaygroundDesktopExperience() { const spawnUnsubRef = useRef<(() => void) | null>(null); const busyDemoDisposeRef = useRef<(() => void) | null>(null); const busyDemoFinishTimerRef = useRef(null); - const demoTimersRef = useRef([]); + const commandExitDemoFinishTimerRef = useRef(null); const handleOpenGithub = useCallback(() => { window.open( @@ -230,13 +230,15 @@ function PlaygroundDesktopExperience() { // An unwatched command, so the command-exit track owns the bell: // the user attends the pane, leaves, and the exit rings. onTriggerCommandExitDemo: (durationMs) => { + if (commandExitDemoFinishTimerRef.current !== null) { + window.clearTimeout(commandExitDemoFinishTimerRef.current); + } startFakeCommand(adapter, PANE_SPLASH, "slowbuild"); - demoTimersRef.current.push( - window.setTimeout(() => { - finishFakeCommand(adapter, PANE_SPLASH); - shellRegistryRef.current?.ensureShell(PANE_SPLASH).reportRunningCommand(); - }, durationMs), - ); + commandExitDemoFinishTimerRef.current = window.setTimeout(() => { + commandExitDemoFinishTimerRef.current = null; + finishFakeCommand(adapter, PANE_SPLASH); + shellRegistryRef.current?.ensureShell(PANE_SPLASH).reportRunningCommand(); + }, durationMs); }, onTogglePlaceToPaste: () => setPlaceToPasteOpen((open) => !open), onOpenGithub: handleOpenGithub, @@ -297,8 +299,10 @@ function PlaygroundDesktopExperience() { window.clearTimeout(busyDemoFinishTimerRef.current); busyDemoFinishTimerRef.current = null; } - for (const timer of demoTimersRef.current) window.clearTimeout(timer); - demoTimersRef.current = []; + if (commandExitDemoFinishTimerRef.current !== null) { + window.clearTimeout(commandExitDemoFinishTimerRef.current); + commandExitDemoFinishTimerRef.current = null; + } }; }, [handleOpenGithub, handleOpenPocket, tryAutoStart]);