@erclx/canon 4.102.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.
@@ -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.102.0",
4
+ "version": "4.103.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -91,12 +91,15 @@ Run `canon tooling diff <stack> <target>` for the list resolved against a real t
91
91
 
92
92
  ### web
93
93
 
94
+ - `.github/workflows/readme-screenshot.yml`
94
95
  - `.github/workflows/verify.yml`
95
96
  - `.vscode/extensions.json`
96
97
  - `.vscode/settings.json`
97
98
  - `e2e/home.spec.ts`
98
99
  - `e2e/screenshot.ts`
99
100
  - `eslint.config.js`
101
+ - `scripts/lib/preview-server.sh`
102
+ - `scripts/readme-screenshot.sh`
100
103
  - `scripts/screenshot.sh`
101
104
  - `scripts/verify.sh`
102
105
  - `scripts/worktree-port.sh`
@@ -88,7 +88,7 @@ Report each dispatch by naming the target, the clone, the branch, the model, and
88
88
 
89
89
  1. Address every finding whatever its severity. A minor is a finding, and a reviewer calling one non-blocking does not close the pass that raised it.
90
90
  2. Resolve who holds the branch before dispatching anybody. Run `canon sessions list --branch chore/agents --repository <clone> --json` and read the count rather than the first row.
91
- - A live session holds it: send that session the findings and name `canon:review-address` for it to run, which is step 6 of `## The worker role` and the step that session already stands at. Never name this skill in that message. This body carries `disable-model-invocation: true`, which blocks the `Skill` tool rather than only suppressing an auto-trigger, and a session acting on an inbound message reaches a skill that way and no other, so a message naming this one halts the address leg with the branch built and the findings unread. A launch prompt is the one route to a flagged body, which is why the dispatch above names this skill and this message must not. Assuming the worker was gone is what one hand-driven pass got wrong. All four sessions that opened those pull requests were still alive holding their worktrees hours later, and of two fresh addressers sent over them, one refused on the worktree lock and one cut a second worktree on the same branch, which would have put two sessions pushing to one ref.
91
+ - A live session holds it: send that session the findings and name `canon:review-address` for it to run, which is step 7 of `## The worker role` and the step that session already stands at. Never name this skill in that message. This body carries `disable-model-invocation: true`, which blocks the `Skill` tool rather than only suppressing an auto-trigger, and a session acting on an inbound message reaches a skill that way and no other, so a message naming this one halts the address leg with the branch built and the findings unread. A launch prompt is the one route to a flagged body, which is why the dispatch above names this skill and this message must not. Assuming the worker was gone is what one hand-driven pass got wrong. All four sessions that opened those pull requests were still alive holding their worktrees hours later, and of two fresh addressers sent over them, one refused on the worktree lock and one cut a second worktree on the same branch, which would have put two sessions pushing to one ref.
92
92
  - No live session holds it: dispatch a fresh worker into that clone and brief it with the findings, since it holds none of the reasoning behind the diff.
93
93
  - More than one row: report the ambiguity and stop rather than picking among candidates.
94
94
  3. Re-review when the answer lands, scoped to the commits added since. A worker's reply never closes a target. One fix for two minor findings closed both and introduced four more, every one of them in prose that fix added, and a loop trusting the reply merges that.
@@ -101,11 +101,12 @@ One session, one target, from the worktree entry to the merge of the branch it o
101
101
 
102
102
  1. Confirm the clone is current. Fetch, then compare against the origin's default branch. Report a checkout that is behind and stop, rather than branching from a stale base.
103
103
  2. Enter a worktree. Invoke `canon:session-worktree chore/agents`, which takes the branch as its tier 0 argument and enters a linked worktree inside this target. Branching in the checkout itself is what this avoids, since the operator may be working in it.
104
- 3. Diagnose and repair. Invoke `canon:canon-operator`, which reads `canon sync --check . --json` and routes each finding to the command or the skill that owns it. Do not restate that routing here and do not edit a managed file by hand.
105
- 4. Commit and open the pull request through `canon:git-commit` and `canon:git-pr`, handing each the fixed shape above rather than taking the title the generator derives from the diff.
106
- 5. Announce as the pull request opens, carrying its URL, its number, and the branch, to whoever dispatched this session. That transition is the one moment only this session can observe.
107
- 6. Answer the review. Invoke `canon:review-address`, which pulls the findings and the CI state on this branch's open pull request, fixes each in the working tree, replies, and pushes. Answer every finding whatever its severity.
108
- 7. Stop there. Do not mark the pull request ready, do not merge, and do not declare the review closed. A narrow re-review posts `## Review closed`.
104
+ 3. Check what this target holds. Invoke `canon:target-check`, which reports per domain what the target carries against what the toolkit ships and repairs nothing. It runs before any repair so the wave reads one stated set of domains per target rather than whatever each session decided to examine.
105
+ 4. Diagnose and repair. Invoke `canon:canon-operator`, which reads `canon sync --check . --json` and routes each finding to the command or the skill that owns it. Do not restate that routing here and do not edit a managed file by hand.
106
+ 5. Commit and open the pull request through `canon:git-commit` and `canon:git-pr`, handing each the fixed shape above rather than taking the title the generator derives from the diff.
107
+ 6. Announce as the pull request opens, carrying its URL, its number, and the branch, to whoever dispatched this session. That transition is the one moment only this session can observe.
108
+ 7. Answer the review. Invoke `canon:review-address`, which pulls the findings and the CI state on this branch's open pull request, fixes each in the working tree, replies, and pushes. Answer every finding whatever its severity.
109
+ 8. Stop there. Do not mark the pull request ready, do not merge, and do not declare the review closed. A narrow re-review posts `## Review closed`.
109
110
 
110
111
  Send a block out as a message before it becomes an interactive prompt. A session already waiting on input never reaches the tool round an inbound message drains at, so an answer relayed afterwards arrives under the open question and changes nothing.
111
112
 
