@erclx/canon 4.99.0 → 4.100.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.
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  <div align="center">
2
2
 
3
- <img src="assets/brand/mark-accent.svg" alt="canon" width="56" height="56">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="assets/brand/mark-accent.svg">
5
+ <img src="assets/brand/mark-accent-light.svg" alt="canon" width="56" height="56">
6
+ </picture>
4
7
 
5
8
  # canon
6
9
 
@@ -14,7 +17,10 @@ canon is a CLI and Claude Code plugin that stops your AI conventions drifting ap
14
17
 
15
18
  </div>
16
19
 
17
- ![The first screen of the canon landing page, a project before and after one canon init, with the file counts each step lays down](assets/evidence/arrival.png)
20
+ <picture>
21
+ <source media="(prefers-color-scheme: dark)" srcset="assets/evidence/arrival.png">
22
+ <img src="assets/evidence/arrival-light.png" alt="The first screen of the canon landing page, a project before and after one canon init, with the file counts each step lays down">
23
+ </picture>
18
24
 
19
25
  The page is one real session the toolkit ran on itself, and every count on it is read from the repo when the page builds.
20
26
 
@@ -39,7 +45,10 @@ claude plugin marketplace add https://github.com/erclx/canon
39
45
  claude plugin install canon@canon
40
46
  ```
41
47
 
42
- ![Adding the canon marketplace and installing the plugin in Claude Code](assets/evidence/install.png)
48
+ <picture>
49
+ <source media="(prefers-color-scheme: dark)" srcset="assets/evidence/install.png">
50
+ <img src="assets/evidence/install-light.png" alt="Adding the canon marketplace and installing the plugin in Claude Code">
51
+ </picture>
43
52
 
44
53
  The skills land as `/canon:<name>`. If your session was already open, run `/reload-plugins` to pick them up.
45
54
 
@@ -67,15 +76,24 @@ Each domain has a canonical source in this repo and a thin install or sync CLI o
67
76
  | Transcripts | A YouTube transcript with metadata frontmatter | `canon transcripts <url>` writes it into any repo |
68
77
  | Sandbox | Scenario scaffolds that provision an isolated project state for verifying each domain flow | `canon sandbox` |
69
78
 
70
- ![The canon catalog as a ledger of five rows, with the count each ships and a few real names: skills, rules, standards, commands, and stacks](assets/evidence/catalog.png)
79
+ <picture>
80
+ <source media="(prefers-color-scheme: dark)" srcset="assets/evidence/catalog.png">
81
+ <img src="assets/evidence/catalog-light.png" alt="The canon catalog as a ledger of five rows, with the count each ships and a few real names: skills, rules, standards, commands, and stacks">
82
+ </picture>
71
83
 
72
84
  Every count in that image is read from the catalogs when it is built, so they're what the repo ships today. A tooling stack lands as real files under version control, because a config is something your build reads and your project owns. A standard stays here and is opened by name, so there is no copy in your repo to drift from this one. Governance is the third shape. A rule with a path glob loads only when a matching path is edited, and a rule with none loads every session. Run `canon gov list` to see the glob beside each rule.
73
85
 
74
86
  Two of the outputs are documents you open rather than files you run. A `SLIDES.md` renders to a deck, and a learning workspace renders to a small site.
75
87
 
76
- ![A slide from the showcase deck rendered by canon slides render, a bulleted list under a heading on a dark warm background](examples/slides/evidence/showcase-dark-04.png)
88
+ <picture>
89
+ <source media="(prefers-color-scheme: dark)" srcset="examples/slides/evidence/showcase-dark-04.png">
90
+ <img src="examples/slides/evidence/showcase-light-04.png" alt="A slide from the showcase deck rendered by canon slides render, a bulleted list under a heading on a warm background">
91
+ </picture>
77
92
 
78
- ![The learning workspace listing rendered by canon teach, three workspaces with their lesson and term counts, one still a stub](examples/teach/evidence/root-listing.png)
93
+ <picture>
94
+ <source media="(prefers-color-scheme: dark)" srcset="examples/teach/evidence/root-listing.png">
95
+ <img src="examples/teach/evidence/root-listing-light.png" alt="The learning workspace listing rendered by canon teach, three workspaces with their lesson and term counts, one still a stub">
96
+ </picture>
79
97
 
80
98
  ## It runs on itself
81
99
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.99.0",
4
+ "version": "4.100.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -18,9 +18,13 @@ canon capture https://example.com --selector .window --out preview.png
18
18
 
19
19
  ## What this repository captures
20
20
 
21
- A capture set here spans two folders. `assets/captures/` holds three sources and the template each is written from, and `assets/evidence/` holds their images and the built-page capture a document points at with the stamp answering for each, under the segment `canon pr evidence` compares. One run over the source folder rebuilds every image, and it takes `--out assets/evidence` to put each PNG where its document points rather than beside the markup it rendered. None of the three sources is edited by hand. `scripts/core/regen-hero.sh` writes each `.html` from the template beside it, filling one shared value map into all of them, and `bun run check` regenerates them and fails on the difference.
21
+ A capture set here spans two folders. `assets/captures/` holds five sources and the three templates they are written from, and `assets/evidence/` holds their images and the built-page capture a document points at with the stamp answering for each, under the segment `canon pr evidence` compares. One run over the source folder rebuilds every image, and it takes `--out assets/evidence` to put each PNG where its document points rather than beside the markup it rendered. None of the sources is edited by hand. `scripts/core/regen-hero.sh` writes each `.html` from the template beside it, filling one shared value map into all of them, and `bun run check` regenerates them and fails on the difference.
22
22
 
23
- Two of the three take catalog data, so a stack gaining a rule moves the frame on the next run. `install.html` is the exception, since it holds terminal text from a real run in its template rather than derived from a live catalog at build time. `assets/evidence/arrival.png` is the one image with no source here: it is the built page's `.fold` element captured in dark mode against a local `web/` preview, since `canon capture` takes no color scheme and the page follows the browser's default of light, so it is retaken by hand when the page's first screen changes.
23
+ Two of the three templates take catalog data, so a stack gaining a rule moves the frame on the next run. `install.html` is the exception, since it holds terminal text from a real run in its template rather than derived from a live catalog at build time. `assets/evidence/arrival.png` is the one image with no source here: it is the built page's `.fold` element captured in dark mode against a local `web/` preview, since `canon capture` takes no color scheme and the page follows the browser's default of light, so it is retaken by hand when the page's first screen changes.
24
+
25
+ The README shows each still in two themes through `<picture>`, so a frame is a pair named `<name>` and `<name>-light`. `catalog` and `install` take the light half from the same template, which `regen-hero.sh` fills a second time with the plain color roles pointed at the light ones, and each half is its own capture set with its own stamp. `arrival-light.png` is the same `.fold` capture of the same address taken without forcing dark, so it is retaken by hand alongside `arrival.png`, and nothing catches one drifting from the other.
26
+
27
+ A stamp for a URL source digests the address string rather than the page it served, so two themes of one address carry one `source-sha256` and only `image-sha256` tells the frames apart. That holds for `examples/teach/evidence/root-listing-light.png`, which is retaken by serving a copy of the fixture with `data-theme="light"` on its `<html>` element at the same address, since the page follows the dark default and the command takes no scheme.
24
28
 
25
29
  `assets/captures/` is read flat and never descends, by the regeneration script, by this command, and by the drift stage alike. A source nested a further folder down is skipped by all three with nothing reported, so a new frame is a template dropped directly in rather than a folder of its own. The split is what retired the name prefix the flat layout used to need: a frame is named for itself now, since `assets/captures/` no longer mixes markup in with the images.
26
30
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.99.0",
4
+ "version": "4.100.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -15,6 +15,10 @@
15
15
  # component half is left out on purpose: it is a scrollbar and a status marker,
16
16
  # and a static capture frame renders neither.
17
17
  #
18
+ # A frame a README shows in both themes fills once more against the light roles
19
+ # and writes `<name>-light.html` beside the dark one, so each theme is its own
20
+ # capture set with its own stamp.
21
+ #
18
22
  # Only the HTML regenerates here. The PNG beside it is a chromium render whose
19
23
  # bytes move with the browser version, so asserting it in verify.sh would fail
20
24
  # on a machine whose chromium differs rather than on a stale count. Rebuild the
@@ -393,9 +397,38 @@ if (templates.length === 0) {
393
397
  process.exit(1)
394
398
  }
395
399
 
396
- for (const template of templates) {
400
+ // The frames a README serves in both themes. The emitter already carries a
401
+ // `--color-light-*` role beside each `--color-*` one, so the light frame is the
402
+ // same template with the plain roles pointed at the light ones rather than a
403
+ // second copy of any hex value. The hero is a landing-page frame no README
404
+ // shows, so it stays dark.
405
+ const LIGHT_TEMPLATES = new Set(["catalog.html.tmpl", "install.html.tmpl"])
406
+
407
+ const lightRoles = [...tokenCss.matchAll(/--color-light-([a-z-]+):/g)].map((match) => match[1])
408
+ if (lightRoles.length === 0) {
409
+ console.error("regen-hero: token css carries no --color-light-* roles, refusing to write a light frame")
410
+ process.exit(1)
411
+ }
412
+ const lightTokenCss = [
413
+ tokenCss,
414
+ "",
415
+ " :root {",
416
+ ...lightRoles.map((role) => ` --color-${role}: var(--color-light-${role});`),
417
+ " --frame-shadow: rgb(0 0 0 / 16%);",
418
+ " }",
419
+ ].join("\n")
420
+
421
+ const frames = templates.flatMap((template) => {
422
+ const base = template.replace(/\.html\.tmpl$/, "")
423
+ const dark = { template, out: `${base}.html`, tokens: tokenCss }
424
+ return LIGHT_TEMPLATES.has(template)
425
+ ? [dark, { template, out: `${base}-light.html`, tokens: lightTokenCss }]
426
+ : [dark]
427
+ })
428
+
429
+ for (const { template, out, tokens } of frames) {
397
430
  let html = await Bun.file(ASSET_DIR + "/" + template).text()
398
- for (const [key, value] of Object.entries(values)) {
431
+ for (const [key, value] of Object.entries({ ...values, TOKENS: tokens })) {
399
432
  html = html.replaceAll(`{{${key}}}`, value)
400
433
  }
401
434
 
@@ -405,6 +438,6 @@ for (const template of templates) {
405
438
  process.exit(1)
406
439
  }
407
440
 
408
- await Bun.write(ASSET_DIR + "/" + template.replace(/\.tmpl$/, ""), html)
441
+ await Bun.write(ASSET_DIR + "/" + out, html)
409
442
  }
410
443
  '
@@ -3,6 +3,12 @@ import { chromium } from '@playwright/test'
3
3
  import { mkdir } from 'fs/promises'
4
4
  import path from 'path'
5
5
 
6
+ /** One addressable part of a route, captured beside the whole-page frame. */
7
+ interface CaptureSection {
8
+ name: string
9
+ selector: string
10
+ }
11
+
6
12
  interface CaptureCase {
7
13
  section: string
8
14
  theme: string
@@ -10,9 +16,35 @@ interface CaptureCase {
10
16
  width: number
11
17
  height: number
12
18
  evidence?: boolean
19
+ sections?: CaptureSection[]
13
20
  setup?: (page: Page) => Promise<void>
14
21
  }
15
22
 
23
+ /**
24
+ * The landing page's own beats, top to bottom. Every entry is a section a
25
+ * reviewer reads on its own, so a diff lands on the part that moved rather
26
+ * than on one image of the whole page.
27
+ */
28
+ const HOME_SECTIONS: CaptureSection[] = [
29
+ { name: 'top', selector: '#top' },
30
+ { name: 'proof', selector: '#proof' },
31
+ { name: 'ask', selector: '#ask' },
32
+ { name: 'rules', selector: '#rules' },
33
+ { name: 'skills', selector: '#skills' },
34
+ { name: 'plan', selector: '#plan' },
35
+ { name: 'dispatch', selector: '#dispatch' },
36
+ { name: 'workers', selector: '#workers' },
37
+ { name: 'gate', selector: '#gate' },
38
+ { name: 'memory', selector: '#memory' },
39
+ { name: 'evidence', selector: '#evidence' },
40
+ { name: 'loop', selector: '#loop' },
41
+ { name: 'merge', selector: '#merge' },
42
+ { name: 'provenance', selector: '#provenance' },
43
+ { name: 'start', selector: '#start' },
44
+ { name: 'field', selector: '#field' },
45
+ { name: 'close', selector: '#close' },
46
+ ]
47
+
16
48
  const CASES: CaptureCase[] = [
17
49
  {
18
50
  section: 'home',
@@ -21,6 +53,7 @@ const CASES: CaptureCase[] = [
21
53
  width: 1280,
22
54
  height: 800,
23
55
  evidence: true,
56
+ sections: HOME_SECTIONS,
24
57
  },
25
58
  {
26
59
  section: 'home',
@@ -28,6 +61,7 @@ const CASES: CaptureCase[] = [
28
61
  route: '/',
29
62
  width: 1280,
30
63
  height: 800,
64
+ sections: HOME_SECTIONS,
31
65
  setup: (page) => page.emulateMedia({ colorScheme: 'dark' }),
32
66
  },
33
67
  ]
@@ -53,6 +87,108 @@ try {
53
87
 
54
88
  const OUT_DIR = path.join('screenshots', hostname)
55
89
 
90
+ /** Every wait on a lazy load is bounded explicitly. */
91
+ const SETTLE_TIMEOUT = 15_000
92
+
93
+ /** Marks the chrome a section frame drops, set on the page and read by the screenshot's own stylesheet. */
94
+ const FLOATING_ATTRIBUTE = 'data-capture-floating'
95
+
96
+ /**
97
+ * Brings the page to the state a reader arrives at, before anything is shot.
98
+ * A figure gated on an IntersectionObserver and a frame marked `loading=lazy`
99
+ * both stay at their start state until something scrolls past them, and a full
100
+ * page capture scrolls nothing, so each named section is scrolled into view
101
+ * once and the page then returns to the top. Fonts, images and frames settle
102
+ * afterwards, since the scroll is what starts the lazy loads.
103
+ */
104
+ async function settle(
105
+ page: Page,
106
+ label: string,
107
+ sections: readonly CaptureSection[],
108
+ ): Promise<CaptureSection[]> {
109
+ const present: CaptureSection[] = []
110
+ for (const section of sections) {
111
+ const element = page.locator(section.selector)
112
+ // Read the count before scrolling, since a selector matching nothing
113
+ // otherwise costs the default timeout once per section. A miss is
114
+ // reported and skipped rather than raised, because `CASES` is a template
115
+ // a project rewrites and a target that syncs this file before editing its
116
+ // own list would otherwise capture nothing at all. The project owning the
117
+ // selectors is the one that tests them, which is what the section spec
118
+ // beside its own page does.
119
+ if ((await element.count()) === 0) {
120
+ console.warn(
121
+ `${label}: no element matches ${section.selector}, named as section "${section.name}". Skipped.`,
122
+ )
123
+ continue
124
+ }
125
+ present.push(section)
126
+ await element.scrollIntoViewIfNeeded()
127
+ }
128
+ await page.evaluate(() => window.scrollTo(0, 0))
129
+ await page.evaluate(() => document.fonts.ready.then(() => undefined))
130
+ // Settle on what the scroll started rather than on a second `networkidle`,
131
+ // which is a lifecycle event of the navigation and does not re-arm for a
132
+ // load a scroll triggered afterwards.
133
+ await page.waitForFunction(
134
+ () => {
135
+ if (document.readyState !== 'complete') return false
136
+ for (const image of document.images) if (!image.complete) return false
137
+ for (const frame of document.querySelectorAll('iframe')) {
138
+ try {
139
+ const inner = frame.contentDocument
140
+ if (inner !== null && inner.readyState !== 'complete') return false
141
+ } catch {
142
+ // Cross-origin, so there is no readyState to read and nothing here
143
+ // can wait on it.
144
+ }
145
+ }
146
+ return true
147
+ },
148
+ undefined,
149
+ { timeout: SETTLE_TIMEOUT },
150
+ )
151
+ return present
152
+ }
153
+
154
+ /**
155
+ * Marks every element the page floats over its own content, which is chrome
156
+ * such as a sticky bar or a fixed dialog. A section taller than the viewport
157
+ * is scrolled into view to be shot, and the chrome rides that scroll into the
158
+ * middle of the frame, painting over the part the frame exists to show. The
159
+ * mark is an attribute rather than a style, so the rule that acts on it is
160
+ * carried by the screenshot itself and no state is left behind to restore.
161
+ *
162
+ * Marking costs the whole-page frame nothing, since that one is shot before
163
+ * this runs and carries the chrome where a reader meets it.
164
+ */
165
+ async function markFloatingChrome(page: Page) {
166
+ await page.evaluate((attribute) => {
167
+ for (const node of document.querySelectorAll('body *')) {
168
+ const { position } = getComputedStyle(node)
169
+ if (position === 'sticky' || position === 'fixed') {
170
+ node.setAttribute(attribute, '')
171
+ }
172
+ }
173
+ }, FLOATING_ATTRIBUTE)
174
+ }
175
+
176
+ async function shoot(page: Page, dir: string, file: string, selector?: string) {
177
+ await mkdir(dir, { recursive: true })
178
+ const target = path.join(dir, file)
179
+ if (selector === undefined) {
180
+ await page.screenshot({ path: target, fullPage: true })
181
+ } else {
182
+ // Floating chrome keeps the space it already occupies, so dropping it out
183
+ // of its own layer moves nothing and leaves every box the size it was.
184
+ await page.locator(selector).screenshot({
185
+ path: target,
186
+ style: `[${FLOATING_ATTRIBUTE}] { position: static !important; }`,
187
+ })
188
+ }
189
+ console.log(`captured ${target}`)
190
+ }
191
+
56
192
  const browser = await chromium.launch()
57
193
  const consoleErrors: string[] = []
58
194
  let ranCases = 0
@@ -63,6 +199,10 @@ for (const captureCase of CASES) {
63
199
  ranCases++
64
200
  const context = await browser.newContext({
65
201
  viewport: { width: captureCase.width, height: captureCase.height },
202
+ // A page honoring the preference lands its own motion at the end state,
203
+ // which is what a still frame has to show. Playwright reads it as a
204
+ // context option, so it reaches no page written beside `viewport` above.
205
+ reducedMotion: 'reduce',
66
206
  })
67
207
  const page = await context.newPage()
68
208
 
@@ -81,19 +221,23 @@ for (const captureCase of CASES) {
81
221
  await page.goto(`${BASE_URL}${captureCase.route}`)
82
222
  await page.waitForLoadState('networkidle')
83
223
 
84
- const sectionDir = path.join(OUT_DIR, captureCase.section)
85
- await mkdir(sectionDir, { recursive: true })
224
+ const label = `${captureCase.section}/${captureCase.theme}`
225
+ const sections = await settle(page, label, captureCase.sections ?? [])
226
+
227
+ const sweepDir = path.join(OUT_DIR, captureCase.section)
228
+ const evidenceDir = path.join('evidence', captureCase.section)
229
+ const file = `${captureCase.theme}.png`
230
+
231
+ await shoot(page, sweepDir, file)
232
+ if (captureCase.evidence) await shoot(page, evidenceDir, file)
86
233
 
87
- const file = path.join(sectionDir, `${captureCase.theme}.png`)
88
- await page.screenshot({ path: file, fullPage: true })
89
- console.log(`captured ${file}`)
234
+ if (sections.length > 0) await markFloatingChrome(page)
90
235
 
91
- if (captureCase.evidence) {
92
- const evidenceDir = path.join('evidence', captureCase.section)
93
- await mkdir(evidenceDir, { recursive: true })
94
- const evidenceFile = path.join(evidenceDir, `${captureCase.theme}.png`)
95
- await page.screenshot({ path: evidenceFile, fullPage: true })
96
- console.log(`captured ${evidenceFile}`)
236
+ for (const { name, selector } of sections) {
237
+ await shoot(page, path.join(sweepDir, name), file, selector)
238
+ if (captureCase.evidence) {
239
+ await shoot(page, path.join(evidenceDir, name), file, selector)
240
+ }
97
241
  }
98
242
 
99
243
  await context.close()
@@ -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/<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. `--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, 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`.
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.
@@ -112,7 +112,7 @@ Append rows:
112
112
 
113
113
  `governance/rules/ui/440-surface-capture.md` is what asks a session to run the capture after a surface changes. It now fires on every component file too, reversing the route-and-page-only scope this reference once described, after a shipped batch of components carried no capture and a defect went unseen. The rule body states its own exemption for a component the production build strips out, so a reader chasing that case reads it there rather than here.
114
114
 
115
- The sweep under `screenshots/` is ignored again, and only a flagged case's `evidence/` output tracks in git, so the first capture a scaffolded target runs after this change is the baseline it commits there.
115
+ The sweep under `screenshots/` is ignored again, and only a flagged case's `evidence/` output tracks in git, so the first capture a scaffolded target runs after this change is the baseline it commits there. A case naming sections commits a folder per section under that baseline rather than one file per theme, which is what lets a pull request comparison land on the part of the page that moved instead of on one image of the whole of it. The count is the section list times the themes, so a list is worth sizing against how many frames each later change has to regenerate and a reviewer has to read.
116
116
 
117
117
  ## Gitignore (extend)
118
118