@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +3 -0
- package/claude/skills/canon-rollout/SKILL.md +7 -6
- package/claude/skills/draft-readme/REQUIREMENT.md +2 -0
- package/claude/skills/draft-readme/SKILL.md +9 -1
- package/claude/skills/target-check/REQUIREMENT.md +42 -0
- package/claude/skills/target-check/SKILL.md +91 -0
- package/claude/skills/target-check/references/domains.md +88 -0
- package/claude/skills/ui-checklist/SKILL.md +17 -0
- package/docs/agents/commands.md +1 -1
- package/docs/target-projects.md +2 -0
- package/docs/workflow/ai-workflow.md +11 -10
- package/governance/rules/ci/700-ci-workflow.md +11 -0
- package/package.json +1 -1
- package/scripts/core/regen-web-previews.ts +1 -0
- package/src/claude/cases/setup.ts +8 -0
- package/src/commands/design.ts +8 -2
- package/src/design/css.ts +5 -2
- package/src/design/render.ts +46 -6
- package/standards/readme.md +22 -3
- package/tooling/astro/manifest.toml +1 -0
- package/tooling/nextjs/manifest.toml +1 -0
- package/tooling/web/configs/.github/workflows/readme-screenshot.yml +110 -0
- package/tooling/web/configs/e2e/screenshot.ts +69 -15
- package/tooling/web/configs/scripts/lib/preview-server.sh +31 -0
- package/tooling/web/configs/scripts/readme-screenshot.sh +26 -0
- package/tooling/web/configs/scripts/screenshot.sh +1 -24
- package/tooling/web/manifest.toml +1 -0
- package/tooling/web/reference.md +6 -1
|
@@ -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
|
|
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.
|
|
105
|
-
4.
|
|
106
|
-
5.
|
|
107
|
-
6.
|
|
108
|
-
7.
|
|
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.
|
package/docs/agents/commands.md
CHANGED
|
@@ -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) |
|
package/docs/target-projects.md
CHANGED
|
@@ -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:
|
|
295
|
-
| `canon:
|
|
296
|
-
| `canon:migration-
|
|
297
|
-
| `canon:migration-
|
|
298
|
-
| `canon:migration-
|
|
299
|
-
| `canon:
|
|
300
|
-
| `canon:canon-feedback-
|
|
301
|
-
| `canon:canon-
|
|
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
|
@@ -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.",
|
package/src/commands/design.ts
CHANGED
|
@@ -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
|
-
.
|
|
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(
|
|
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:
|
|
149
|
+
font-display: ${display};
|
|
147
150
|
src: url(data:font/woff2;base64,${face.base64}) format('woff2');
|
|
148
151
|
}`,
|
|
149
152
|
)
|
package/src/design/render.ts
CHANGED
|
@@ -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
|
-
|
|
31
|
-
writeFileSync(
|
|
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
|
-
|
|
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:
|
|
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; }
|
package/standards/readme.md
CHANGED
|
@@ -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:
|
|
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
|
|
17
|
-
|
|
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
|
|
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:
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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/"]
|
package/tooling/web/reference.md
CHANGED
|
@@ -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
|
|
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.
|