@@ -16,6 +16,7 @@ A README whose real documentation lives in `canon/context/` or `docs/` was a sec
16
16
  - Read `standards/readme.md` before drafting, since its `## Voice` section is scoped to a repository-root README and yields to `write-human`'s reference voice everywhere else
17
17
  - Detect every project type the standard's `## Content` list applies to, from the manifest and the tree, rather than drafting against the closest single type
18
18
  - Treat an existing README with no H1, or with headings that name only the scaffold that wrote it, as unedited generator output to draft over rather than a page to sync section by section
19
+ - Read the header block's slots off the repository, being the mark, the claim, the live link, and the product screenshot, and skip the link and the screenshot for a project with no page, so every element traces to a file or field the project holds
19
20
  - Verify a candidate badge by the value it would render rather than by a fetch's status code, since a badge service answers 200 for a query it cannot satisfy
20
21
  - Confirm the resolved path, the detected types, the badge candidates, and the full content with the user before writing, since project-type detection and badge selection are judgment calls with no diff to preview them against
21
22
 
@@ -23,6 +24,7 @@ A README whose real documentation lives in `canon/context/` or `docs/` was a sec
23
24
 
24
25
  - Rewrite an authored README. One carrying an H1 that names the project refuses toward `docs-sync`.
25
26
  - Offer to overwrite an authored README on the refusal path, or propose conforming it to what this skill would have drafted. A target project's own citations into its README are invisible from here, so the refusal reports rather than proposes.
27
+ - Invent a screenshot or a mark the project does not hold, or write a placeholder path for one. An omitted slot is a correct page and an invented image is the failure.
26
28
  - Assume this skill's own invocation frequency needs no check. Whether anything reaches for it beyond an author typing its name has no answer at creation time, so a review pass some months in should read that back rather than take the assumption on faith.
27
29
 
28
30
  ## Guards
@@ -27,10 +27,16 @@ Read these files in parallel:
27
27
  - Read the project's manifest (`package.json`, `pyproject.toml`, `Cargo.toml`, or equivalent) for a `bin` field or CLI entry point, an installable package name, and its declared dependencies.
28
28
  - Check for a `claude/skills/` or `.claude/skills/` folder, a `plugin.json`, or a marketplace manifest, each naming an agent-facing or marketplace-distributed surface.
29
29
  - A project is often several of these at once. Note every type that applies rather than stopping at the first match, since the Draft step covers each one the project actually is.
30
+ - Check for a page. A dependency on a site framework or a site config file in the manifest or the tree marks a project as having one. Read a live URL separately, from a `homepage` field or a deploy config, since a project can have a page and no known URL.
31
+ - A monorepo can carry a page in one package and a CLI in another, so both signals may fire. Report each rather than picking one.
32
+ - Look for the mark and the screenshot among images the project already commits: scan the existing README for image references, then the asset and public folders.
30
33
 
31
34
  ## Draft
32
35
 
33
36
  - Draft the page against `${CLAUDE_SKILL_DIR}/../../standards/readme.md`: H1, a 2-3 sentence description in plain text, then the required sections, then whichever optional sections and per-type content the Detect step found.
37
+ - Open the page with the standard's header block, filling each slot from what Detect found: mark, title, badges, a one-line claim from the manifest description, the live link, then the product screenshot.
38
+ - Fill the screenshot slot only with an image the project already commits, referenced with alt text naming what it depicts. This skill cannot capture one. Omit the slot when no such image exists and say so in the preview. Never write a placeholder path.
39
+ - Omit the link and the screenshot for a project with no page, and the mark for a project with none, without a note in the drafted page. A project with a page and no known URL gets the screenshot slot, and the Confirm step asks for the link.
34
40
  - Cover every applicable project type from the standard's `## Content` list rather than picking the closest one.
35
41
  - Candidate badges: check for a published package (a registry field in the manifest), a CI workflow, and a `LICENSE` file.
36
42
  - State each candidate's rendered value in the preview rather than trusting a fetch's status code, since a badge service answers 200 for a query it cannot satisfy.
@@ -38,7 +44,8 @@ Read these files in parallel:
38
44
 
39
45
  ## Confirm
40
46
 
41
- - Show the resolved path, the detected project types, the badge candidates and what backs each one, and the full drafted content before writing.
47
+ - Show the resolved path, the detected project types, the header slots filled or skipped with the reason for each, the badge candidates and what backs each one, and the full drafted content before writing.
48
+ - The skill can confirm that an image file exists and is referenced, and it cannot verify the picture shows the product. Ask the user to look at it in the preview.
42
49
  - Confirm with the user. This skill waits for that answer rather than treating the tool permission dialog as the gate, since project-type detection and badge selection are judgment calls with no diff to preview them against.
43
50
 
44
51
  ## Write
@@ -52,6 +59,7 @@ Read these files in parallel:
52
59
 
53
60
  **Target:** `<path>` (root | nested)
54
61
  **Detected:** `<project types>`
62
+ **Header:** `<each slot as filled (source) or skipped (reason)>`
55
63
  **Badges:** `<candidates, or none>`
56
64
 
