@erclx/canon 4.101.0 → 4.103.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +5 -6
  3. package/claude/skills/canon-cli/SKILL.md +4 -1
  4. package/claude/skills/canon-rollout/SKILL.md +7 -6
  5. package/claude/skills/draft-readme/REQUIREMENT.md +2 -0
  6. package/claude/skills/draft-readme/SKILL.md +9 -1
  7. package/claude/skills/git-ship/SKILL.md +3 -7
  8. package/claude/skills/memory-capture/SKILL.md +0 -6
  9. package/claude/skills/memory-review/REQUIREMENT.md +1 -1
  10. package/claude/skills/memory-review/SKILL.md +2 -3
  11. package/claude/skills/plan-groundwork/SKILL.md +3 -2
  12. package/claude/skills/target-check/REQUIREMENT.md +42 -0
  13. package/claude/skills/target-check/SKILL.md +91 -0
  14. package/claude/skills/target-check/references/domains.md +88 -0
  15. package/claude/skills/ui-checklist/SKILL.md +17 -0
  16. package/docs/agents/commands.md +1 -1
  17. package/docs/agents/install-and-sync.md +7 -1
  18. package/docs/agents/scripting.md +1 -1
  19. package/docs/target-projects.md +3 -1
  20. package/docs/workflow/ai-workflow.md +16 -13
  21. package/governance/rules/ci/700-ci-workflow.md +11 -0
  22. package/package.json +1 -1
  23. package/scripts/core/regen-web-favicon.ts +9 -40
  24. package/scripts/core/regen-web-previews.ts +1 -0
  25. package/src/claude/cases/setup.ts +8 -0
  26. package/src/commands/design.ts +8 -2
  27. package/src/commands/tooling.ts +92 -19
  28. package/src/design/css.ts +5 -2
  29. package/src/design/favicon.ts +33 -0
  30. package/src/design/render.ts +46 -6
  31. package/src/design/tokens.ts +1 -1
  32. package/standards/groundwork.md +8 -2
  33. package/standards/readme.md +22 -3
  34. package/tooling/astro/manifest.toml +1 -0
  35. package/tooling/base/manifest.toml +2 -2
  36. package/tooling/base/reference.md +1 -1
  37. package/tooling/nextjs/manifest.toml +1 -0
  38. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +110 -0
  39. package/tooling/web/configs/e2e/screenshot.ts +69 -15
  40. package/tooling/web/configs/scripts/lib/preview-server.sh +31 -0
  41. package/tooling/web/configs/scripts/readme-screenshot.sh +26 -0
  42. package/tooling/web/configs/scripts/screenshot.sh +1 -24
  43. package/tooling/web/manifest.toml +1 -0
  44. package/tooling/web/reference.md +6 -1
