@erclx/canon 4.52.0 → 4.54.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.
@@ -0,0 +1,64 @@
1
+ import { dirname, relative, resolve } from 'node:path'
2
+ import { readField, readFrontmatter } from '@/indexes/frontmatter'
3
+ import { collectEntries } from '@/indexes/render'
4
+ import { listIndexes } from '@/indexes/walk'
5
+
6
+ export interface CatalogEntry {
7
+ readonly path: string
8
+ readonly title: string
9
+ readonly description: string
10
+ }
11
+
12
+ export interface IndexCatalog {
13
+ readonly entries: CatalogEntry[]
14
+ readonly errors: string[]
15
+ }
16
+
17
+ /**
18
+ * Flattens every folder index under `root` into one queryable catalog.
19
+ *
20
+ * A folder's own frontmatter error lands in `errors` without dropping the
21
+ * rest of the walk, the same isolation `regenOne` gives one folder.
22
+ */
23
+ export async function buildIndexCatalog(root: string): Promise<IndexCatalog> {
24
+ const indexPaths = await listIndexes(root)
25
+ const entries: CatalogEntry[] = []
26
+ const errors: string[] = []
27
+
28
+ for (const indexPath of indexPaths) {
29
+ const dir = dirname(indexPath)
30
+ const frontmatter = await readFrontmatter(indexPath)
31
+ const title = readField(frontmatter, 'title')
32
+ const subtitle = readField(frontmatter, 'subtitle')
33
+
34
+ if (!title || !subtitle) {
35
+ errors.push(
36
+ `missing frontmatter field "title" or "subtitle" in ${indexPath}`,
37
+ )
38
+ } else {
39
+ entries.push({
40
+ path: relative(root, indexPath),
41
+ title,
42
+ description: subtitle,
43
+ })
44
+ }
45
+
46
+ const collected = await collectEntries(dir)
47
+ if (!collected.ok) {
48
+ errors.push(...collected.errors)
49
+ continue
50
+ }
51
+
52
+ for (const entry of collected.entries) {
53
+ entries.push({
54
+ path: relative(root, resolve(dir, entry.name)),
55
+ title: entry.title,
56
+ description: entry.description,
57
+ })
58
+ }
59
+ }
60
+
61
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
62
+
63
+ return { entries, errors }
64
+ }
@@ -64,7 +64,7 @@ async function readIndexHead(
64
64
  return { ok: true, head: { raw: frontmatter.raw, title, subtitle } }
65
65
  }
66
66
 