57
65
  ```markdown
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: target-check
3
+ description: What the target check is for, the gap it closes, and the repairing it refuses
4
+ ---
5
+
6
+ # Target check requirement
7
+
8
+ ## Gap
9
+
10
+ Without this skill, a session landing in a target project decides for itself what to look at. `canon-rollout` dispatches a worker into each target and gets a pull request out, and nothing told that worker what a target holds against what the toolkit ships, so a wave is only as good as whatever each session happened to examine. Four hand-driven repairs across two waves examined four different sets of surfaces and none of them read the same domain twice.
11
+
12
+ The second half of the gap is silence. A session that runs a verb the target's binary does not carry, or reads a report key that is absent, has no instruction telling it apart from a clean answer, so an unexamined domain reaches the reader as a domain that passed.
13
+
14
+ ## Must
15
+
16
+ - Report one section per domain, so a reader can tell which surface a finding came from
17
+ - Read each domain through the verb that already owns that comparison, rather than inspecting the same files by hand, since the verb is the surface under test
18
+ - Take the installed domains from the catalog the report returns rather than from a list in the body, so a domain the toolkit adds later needs no edit here
19
+ - Report a refusal, an absent key, and a missing subcommand as unread, naming the cause, so an unexamined domain never reads as a passing one
20
+ - Report the Claude harness as unchecked on every run while no verb answers for it, as a row rather than an omission
21
+ - Report the toolkit version first, since a target behind on the binary explains every unread section under it
22
+ - Run every read non-interactively by default, because every caller in the near term is a dispatched worker with nobody present to answer a prompt
23
+
24
+ ## Must not
25
+
26
+ - Repair a finding, write a file in the target, or open a pull request. A check that repaired as it read leaves the reader unable to tell a finding from a fix, and the wave decides what to fix.
27
+ - Emit a verdict across the domains. Five answer from verbs and one answers from nothing, so any roll-up is a figure that lies about the sixth.
28
+ - Answer the harness domain by file inspection. A section reaching a verdict from a hand-rolled read is exactly what the other five sections avoid, and a hole a reader can see is worth more than a number nobody can trace.
29
+ - Hardcode a stack, rule, or domain name. A name written into the body goes stale on its own cadence.
30
+
31
+ ## Guards
32
+
33
+ - `canon` absent from the path: stop, since no domain can be read
34
+ - No `.claude/` at the working root: stop and name the scaffold command, since nothing is installed to check
35
+ - The working root is the toolkit itself: stop and name `canon-rollout`, since this skill reads what the toolkit installed into rather than the source
36
+
37
+ ## Out of scope
38
+
39
+ - Document health, which asks a content question rather than a structural one and takes its own surface
40
+ - Repairing anything a section names. Each routes to the command or skill that owns it.
41
+ - A target's own routes, which `canon inventory` reports as a listing rather than a verdict and which is a fact about the project rather than about what the toolkit ships
42
+ - First-time scaffold, which is `setup-init`, and per-intent routing of a single toolkit operation, which is `canon-operator`
@@ -0,0 +1,91 @@
1
+ ---
2
+ name: target-check
3
+ description: Reports what a target project holds against what the toolkit currently ships, one section per domain, reading through `canon` verbs rather than inspecting files. Use when asked to "check this target", "what has this project fallen behind on", "is this project current with the toolkit", "run the target check", or as the first thing a rollout worker runs after entering a target. Do NOT use to repair a finding, which the owning command or skill does, and do NOT use in the toolkit repository itself.
4
+ ---
5
+
6
+ # Target check
7
+
8
+ Reports, per domain, what this target holds against what the toolkit currently ships. It reports and never repairs, so whoever read the report decides what to fix.
9
+
10
+ Six domains carry the report. Five answer from a verb that already exists and the sixth answers from nothing, which it says rather than omitting. `${CLAUDE_SKILL_DIR}/references/domains.md` holds what each one reads, what current looks like, and where a finding routes. This body holds the order, the guards, and the report shape.
11
+
12
+ ## Guards
13
+
14
+ - `canon` absent from the path: stop. `❌ canon is not on PATH, so no domain can be read.`
15
+ - No `.claude/` directory at the working root: stop. `❌ No .claude/ here, so nothing is installed to check. Run canon init first.`
16
+ - A `claude/skills/` folder at the working root, with no leading dot: stop. `❌ This is the toolkit itself rather than a target. canon-rollout takes a change out to the targets.` A target receives skills at `.claude/skills/` and authors none, so the spelling without the dot separates the source from what it installs into.
17
+ - Run every command under `CANON_NON_INTERACTIVE=1` and prompt at no point. Every caller in the near term is a worker a wave dispatched, with nobody present to answer, so the non-interactive path is the only path rather than a flag a caller remembers.
18
+ - Branch on each record's own fields rather than on an exit status. An operator's shell profile may wrap `canon` in a function whose status comes from a trailing command, which reports every refusal as success.
19
+
20
+ ## Step 1: read the reports
21
+
22
+ Run these from the target root, in parallel:
23
+
24
+ ```bash
25
+ CANON_NON_INTERACTIVE=1 canon sync --check . --json
26
+ CANON_NON_INTERACTIVE=1 canon context audit --json
27
+ ```
28
+
29
+ The first answers five domains from one read, being `skew`, `domains`, `seeds`, `tooling`, and the layout fields `unmigrated`, `superseded`, and `reverse`. The second deepens a domain that first read only counts.
30
+
31
+ Then run `CANON_NON_INTERACTIVE=1 canon records validate <kind> --json` once per record folder the target carries, taking `<kind>` from what is present under the record root rather than from a list held here.
32
+
33
+ ### The tooling read takes a stack, always
34
+
35
+ The tooling comparison runs last, because it needs a value the first read returns. Take `<stack>` from the first name in the report's `tooling.chain`, which records the stack nearest the target, and pass it:
36
+
37
+ ```bash
38
+ CANON_NON_INTERACTIVE=1 canon tooling sync --check <stack>
39
+ ```
40
+
41
+ Refuse the domain and report it unread when `chain` names nothing, rather than running the command bare. A bare call reaches the stack prompt, and the non-interactive variable this skill's Guards make mandatory resolves that prompt to the first option in the catalog instead of refusing it. The domain would then compare every target against whichever stack sorts first, and report drift that is an artifact of the wrong comparison rather than anything the target did. It would fire on every run rather than on an edge, since nothing about the target is read on the way to that default.
42
+
43
+ Read every installed domain from the catalog the report returns rather than from a name written into this body. `domains` enumerates what the target actually stamped, so a domain the toolkit adds later reaches this report with no edit here, and a name hardcoded here is a bug.
44
+
45
+ ## Step 2: report each domain
46
+
47
+ Report the six in this order, one section each, per `${CLAUDE_SKILL_DIR}/references/domains.md`:
48
+
49
+ 1. Toolkit version
50
+ 2. The `canon/` folder
51
+ 3. Governance rules
52
+ 4. Tooling
53
+ 5. Seeds
54
+ 6. The Claude harness
55
+
56
+ Give each section three things and nothing else: the state, the evidence the state was read from, and the command or skill that repairs it. Naming the repair is not running it.
57
+
58
+ ### Unread is not current
59
+
60
+ A refused command, an absent key, or a subcommand the target's binary does not carry leaves a domain unread. Report it as unread, name which read could not be taken, and name the cause. Reading an absent key as an empty answer reports a clean target to a project nothing looked at, which is the failure this report exists to replace.
61
+
62
+ The version domain is what makes that case legible rather than mysterious. A target a release behind is missing verbs the other five sections call, so the version section is read first and its state explains every unread section under it.
63
+
64
+ ### No verdict across the six
65
+
66
+ Report a state per domain and no number over them. Five sections answer from verbs and the sixth answers from nothing, so any roll-up is a figure that lies about the sixth. `canon inventory` reports a listing rather than a verdict for the same reason, and it is absent from the six here because its subject is a target's own routes rather than what the toolkit ships.
67
+
68
+ ## Step 3: stop at the report
69
+
70
+ Repair nothing, write no file in the target, and open no pull request. A wave decides what to fix after reading this, and a check that repaired as it read would leave the reader unable to tell a finding from a fix.
71
+
72
+ ## Output
73
+
74
+ Lead with the table, then one detail block per domain that is not current:
75
+
76
+ ```markdown
77
+ Target check: `<path>`, against canon `<version>`.
78
+
79
+ | Domain | State | Evidence |
80
+ | ---------------- | ------------------------- | --------------------------------- |
81
+ | Toolkit version | current / behind / unread | `skew.installed` against `latest` |
82
+ | `canon/` folder | current / behind / unread | what the layout fields named |
83
+ | Governance rules | current / behind / unread | the per-state counts |
84
+ | Tooling | current / behind / unread | what a sync would change |
85
+ | Seeds | current / behind / unread | the per-state counts |
86
+ | Claude harness | unchecked | no verb reads it |
87
+
88
+ No overall verdict. The harness row is unchecked rather than passing.
89
+ ```
90
+
91
+ Keep the harness row `unchecked` on every run until a verb reads it. A domain left out of the table reads as a domain that passed, which is the one shape the row exists to prevent.
@@ -0,0 +1,88 @@
1
+ ---
2
+ title: Target check domains
3
+ description: What each of the six domains reads, what counts as current, and where a finding routes
4
+ ---
5
+
6
+ # Target check domains
7
+
8
+ One section per domain, in the order the body reports them. Each names the read, what current looks like, what the states mean, and the command or skill a finding routes to. Naming a repair is not running one.
9
+
10
+ ## 1. Toolkit version
11
+
12
+ Read `skew` off `canon sync --check . --json`. It carries `state`, `installed`, and `latest`.
13
+
14
+ - Current: `state` reads `current`, or `installed` equals `latest`.
15
+ - Behind: `installed` trails `latest`. Name both versions.
16
+ - Unread: no `skew` key, or the whole report refused. Say the version could not be read and treat every section under it as read against an unknown binary.
17
+
18
+ Routes to `canon upgrade`, which reinstalls with the manager that installed it.
19
+
20
+ This domain is first because it conditions the other five. A target a release behind carries an older verb surface, so a section reporting unread below may be reporting a missing subcommand rather than a missing surface, and the version state is what separates those two readings.
21
+
22
+ ## 2. The `canon/` folder
23
+
24
+ Read three fields off the same report, then two verbs:
25
+
26
+ - `unmigrated`: a domain still at the root layout with nothing under `.claude/`. The most urgent finding, because such a domain reports no drift of its own while being entirely behind.
27
+ - `superseded`: a file a newer seed folder replaced. No command moves it, since the content is the project's own.
28
+ - `reverse`: `unclaimed` names folders at roots the toolkit stopped shipping, each with an `attribution` of `dropped`, `unattributed`, or `project`. `migrations` names a proposal-only skill with a live case here and the `reason` behind it.
29
+ - `canon context audit --json`: required sections, entry length, citations, and index drift across the context folder.
30
+ - `canon records validate <kind> --json`, once per record folder the target carries.
31
+
32
+ - Current: `unmigrated` and `superseded` both empty, `reverse.unclaimed` carrying nothing outside `project` attribution, and both verbs finding nothing.
33
+ - Behind: any of those carrying an entry. Name the entry and its attribution.
34
+ - Unread: `historyUnavailable` set on `reverse`, a `reverse` key the report does not carry at all, or either verb refusing. A missing `reverse` key is a binary predating the field rather than a target with nothing unclaimed.
35
+
36
+ Routes by finding. `unmigrated` routes to a relocation the user runs, since no command moves it. `superseded` routes to `migration-superseded`. A `migrations` entry routes to the skill its `skill` field names, spelled as the report spells it. An `unclaimed` folder carrying `project` attribution routes nowhere, since history proved the project owns it.
37
+
38
+ ## 3. Governance rules
39
+
40
+ Read `domains` off the report and take every entry it carries rather than naming governance here. Each entry carries `stamped`, per-state `counts`, and `entries` with a `state` and a `rel` path.
41
+
42
+ The states are `matching`, `stale`, `customized`, `drifted`, `orphaned`, `stranded`, and `missing`.
43
+
44
+ - Current: every count but `matching` reads zero.
45
+ - Behind: any other count is non-zero. Report the counts and name the paths behind `stale` and `missing`, which a sync repairs, separately from `customized` and `drifted`, which the project wrote and a sync would overwrite.
46
+ - Unread: `stamped` false with no entries, or the domain absent from the array while the target carries installed rules.
47
+
48
+ Routes to `canon <domain> sync`, or to `canon sync` for every installed domain at once. Read the `canon-cli` skill before naming either, since a command name says nothing about what it does to a file already there, and a `customized` entry is exactly the file a sync takes back.
49
+
50
+ ## 4. Tooling
51
+
52
+ Read the `tooling` block off the report first, because the comparison command needs a value it carries.
53
+
54
+ Read `measured` before anything under it. Every count is zero when `measured` is false, which is an absence of measurement rather than a measured zero.
55
+
56
+ On a measured report, take `<stack>` from the first name in `chain` and run `canon tooling sync --check <stack>`. Never run it bare. The stack argument is what the target carries, and omitting it reaches a prompt that the non-interactive variable resolves to the catalog's first entry rather than refusing, which compares the target against a stack it does not use and reports the difference as drift.
57
+
58
+ - Current: `measured` true, `chain` naming stacks the toolkit still ships, and `--check` reporting nothing it would change.
59
+ - Behind: `--check` naming files it would replace, or `counts.gitignore` carrying missing managed entries.
60
+ - Unread: `measured` false. It splits three ways, and each is reported as unmeasured with its cause named rather than as clean. An empty `chain` at a workspace root is by design, since a chain there would guess at what the packages hold. An empty `chain` anywhere else means no tooling install is recorded. A `chain` carrying names the toolkit no longer ships means injecting would write against a retired name.
61
+
62
+ Routes to `canon tooling sync <stack>`, or to `canon tooling inject --gitignore <stack>` for ignore entries alone, taking `<stack>` the same way the comparison above does. An unmeasured report names no stack, so it routes to neither.
63
+
64
+ `canon tooling diff` is the verb this section wants and it does not exist yet. `--check` answers the same question today and stays working as an alias by design, so the retarget when that verb lands is a change of name rather than of behavior.
65
+
66
+ ## 5. Seeds
67
+
68
+ Read `seeds.entries` off the report. Each entry is `matching`, `stale`, `drifted`, or `missing`, and the set spans `CLAUDE.md`, the `canon/` records, the generated indexes, `.claude/settings.json`, and the hook scripts under `.claude/hooks/`.
69
+
70
+ - Current: every entry reads `matching`.
71
+ - Behind: anything else. Report `stale` and `missing` separately from `drifted`, which means the content matches no version the toolkit ever published, so the project wrote it.
72
+ - Unread: `historyUnavailable` set on `seeds`, which leaves every difference unattributed. Say so rather than reporting a file as untouched.
73
+
74
+ Routes to `seed-sync`, which proposes per-section edits without overwriting a customization. It is a proposal rather than a write, which is why a `drifted` `CLAUDE.md` routes there rather than to a sync.
75
+
76
+ A seed entry matching says the file's bytes match the shipped seed. It says nothing about whether the harness those files configure is wired, which is the next domain.
77
+
78
+ ## 6. The Claude harness
79
+
80
+ No verb reads this domain. Report it `unchecked` on every run and report it as a row rather than omitting it.
81
+
82
+ What goes unread is whether the target's hooks fire, whether `.claude/settings.json` registers them, which plugin version the target's sessions actually load, and whether the marketplace cache matches what the toolkit ships.
83
+
84
+ Confirm the gap against the live surface rather than against a list held here, by reading `canon claude --help` and the help of any subcommand under it that sounds like an answer. A list written into this file freezes on the day it was written and would report a gap a later release had closed. `canon claude skills drift` is the verb that comes nearest, and it reads the toolkit's own history against a ref, which says what moved upstream rather than what this target holds. Report the domain checked, with what the verb answered, on the run where a verb does answer one of the four questions above.
85
+
86
+ Reading it by file inspection is the alternative and it is refused. Every other section here answers from the verb under test, and a section answering from a hand-rolled read of the same files reaches a verdict nothing else in the report is entitled to make. A hole a reader can see is worth more than a number nobody can trace.
87
+
88
+ `canon claude plugin-update` is what an operator runs to bring a cache forward. Name it as the action available rather than as a repair for a finding, since no finding was taken.
@@ -31,6 +31,23 @@ Group visual items by feature area. Use `- [ ]` checkbox syntax, and write each
31
31
  - [ ] <action> → <expected visual result>
32
32
  ```