@@ -24,6 +24,7 @@ packages = [
24
24
 
25
25
  [scripts.override]
26
26
  "screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4321) && export PREVIEW_PORT && bash scripts/screenshot.sh"
27
+ "readme-screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4321) && export PREVIEW_PORT && bash scripts/readme-screenshot.sh"
27
28
  "dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && astro dev"
28
29
  "preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && astro preview"
29
30
 
@@ -18,9 +18,9 @@ packages = [
18
18
 
19
19
  [scripts]
20
20
  "check:spell" = "cspell '**' '.*/**' '.*' --no-progress --color --show-context"
21
- "check:format" = "prettier --check --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && shfmt --diff --indent 2 scripts/"
21
+ "check:format" = "prettier --check --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && find scripts -name '*.sh' -exec shfmt --diff --indent 2 {} +"
22
22
  "check:shell" = "find scripts -name '*.sh' -exec shellcheck --severity=warning {} +"
23
- "format" = "prettier --write --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && shfmt --write --indent 2 scripts/"
23
+ "format" = "prettier --write --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && find scripts -name '*.sh' -exec shfmt --write --indent 2 {} +"
24
24
  "prepare" = "husky"
25
25
  "check" = "./scripts/verify.sh"
26
26
 
@@ -35,7 +35,7 @@ Seeds live in `tooling/base/seeds/`. Sync drops each once on first install and n
35
35
  - Runtime: `bun` as package manager and script runner, `bunx` over `npx` for a one-off executable.
36
36
  - Formatting: Prettier for what it parses, shfmt for shell. Two formatters because Prettier has no shell parser.
37
37
  - Spelling: cspell over the whole tree, with project vocabulary split into a project-terms dictionary and a tech-stack one.
38
- - Shell: shfmt formats and shellcheck lints at warning severity. shfmt takes a directory argument, shellcheck has no directory mode and needs `find`.
38
+ - Shell: shfmt formats and shellcheck lints at warning severity. Both take their files from `find scripts -name '*.sh'`, since `scripts/` can mix shell with TypeScript and shellcheck has no directory mode. A shell script with a shebang and no `.sh` extension is covered by neither, so give it the extension.
39
39
  - Commits: commitlint against conventional commits, wired through the husky `commit-msg` hook. Format is `<type>(<scope>): <subject>` in imperative mood with no trailing period.
40
40
  - Dev dependencies: `prettier`, `cspell`, `husky`, `@commitlint/cli`, `@commitlint/config-conventional`. Install via `bun add -D`.
41
41
 
@@ -17,6 +17,7 @@ packages = [
17
17
  [scripts.override]
18
18
  "dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && next dev --port $((3000 + WORKTREE_PORT_OFFSET))"
19
19
  "screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 3000) && export PREVIEW_PORT && bash scripts/screenshot.sh"
20
+ "readme-screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 3000) && export PREVIEW_PORT && bash scripts/readme-screenshot.sh"
20
21
 
21
22
  [gitignore]
22
23
  "# Next" = [".next/"]
@@ -0,0 +1,110 @@
1
+ name: README Screenshot
2
+ # canon-no-seed: a stack-specific workflow with no root counterpart by design, since this repository reports its own capture in pr-visual-checks.yml and never commits it.
3
+
4
+ # No `workflow_dispatch`: the job commits onto a pull request's head branch,
5
+ # and a manual run has no pull request to commit onto.
6
+ on:
7
+ pull_request:
8
+ paths:
9
+ # Project-specific. Replace the placeholder with the paths whose change
10
+ # moves the README frame, such as the component and styles it renders.
11
+ # The three entries below the placeholder keep the job firing on this
12
+ # file's own installation, which is what lets the first step refuse an
13
+ # unfilled template instead of the job never running.
14
+ - 'REPLACE_WITH_PROJECT_PATHS'
15
+ - 'scripts/readme-screenshot.sh'
16
+ - 'scripts/lib/preview-server.sh'
17
+ - '.github/workflows/readme-screenshot.yml'
18
+
19
+ concurrency:
20
+ group: readme-screenshot-${{ github.ref }}
21
+ cancel-in-progress: true
22
+
23
+ jobs:
24
+ capture:
25
+ name: 🖼️ Refresh README Screenshot
26
+ runs-on: ubuntu-latest
27
+ # The default GITHUB_TOKEN cannot push to a fork's branch, so a PR from
28
+ # outside this repository is skipped rather than left to fail on the push
29
+ # step below.
30
+ if: github.event.pull_request.head.repo.full_name == github.repository
31
+ permissions:
32
+ contents: write
33
+ steps:
34
+ - name: Checkout
35
+ uses: actions/checkout@v4
36
+ with:
37
+ ref: ${{ github.head_ref }}
38
+
39
+ - name: Refuse An Unfilled Template
40
+ # A path list left at its placeholder never fires on the pages it was
41
+ # installed to watch, and that failure leaves no run, no diff, and no
42
+ # complaint. Failing here is what tells the project it has not
43
+ # finished installing.
44
+ run: |
45
+ if grep -q "^ *- 'REPLACE_WITH_PROJECT_PATHS'" .github/workflows/readme-screenshot.yml; then
46
+ echo "::error file=.github/workflows/readme-screenshot.yml::The paths list still holds REPLACE_WITH_PROJECT_PATHS. Replace it with the paths that move the README frame."
47
+ exit 1
48
+ fi
49
+
50
+ - name: Setup Node
51
+ uses: actions/setup-node@v4
52
+ with:
53
+ node-version: '22'
54
+
55
+ - name: Setup Bun
56
+ uses: oven-sh/setup-bun@v2
57
+ with:
58
+ bun-version: latest
59
+
60
+ - name: Install Dependencies
61
+ run: bun install --frozen-lockfile
62
+
63
+ - name: Cache Playwright Browser
64
+ id: playwright-cache
65
+ uses: actions/cache@v4
66
+ with:
67
+ path: ~/.cache/ms-playwright
68
+ key: playwright-${{ runner.os }}-chromium-${{ hashFiles('**/package.json') }}
69
+
70
+ - name: Install Playwright System Dependencies
71
+ # Unconditional: the cache holds browser binaries and nothing apt
72
+ # wrote, so gating this on a cache hit leaves a warm run launching
73
+ # chromium without the libraries it needs.
74
+ run: bunx playwright install-deps chromium
75
+
76
+ - name: Install Playwright Browser
77
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
78
+ run: bunx playwright install chromium
79
+
80
+ - name: Capture README Screenshot
81
+ run: bun run readme-screenshot
82
+
83
+ - name: Check For Changes
84
+ id: diff
85
+ run: |
86
+ if git status --porcelain assets/evidence/readme | grep -q .; then
87
+ echo "changed=true" >> "$GITHUB_OUTPUT"
88
+ else
89
+ echo "changed=false" >> "$GITHUB_OUTPUT"
90
+ fi
91
+
92
+ - name: Commit And Push
93
+ # Self-terminating: this push retriggers this same workflow on the PR,
94
+ # which captures again, finds no diff against what was just pushed,
95
+ # and stops here without pushing a second time.
96
+ if: steps.diff.outputs.changed == 'true'
97
+ # The hooks are off because this commit carries nothing they can read.
98
+ # `pre-push` runs the full verify, whose format step shells out to
99
+ # `shfmt`, which the runner does not install, so a push through them
100
+ # exits 127. The jobs in verify.yml already run those checks against
101
+ # the source, and what this step pushes is PNG bytes a capture
102
+ # produced.
103
+ env:
104
+ HUSKY: 0
105
+ run: |
106
+ git config user.name "github-actions[bot]"
107
+ git config user.email "github-actions[bot]@users.noreply.github.com"
108
+ git add assets/evidence/readme
109
+ git commit -m "chore(readme): refresh README screenshot"
110
+ git push origin "HEAD:${{ github.head_ref }}"
@@ -9,12 +9,33 @@ interface CaptureSection {
9
9
  selector: string
10
10
  }
11
11
 
12
+ interface Viewport {
13
+ readonly name: string
14
+ readonly width: number
15
+ readonly height: number
16
+ /** Whether a case flagged `evidence: true` also writes its frames at this width. */
17
+ readonly evidence: boolean
18
+ }
19
+
20
+ /**
21
+ * The breakpoint buckets every case is crossed with, named by width. 320 is
22
+ * the base a page has to hold without wrapping or clipping. Committing every
23
+ * width multiplies the tracked frames by the width count, so only the base and
24
+ * the desktop width carry `evidence`.
25
+ */
26
+ const VIEWPORTS: readonly Viewport[] = [
27
+ { name: '320', width: 320, height: 800, evidence: true },
28
+ { name: '768', width: 768, height: 800, evidence: false },
29
+ { name: '1280', width: 1280, height: 800, evidence: true },
30
+ { name: '1536', width: 1536, height: 800, evidence: false },
31
+ ]
32
+
12
33
  interface CaptureCase {
13
34
  section: string
14
35
  theme: string
15
36
  route: string
16
- width: number
17
- height: number
37
+ /** Names of viewports this case skips, for a route that reflows rather than breaks at that width. */
38
+ excludeViewports?: readonly string[]
18
39
  evidence?: boolean
19
40
  sections?: CaptureSection[]
20
41
  setup?: (page: Page) => Promise<void>
@@ -50,8 +71,6 @@ const CASES: CaptureCase[] = [
50
71
  section: 'home',
51
72
  theme: 'default',
52
73
  route: '/',
53
- width: 1280,
54
- height: 800,
55
74
  evidence: true,
56
75
  sections: HOME_SECTIONS,
57
76
  },
@@ -59,13 +78,28 @@ const CASES: CaptureCase[] = [
59
78
  section: 'home',
60
79
  theme: 'dark',
61
80
  route: '/',
62
- width: 1280,
63
- height: 800,
64
81
  sections: HOME_SECTIONS,
65
82
  setup: (page) => page.emulateMedia({ colorScheme: 'dark' }),
66
83
  },
67
84
  ]
68
85
 
86
+ /** One case at one viewport, which is the unit a filter selects and a context is built for. */
87
+ interface CaptureRun {
88
+ readonly captureCase: CaptureCase
89
+ readonly viewport: Viewport
90
+ readonly label: string
91
+ }
92
+
93
+ const ALL_RUNS: readonly CaptureRun[] = CASES.flatMap((captureCase) =>
94
+ VIEWPORTS.filter(
95
+ (viewport) => !captureCase.excludeViewports?.includes(viewport.name),
96
+ ).map((viewport) => ({
97
+ captureCase,
98
+ viewport,
99
+ label: `${captureCase.section}/${viewport.name}--${captureCase.theme}`,
100
+ })),
101
+ )
102
+
69
103
  const args = process.argv.slice(2)
70
104
  const checkConsoleClean = args.includes('--check-console-clean')
71
105
  const requireBaseUrl = args.includes('--require-base-url')
@@ -87,6 +121,28 @@ try {
87
121
 
88
122
  const OUT_DIR = path.join('screenshots', hostname)
89
123
 
124
+ const SCREENSHOT_FILTER = process.env.SCREENSHOT_FILTER?.trim() ?? ''
125
+
126
+ const filterTerms = SCREENSHOT_FILTER.split(',')
127
+ .map((term) => term.trim())
128
+ .filter(Boolean)
129
+
130
+ const RUNS =
131
+ filterTerms.length > 0
132
+ ? ALL_RUNS.filter((run) =>
133
+ filterTerms.some((term) => run.label.includes(term)),
134
+ )
135
+ : ALL_RUNS
136
+
137
+ if (filterTerms.length > 0 && RUNS.length === 0) {
138
+ console.error(
139
+ `No cases matched SCREENSHOT_FILTER=${SCREENSHOT_FILTER}. Available labels:\n${ALL_RUNS.map(
140
+ (run) => ` ${run.label}`,
141
+ ).join('\n')}`,
142
+ )
143
+ process.exit(1)
144
+ }
145
+
90
146
  /** Every wait on a lazy load is bounded explicitly. */
91
147
  const SETTLE_TIMEOUT = 15_000
92
148
 
@@ -198,12 +254,12 @@ const browser = await chromium.launch()
198
254
  const consoleErrors: string[] = []
199
255
  let ranCases = 0
200
256
 
201
- for (const captureCase of CASES) {
257
+ for (const { captureCase, viewport, label } of RUNS) {
202
258
  if (captureCase.evidence && requireBaseUrl) continue
203
259
 
204
260
  ranCases++
205
261
  const context = await browser.newContext({
206
- viewport: { width: captureCase.width, height: captureCase.height },
262
+ viewport: { width: viewport.width, height: viewport.height },
207
263
  // A page honoring the preference lands its own motion at the end state,
208
264
  // which is what a still frame has to show. Playwright reads it as a
209
265
  // context option, so it reaches no page written beside `viewport` above.
@@ -214,9 +270,7 @@ for (const captureCase of CASES) {
214
270
  if (checkConsoleClean) {
215
271
  page.on('console', (msg) => {
216
272
  if (msg.type() === 'error') {
217
- consoleErrors.push(
218
- `${captureCase.section}/${captureCase.theme}: ${msg.text()}`,
219
- )
273
+ consoleErrors.push(`${label}: ${msg.text()}`)
220
274
  }
221
275
  })
222
276
  }
@@ -226,21 +280,21 @@ for (const captureCase of CASES) {
226
280
  await page.goto(`${BASE_URL}${captureCase.route}`)
227
281
  await page.waitForLoadState('networkidle')
228
282
 
229
- const label = `${captureCase.section}/${captureCase.theme}`
230
283
  const sections = await settle(page, label, captureCase.sections ?? [])
231
284
 
232
285
  const sweepDir = path.join(OUT_DIR, captureCase.section)
233
286
  const evidenceDir = path.join('evidence', captureCase.section)
234
- const file = `${captureCase.theme}.png`
287
+ const file = `${viewport.name}--${captureCase.theme}.png`
288
+ const writesEvidence = captureCase.evidence && viewport.evidence
235
289
 
236
290
  await shoot(page, sweepDir, file)
237
- if (captureCase.evidence) await shoot(page, evidenceDir, file)
291
+ if (writesEvidence) await shoot(page, evidenceDir, file)
238
292
 
239
293
  if (sections.length > 0) await markFloatingChrome(page)
240
294
 
241
295
  for (const { name, selector } of sections) {
242
296
  await shoot(page, path.join(sweepDir, name), file, selector)
243
- if (captureCase.evidence) {
297
+ if (writesEvidence) {
244
298
  await shoot(page, path.join(evidenceDir, name), file, selector)
245
299
  }
246
300
  }
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bash
2
+ # Sourced, not executed. Builds the site, serves it in the background on
3
+ # PREVIEW_PORT, and blocks until it answers or exits with an error. Shared by
4
+ # scripts/screenshot.sh and scripts/readme-screenshot.sh so the two don't
5
+ # carry two copies of the same startup boilerplate.
6
+ set -euo pipefail
7
+
8
+ : "${PREVIEW_PORT:=4173}"
9
+
10
+ if curl -sSf "http://localhost:$PREVIEW_PORT/" >/dev/null 2>&1; then
11
+ echo "Port $PREVIEW_PORT already in use. Stop the listening process or set PREVIEW_PORT." >&2
12
+ exit 1
13
+ fi
14
+
15
+ bun run build
16
+ bun run preview >/dev/null 2>&1 &
17
+ PREVIEW_PID=$!
18
+ # shellcheck disable=SC2154 # pid is bound by the for loop inside the single-quoted trap body, which shellcheck does not track there
19
+ trap 'kill "$PREVIEW_PID" 2>/dev/null || true; if command -v lsof >/dev/null 2>&1; then for pid in $(lsof -ti tcp:"$PREVIEW_PORT" 2>/dev/null); do kill "$pid" 2>/dev/null || true; done; else echo "lsof not found on PATH; cannot confirm the preview port is clear of a detached grandchild." >&2; fi; wait "$PREVIEW_PID" 2>/dev/null || true' EXIT
20
+
21
+ for _ in $(seq 1 40); do
22
+ if curl -sSf "http://localhost:$PREVIEW_PORT/" >/dev/null 2>&1; then
23
+ break
24
+ fi
25
+ sleep 0.25
26
+ done
27
+
28
+ if ! curl -sSf "http://localhost:$PREVIEW_PORT/" >/dev/null 2>&1; then
29
+ echo "Preview server not responding on port $PREVIEW_PORT after 10s." >&2
30
+ exit 1
31
+ fi
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Project-specific. Both are placeholders the project fills in, and the script
5
+ # refuses to run until it does, since a shipped default would capture the wrong
6
+ # part of the page in every project but the one it was written for.
7
+ README_FILTER='' # case label to capture, such as header/1280
8
+ README_EVIDENCE_DIR='assets/evidence/readme'
9
+
10
+ if [[ -z "$README_FILTER" ]]; then
11
+ echo "README_FILTER is unset in scripts/readme-screenshot.sh. Name the case label the README frame is captured from, such as header/1280." >&2
12
+ exit 1
13
+ fi
14
+
15
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/preview-server.sh"
16
+
17
+ SCREENSHOT_BASE_URL="http://localhost:$PREVIEW_PORT" SCREENSHOT_FILTER="$README_FILTER" bun e2e/screenshot.ts
18
+
19
+ # The filter's section and viewport name the folder and the file prefix the
20
+ # capture wrote under screenshots/<hostname>/.
21
+ frame_dir="screenshots/localhost/$(dirname "$README_FILTER")"
22
+ frame_prefix="$(basename "$README_FILTER")"
23
+
24
+ mkdir -p "$README_EVIDENCE_DIR"
25
+ cp "$frame_dir/$frame_prefix--default.png" "$README_EVIDENCE_DIR/light.png"
26
+ cp "$frame_dir/$frame_prefix--dark.png" "$README_EVIDENCE_DIR/dark.png"
@@ -1,29 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- : "${PREVIEW_PORT:=4173}"
5
-
6
- if curl -sSf "http://localhost:$PREVIEW_PORT/" >/dev/null 2>&1; then
7
- echo "Port $PREVIEW_PORT already in use. Stop the listening process or set PREVIEW_PORT." >&2
8
- exit 1
9
- fi
10
-
11
- bun run build
12
- bun run preview >/dev/null 2>&1 &
13
- PREVIEW_PID=$!
14
- # shellcheck disable=SC2154 # pid is bound by the for loop inside the single-quoted trap body, which shellcheck does not track there
15
- trap 'kill "$PREVIEW_PID" 2>/dev/null || true; if command -v lsof >/dev/null 2>&1; then for pid in $(lsof -ti tcp:"$PREVIEW_PORT" 2>/dev/null); do kill "$pid" 2>/dev/null || true; done; else echo "lsof not found on PATH; cannot confirm the preview port is clear of a detached grandchild." >&2; fi; wait "$PREVIEW_PID" 2>/dev/null || true' EXIT
16
-
17
- for _ in $(seq 1 40); do
18
- if curl -sSf "http://localhost:$PREVIEW_PORT/" >/dev/null 2>&1; then
19
- break
20
- fi
21
- sleep 0.25
22
- done
23
-
24
- if ! curl -sSf "http://localhost:$PREVIEW_PORT/" >/dev/null 2>&1; then
25
- echo "Preview server not responding on port $PREVIEW_PORT after 10s." >&2
26
- exit 1
27
- fi
4
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/preview-server.sh"
28
5
 
29
6
  SCREENSHOT_BASE_URL="http://localhost:$PREVIEW_PORT" bun e2e/screenshot.ts
@@ -52,6 +52,7 @@ packages = [
52
52
  [scripts.override]
53
53
  "lint" = "eslint . --max-warnings 0"
54
54
  "screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4173) && export PREVIEW_PORT && bash scripts/screenshot.sh"
55
+ "readme-screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4173) && export PREVIEW_PORT && bash scripts/readme-screenshot.sh"
55
56
 
56
57
  [gitignore]
57
58
  "# Build" = ["dist/"]
@@ -12,7 +12,11 @@ Golden config files live in `tooling/web/configs/` and are copied into the targe
12
12
 
13
13
  - `eslint.config.js`: flat config with `@eslint/js`, `typescript-eslint`, React hooks, import sort, check-file, vitest rules scoped to test files, `eslint-config-prettier` last.
14
14
  - `src/test/setup.ts`: `@testing-library/jest-dom` import, `cleanup` after each test.
15
- - `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<hostname>/<section>/<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. A case flagged `evidence: true` additionally writes `evidence/<section>/<theme>.png`, with no hostname segment. `--require-base-url` skips a flagged case entirely, so a production smoke run neither writes to the committed path nor counts an evidence route in its console-clean check, and exits 1 when that leaves zero cases run, so flagging every case cannot silence the production check without saying so. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. A case may also name `sections`, one `{ name, selector }` per addressable part of the route, and each writes its own frame at `screenshots/<hostname>/<section>/<name>/<theme>.png` beside the whole-page one, plus `evidence/<section>/<name>/<theme>.png` where the case is flagged. The field is optional, so a case that names none captures the page alone as before, and a selector matching nothing is reported and skipped rather than raised, since `CASES` is a template a project rewrites and a target that syncs this file before editing its own list would otherwise capture nothing at all. Testing that every named section resolves belongs to the project that owns the selectors, beside its own page. Every case settles before anything is shot: the context is created with `reducedMotion: 'reduce'`, each named section is scrolled into view so an `IntersectionObserver` fires and a frame marked `loading="lazy"` loads, the page returns to the top, and fonts, images and same-origin frames are waited out on a bounded condition rather than on a second `networkidle`, which is a lifecycle event of the navigation and does not re-arm for a load a scroll started. A section frame drops the page's floating chrome, being whatever computes to `position: sticky` or `fixed`, because a section taller than the viewport is scrolled into view to be cut and a sticky bar otherwise rides that scroll into the middle of the frame. The whole-page frame is shot first and keeps its chrome. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
15
+ - `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, and a route, and the loop below crosses every case with the `VIEWPORTS` array and writes `screenshots/<hostname>/<section>/<width>--<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. `VIEWPORTS` holds four breakpoint buckets named by width, 320, 768, 1280 and 1536, with 320 as the base a page has to hold without wrapping or clipping. A case flagged `evidence: true` additionally writes `evidence/<section>/<width>--<theme>.png`, with no hostname segment, at the viewports flagged `evidence` alone, which are 320 and 1280. Committing all four multiplies the tracked frames by the width count, so the flag is what keeps the tracked set at twice the sections rather than four times. A case may name `excludeViewports` to skip a width, for a route whose long-form prose reflows at 320 instead of breaking. `SCREENSHOT_FILTER` narrows a run to the cases whose label, `<section>/<width>--<theme>`, contains any comma-separated term, exits 1 when nothing matches and lists the labels that exist, and leaves the rest of the sweep alone. `--require-base-url` skips a flagged case entirely, so a production smoke run neither writes to the committed path nor counts an evidence route in its console-clean check, and exits 1 when that leaves zero cases run, so flagging every case cannot silence the production check without saying so. Per-project cases extend the one record. A route's widths and themes sit together under its section folder, so the filename carries both. A case may also name `sections`, one `{ name, selector }` per addressable part of the route, and each writes its own frame at `screenshots/<hostname>/<section>/<name>/<width>--<theme>.png` beside the whole-page one, plus `evidence/<section>/<name>/<width>--<theme>.png` where the case is flagged and the width carries `evidence`. The field is optional, so a case that names none captures the page alone as before, and a selector matching nothing is reported and skipped rather than raised, since `CASES` is a template a project rewrites and a target that syncs this file before editing its own list would otherwise capture nothing at all. Testing that every named section resolves belongs to the project that owns the selectors, beside its own page. Every case settles before anything is shot: the context is created with `reducedMotion: 'reduce'`, each named section is scrolled into view so an `IntersectionObserver` fires and a frame marked `loading="lazy"` loads, the page returns to the top, and fonts, images and same-origin frames are waited out on a bounded condition rather than on a second `networkidle`, which is a lifecycle event of the navigation and does not re-arm for a load a scroll started. A section frame drops the page's floating chrome, being whatever computes to `position: sticky` or `fixed`, because a section taller than the viewport is scrolled into view to be cut and a sticky bar otherwise rides that scroll into the middle of the frame. The whole-page frame is shot first and keeps its chrome. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
16
+ - `scripts/screenshot.sh`: sources `scripts/lib/preview-server.sh`, then runs the capture against the served preview.
17
+ - `scripts/lib/preview-server.sh`: sourced, never executed. Builds, serves on `PREVIEW_PORT`, blocks until the port answers, and traps the whole process group on exit, including the detached grandchild `lsof` finds. Both capture scripts source it so the startup lives once.
18
+ - `scripts/readme-screenshot.sh`: captures one case under `SCREENSHOT_FILTER` and copies its light and dark frames to `assets/evidence/readme/`. `README_FILTER` ships empty with `header/1280` as its example, and the script exits 1 until a project fills it, since a shipped default would capture the wrong part of the page in every project but one. Its copy reads from `screenshots/<hostname>/`, which is where the harness writes.
19
+ - `.github/workflows/readme-screenshot.yml`: refreshes that frame on a pull request and commits it back to the branch. The path list ships as `REPLACE_WITH_PROJECT_PATHS`, and the job's first step after checkout exits 1 while it stays, since a job that never fires leaves no run and no diff to notice. The workflow's own path is in the list so the installing pull request reaches that step. When a commit-back is right and when a report is, `governance/rules/ci/700-ci-workflow.md` states the test.
16
20
  - `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for ESLint, Tailwind, Playwright, Vitest.
17
21
  - `.github/workflows/verify.yml`: `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests` jobs.
18
22
  - `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order.
@@ -106,6 +110,7 @@ Append rows:
106
110
  | `bun run test:e2e` | Run Playwright E2E tests. |
107
111
  | `bun run test:e2e:changed` | Run Playwright E2E tests for specs the import graph reaches from the current diff. |
108
112
  | `bun run screenshot` | Build, preview, then capture screenshots. |
113
+ | `bun run readme-screenshot` | Build, preview, then capture the README frame into `assets/evidence/readme/`. |
109
114
  | `bun run smoke:prod` | Capture against `SCREENSHOT_BASE_URL`, requiring it set and failing on any console error. |
110
115
 
111
116
  `canon tooling verify <stack>` is the only automated caller of `bun run screenshot`, running it for any stack whose `package.json` declares the script and asserting that PNG files land under `screenshots/`. It counts them with a recursive find carrying no depth limit, so the section folders the seed writes satisfy the assertion without a change to it. Do not flatten the layout to protect that check. No ship chain captures a screenshot, so the output path the seed writes is a contract that one verifier reads rather than a default a ship step depends on.