67
- async function collectEntries(
67
+ export async function collectEntries(
68
68
  dir: string,
69
69
  ): Promise<
70
70
  { ok: true; entries: IndexEntry[] } | { ok: false; errors: string[] }
@@ -6,6 +6,8 @@ export type ScanInputRefusal =
6
6
  | 'unreadable-event'
7
7
  | 'not-a-pull-request'
8
8
  | 'unreadable-review'
9
+ | 'conflicting-body-input'
10
+ | 'unreadable-body-file'
9
11
 
10
12
  export type ResolvedScanInput =
11
13
  | {
@@ -26,6 +28,7 @@ export interface ScanInputOptions {
26
28
  readonly event?: string
27
29
  readonly title?: string
28
30
  readonly body?: string
31
+ readonly bodyFile?: string
29
32
  readonly head?: string
30
33
  }
31
34
 
@@ -46,6 +49,27 @@ export function resolveScanInput(opts: ScanInputOptions): ResolvedScanInput {
46
49
  let headRefName = opts.head
47
50
  let source: 'pull-request' | 'review' = 'pull-request'
48
51
 
52
+ if (opts.bodyFile !== undefined) {
53
+ if (body !== undefined) {
54
+ return {
55
+ kind: 'refused',
56
+ reason: 'conflicting-body-input',
57
+ message:
58
+ '--body and --body-file cannot both be given, since only one text can be scanned.',
59
+ }
60
+ }
61
+
62
+ try {
63
+ body = readFileSync(opts.bodyFile, 'utf8')
64
+ } catch {
65
+ return {
66
+ kind: 'refused',
67
+ reason: 'unreadable-body-file',
68
+ message: `${opts.bodyFile} could not be read, so no body was there to scan.`,
69
+ }
70
+ }
71
+ }
72
+
49
73
  if (opts.event !== undefined) {
50
74
  let raw: string
51
75
  try {
@@ -72,6 +72,7 @@ export const FINDING_KINDS = [
72
72
  'closing-partial',
73
73
  'item-incomplete',
74
74
  'category-mismatch',
75
+ 'operator-call-phrasing',
75
76
  ] as const
76
77
 
77
78
  export type FindingKind = (typeof FINDING_KINDS)[number]
@@ -157,6 +158,53 @@ export function isSharedScratch(kind: RecordKind): boolean {
157
158
  const NONE_IDENTIFIED = 'None identified.'
158
159
  const NUMBERED_FILE = /^\d{2}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
159
160
 
161
+ /**
162
+ * The suggestion the plan standard fixes for a question that turns on the
163
+ * operator's preference rather than on a technical default. `src/tasks/answers.ts`
164
+ * reads this alongside `normalizeOperatorCall` so the dispatch gate and this
165
+ * write-time check cannot drift into recognizing different spellings.
166
+ */
167
+ export const OPERATOR_CALL = 'needs your call'
168
+
169
+ /**
170
+ * The two demonstrated paraphrases of `OPERATOR_CALL`, longer variant first so
171
+ * it is not read as a shorter match sitting inside it: `operator's call` is a
172
+ * substring of `the operator's call`, and checking it first would rewrite the
173
+ * `the` into place with the wrong phrase on either side.
174
+ */
175
+ const OPERATOR_CALL_PHRASES = [
176
+ "the operator's call",
177
+ "operator's call",
178
+ ] as const
179
+
180
+ /**
181
+ * Reads a recognized paraphrase as the canonical phrase, so a caller testing
182
+ * `startsWith(OPERATOR_CALL)` sees one spelling regardless of which wording the
183
+ * author used. Case-insensitive, since the corpus capitalizes a suggestion's
184
+ * first word, and it rewrites only the matched span so the reason text either
185
+ * side of it survives untouched.
186
+ *
187
+ * Short-circuits on an already-canonical opening before scanning the rest of
188
+ * the text. Without that, a suggestion already opening with `needs your call`
189
+ * that goes on to mention a paraphrase inside its own reason, such as
190
+ * `needs your call, since the operator's call outranks a default`, has that
191
+ * later occurrence rewritten too, which garbles the reason the dispatcher
192
+ * reports rather than leaving it as the author wrote it.
193
+ */
194
+ export function normalizeOperatorCall(text: string): string {
195
+ const lower = text.toLowerCase()
196
+ if (lower.startsWith(OPERATOR_CALL)) return text
197
+
198
+ for (const phrase of OPERATOR_CALL_PHRASES) {
199
+ const at = lower.indexOf(phrase)
200
+ if (at !== -1) {
201
+ return `${text.slice(0, at)}your call${text.slice(at + phrase.length)}`
202
+ }
203
+ }
204
+
205
+ return text
206
+ }
207
+
160
208
  function finding(
161
209
  kind: FindingKind,
162
210
  record: string,
@@ -295,6 +343,9 @@ function shorten(label: string): string {
295
343
  return label.length > 60 ? `${label.slice(0, 57)}...` : label
296
344
  }
297
345
 
346
+ const SUGGESTED_PREFIX = '- Suggested:'
347
+ const ANSWER_PREFIX = '- Answer:'
348
+
298
349
  function checkQuestionContract(name: string, lines: string[]): Finding[] {
299
350
  if (lines.some((line) => line.trim() === NONE_IDENTIFIED)) return []
300
351
 
@@ -302,8 +353,12 @@ function checkQuestionContract(name: string, lines: string[]): Finding[] {
302
353
 
303
354
  for (const question of readQuestions(lines)) {
304
355
  const subject = shorten(question.label)
356
+ const suggested = question.body.find((line) =>
357
+ line.startsWith(SUGGESTED_PREFIX),
358
+ )
359
+ const answer = question.body.find((line) => line.startsWith(ANSWER_PREFIX))
305
360
 
306
- if (!question.body.some((line) => line.startsWith('- Suggested:'))) {
361
+ if (!suggested) {
307
362
  findings.push(
308
363
  finding(
309
364
  'suggestion-missing',
@@ -314,7 +369,7 @@ function checkQuestionContract(name: string, lines: string[]): Finding[] {
314
369
  )
315
370
  }
316
371
 
317
- if (!question.body.some((line) => line.startsWith('- Answer:'))) {
372
+ if (!answer) {
318
373
  findings.push(
319
374
  finding(
320
375
  'question-unanswerable',
@@ -324,6 +379,29 @@ function checkQuestionContract(name: string, lines: string[]): Finding[] {
324
379
  ),
325
380
  )
326
381
  }
382
+
383
+ if (
384
+ suggested &&
385
+ answer &&
386
+ answer.slice(ANSWER_PREFIX.length).trim() === ''
387
+ ) {
388
+ const text = suggested.slice(SUGGESTED_PREFIX.length).trim()
389
+ const normalized = normalizeOperatorCall(text)
390
+
391
+ if (
392
+ normalized.toLowerCase().startsWith(OPERATOR_CALL) &&
393
+ !text.toLowerCase().startsWith(OPERATOR_CALL)
394
+ ) {
395
+ findings.push(
396
+ finding(
397
+ 'operator-call-phrasing',
398
+ name,
399
+ subject,
400
+ 'defers to the operator over a blank Answer without opening with the canonical needs your call, so a paraphrase reaches the corpus instead of the fixed spelling.',
401
+ ),
402
+ )
403
+ }
404
+ }
327
405
  }
328
406
 
329
407
  return findings
@@ -3,20 +3,17 @@ import { readFile } from 'node:fs/promises'
3
3
  import { isAbsolute, relative, resolve } from 'node:path'
4
4
  import { isUnder } from '@/paths'
5
5
  import { recordDir, recordDirs } from '@/record-root'
6
- import { readQuestions, splitPlanSections } from '@/records/validate'
6
+ import {
7
+ normalizeOperatorCall,
8
+ OPERATOR_CALL,
9
+ readQuestions,
10
+ splitPlanSections,
11
+ } from '@/records/validate'
7
12
 
8
13
  const PLANS = 'plans'
9
14
  const TASKS = 'tasks'
10
15
  const ARCHIVE = 'archive'
11
16
 
12
- /**
13
- * The suggestion the plan standard fixes for a question that turns on the
14
- * operator's preference rather than on a technical default. Every other
15
- * suggestion is accepted by a blank slot, so only this phrase over an empty
16
- * `- Answer:` is a stop.
17
- */
18
- const OPERATOR_CALL = 'needs your call'
19
-
20
17
  const SUGGESTED_PREFIX = '- Suggested:'
21
18
  const ANSWER_PREFIX = '- Answer:'
22
19
 
@@ -112,9 +109,15 @@ function isAnswered(body: readonly string[]): boolean {
112
109
  * behind a full stop, so both separators come off. Reporting the phrase with
113
110
  * whatever punctuation followed it hands the operator a stray mark where the
114
111
  * reason should start.
112
+ *
113
+ * Takes the normalized suggestion rather than the raw one, so the length
114
+ * stripped from the front is always `OPERATOR_CALL`'s own regardless of which
115
+ * recognized wording the author wrote. The `operator's call` and
116
+ * `the operator's call` variants read longer than `your call`, and slicing by
117
+ * the canonical length against the raw text would cut into the reason itself.
115
118
  */
116
- function reasonOf(suggested: string): string {
117
- const rest = suggested.slice(OPERATOR_CALL.length).replace(/^[,.;:\s]+/, '')
119
+ function reasonOf(normalized: string): string {
120
+ const rest = normalized.slice(OPERATOR_CALL.length).replace(/^[,.;:\s]+/, '')
118
121
 
119
122
  return rest.length > 0 ? rest : 'no reason stated'
120
123
  }
@@ -130,10 +133,13 @@ function openQuestions(lines: readonly string[]): OpenQuestion[] {
130
133
 
131
134
  for (const question of readQuestions(lines)) {
132
135
  const suggested = suggestionOf(question.body)
133
- if (!suggested?.toLowerCase().startsWith(OPERATOR_CALL)) continue
136
+ if (!suggested) continue
137
+
138
+ const normalized = normalizeOperatorCall(suggested)
139
+ if (!normalized.toLowerCase().startsWith(OPERATOR_CALL)) continue
134
140
  if (isAnswered(question.body)) continue
135
141
 
136
- open.push({ label: question.label, why: reasonOf(suggested) })
142
+ open.push({ label: question.label, why: reasonOf(normalized) })
137
143
  }
138
144
 
139
145
  return open
@@ -6,6 +6,7 @@ const baseURL = `http://localhost:${4321 + (Number(process.env.WORKTREE_PORT_OFF
6
6
  export default defineConfig({
7
7
  testDir: 'e2e',
8
8
  forbidOnly: isCI,
9
+ // Absorbs shared-runner noise on a fresh scaffold with no flake history, at the cost of hiding a real defect until someone reads the run summary.
9
10
  retries: isCI ? 2 : 0,
10
11
  // No override here: Playwright's own CPU-derived default suits every CI runner
11
12
  reporter: isCI ? 'list' : 'html',
@@ -19,7 +20,9 @@ export default defineConfig({
19
20
  { name: 'webkit', use: { ...devices['Desktop Safari'] } },
20
21
  ],
21
22
  webServer: {
22
- command: 'bun run build && bun run preview',
23
+ command: process.env.DIST_PREBUILT
24
+ ? 'bun run preview'
25
+ : 'bun run build && bun run preview',
23
26
  url: baseURL,
24
27
  reuseExistingServer: false,
25
28
  env: { ASTRO_PREVIEW_BACKGROUND: '0' },
@@ -19,7 +19,7 @@ The astro stack covers Astro + TypeScript projects: content sites, marketing sit
19
19
 
20
20
  - `astro.config.mjs`: `@astrojs/react` integration, `@tailwindcss/vite` in `vite.plugins`, `@/` path alias via `vite.resolve.alias`, `ASTRO_SITE` env for the `site` field. Port `4321` plus `WORKTREE_PORT_OFFSET` at `server.port`, with `strictPort` under `vite.server` and `vite.preview`. Astro merges the user's `vite` block into the config backing both its dev and its static preview server, and feeds `server.port` through as the preview port, so the port sits at the top level while the bind guarantee sits under `vite`.
21
21
  - `vitest.config.ts`: uses `getViteConfig` from `astro/config` (not `mergeConfig`). jsdom, globals, setup file, `passWithNoTests: true`, v8 coverage, `**/*.astro` in coverage excludes.
22
- - `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `4321` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. Astro's dev/prod gap is wide (MDX, island hydration, asset optimization), so E2E always tests the built `dist/`.
22
+ - `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `4321` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. Astro's dev/prod gap is wide (MDX, island hydration, asset optimization), so E2E always tests the built `dist/`. `DIST_PREBUILT` set in the environment drops the `build` half, running `bun run preview` alone against a `dist/` a prior CI job already produced.
23
23
  - `tsconfig.json`: extends `astro/tsconfigs/strict`, adds `skipLibCheck`, `vitest/globals` and `@testing-library/jest-dom` in types, `@/` paths.
24
24
  - `eslint.config.js`: overrides the web layer. Adds `eslint-plugin-astro` (`.astro` parser via `astro-eslint-parser`). React-hooks scoped to `.jsx`/`.tsx` only (`.astro` is not React). `src/pages/**` exempt from filename and folder naming conventions because Astro's file-based routing ties names to URL segments.
25
25
 
@@ -6,6 +6,7 @@ const baseURL = `http://localhost:${5173 + (Number(process.env.WORKTREE_PORT_OFF
6
6
  export default defineConfig({
7
7
  testDir: 'e2e',
8
8
  forbidOnly: isCI,
9
+ // Absorbs shared-runner noise on a fresh scaffold with no flake history, at the cost of hiding a real defect until someone reads the run summary.
9
10
  retries: isCI ? 2 : 0,
10
11
  // No override here: Playwright's own CPU-derived default suits every CI runner
11
12
  reporter: isCI ? 'list' : 'html',
@@ -5,6 +5,10 @@ on:
5
5
  branches: [main]
6
6
  workflow_dispatch:
7
7
 
8
+ concurrency:
9
+ group: verify-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
8
12
  jobs:
9
13
  static-checks:
10
14
  name: 🔍 Static Checks
@@ -90,6 +94,13 @@ jobs:
90
94
  - name: Build
91
95
  run: bun run build
92
96
 
97
+ - name: Upload Build Output
98
+ uses: actions/upload-artifact@v4
99
+ with:
100
+ name: build-output
101
+ path: dist/
102
+ retention-days: 1
103
+
93
104
  e2e-tests:
94
105
  name: 🎭 E2E Tests
95
106
  runs-on: ubuntu-latest
@@ -111,18 +122,29 @@ jobs:
111
122
  - name: Install Dependencies
112
123
  run: bun install --frozen-lockfile
113
124
 
125
+ - name: Download Build Output
126
+ uses: actions/download-artifact@v4
127
+ with:
128
+ name: build-output
129
+ path: dist/
130
+
131
+ - name: Install Playwright System Dependencies
132
+ run: bunx playwright install-deps chromium
133
+
114
134
  - name: Cache Playwright Browsers
115
135
  id: playwright-cache
116
136
  uses: actions/cache@v4
117
137
  with:
118
138
  path: ~/.cache/ms-playwright
119
- key: playwright-${{ runner.os }}-${{ hashFiles('**/package.json') }}
139
+ key: playwright-chromium-${{ runner.os }}-${{ hashFiles('**/package.json') }}
120
140
 
121
141
  - name: Install Playwright Chromium
122
142
  if: steps.playwright-cache.outputs.cache-hit != 'true'
123
143
  run: bunx playwright install chromium
124
144
 
125
145
  - name: Run E2E Tests
146
+ env:
147
+ DIST_PREBUILT: 'true'
126
148
  run: bun run test:e2e --project=chromium
127
149
 
128
150
  - name: Upload E2E Report
@@ -24,10 +24,29 @@ const CASES: CaptureCase[] = [
24
24
  },
25
25
  ]
26
26
 
27
+ const args = process.argv.slice(2)
28
+ const checkConsoleClean = args.includes('--check-console-clean')
29
+ const requireBaseUrl = args.includes('--require-base-url')
30
+
31
+ if (requireBaseUrl && !process.env.SCREENSHOT_BASE_URL) {
32
+ console.error('SCREENSHOT_BASE_URL is required with --require-base-url')
33
+ process.exit(1)
34
+ }
35
+
27
36
  const BASE_URL = process.env.SCREENSHOT_BASE_URL ?? 'http://localhost:4173'
28
- const OUT_DIR = 'screenshots'
37
+
38
+ let hostname: string
39
+ try {
40
+ hostname = new URL(BASE_URL).hostname
41
+ } catch {
42
+ console.error(`SCREENSHOT_BASE_URL is not a valid URL: ${BASE_URL}`)
43
+ process.exit(1)
44
+ }
45
+
46
+ const OUT_DIR = path.join('screenshots', hostname)
29
47
 
30
48
  const browser = await chromium.launch()
49
+ const consoleErrors: string[] = []
31
50
 
32
51
  for (const captureCase of CASES) {
33
52
  const context = await browser.newContext({
@@ -35,6 +54,16 @@ for (const captureCase of CASES) {
35
54
  })
36
55
  const page = await context.newPage()
37
56
 
57
+ if (checkConsoleClean) {
58
+ page.on('console', (msg) => {
59
+ if (msg.type() === 'error') {
60
+ consoleErrors.push(
61
+ `${captureCase.section}/${captureCase.theme}: ${msg.text()}`,
62
+ )
63
+ }
64
+ })
65
+ }
66
+
38
67
  if (captureCase.setup) await captureCase.setup(page)
39
68
 
40
69
  await page.goto(`${BASE_URL}${captureCase.route}`)
@@ -51,3 +80,9 @@ for (const captureCase of CASES) {
51
80
  }
52
81
 
53
82
  await browser.close()
83
+
84
+ if (checkConsoleClean && consoleErrors.length > 0) {
85
+ console.error('console errors detected:')
86
+ for (const error of consoleErrors) console.error(` ${error}`)
87
+ process.exit(1)
88
+ }
@@ -11,7 +11,7 @@ fi
11
11
  bun run build
12
12
  bun run preview >/dev/null 2>&1 &
13
13
  PREVIEW_PID=$!
14
- trap 'kill "$PREVIEW_PID" 2>/dev/null; wait "$PREVIEW_PID" 2>/dev/null || true' EXIT
14
+ trap 'kill "$PREVIEW_PID" 2>/dev/null || true; wait "$PREVIEW_PID" 2>/dev/null || true' EXIT
15
15
 
16
16
  for _ in $(seq 1 40); do
17
17
  if curl -sSf "http://localhost:$PREVIEW_PORT/" >/dev/null 2>&1; then
@@ -47,6 +47,7 @@ packages = [
47
47
  "test:e2e:ui" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && playwright test --ui"
48
48
  "test:e2e:report" = "playwright show-report"
49
49
  "check:full" = "./scripts/verify.sh && bun run test:e2e"
50
+ "smoke:prod" = "bun e2e/screenshot.ts --check-console-clean --require-base-url"
50
51
 
51
52
  [scripts.override]
52
53
  "lint" = "eslint . --max-warnings 0"
@@ -55,5 +56,5 @@ packages = [
55
56
  [gitignore]
56
57
  "# Build" = ["dist/"]
57
58
  "# Coverage" = ["coverage/"]
58
- "# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/", "screenshots/"]
59
+ "# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]
59
60
  "# VSCode" = [".vscode/*", "!.vscode/extensions.json", "!.vscode/settings.json"]
@@ -12,7 +12,7 @@ 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/<section>/<theme>.png`. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone.
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. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. `--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
16
  - `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for ESLint, Tailwind, Playwright, Vitest.
17
17
  - `.github/workflows/verify.yml`: `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests` jobs.
18
18
  - `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order.
@@ -106,11 +106,20 @@ Append rows:
106
106
  | `bun run test:e2e` | Run Playwright E2E tests. |
107
107
  | `bun run test:e2e:changed` | Run Playwright E2E tests for specs the import graph reaches from the current diff. |
108
108
  | `bun run screenshot` | Build, preview, then capture screenshots. |
109
+ | `bun run smoke:prod` | Capture against `SCREENSHOT_BASE_URL`, requiring it set and failing on any console error. |
109
110
 
110
111
  `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.
111
112
 
112
113
  `governance/rules/ui/440-surface-capture.md` is what asks a session to run the capture after a route changes. It fires on route and page files rather than on every component, so a shared component changing every screen fires nothing and the operator runs the capture by hand.
113
114
 
115
+ The screenshot output now tracks in git rather than getting discarded, so the first capture a scaffolded target runs after this change is the baseline it commits.
116
+
117
+ ## Gitignore (extend)
118
+
119
+ `[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
120
+
121
+ - `"# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]`
122
+
114
123
  ## Verify script
115
124
 
116
125
  The web layer's `scripts/verify.sh` replaces the base version. Order: typecheck, lint, format, spelling, shell, unit tests, build. Stack adapters may override if their typecheck or build differs.