33
33
 
34
+ ## The widths block
35
+
36
+ A screenshot shows one width, so a reviewer cannot tell from the picture alone whether any other width was looked at. When a change touches layout, spacing, or anything responsive, put this block above the visual items, since it frames what the screenshots beside it can support:
37
+
38
+ ```markdown
39
+ **Widths:**
40
+
41
+ Seen at: <the widths the reviewer looked at>
42
+ Not seen at: <the widths nobody looked at, or "none">
43
+ ```
44
+
45
+ Leave both lines for the reviewer to complete. This session cannot verify a width, so the block asks what was seen and never asserts coverage. Fix no width list in the block, since which widths matter belongs to the project.
46
+
47
+ Keep the `Not seen at:` line even when the answer is "none", so an omission reads as a claim rather than a gap.
48
+
49
+ Omit the block when the change cannot differ by width, such as copy, color, or a state that renders the same everywhere, the way the missing-test heading is omitted below.
50
+
34
51
  ## The missing-test list
35
52
 
36
53
  Name every automatable change this branch ships with no test, one per line, with the layer `test-craft` would place it at. Do not write the test and do not read the project's test config to write one against.
@@ -22,7 +22,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
22
22
  | `canon indexes regen` | Regenerate `index.md` files from sibling frontmatter |
