@erclx/canon 5.59.0 → 5.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/design-record/references/design.md +19 -0
  4. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  5. package/claude/skills/document-health/SKILL.md +23 -3
  6. package/claude/skills/document-health/references/axes.md +21 -1
  7. package/claude/skills/plan-feature/SKILL.md +1 -1
  8. package/claude/skills/review-pr/SKILL.md +1 -1
  9. package/claude/skills/review-pr/references/scope.md +10 -0
  10. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  11. package/claude/skills/review-ui/SKILL.md +3 -2
  12. package/claude/skills/review-ui/references/post.md +2 -2
  13. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  14. package/claude/skills/ui-checklist/SKILL.md +12 -0
  15. package/docs/agents/records.md +1 -1
  16. package/docs/target/migrations.md +1 -1
  17. package/docs/workflow/parallel-features.md +1 -1
  18. package/package.json +2 -1
  19. package/src/commands/design.ts +57 -1
  20. package/src/commands/driver.ts +29 -3
  21. package/src/commands/migrate.ts +7 -0
  22. package/src/commands/records.ts +3 -2
  23. package/src/commands/tooling.ts +17 -2
  24. package/src/design/audit.ts +133 -0
  25. package/src/design/budget.ts +2 -1
  26. package/src/design/css.ts +1 -5
  27. package/src/design/document.ts +5 -1
  28. package/src/design/themes.ts +176 -0
  29. package/src/design/tokens.ts +3 -0
  30. package/src/driver/drive.ts +17 -1
  31. package/src/driver/probes/axe.ts +195 -0
  32. package/src/driver/steps.ts +20 -1
  33. package/src/gate/measures.ts +9 -3
  34. package/src/markdown/structure.ts +4 -1
  35. package/src/migrate/records.ts +46 -7
  36. package/src/records/stale-canonical.ts +2 -2
  37. package/src/sync/stamp.ts +24 -4
  38. package/src/tooling/inject.ts +6 -3
  39. package/src/tooling/install.ts +55 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.61.0](https://github.com/erclx/canon/compare/v5.60.0...v5.61.0) (2026-10-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * **design:** check themes and add a review point ([#2673](https://github.com/erclx/canon/issues/2673)) ([8cdaa5c](https://github.com/erclx/canon/commit/8cdaa5c001b2d783108c5ce6d70da0b6cca0719c))
9
+ * **driver:** add an axe probe that gates the UI review ([#2672](https://github.com/erclx/canon/issues/2672)) ([785e474](https://github.com/erclx/canon/commit/785e474b76eae4f41a55fd588df0b3691756742d))
10
+ * **migrate:** drop a covered ignore line from the records sweep ([#2668](https://github.com/erclx/canon/issues/2668)) ([188bee8](https://github.com/erclx/canon/commit/188bee8c0061045e46e660da50422b5d5673cf14))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **markdown:** measure a bullet without its list marker ([#2670](https://github.com/erclx/canon/issues/2670)) ([c7b9d70](https://github.com/erclx/canon/commit/c7b9d70fc522a91dc9b2b058481d1eb984abd3ad))
16
+
17
+ ## [5.60.0](https://github.com/erclx/canon/compare/v5.59.0...v5.60.0) (2026-10-24)
18
+
19
+
20
+ ### Features
21
+
22
+ * **design:** add canon design audit verb ([#2661](https://github.com/erclx/canon/issues/2661)) ([303a52a](https://github.com/erclx/canon/commit/303a52ada294d9740205885336b6f1ce006ddbba))
23
+ * **skills:** reach design-taste in planner and later review passes ([#2657](https://github.com/erclx/canon/issues/2657)) ([dedc351](https://github.com/erclx/canon/commit/dedc35132a57cf8f82e9a25603e6128e0c77d6ed))
24
+ * **tooling:** bound the dev dependency install ([#2666](https://github.com/erclx/canon/issues/2666)) ([09017a6](https://github.com/erclx/canon/commit/09017a6bfcf11d3feffa1eccfa1ec99f8a0ff3ae))
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * **sync:** drop retired domains when reading the stamp ([#2664](https://github.com/erclx/canon/issues/2664)) ([21e0a1c](https://github.com/erclx/canon/commit/21e0a1cccfcab13dba5e50e3a55ea620d0d77907))
30
+
3
31
  ## [5.59.0](https://github.com/erclx/canon/compare/v5.58.0...v5.59.0) (2026-10-24)
4
32
 
5
33
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
4
- "version": "5.59.0",
4
+ "version": "5.61.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -14,6 +14,7 @@ Applies to `canon/DESIGN.md`. Captures visual intent and the decisions behind ho
14
14
  - [What goes in](#what-goes-in)
15
15
  - [What does not go in](#what-does-not-go-in)
16
16
  - [Format](#format)
17
+ - [Frontmatter](#frontmatter)
17
18
  - [Prose budget](#prose-budget)
18
19
  - [Sections](#sections)
19
20
  - [The uncertainty tag](#the-uncertainty-tag)
@@ -56,6 +57,17 @@ Everything below applies to both shapes unless it names one.
56
57
  - Plain English over technical notation. If a section could be removed and the developer would still build correctly from the stylesheet and code alone, remove it.
57
58
  - Keep table headers and role names intact so the parser can read the tables.
58
59
 
60
+ ## Frontmatter
61
+
62
+ Both fields are optional, and a record without them audits as it always did. Frontmatter never counts toward the prose budget.
63
+
64
+ - `themes:` lists each theme as the stylesheet prelude that opens it, such as `:root` or `@media (prefers-color-scheme: dark)`.
65
+ - `canon design audit` checks every color property `## Colors` names against each theme, and reports a theme that omits one or opens no block.
66
+ - `reviewed:` is the day the record was last read whole, as `YYYY-MM-DD`. `canon records stale canonical` returns it with the releases tagged since.
67
+ - No skill step sets `reviewed:`, so whoever reads the record whole updates it.
68
+
69
+ Quote style and spacing may differ between the record and the stylesheet. A declaration inside a declared media query counts for the query alone, and `:root` never matches `:root[data-theme='dark']` by containment. A values record renders `reviewed:` from its token module and declares no `themes:`.
70
+
59
71
  ## Prose budget
60
72
 
61
73
  The record is a lookup, so its prose stays short enough to scan. Why a rule was chosen belongs in the project's context entry for design, never here.
@@ -113,6 +125,13 @@ A prose section takes its uncertainty inline instead, in a sentence saying what
113
125
  The template is the intent record. The column headers are read by exact key, so they stay verbatim. Row names are not, which leaves a project free to rename a role, add one, or drop one it has no use for.
114
126
 
115
127
  ```markdown
128
+ ---
129
+ themes:
130
+ - ':root'
131
+ - ":root[data-theme='dark']"
132
+ reviewed: YYYY-MM-DD
133
+ ---
134
+
116
135
  # Design
117
136
 
118
137
  Values live in `<path to the stylesheet>`. This record holds the intent behind them.
@@ -17,7 +17,7 @@ Length, placement, and staleness answer to different evidence, and one list hand
17
17
  - Propose a split or an owner as a structure line for each overgrown entry and doubled or unnamed coverage finding, with the headings and the reason
18
18
  - Ship the staleness axis as a reading rather than cutting it for lacking a verb
19
19
  - Report a refusal, an absent key, or a missing subcommand as an unread axis naming the cause, and say which layer answered the classifier sweep
20
- - Run standalone, read the requirements record for identity drift when the scope reaches it, and print the `canon records stale canonical` reminder
20
+ - Run standalone, reading the requirements record for drift with its reminder, and the design record through `canon design audit --json` and its review reminder, routing findings to `design-record`
21
21
 
22
22
  ## Must not
23
23
 
@@ -60,21 +60,31 @@ A refusal, an absent key, and a subcommand the project's binary does not carry e
60
60
  - `canon context audit` refuses with `reason: "no-folders"` where the layout is not the expected one. Reading that honest refusal as an empty finding set reports a healthy corpus nothing looked at.
61
61
  - A narrower read is not an unread one. The sweep running on its regex layer alone still answered, so report it with the layer named.
62
62
 
63
+ ### The design record
64
+
65
+ `canon/DESIGN.md` reads outside both verbs above, since neither measures its prose budget or its tokens. When the scope reaches it, read it through its own verb:
66
+
67
+ ```bash
68
+ canon design audit --json
69
+ ```
70
+
71
+ The record carries `prose`, the lines past the budget, and `tokens`, the custom properties the stylesheet lacks. Where the record declares `themes:`, `tokens.themes` adds one cell per theme. `${CLAUDE_SKILL_DIR}/references/axes.md` holds how each reads and where it routes. Skip the verb when the scope excludes the record or the record is `null`.
72
+
63
73
  ## Step 3: read the staleness axis
64
74
 
65
75
  No verb answers whether a document still describes the tree it was written about. Read each document against the surfaces it names and report where the two have parted, taking the file paths, commands, and counts it states as the claims to test.
66
76
 
67
77
  Mark every staleness finding as a reading. It is this session's judgment rather than a measurement, and a report that sets it beside two verb-backed axes without marking it implies evidence the axis does not carry.
68
78
 
69
- ### The requirements record
79
+ ### The review points
70
80
 
71
- When the scope reaches `canon/REQUIREMENTS.md`, read it for identity drift as well as for stale paths, and take the review point from the verb:
81
+ When the scope reaches `canon/REQUIREMENTS.md` or `canon/DESIGN.md`, take the review point for each record the scope reaches from the verb:
72
82
 
73
83
  ```bash
74
84
  canon records stale canonical --json
75
85
  ```
76
86
 
77
- The doc in the record carries `reviewed` and `releasesSince`. `${CLAUDE_SKILL_DIR}/references/axes.md` holds the identity claim shapes to test and how the record reads. Skip both the read and the verb when the scope excludes the doc.
87
+ Each doc in the record carries `reviewed` and `releasesSince`. Skip the verb when the scope reaches neither record. When the scope reaches the requirements record, also read it for identity drift as well as stale paths, with the claim shapes in `${CLAUDE_SKILL_DIR}/references/axes.md`.
78
88
 
79
89
  Nothing triggers this review but a request. A finished review hands the rewrite to the operator, and its last edit sets `reviewed:` in the doc's frontmatter to the day the operator's answers landed. This run writes neither.
80
90
 
@@ -108,6 +118,7 @@ When Step 3 read the canonical records, add one reminder line per doc the verb r
108
118
 
109
119
  ```markdown
110
120
  `canon/REQUIREMENTS.md`: reviewed <date>, <n> releases since.
121
+ `canon/DESIGN.md`: reviewed <date>, <n> releases since.
111
122
  ```
112
123
 
113
124
  Write `reviewed value is not a date: <value>` for a doc carrying `invalidReviewed`, and `no release tags to count` in place of the count when `tagged` is false. Name the unread cause instead when the verb refused or the binary carries no `canonical` kind.
@@ -121,3 +132,12 @@ Structure `canon/context/surfaces/`: `cli.md` (300 lines) splits into `cli.md` t
121
132
  The line is a proposal for the operator, with the headings each entry takes and why. Health applies nothing, and `document-repair` offers the proposal before it rewrites.
122
133
 
123
134
  Keep the evidence line under the table on every run. A table read without it flattens a judgment into a measurement, which is the one shape the three classes exist to prevent.
135
+
136
+ ### The design record row
137
+
138
+ When Step 2 read the design record, add a row for `canon/DESIGN.md`, with both findings `measured`.
139
+
140
+ - Put the `prose` findings in the Length cell and the `tokens` findings in the Placement cell, naming the selector of each `tokens.themes` cell that omits a property or matched no block
141
+ - Write `unread: <reason>` in the Placement cell when `tokens.status` reads `unchecked`, since a check that could not run is no pass
142
+ - Write `n/a: <reason>` there when it reads `not-applicable`, a values record with no property to compare
143
+ - Route every finding to `design-record`, never to `document-repair`
@@ -73,6 +73,26 @@ Both verbs resolve their folders by surface root rather than by a path written i
73
73
  - The audit refuses with `reason: "no-folders"`, naming the folders it looked for. Report that refusal as an unread axis.
74
74
  - The sweep with the model layer off answers from its regex layer alone, a narrower read rather than a refusal, so say which layer answered
75
75
 
76
+ ## The design record
77
+
78
+ | Field | Value |
79
+ | -------------- | ------------------------------------------------------------------------------------------------ |
80
+ | Measures | Whether the design record holds to its prose budget and names only properties its stylesheet has |
81
+ | Verb | `canon design audit --json` |
82
+ | Evidence class | `measured` |
83
+
84
+ The record is exempt from the length and placement verbs above, so this is the only measured read it gets.
85
+
86
+ - `prose`: each place the record passes the budget, with the count and the cap it was read against. Take the caps from the finding rather than from a number written here.
87
+ - `tokens.missing`: a custom property a table names that the stylesheet the lead names does not declare
88
+ - `tokens.status: "unchecked"`: the lead names no stylesheet, or the one it names does not resolve. Report the axis as unread with the `reason`, since nothing was compared.
89
+ - `tokens.status: "not-applicable"`: a values record, which carries `Value` where an intent record carries `Property`. Report it as not applicable with the `reason`.
90
+ - `record: null`: the project holds no design record, so there is nothing to report
91
+
92
+ `tokens.themes` is present only where the record declares `themes:`, with one cell per theme carrying its `selector`, `matched`, and `missing`. A cell with `matched: false` means no block opens that selector, and one with `missing` names the color properties that theme leaves out. Report either under its selector.
93
+
94
+ **Routes to:** `design-record`, which owns the record's shape. `document-repair` rewrites a context folder and does not know the budget.
95
+
76
96
  ## Staleness
77
97
 
78
98
  | Field | Value |
@@ -102,7 +122,7 @@ This record states what the project is and why, so it goes stale by the project
102
122
 
103
123
  ### The review point
104
124
 
105
- The review point beside them is `measured`. `canon records stale canonical --json` returns the `reviewed` date from the record's frontmatter and `releasesSince`, the release tags merged into HEAD after that day.
125
+ The review point beside them is `measured`, for the requirements record and the design record alike. `canon records stale canonical --json` returns the `reviewed` date from each record's frontmatter and `releasesSince`, the release tags merged into HEAD after that day.
106
126
 
107
127
  - A null `releasesSince` means the doc was never reviewed
108
128
  - `tagged: false` means no release tag exists to count, so a zero there measured nothing
@@ -38,7 +38,7 @@ Read these in parallel from the project root, skipping any that do not exist:
38
38
 
39
39
  Also read `canon/DESIGN.md` when the feature touches code or UI, for visual intent and component rules, with values read from the stylesheet its lead names. Skip it for prose, docs, catalog, or config-only changes. For a UI feature, read `canon/design/index.md` too, if it exists, for the surfaces with an accepted design.
40
40
 
41
- Coding standards live in `.claude/rules/`. Claude Code loads them automatically, and path-scoped rules apply to the files they match.
41
+ Coding standards live in `.claude/rules/`. Claude Code loads them automatically, and path-scoped rules apply to the files they match. For a UI feature, load `canon:design-taste` whether or not `canon/DESIGN.md` exists.
42
42
 
43
43
  ### Read the domain's context entry
44
44
 
@@ -78,7 +78,7 @@ Read these in parallel from the project root, skipping any that do not exist:
78
78
  - `.canon/tasks/priority.md`: where this feature sits on the board and what each neighbor waits on, resolved at the main root the way `session-worktree` does, since a linked worktree holds no copy
79
79
  - `.canon/plans/feature-<slug>.md` for the branch, when present: the intent the PR should satisfy, and its `**Review focus:**` section as the inputs this pass must confirm the diff handles
80
80
 
81
- Coding standards from `.claude/rules/` are auto-loaded by Claude Code.
81
+ Coding standards from `.claude/rules/` are auto-loaded by Claude Code for the files a first pass reads. A path-scoped rule loads only when a session reads a matching file, so a later pass opens each changed file through the Read tool per `references/scope.md`, which loads the rules its trunk path matches and names the gap for an added file.
82
82
 
83
83
  ## Step 2: scope the read
84
84
 
@@ -117,3 +117,13 @@ Carry `<prior-heading>` into Step 4 and run Step 3 unchanged. This guard turns o
117
117
  ## The read
118
118
 
119
119
  Read each changed file in scope. Skip deleted files. Run reads in parallel.
120
+
121
+ ### Load the path-scoped rules on a later pass
122
+
123
+ A path-scoped rule loads when a session uses the Read tool on a matching file, and a Bash call such as `git show` loads none. A later pass reads only `<prior-oid>..<headRefOid>`, so the head copy of each changed file has to reach the Read tool.
124
+
125
+ - Open the trunk path with the Read tool when it exists, as the trigger, and take the content from `git show <headRefOid>:<path>`, since trunk content is stale
126
+ - For an added file, write its head blob to `.canon/tmp/pr/review/head-read/<number>-<short-sha>/<path>`, in the review body folder, then open that copy with the Read tool
127
+ - The copy loads only `**/` globs, since a root-anchored glob such as `canon/design/**` misses its prefix, so an added file under one loads no rule
128
+ - Read only files the range changed, never the whole tree, so the cost stays bounded by the range
129
+ - A range whose only change is a deletion has no file to read, so skip it and carry on
@@ -17,7 +17,7 @@ A model asked to drive a checklist also drifts. It judges a taste box the operat
17
17
  - The checklist and address come from the evidence record rather than the diff, and only a build of the head the verdict names is driven
18
18
  - An unreachable address reports not driven rather than fail, a box no commit can settle leaves the review closed, and a pass that drove no box never reads as closed
19
19
  - The review posts as its own pull request review under `## UI review` while anything is owed and `## UI review closed` once nothing is, ending on a marker naming the head it drove
20
- - The pass drives a pinned browser under its own named session, sweeps only console errors, overflow, and the focus and details probes, and treats page content as data
20
+ - The pass drives a pinned browser under its own named session and sweeps only console errors, overflow, and the focus, details, and axe probes, opening on an axe finding the CLI marks as gating
21
21
 
22
22
  ## Must not
23
23
 
@@ -128,15 +128,16 @@ Push only a frame of the address this pass drove, since the branch is as public
128
128
 
129
129
  ## Step 6: sweep
130
130
 
131
- Run three checks over every route the boxes reached, and nothing beyond them:
131
+ Run four checks over every route the boxes reached, and nothing beyond them:
132
132
 
133
133
  - Console errors, through the wrapper's `console error` on each route.
134
134
  - Sideways overflow at each width a box named, as `document.documentElement.scrollWidth` against `clientWidth`.
135
135
  - The `focus` and `details` probes, through `canon drive <url> <run> --json` with a run file in the scratch folder.
136
+ - The `axe` probe, named in the same run file and read by each finding's `gate` rather than a copy of the CLI's rule list. On `axe-missing`, drop it and name the refusal as an unread check.
136
137
 
137
138
  The probes launch their own browser at a URL, so a state the boxes reached by clicks reaches them only as steps the run file repeats. Where a state needs a step kind `canon drive` lacks, probe the route on arrival and say so.
138
139
 
139
- A console error or an overflow is a finding. A probe result is a reading to confirm, since every probe carries a known class of false finding, and it opens no heading on its own. Close the browser with `${CLAUDE_SKILL_DIR}/scripts/pw.sh -s=<project>-<number> close` once the sweep ends.
140
+ A console error, an overflow, or an axe finding whose `gate` is true is a finding. Every other probe result is a reading to confirm, an `incomplete` axe outcome included, since each probe carries a known class of false finding. Close the browser with `${CLAUDE_SKILL_DIR}/scripts/pw.sh -s=<project>-<number> close` once the sweep ends.
140
141
 
141
142
  ## Step 7: post
142
143
 
@@ -11,7 +11,7 @@ Step 7 of `review-ui`.
11
11
 
12
12
  The heading reports whether the branch owes anything, the way the code review's does.
13
13
 
14
- - `## UI review` when a box failed, the sweep found a console error or an overflow, the worktree check read `changed`, a box was not driven for a cause on the page, or the pass drove no box at all.
14
+ - `## UI review` when a box failed, the sweep raised a finding Step 6 names, the worktree check read `changed`, a box was not driven for a cause on the page, or the pass drove no box at all.
15
15
  - `## UI review closed` otherwise.
16
16
 
17
17
  A needs-eyes box leaves the review closed, and so does a box not driven for a cause off the page, such as an address that did not answer or a `(local only)` box on a hosted preview, as long as something else was driven.
@@ -77,7 +77,7 @@ Driven at `<short-sha>` on <address>, rendered by <renderer>.
77
77
 
78
78
  **Sweep**
79
79
 
80
- - <route>, <width>: <console error, overflow, or probe reading to confirm>
80
+ - <route>, <width>: <console error, overflow, axe gate finding with its rule, or probe reading to confirm>
81
81
 
82
82
  **Guessed wording**
83
83
 
@@ -13,7 +13,7 @@ A file under a gitignored folder is unreadable to a pull request reviewer, and a
13
13
 
14
14
  ## Must
15
15
 
16
- - Every change is split into visual-only and automatable before anything is written
16
+ - Every change is split into visual-only and automatable before anything is written, and a behavior the review's engine cannot read, such as a keyboard trap, gets a box of its own
17
17
  - The checklist covers the visual-only half, each item an action and the result a person looks for, with its route, width, and a `(taste)` or `(local only)` marker where one applies
18
18
  - Every automatable change shipping with no test is named, with the layer `test-craft` would place it at, in the same file
19
19
  - The file is written to the `.canon/tmp/handoff/ui-checklist/<slug>.md` handoff at the main worktree root, overwriting
@@ -46,6 +46,18 @@ When the plan cites `canon/design/<surface>/` under `**Design references:**`, na
46
46
 
47
47
  The checkbox stays `- [ ]` followed by the action and the result, since the evidence comment reads boxes by that shape. Only the UI reviewer ticks a box, stamping it with the commit it passed at, and the comment carries that tick forward only while the head it names is current.
48
48
 
49
+ ## Boxes no engine reads
50
+
51
+ The UI review runs axe over every route it reaches, so a box restating what axe checks, such as contrast, a missing name, or a page title, adds a drive and catches nothing. Derive a box instead for each behavior below that the changed surface carries:
52
+
53
+ - **Hover content.** Content shown on hover or focus stays while the pointer moves onto it, and Escape dismisses it, per WCAG 1.4.13.
54
+ - **Keyboard trap.** Tab and Shift+Tab leave every widget the change added, per WCAG 2.1.2.
55
+ - **Outside press.** A menu, popover, or dialog the change added closes on a press outside it and when focus leaves it.
56
+ - **Change on input.** Choosing an option or typing changes no route and moves no focus until the person submits, per WCAG 3.2.2.
57
+ - **Clipped focus ring.** Tab to each control the change added and judge from the capture whether a parent clips its ring, per WCAG 2.4.7.
58
+
59
+ Write each in the box shape above, naming the control. A surface carrying none of these behaviors takes no derived box.
60
+
49
61
  ## The missing-test list
50
62
 
51
63
  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 or read the project's test config to write one against.
@@ -58,4 +58,4 @@ Branch on `reason` in the `--json` record, not the exit code.
58
58
 
59
59
  - `canon records ordinal <kind> <slug> --claim`: reserve the next intake or groundwork ordinal
60
60
  - `canon records validate <kind> [record] --help`: where a record disagrees with its standard, plus a `structure` report that never sets the exit
61
- - `canon records stale --help`: when the requirements record was last reviewed
61
+ - `canon records stale --help`: when the requirements and design records were last reviewed
@@ -20,7 +20,7 @@ canon migrate records --json
20
20
  canon migrate records --write --json
21
21
  ```
22
22
 
23
- The first line takes the `.canon/` ignore entry the verb requires. The second reports the plan and the third applies it, repointing every tracked file that cites a moved folder. A line naming an old record path on purpose keeps it with `canon-keep-record-root` on that line or the nearest non-blank line above.
23
+ The first line takes the `.canon/` ignore entry. The second reports and the third applies, repointing files that cite a moved folder and deleting a `.gitignore` line `.canon/` covers, as `dropped`. A line naming an old path keeps it via `canon-keep-record-root` on that line or the nearest non-blank one above.
24
24
 
25
25
  Then sweep the citations inside the records, which the verb above never reaches.
26
26
 
@@ -34,7 +34,7 @@ Assert the orchestrator role in one warm session with `canon:role-orchestrator`.
34
34
 
35
35
  - `canon records validate plans [plan]` reports a plan breaking its standard, plus `structure` misses that never set the exit, and takes `groundwork` and `intake`
36
36
  - `canon records push` and `canon records pull` carry `.canon/` to and from a private repository. See [records](../agents/records.md).
37
- - `canon records size`, `canon records prune-tmp`, and `canon records stale canonical` report folder size, idle scratch, and releases since the requirements review
37
+ - `canon records size`, `canon records prune-tmp`, and `canon records stale canonical` report folder size, idle scratch, and releases since the requirements and design records were last read
38
38
  - `canon tasks archive` moves a shipped plan to `.canon/plans/archive/` and retargets the task's `Plan:` line
39
39
 
40
40
  ## Shipping a fan-out
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.59.0",
4
+ "version": "5.61.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -51,6 +51,7 @@
51
51
  "web:e2e": "cd web && playwright test"
52
52
  },
53
53
  "dependencies": {
54
+ "axe-core": "4.14.0",
54
55
  "commander": "^13.1.0",
55
56
  "execa": "^9.5.2",
56
57
  "playwright-core": "1.64.1"
@@ -1,7 +1,9 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join, relative } from 'node:path'
3
3
  import type { Command } from 'commander'
4
+ import { auditDesign } from '@/design/audit'
4
5
  import { buildDesignCss } from '@/design/css'
6
+ import { currentWorktreeRoot } from '@/git/worktree'
5
7
  import { HAND_DRAWN_FONT_FACES } from '@/design/fonts'
6
8
  import { DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
7
9
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
@@ -11,7 +13,7 @@ export function register(program: Command): void {
11
13
  const design = program
12
14
  .command('design')
13
15
  .helpOption('-h, --help', 'Show this help message')
14
- .description('Design system commands (regen, css)')
16
+ .description('Design system commands (regen, css, audit)')
15
17
 
16
18
  design
17
19
  .command('regen')
@@ -77,4 +79,58 @@ export function register(program: Command): void {
77
79
  }),
78
80
  )
79
81
  })
82
+
83
+ design
84
+ .command('audit')
85
+ .helpOption('-h, --help', 'Show this help message')
86
+ .description(
87
+ 'Measure the design record against the prose budget and check its custom properties against the stylesheet',
88
+ )
89
+ .option('--json', 'Add a machine-readable record on stdout')
90
+ .option('--root <path>', 'Project root, defaulting to the current worktree')
91
+ .addHelpText(
92
+ 'after',
93
+ [
94
+ '',
95
+ 'Reads canon/DESIGN.md. Prose findings carry their counts against the',
96
+ 'budget the design standard sets. Each custom property in the Colors and',
97
+ 'Shapes tables is checked against the stylesheet the lead names. A lead',
98
+ 'naming none reads as `tokens.status: "unchecked"`, never as a pass.',
99
+ '',
100
+ 'Exit codes:',
101
+ ' 0 the reading completed, whatever it found',
102
+ '',
103
+ 'It gates nothing and writes nothing.',
104
+ '',
105
+ 'Examples:',
106
+ ' canon design audit',
107
+ ' canon design audit --json',
108
+ '',
109
+ ].join('\n'),
110
+ )
111
+ .action(async (opts: { json?: boolean; root?: string }) => {
112
+ const root = opts.root ?? (await currentWorktreeRoot())
113
+ const audit = auditDesign(root)
114
+
115
+ if (opts.json ?? false) {
116
+ process.stdout.write(`${JSON.stringify(audit)}\n`)
117
+ return
118
+ }
119
+
120
+ intro('canon design audit')
121
+ if (audit.record === null) {
122
+ logInfo('No design record to audit')
123
+ } else {
124
+ for (const finding of audit.prose) logWarn(finding)
125
+ if (audit.tokens?.status === 'unchecked') {
126
+ logWarn(`Tokens unchecked: ${audit.tokens.reason}`)
127
+ } else if (audit.tokens?.status === 'checked') {
128
+ for (const property of audit.tokens.missing) {
129
+ logWarn(`${property} is not declared in ${audit.tokens.stylesheet}`)
130
+ }
131
+ }
132
+ if (audit.clean) logInfo(`${audit.record} is within budget`)
133
+ }
134
+ outro()
135
+ })
80
136
  }
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
5
5
  import type { DriverRefusal } from '@/driver/drive'
6
+ import { AXE_GATE_RULES } from '@/driver/probes/axe'
6
7
  import { describeViewport } from '@/driver/probes/viewport'
7
8
  import { PROBE_NAMES, readDriverPlan, type ProbeName } from '@/driver/steps'
8
9
  import type { PlanRefusal } from '@/driver/steps'
@@ -51,6 +52,8 @@ const PLAN_REFUSALS: Record<PlanRefusal, string> = {
51
52
 
52
53
  const DRIVE_REFUSALS: Record<DriverRefusal, string> = {
53
54
  'browser-missing': 'The browser binary is not installed in this project.',
55
+ 'axe-missing':
56
+ 'The axe engine did not resolve from canon, so no page was read with it.',
54
57
  'server-unreachable': 'Nothing answered at the URL, so no state was reached.',
55
58
  'drive-failed': 'The drive failed against a reachable page.',
56
59
  }
@@ -62,6 +65,7 @@ const PROBE_NOTES: Record<ProbeName, string> = {
62
65
  'diagram-geometry':
63
66
  'an SVG label outside its frame, covered by a fill, or on another label',
64
67
  'diagram-strokes': 'an SVG label a stroke crosses, in either paint order',
68
+ axe: 'axe-core on WCAG 2.2 A and AA, frames excluded, run only when named',
65
69
  }
66
70
 
67
71
  export function register(program: Command): void {
@@ -89,9 +93,10 @@ export function register(program: Command): void {
89
93
  'run, since nothing else there measures the page as it first painted.',
90
94
  '',
91
95
  'The run file carries the viewport, the default probe list, and the',
92
- 'steps. A step names its own probes to override the default. Viewport',
93
- 'heights are never defaulted: the heights a defect hides at are a',
94
- 'property of the layout rather than of this command.',
96
+ 'steps. A step names its own probes to override the default, and a run',
97
+ 'naming no list gets every probe but axe. Viewport heights are never',
98
+ 'defaulted: the heights a defect hides at are a property of the layout',
99
+ 'rather than of this command.',
95
100
  '',
96
101
  ' {',
97
102
  ' "viewport": { "width": 1440, "heights": [900, 1200, 1500] },',
@@ -116,6 +121,11 @@ export function register(program: Command): void {
116
121
  (name) => ` ${name.padEnd(18)}${PROBE_NOTES[name]}`,
117
122
  ),
118
123
  '',
124
+ 'An axe finding carries rule, outcome, and gate. outcome is violation or',
125
+ 'incomplete, the second being axe declining to decide. gate is true for',
126
+ 'a violation of one of these rules, which a UI review holds open on:',
127
+ ...wrapRules(AXE_GATE_RULES),
128
+ '',
119
129
  'Reasons:',
120
130
  ' no-run-file nothing readable at the run path',
121
131
  ' unreadable-plan the run file is not valid JSON',
@@ -128,6 +138,7 @@ export function register(program: Command): void {
128
138
  ' no-heights the viewport names no height, which is never defaulted',
129
139
  ' engine-missing the browser engine is not installed in this project',
130
140
  ' browser-missing the engine is installed and its browser binary is not',
141
+ ' axe-missing the run names axe and its engine did not resolve',
131
142
  ' server-unreachable nothing answered at the URL, so no state was reached',
132
143
  ' drive-failed the drive failed against a reachable page',
133
144
  '',
@@ -153,6 +164,21 @@ export function register(program: Command): void {
153
164
  })
154
165
  }
155
166
 
167
+ /** Lays the gate list out at the help text's width. */
168
+ function wrapRules(rules: readonly string[]): string[] {
169
+ const lines: string[] = []
170
+ let line = ' '
171
+ for (const rule of rules) {
172
+ if (line.length + rule.length + 1 > 74) {
173
+ lines.push(line)
174
+ line = ' '
175
+ }
176
+ line += ` ${rule}`
177
+ }
178
+ lines.push(line)
179
+ return lines
180
+ }
181
+
156
182
  async function runDriver(
157
183
  url: string,
158
184
  runPath: string,
@@ -326,6 +326,11 @@ function reportRecords(plan: RecordsPlan, records: number): void {
326
326
  logInfo(
327
327
  `${plural(plan.entries.length, 'file')} to change, ${plural(plan.rewritten, 'citation')} to rewrite.`,
328
328
  )
329
+ if (plan.dropped > 0) {
330
+ logInfo(
331
+ `${plural(plan.dropped, 'ignore line')} dropped, already covered by .canon/.`,
332
+ )
333
+ }
329
334
  logInfo(`${plural(plan.kept, 'citation')} marked to keep the old root.`)
330
335
 
331
336
  // A count rather than a list, and its own line rather than a place in
@@ -401,6 +406,7 @@ function toRecordsRecord(
401
406
  collisions: plan.collisions,
402
407
  files: plan.entries.length,
403
408
  rewritten: plan.rewritten,
409
+ dropped: plan.dropped,
404
410
  kept: plan.kept,
405
411
  excluded: plan.excluded.length,
406
412
  coupled: plan.coupled,
@@ -414,6 +420,7 @@ function toRecordsRecord(
414
420
  paths: plan.entries.map((entry) => ({
415
421
  path: entry.path,
416
422
  rewritten: entry.rewritten,
423
+ dropped: entry.dropped,
417
424
  })),
418
425
  }
419
426
  }
@@ -223,7 +223,7 @@ export function register(program: Command): void {
223
223
  records
224
224
  .command('stale')
225
225
  .description(
226
- 'Report when the requirements record was last reviewed and how many releases have shipped since',
226
+ 'Report when the requirements and design records were last reviewed and how many releases have shipped since',
227
227
  )
228
228
  .argument('<kind>', `Record folder: ${STALE_KINDS.join(', ')}`)
229
229
  .helpOption('-h, --help', 'Show this help message')
@@ -233,7 +233,8 @@ export function register(program: Command): void {
233
233
  'after',
234
234
  [
235
235
  '',
236
- 'canonical: reads REQUIREMENTS.md at whichever surface root carries it,',
236
+ 'canonical: reads REQUIREMENTS.md and DESIGN.md at whichever surface root',
237
+ 'carries each, skipping one the project lacks,',
237
238
  'in the current worktree since it is tracked. It reports its reviewed date',
238
239
  'and the release tags merged into HEAD after that day, or never when it',
239
240
  'carries no date.',
@@ -107,6 +107,9 @@ export function register(program: Command): void {
107
107
  'A target below its git root syncs as a subfolder. It withholds',
108
108
  '.github/, which GitHub reads only at the root.',
109
109
  '',
110
+ 'The dependency install is bounded at 120 seconds. A stalled or failed',
111
+ 'install refuses with exit 1, and the stamp stays unwritten.',
112
+ '',
110
113
  'To gate CI on tooling drift, run headlessly with neither flag. That',
111
114
  'exits 1 when a file would be replaced and 0 when none would, which is',
112
115
  'what `canon sync --check --exit-code` spells with a flag. Pass --check',
@@ -444,7 +447,14 @@ async function runSync(
444
447
  }
445
448
 
446
449
  await injectSeeds(prepared.chain, prepared.target)
447
- await injectManifest(prepared.chain, prepared.target)
450
+ const manifest = await injectManifest(prepared.chain, prepared.target)
451
+ if (!manifest.ok) {
452
+ return refuse(
453
+ opts,
454
+ manifest.reason,
455
+ `${manifest.message}\nConfigs and seeds above were written. Scripts, gitignore entries, and the stamp were not, so the next sync retries.`,
456
+ )
457
+ }
448
458
 
449
459
  await stampChain(prepared.chain, prepared.target)
450
460
 
@@ -553,7 +563,12 @@ async function runInject(
553
563
  await injectSeeds(prepared.chain, prepared.target)
554
564
  }
555
565
  if (opts.manifest || applyAll) {
556
- await injectManifest(prepared.chain, prepared.target)
566
+ const manifest = await injectManifest(prepared.chain, prepared.target)
567
+ if (!manifest.ok) {
568
+ logWarn(manifest.message)
569
+ if (framed) outro()
570
+ return 1
571
+ }
557
572
  } else if (opts.gitignore) {
558
573
  await injectGitignore(prepared.chain, prepared.target)
559
574
  }