23
23
  | `canon indexes list [path]` | Flatten every folder index under a path into one `{path, title, description}` catalog (`--json`) |
24
24
  | `canon docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
25
- | `canon design render` | Render `canon/DESIGN.md` tokens to HTML and CSS |
25
+ | `canon design render` | Render `canon/DESIGN.md` tokens to HTML and CSS, declaring each vendored face the typography table names with `--embed-fonts` |
26
26
  | `canon design regen` | Rewrite this repository's `canon/DESIGN.md` and `src/design/base.css` from `src/design/tokens.ts` |
27
27
  | `canon design board` | Generate a static page set indexing tokens, surfaces, wireframes, past candidates, components, and references against a caller-resolved `--root` (`--out`), defaulting to the main worktree |
28
28
  | `canon design css` | Emit the design tokens and components as CSS on stdout (`--no-components` for properties alone) |
@@ -215,6 +215,8 @@ Neither `canon sync --check` nor any other command notices a surface still sitti
215
215
 
216
216
  `canon sync --check <path>` reports what has drifted without writing anything. It splits each difference by cause, which is the question that decides what to do next.
217
217
 
218
+ `canon:target-check` is the same reading grouped by domain rather than by cause, run from inside the project. It reports the toolkit version, the `canon/` folder, governance rules, tooling, and seeds from the verbs that own each comparison, and reports the Claude harness as unchecked because no verb reads it. It repairs nothing, so it is what to run before deciding rather than instead of deciding.
219
+
218
220
  #### The binary first
219
221
 
220
222
  The report opens by naming the binary running it. The installed version reads against the newest published one, and a version behind that points you at `canon upgrade`, since every section under it is a reading taken by whichever toolkit you happen to have. An unreachable registry reports unknown with its reason rather than failing, so `--exit-code` still gates on the drift the check measured locally and an offline machine stays green.
@@ -289,16 +289,17 @@ This section is the corpus the coverage claim is measured against: every name `c
289
289
 
290
290
  ### Keep the project current with the toolkit
291
291
 
292
- | Skill | When to use |
293
- | -------------------------------- | ---------------------------------------------------------------------------------- |
294
- | `canon:seed-sync` | After a toolkit update, to reconcile installed seeds without losing customizations |
295
- | `canon:migration-claude-md` | When `CLAUDE.md` grew past what always-load context should carry |
296
- | `canon:migration-context` | When `docs/` holds agent-flavored files belonging in `canon/context/` |
297
- | `canon:migration-superseded` | When a drift report names a `.claude/` file a folder has replaced |
298
- | `canon:migration-standards-drop` | When the project still holds an installed `.claude/standards/` tree |
299
- | `canon:canon-feedback-file` | When something in the toolkit is broken, missing, or off |
300
- | `canon:canon-feedback-triage` | In the toolkit repo, to work through the open feedback issues |
301
- | `canon:canon-rollout` | In the toolkit repo, to take one change out to every consuming project at once |
292
+ | Skill | When to use |
293
+ | -------------------------------- | -------------------------------------------------------------------------------------- |
294
+ | `canon:target-check` | In a target project, to report per domain what it holds against what the toolkit ships |
295
+ | `canon:seed-sync` | After a toolkit update, to reconcile installed seeds without losing customizations |
296
+ | `canon:migration-claude-md` | When `CLAUDE.md` grew past what always-load context should carry |
297
+ | `canon:migration-context` | When `docs/` holds agent-flavored files belonging in `canon/context/` |
298
+ | `canon:migration-superseded` | When a drift report names a `.claude/` file a folder has replaced |
299
+ | `canon:migration-standards-drop` | When the project still holds an installed `.claude/standards/` tree |
300
+ | `canon:canon-feedback-file` | When something in the toolkit is broken, missing, or off |
301
+ | `canon:canon-feedback-triage` | In the toolkit repo, to work through the open feedback issues |
302
+ | `canon:canon-rollout` | In the toolkit repo, to take one change out to every consuming project at once |
302
303
 
303
304
  ### Generate an artifact on demand
304
305
 
@@ -39,6 +39,17 @@ paths:
39
39
  - Install with `bun install --frozen-lockfile`.
40
40
  - Key the Playwright browser cache on the Playwright version string, never a static key.
41
41
 
42
+ ## Commit-back
43
+
44
+ - Commit a refreshed file back to the pull request branch only when the branch's own change moved it. Report the diff without committing when something outside the branch can move it, such as a count read at build time or a font that renders differently per machine, since that commit lands on a branch that never asked for it.
45
+ - Commit only what a deterministic step regenerates from source, and run the diff check before the commit step so an unchanged capture pushes nothing.
46
+ - Skip a pull request from a fork with `github.event.pull_request.head.repo.full_name == github.repository`, since the default token cannot push to a fork's branch.
47
+ - Set the concurrency group on the ref so a newer push cancels the run committing onto a stale head.
48
+ - Set `HUSKY: 0` on the commit step when the runner lacks a tool a hook shells out to.
49
+ - Never commit back to the trunk. A commit with no pull request and no reviewer needs an operator's decision, not a workflow's.
50
+ - Leave `workflow_dispatch` off a commit-back workflow. A manual run has no branch to commit onto, so the trigger would pass and do nothing.
51
+ - Fail a template's first step while a path list still holds its placeholder, so an unfinished install fails loudly rather than never firing.
52
+
42
53
  ## Authority
43
54
 
44
55
  - Load the `canon:ci-workflow` skill for the workflow template and the per-project adaptation. Report it rather than proceeding silently when the skill does not resolve.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.102.0",
4
+ "version": "4.103.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -21,6 +21,7 @@ const root = new URL('../..', import.meta.url).pathname
21
21
  const { cssPath } = renderDesignDoc(
22
22
  `${root}${DESIGN_DOCUMENT}`,
23
23
  `${root}web/public/previews/design-tokens`,
24
+ { embedFonts: true },
24
25
  )
25
26
 
26
27
  const css = readFileSync(cssPath, 'utf8')
@@ -91,6 +91,14 @@ export const SETUP_CASES: readonly SkillCase[] = [
91
91
  'Take this change out to every project that installed the toolkit and get each one to a pull request.',
92
92
  expect: 'canon-rollout',
93
93
  },
94
+ // Reporting per domain is what separates this from canon-operator, which
95
+ // diagnoses on the way to running the operation an intent named. This answers
96
+ // one fixed question across a fixed domain list and repairs nothing.
97
+ {
98
+ prompt:
99
+ 'Report what this project has fallen behind the toolkit on, domain by domain, and fix nothing.',
100
+ expect: 'target-check',
101
+ },
94
102
  {
95
103
  prompt:
96
104
  "This project needs its own coding rule that the toolkit doesn't ship.",
@@ -119,7 +119,11 @@ export function register(program: Command): void {
119
119
  'Output directory',
120
120
  creationRel(process.cwd(), SCRATCH, 'render', 'design'),
121
121
  )
122
- .action((opts: { source: string; out: string }) => {
122
+ .option(
123
+ '--embed-fonts',
124
+ 'Declare each vendored face the typography table names, and set the page body in the body stack',
125
+ )
126
+ .action((opts: { source: string; out: string; embedFonts?: boolean }) => {
123
127
  const sourcePath = resolve(process.cwd(), opts.source)
124
128
  const outDir = resolve(process.cwd(), opts.out)
125
129
  const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
@@ -133,7 +137,9 @@ export function register(program: Command): void {
133
137
  process.stderr.write(
134
138
  `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}Render design tokens${NC}\n`,
135
139
  )
136
- const result = renderDesignDoc(sourcePath, outDir)
140
+ const result = renderDesignDoc(sourcePath, outDir, {
141
+ embedFonts: opts.embedFonts,
142
+ })
137
143
  process.stderr.write(
138
144
  `${GREY}│${NC} ${GREEN}✓${NC} ${result.htmlPath}\n${GREY}│${NC} ${GREEN}✓${NC} ${result.cssPath}\n${GREY}└${NC}\n`,
139
145
  )
package/src/design/css.ts CHANGED
@@ -136,14 +136,17 @@ ${component.rules}`,
136
136
  * installed. Defaults to the mono stack's primary family; teach passes its own
137
137
  * three faces instead of widening this default for every consumer.
138
138
  */
139
- function fontFaceBlock(faces: readonly FontFace[]): string {
139
+ export function fontFaceBlock(
140
+ faces: readonly FontFace[],
141
+ display: 'swap' | 'block' = 'swap',
142
+ ): string {
140
143
  return faces
141
144
  .map(
142
145
  (face) => `@font-face {
143
146
  font-family: '${face.family}';
144
147
  font-weight: ${face.weight};
145
148
  font-style: normal;
146
- font-display: swap;
149
+ font-display: ${display};
147
150
  src: url(data:font/woff2;base64,${face.base64}) format('woff2');
148
151
  }`,
149
152
  )
@@ -2,6 +2,9 @@ import { mkdirSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Cell, DesignDoc, Row } from '@/design/parse'
4
4
  import { parseDesignDoc } from '@/design/parse'
5
+ import { fontFaceBlock } from '@/design/css'
6
+ import type { FontFace } from '@/design/fonts'
7
+ import { FONT_FACES } from '@/design/fonts'
5
8
  import { colorValue } from '@/design/tokens'
6
9
 
7
10
  export interface RenderResult {
@@ -19,19 +22,55 @@ const FAVICON_HREF = `data:image/svg+xml,${encodeURIComponent(
19
22
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 10 80 80"><path d="M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z" fill="${FAVICON_COLOR}" /><rect x="44" y="15" width="12" height="70" rx="2" fill="${FAVICON_COLOR}" /></svg>`,
20
23
  )}`
21
24
 
25
+ export interface RenderOptions {
26
+ /**
27
+ * Declare each vendored face the typography table names, and let the page
28
+ * body take the body role's stack. Off by default, since a face is a large
29
+ * base64 block a target's render would otherwise grow by unasked.
30
+ */
31
+ readonly embedFonts?: boolean
32
+ }
33
+
22
34
  export function renderDesignDoc(
23
35
  sourcePath: string,
24
36
  outDir: string,
37
+ options: RenderOptions = {},
25
38
  ): RenderResult {
26
39
  const doc = parseDesignDoc(sourcePath)
27
40
  mkdirSync(outDir, { recursive: true })
28
41
  const cssPath = join(outDir, 'design.css')
29
42
  const htmlPath = join(outDir, 'index.html')
30
- writeFileSync(cssPath, buildCss(doc))
31
- writeFileSync(htmlPath, buildHtml(doc))
43
+ const faces = options.embedFonts ? namedFaces(doc) : []
44
+ writeFileSync(cssPath, buildCss(doc, faces))
45
+ writeFileSync(htmlPath, buildHtml(doc, bodyFamily(doc, faces)))
32
46
  return { htmlPath, cssPath }
33
47
  }
34
48
 
49
+ /** The vendored faces whose family appears in a typography `Family` cell. */
50
+ function namedFaces(doc: DesignDoc): FontFace[] {
51
+ const named = new Set(
52
+ doc.typography.flatMap((row) =>
53
+ val(row, 'Family')
54
+ .split(',')
55
+ .map((family) => family.trim().replace(/^['"]|['"]$/g, '')),
56
+ ),
57
+ )
58
+ return FONT_FACES.filter((face) => named.has(face.family))
59
+ }
60
+
61
+ /** A stack lands raw in a `<style>` block, where HTML escaping does nothing. */
62
+ const SAFE_STACK = /^[\w\s,'"-]+$/
63
+
64
+ /** The stack the body role names, once a face for it is embedded. */
65
+ function bodyFamily(doc: DesignDoc, faces: readonly FontFace[]): string {
66
+ if (!faces.length) return 'system-ui, sans-serif'
67
+ const row =
68
+ doc.typography.find((r) => slug(val(r, 'Role')) === 'body') ??
69
+ doc.typography[0]
70
+ const family = val(row, 'Family')
71
+ return SAFE_STACK.test(family) ? family : 'system-ui, sans-serif'
72
+ }
73
+
35
74
  function slug(s: string): string {
36
75
  return s
37
76
  .toLowerCase()
@@ -105,7 +144,7 @@ function confidence(doc: DesignDoc): Confidence {
105
144
  return { tagged, total }
106
145
  }
107
146
 
108
- function buildCss(doc: DesignDoc): string {
147
+ function buildCss(doc: DesignDoc, faces: readonly FontFace[]): string {
109
148
  const lines: string[] = [':root {']
110
149
  for (const row of doc.color) {
111
150
  if (val(row, 'Value')) {
@@ -135,7 +174,8 @@ function buildCss(doc: DesignDoc): string {
135
174
  }
136
175
  }
137
176
  lines.push('}')
138
- return lines.join('\n') + '\n'
177
+ const root = lines.join('\n') + '\n'
178
+ return faces.length ? `${fontFaceBlock(faces, 'block')}\n\n${root}` : root
139
179
  }
140
180
 
141
181
  function escape(s: string): string {
@@ -178,7 +218,7 @@ function previewChrome(): string {
178
218
  return [' :root {', ...lines, ' }'].join('\n')
179
219
  }
180
220
 
181
- function buildHtml(doc: DesignDoc): string {
221
+ function buildHtml(doc: DesignDoc, bodyStack: string): string {
182
222
  const sections = [
183
223
  sectionPersonality(doc.personality),
184
224
  sectionColor(doc.color),
@@ -205,7 +245,7 @@ function buildHtml(doc: DesignDoc): string {
205
245
  <link rel="stylesheet" href="design.css">
206
246
  <style>
207
247
  ${previewChrome()}
208
- body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 960px; color: var(--preview-ink); background: var(--preview-paper); }
248
+ body { font-family: ${bodyStack}; margin: 2rem; max-width: 960px; color: var(--preview-ink); background: var(--preview-paper); }
209
249
  h1 { margin-top: 0; }
210
250
  h2 { margin-top: 2rem; border-bottom: 1px solid var(--preview-rule); padding-bottom: 0.25rem; }
211
251
  table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }
@@ -11,7 +11,7 @@ The reader is what changes. Reference prose serves someone who already committed
11
11
 
12
12
  ## Scope
13
13
 
14
- Governs every `README.md`: voice, heading structure, required and optional sections, badge selection, and what the page links out to instead of carrying.
14
+ Governs every `README.md`: voice, heading structure, required and optional sections, the header block, badge selection, and what the page links out to instead of carrying.
15
15
 
16
16
  Does not govern:
17
17
 
@@ -34,14 +34,14 @@ Scoped to the README at a repository root. A nested README documenting a folder,
34
34
 
35
35
  - H1 title, H2 major sections, H3 subsections. Maintain proper hierarchy for GitHub's auto-generated table of contents.
36
36
  - Use sentence case for all headings (proper nouns and product names retain their casing)
37
- - Project description in plain text directly under H1. Keep it to 2-3 sentences.
37
+ - Project description in plain text below the header block, or directly under the H1 when the block holds nothing beneath the title. Keep it to 2-3 sentences.
38
38
  - Do not create deeply nested heading structures that harm scannability
39
39
  - Do not use horizontal rules or dividers (`---`)
40
40
 
41
41
  ## Sections
42
42
 
43
43
  - Required: project description, installation/setup, usage examples, support/help resources
44
- - Optional: badges (at top, before description), features, contributing (link to `CONTRIBUTING.md`), license (link to `LICENSE`)
44
+ - Optional: the header block below, features, contributing (link to `CONTRIBUTING.md`), license (link to `LICENSE`)
45
45
  - Do not include full API documentation. Link to separate docs instead.
46
46
  - Do not include license text. Reference the `LICENSE` file.
47
47
  - Do not include detailed contribution guidelines. Reference `CONTRIBUTING.md`.
@@ -49,6 +49,25 @@ Scoped to the README at a repository root. A nested README documenting a folder,
49
49
  - A README that points to the project's own context entries instead of restating them satisfies the required list by routing rather than carrying. A repository documented in `docs/` or `canon/context/` names the entry point and the listing command, then stops. The reader is one hop away, the same way `## Badges` treats zero as complete.
50
50
  - A README missing an H1, or whose headings restate the tool that scaffolded it rather than the project, is unedited generator output rather than a page anyone wrote. Draft over it. Nothing here asks a session to preserve a section a scaffold wrote and nobody replaced.
51
51
 
52
+ ### Header block
53
+
54
+ The opening of a root README is a block of up to six elements in a fixed order. Every element is read off the repository rather than written from scratch, and each one is a slot: a project that holds nothing for it leaves it out.
55
+
56
+ 1. Mark: the project's logo or icon, from an image the repository already commits. Omit it when the project has none.
57
+ 2. Title: the H1, naming the project.
58
+ 3. Badges: the block `### Badges` below governs, placed directly under the title.
59
+ 4. Claim: one line saying what the project does, taken from the manifest description or the repository description.
60
+ 5. Live link: the URL of the running surface, from a `homepage` field or a deploy config. Omit it when the project has no page or no known URL.
61
+ 6. Product screenshot: an image the project already commits and references, shown with alt text naming what it depicts. Present only when the project has a page. Omit it when no such image exists rather than writing a placeholder path.
62
+
63
+ The description of 2-3 sentences follows the block and expands the claim rather than repeating it.
64
+
65
+ - A project with no page omits the link and the screenshot. A library or a CLI reads as a complete block with a title, badges, and a claim.
66
+ - The order is the block's rather than the page's. The screenshot sits last inside the block, which puts it in the first row of content a reader meets below the title, badges, and claim.
67
+ - A themed light and dark pair counts as one element in the slot rather than two. The block does not require a pair and leaves the handling of themes to the project.
68
+ - Centering the block in an HTML wrapper is optional. Plain markdown reaches every host a README renders on, and a wrapper is styling a project can add.
69
+ - Never invent an element. A mark or a screenshot the project does not hold stays out of the page.
70
+
52
71
  ### Badges
53
72
 
54
73
  A badge earns its place by carrying a fact the reader cannot get from the page they are already on. Three classes pass that test.
@@ -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
 
@@ -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.