@erclx/canon 5.23.0 → 5.25.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 (98) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
  3. package/claude/skills/backlog-triage/SKILL.md +19 -6
  4. package/claude/skills/canon-operator/REQUIREMENT.md +1 -0
  5. package/claude/skills/canon-operator/SKILL.md +5 -1
  6. package/claude/skills/canvas/REQUIREMENT.md +49 -0
  7. package/claude/skills/canvas/SKILL.md +106 -0
  8. package/claude/skills/design-extract/REQUIREMENT.md +1 -1
  9. package/claude/skills/draft-doc/REQUIREMENT.md +2 -1
  10. package/claude/skills/draft-doc/references/readme/agent-facing.md +59 -0
  11. package/claude/skills/draft-doc/references/readme/application.md +66 -0
  12. package/claude/skills/draft-doc/references/readme/cli.md +64 -0
  13. package/claude/skills/draft-doc/references/readme/library.md +57 -0
  14. package/claude/skills/draft-doc/references/readme/plugin.md +66 -0
  15. package/claude/skills/draft-doc/references/readme.md +33 -4
  16. package/claude/skills/draft-figure/REQUIREMENT.md +14 -3
  17. package/claude/skills/draft-figure/SKILL.md +24 -51
  18. package/claude/skills/draft-figure/references/architecture-set.md +35 -0
  19. package/claude/skills/draft-figure/references/hand-drawn.md +31 -0
  20. package/claude/skills/draft-figure/references/mermaid.md +55 -0
  21. package/claude/skills/git-followup/SKILL.md +17 -6
  22. package/claude/skills/git-pr/references/evidence.md +4 -2
  23. package/claude/skills/git-split/SKILL.md +1 -1
  24. package/claude/skills/index-lookup/SKILL.md +2 -2
  25. package/claude/skills/review-address/SKILL.md +6 -13
  26. package/claude/skills/review-address/references/rest-reads.md +43 -0
  27. package/claude/skills/review-craft/references/ui.md +7 -1
  28. package/claude/skills/review-pr/REQUIREMENT.md +1 -0
  29. package/claude/skills/review-pr/SKILL.md +9 -3
  30. package/claude/skills/review-pr/references/close-out.md +2 -2
  31. package/claude/skills/review-pr/references/post.md +17 -5
  32. package/claude/skills/review-pr/references/scope.md +10 -8
  33. package/claude/skills/review-ui/SKILL.md +12 -4
  34. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +30 -1
  35. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +4 -0
  36. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +4 -4
  37. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +6 -0
  38. package/claude/skills/role-orchestrator/scripts/poll.ts +90 -27
  39. package/claude/skills/role-orchestrator/scripts/repo.ts +3 -5
  40. package/claude/skills/role-orchestrator/scripts/watch.ts +4 -7
  41. package/docs/agents/canvas.md +6 -2
  42. package/docs/agents/commands.md +45 -45
  43. package/docs/agents/context-audit-checks.md +2 -2
  44. package/docs/agents/index.md +0 -1
  45. package/docs/agents/key-changes.md +3 -3
  46. package/docs/agents/pr-evidence.md +11 -2
  47. package/docs/agents/pr-reads.md +5 -4
  48. package/docs/target/projects.md +4 -4
  49. package/docs/workflow/ai-workflow.md +0 -1
  50. package/docs/workflow/skill-map.md +3 -3
  51. package/docs/workflow/visual-design-workflow.md +2 -1
  52. package/governance/renames.toml +0 -4
  53. package/governance/rules/standards/markdown.md +1 -0
  54. package/package.json +1 -2
  55. package/scripts/core/install-check.sh +1 -1
  56. package/src/audits/catalog.ts +2 -2
  57. package/src/canvas/client/inspector/field.tsx +200 -0
  58. package/src/canvas/client/inspector/fields.css +151 -0
  59. package/src/canvas/client/inspector/section.tsx +27 -0
  60. package/src/canvas/client/inspector/values.ts +60 -0
  61. package/src/canvas/client/inspector.tsx +192 -111
  62. package/src/canvas/client/shell.css +0 -94
  63. package/src/canvas/tokens.ts +9 -1
  64. package/src/claude/cases/misc.ts +5 -0
  65. package/src/claude/cases/workflow.ts +1 -1
  66. package/src/claude/seeds.ts +0 -1
  67. package/src/commands/canvas.ts +29 -0
  68. package/src/commands/design.ts +2 -64
  69. package/src/commands/pr.ts +219 -202
  70. package/src/context/audit.ts +7 -6
  71. package/src/design/budget.ts +149 -0
  72. package/src/design/document.ts +22 -10
  73. package/src/design/render.ts +11 -1
  74. package/src/design/tokens.ts +40 -61
  75. package/src/gate/measures.ts +49 -0
  76. package/src/gate/stages.ts +5 -1
  77. package/src/help.ts +2 -2
  78. package/src/migrate/skill-names.ts +7 -1
  79. package/src/pr/evidence.ts +25 -5
  80. package/src/pr/rest.ts +137 -0
  81. package/standards/canvas.md +112 -0
  82. package/standards/context.md +2 -1
  83. package/standards/design.md +13 -3
  84. package/standards/figures.md +0 -1
  85. package/standards/index.md +1 -1
  86. package/standards/markdown.md +1 -0
  87. package/standards/mermaid.md +1 -7
  88. package/tooling/claude/reference.md +0 -5
  89. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +1 -1
  90. package/claude/skills/draft-diagram/REQUIREMENT.md +0 -45
  91. package/claude/skills/draft-diagram/SKILL.md +0 -177
  92. package/docs/agents/design-board.md +0 -33
  93. package/governance/rules/standards/diagrams.md +0 -14
  94. package/governance/rules/standards/mermaid.md +0 -15
  95. package/scripts/core/check-gallery-exclusion.sh +0 -50
  96. package/src/design/board.ts +0 -576
  97. package/standards/diagrams.md +0 -132
  98. package/tooling/claude/seeds/.claude/diagrams/index.md +0 -8
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The prose budget `standards/design.md` sets for a design record, and the
3
+ * counter the `design` gate stage reads it through.
4
+ *
5
+ * The limits live here and in the standard, never in the stage, so the stage
6
+ * names a failure without restating what it measured against.
7
+ *
8
+ * A row is excluded on the same test `table()` in `@/design/parse` reads one
9
+ * by, a line opening with a pipe, so a code span carrying a pipe or a cell
10
+ * carrying the uncertainty tag never reaches the count. A line opening with
11
+ * `<` is dropped the way `prose()` drops it, and so is a fenced block or an
12
+ * HTML comment spanning several lines, which `prose()` would read in part. A
13
+ * `###` subsection counts toward the `##` section holding it, since the
14
+ * standard fixes the sections and a subheading is no way past the cap.
15
+ */
16
+ export const DESIGN_BUDGET = {
17
+ /** Words before the first `##` section, the title excluded. */
18
+ leadWords: 40,
19
+ /** Words in `## Personality`, held to one paragraph. */
20
+ personalityWords: 60,
21
+ personalityParagraphs: 1,
22
+ /** Bullets or paragraphs in any other section, beside its table. */
23
+ rules: 3,
24
+ /** Words in one of those rules, the proxy for one line. */
25
+ ruleWords: 25,
26
+ } as const
27
+
28
+ const PERSONALITY = 'Personality'
29
+
30
+ /** A list item at any depth, which starts a rule of its own. */
31
+ const BULLET = /^\s*(?:[-*+]|\d+[.)])\s+/
32
+
33
+ interface Section {
34
+ readonly heading: string | undefined
35
+ readonly lines: readonly string[]
36
+ }
37
+
38
+ /**
39
+ * Every place the record exceeds the budget, one sentence each, in document
40
+ * order. An empty list is a record within budget, including an empty one.
41
+ */
42
+ export function measureDesignProse(source: string): string[] {
43
+ const findings: string[] = []
44
+
45
+ for (const section of splitSections(source)) {
46
+ const rules = readRules(section.lines)
47
+ const total = rules.reduce((sum, rule) => sum + rule, 0)
48
+
49
+ if (section.heading === undefined) {
50
+ if (total > DESIGN_BUDGET.leadWords) {
51
+ findings.push(
52
+ `The lead holds ${total} words against a cap of ${DESIGN_BUDGET.leadWords}`,
53
+ )
54
+ }
55
+ } else if (section.heading === PERSONALITY) {
56
+ if (rules.length > DESIGN_BUDGET.personalityParagraphs) {
57
+ findings.push(
58
+ `${PERSONALITY} holds ${rules.length} paragraphs against a cap of ${DESIGN_BUDGET.personalityParagraphs}`,
59
+ )
60
+ }
61
+ if (total > DESIGN_BUDGET.personalityWords) {
62
+ findings.push(
63
+ `${PERSONALITY} holds ${total} words against a cap of ${DESIGN_BUDGET.personalityWords}`,
64
+ )
65
+ }
66
+ } else {
67
+ if (rules.length > DESIGN_BUDGET.rules) {
68
+ findings.push(
69
+ `${section.heading} holds ${rules.length} rules against a cap of ${DESIGN_BUDGET.rules}`,
70
+ )
71
+ }
72
+ rules.forEach((words, index) => {
73
+ if (words > DESIGN_BUDGET.ruleWords) {
74
+ findings.push(
75
+ `${section.heading} rule ${index + 1} holds ${words} words against a cap of ${DESIGN_BUDGET.ruleWords}`,
76
+ )
77
+ }
78
+ })
79
+ }
80
+ }
81
+
82
+ return findings
83
+ }
84
+
85
+ /** The lead under an undefined heading, then each `##` section in order. */
86
+ function splitSections(source: string): Section[] {
87
+ const sections: { heading: string | undefined; lines: string[] }[] = [
88
+ { heading: undefined, lines: [] },
89
+ ]
90
+ for (const line of source.split('\n')) {
91
+ const match = line.match(/^##\s+(.+?)\s*$/)
92
+ if (match) {
93
+ sections.push({ heading: match[1], lines: [] })
94
+ } else if (!/^#\s/.test(line)) {
95
+ sections[sections.length - 1].lines.push(line)
96
+ }
97
+ }
98
+ return sections
99
+ }
100
+
101
+ /**
102
+ * The word count of each rule in a section. A rule opens on a bullet or on
103
+ * the first prose line after a break, and every following prose line joins it
104
+ * until a blank line, a table row, a heading, or the next bullet.
105
+ */
106
+ function readRules(lines: readonly string[]): number[] {
107
+ const rules: number[] = []
108
+ let isOpen = false
109
+ let isFenced = false
110
+ let isCommented = false
111
+
112
+ for (const line of lines) {
113
+ const trimmed = line.trim()
114
+ if (isCommented) {
115
+ isCommented = !trimmed.includes('-->')
116
+ continue
117
+ }
118
+ if (trimmed.startsWith('<!--')) {
119
+ isCommented = !trimmed.includes('-->', 4)
120
+ isOpen = false
121
+ continue
122
+ }
123
+ if (trimmed.startsWith('```')) {
124
+ isFenced = !isFenced
125
+ isOpen = false
126
+ continue
127
+ }
128
+ if (
129
+ isFenced ||
130
+ trimmed === '' ||
131
+ trimmed.startsWith('|') ||
132
+ trimmed.startsWith('#') ||
133
+ trimmed.startsWith('<')
134
+ ) {
135
+ isOpen = false
136
+ continue
137
+ }
138
+ const text = trimmed.replace(BULLET, '')
139
+ const count = text.split(/\s+/).filter(Boolean).length
140
+ if (BULLET.test(line) || !isOpen) {
141
+ rules.push(count)
142
+ isOpen = true
143
+ } else {
144
+ rules[rules.length - 1] += count
145
+ }
146
+ }
147
+
148
+ return rules
149
+ }
@@ -88,6 +88,20 @@ function borderRows(tokens: readonly BorderToken[]): string[][] {
88
88
  })
89
89
  }
90
90
 
91
+ /**
92
+ * The lead a reader meets before the first section. It points at the module
93
+ * and at the context entry, since the record carries values and one-line rules
94
+ * and every reason behind them lives in the entry.
95
+ */
96
+ const LEAD = [
97
+ 'Authoring guidance: `standards/design.md`. Rendered from `src/design/tokens.ts` by `canon design regen`, so edit the module and never this file.',
98
+ 'Why each value was chosen lives in `canon/context/design/tokens.md`.',
99
+ ].join(' ')
100
+
101
+ function bullets(rules: readonly string[]): string {
102
+ return rules.map((rule) => `- ${rule}`).join('\n')
103
+ }
104
+
91
105
  function section(heading: string, ...blocks: string[]): string {
92
106
  return [`## ${heading}`, ...blocks.filter((block) => block !== '')].join(
93
107
  '\n\n',
@@ -98,21 +112,19 @@ export function renderDesignDocument(tokens: DesignTokens = TOKENS): string {
98
112
  const body = [
99
113
  '# Design',
100
114
  '',
101
- 'Authoring guidance: `standards/design.md`.',
102
- '',
103
- tokens.preamble,
115
+ LEAD,
104
116
  '',
105
117
  section('Personality', tokens.personality),
106
118
  '',
107
119
  section(
108
120
  'Color',
109
- tokens.colorNote,
121
+ bullets(tokens.colorRules),
110
122
  formatTable(['Role', 'Intent', 'Value'], colorRows(tokens.color)),
111
123
  ),
112
124
  '',
113
125
  section(
114
126
  'Typography',
115
- tokens.typographyNote,
127
+ bullets(tokens.typographyRules),
116
128
  formatTable(
117
129
  ['Role', 'Family', 'Weight', 'Size', 'Line height'],
118
130
  typeRows(tokens.typography),
@@ -121,24 +133,24 @@ export function renderDesignDocument(tokens: DesignTokens = TOKENS): string {
121
133
  '',
122
134
  section(
123
135
  'Spacing',
124
- tokens.spacingNote,
136
+ bullets(tokens.spacingRules),
125
137
  formatTable(['Step', 'Multiplier', 'Value'], spaceRows(tokens.spacing)),
126
138
  ),
127
139
  '',
128
140
  section(
129
141
  'Borders',
130
- tokens.bordersNote,
142
+ bullets(tokens.bordersRules),
131
143
  formatTable(
132
144
  ['Role', 'Radius', 'Width', 'When used'],
133
145
  borderRows(tokens.borders),
134
146
  ),
135
147
  ),
136
148
  '',
137
- section('Layout', tokens.layout),
149
+ section('Layout', bullets(tokens.layout)),
138
150
  '',
139
- section('Motion', tokens.motion),
151
+ section('Motion', bullets(tokens.motion)),
140
152
  '',
141
- section('Iconography', tokens.iconography),
153
+ section('Iconography', bullets(tokens.iconography)),
142
154
  ]
143
155
 
144
156
  return `${body.join('\n')}\n`
@@ -328,7 +328,17 @@ function sectionBorders(rows: Row[]): string {
328
328
  return `<h2>Borders</h2>\n<table><thead><tr><th>Role</th><th>Radius</th><th>Width</th><th>When used</th><th>Sample</th></tr></thead><tbody>${body}</tbody></table>`
329
329
  }
330
330
 
331
+ /** A section the record writes as one-line rules draws them as a list. */
332
+ const BULLET = /^[-*+]\s+/
333
+
331
334
  function sectionLine(title: string, text: string): string {
332
335
  if (!text) return ''
333
- return `<h2>${title}</h2>\n<p>${escape(text)}</p>`
336
+ const lines = text.split('\n')
337
+ if (!lines.every((line) => BULLET.test(line))) {
338
+ return `<h2>${title}</h2>\n<p>${escape(text)}</p>`
339
+ }
340
+ const items = lines
341
+ .map((line) => `<li>${escape(line.replace(BULLET, ''))}</li>`)
342
+ .join('')
343
+ return `<h2>${title}</h2>\n<ul>${items}</ul>`
334
344
  }
@@ -59,21 +59,25 @@ export interface BorderToken {
59
59
  readonly verify?: readonly ('radius' | 'width')[]
60
60
  }
61
61
 
62
+ /**
63
+ * Every prose slot is a list of one-line rules rather than a string, so a
64
+ * bullet is the unit the renderer emits and the prose budget counts, and a
65
+ * value needing a paragraph of why goes to `canon/context/design/tokens.md`.
66
+ */
62
67
  export interface DesignTokens {
63
68
  readonly personality: string
64
69
  readonly color: readonly ColorToken[]
65
- readonly colorNote: string
70
+ readonly colorRules: readonly string[]
66
71
  readonly typeScale: readonly TypeStep[]
67
72
  readonly typography: readonly TypeToken[]
68
- readonly typographyNote: string
73
+ readonly typographyRules: readonly string[]
69
74
  readonly spacing: readonly SpaceToken[]
70
- readonly spacingNote: string
75
+ readonly spacingRules: readonly string[]
71
76
  readonly borders: readonly BorderToken[]
72
- readonly bordersNote: string
73
- readonly layout: string
74
- readonly motion: string
75
- readonly iconography: string
76
- readonly preamble: string
77
+ readonly bordersRules: readonly string[]
78
+ readonly layout: readonly string[]
79
+ readonly motion: readonly string[]
80
+ readonly iconography: readonly string[]
77
81
  }
78
82
 
79
83
  /** The monospace stack, taken by code and by the terminal frames a page embeds. */
@@ -94,35 +98,14 @@ const DARK_GROUNDS = ['background', 'surface'] as const
94
98
  const LIGHT_GROUNDS = ['light-background', 'light-surface'] as const
95
99
 
96
100
  export const TOKENS: DesignTokens = {
97
- preamble: [
98
- 'This document is rendered from `src/design/tokens.ts` by `canon design regen`, and the `design` stage of `bun run check` fails when the two disagree. Edit the module, never this file.',
99
- '',
100
- 'The values below are the system rather than a reading of one. Until 2026-09-01 this record transcribed two surfaces and agreed with nothing else, which is what made a change to it reach nobody. The slide theme, the token preview, and a teach workspace stylesheet now read the module this file is rendered from, so a value changed there changes what all three render.',
101
- '',
102
- 'The two rendered captures read it as well. `scripts/core/regen-hero.ts` fills `assets/captures/hero.html.tmpl` and `assets/captures/install.html.tmpl` with what `canon design css --no-components` emits, so both frames now carry the custom properties rather than their own copies of the hex, and a value moved here moves what the next capture renders.',
103
- '',
104
- 'The terminal framing is the one surface left holding its own values, and that is a decision rather than a gap. `scripts/lib/ui.sh` and `src/ui.ts` each spell six escape constants, and `canon/context/scripts/framing.md` records one color source per language with a check behind each, so generating a third spelling from here would break the rule those two checks enforce. What the record is still incomplete about is the other half of those six: `WHITE` and `GREY` name no role below, so the terminal palette is described here in part rather than in whole.',
105
- ].join('\n'),
106
-
107
- personality: [
108
- 'Warm neutrals carry the frame under a single rust accent, set in Geist. The subject is a toolkit for people who read documents and diffs as much as they run commands, so the voice is a proportional one and the page reads as prose. One accent carries every count, link, and primary action. Promoting a second and third into structural roles is what reads as a generated interface, so the palette stays at one.',
109
- '',
110
- 'Monospace is a role the page uses rather than its voice. The `code` role takes it, and so does any terminal frame a surface embeds, since those show what the shell printed and matching the shell is what makes them legible. Every other role is Geist.',
111
- ].join('\n'),
101
+ personality:
102
+ 'Warm neutrals under one rust accent, set in Geist. The page reads as prose, and monospace appears only where a surface shows what a shell printed. One accent carries every count, link, and primary action.',
112
103
 
113
- colorNote: [
114
- 'Every role clears WCAG AA at 4.5:1 against each ground it declares, asserted in `src/design/contrast.test.ts`.',
115
- '',
116
- 'Warning and error hold ANSI codes because that is what `scripts/lib/ui.sh` writes and no rendered surface implements an equivalent. Giving either a hex value would invent a mapping no file has, so they carry no contrast reading either.',
117
- '',
118
- 'Success is the one of the three that does have a rendered equivalent, which is why it carries a hex. `assets/captures/install.html` marks every confirmed step with it, and the shell writes `ANSI 32` for the same role, so the two are one role in two registers rather than one value in two spellings. The hex is what the rendered surface picked and no reading claims the terminal renders that value. It declares `background` alone as its ground, since that is the only role it is drawn on, where every other dark text role is drawn on both. It is the one role below that is not derived.',
119
- '',
120
- "Every other role is derived rather than picked, solved in OKLCH by binary search for the lightness that hits a target contrast against its ground, using this module's own anchors. Six anchors are the whole system: ground lightness 0.985 light and 0.165 dark, neutral chroma 0.003 light and 0.004 dark, neutral hue 90 both, accent hue 22 light and 28 dark, accent chroma 0.13 light and 0.12 dark for a mark and 0.095 light and 0.09 dark for a fill. Only the mark step is rendered below, in `accent`. That token already stands in for a fill in practice: `assets/captures/hero.html`'s `.cmd` rule paints its whole background with `var(--color-accent)` and sets text on top, which is a fill use rather than a mark one. The fill chroma step has no token of its own yet, so a lower-saturation `accent-fill` would recolor that button rather than introduce a new consumer. Targets, which are inputs rather than results: text 13.5, body 8.6, secondary 5.6, muted 4.6, accent 5.2. Dark is its own anchor set rather than an inversion of light, and every arm measured in the groundwork behind this landed on identical neutral ratios, which is what made the accent choice a question about hue alone.",
121
- '',
122
- '`muted` and `light-muted` solve against `surface` rather than `background`. Both declare two grounds and the groundwork solved its target against one, so the recorded hex cleared 4.6 against `background` and read 4.40 dark, 4.24 light against `surface`, the tighter of the two since surface sits a step closer to its text color. The values here are re-solved for the same 4.6 target read against `surface` instead, which clears both: 4.58 dark, 4.56 light against `surface`, and 4.80 dark, 4.95 light against `background`.',
123
- '',
124
- 'The accent is a quiet red at hue 22 light, 28 dark, chosen over a vivid red that read as an error state against a page reporting success, over indigo which carries less distinctiveness at hue 280 in tooling already dominated by that hue, and over rust at hue 42, a larger temperature shift than the problem required. The debt this accepts: `error` and `warning` now share a register with the most repeated element on every rendered surface, so both need differentiating by lightness or by an icon rather than by hue, and that work is unscheduled.',
125
- ].join('\n'),
104
+ colorRules: [
105
+ 'One accent. Never promote a second color into a structural role.',
106
+ 'Every text role clears WCAG AA at 4.5:1 against each ground it is drawn on.',
107
+ '`warning` and `error` exist only in the terminal, as ANSI codes.',
108
+ ],
126
109
 
127
110
  color: [
128
111
  {
@@ -242,15 +225,11 @@ export const TOKENS: DesignTokens = {
242
225
  },
243
226
  ],
244
227
 
245
- typographyNote: [
246
- 'Seven sizes are on offer, named `t0` through `t6` and emitted as custom properties beside the roles. They are 3.175, 2.375, 1.375, 1.125, 0.9375, 0.8125 and 0.6875 rem, which paint at 50.8, 38, 22, 18, 15, 13 and 11 pixels, and each role takes one. A step answers what sizes exist and a role answers what a stylesheet asks for, so the two stay separate: a role whose step did not move renders as it did, and a role that moved is the only thing that changes what a surface paints. Two steps, `t3` at 18 pixels and `t6` at 11, are offered with no role pointing at them yet.',
247
- '',
248
- 'Every role but `code` is Geist and `code` is monospace. The hero headline takes the largest step at 50.8 pixels, which no other role reaches, since a headline set at the display cap reads as an opening rather than as a section heading.',
249
- '',
250
- 'A tagged cell is one no rendering surface exercises yet, which is a declaration the system has not tested rather than one it has.',
251
- '',
252
- 'Two rules set tracking and no others touch it. The label role carries `0.05em`, and the display role tightens to `-0.01em`.',
253
- ].join('\n'),
228
+ typographyRules: [
229
+ 'Geist for every role except `code`, which is monospace.',
230
+ 'Tracking: `label` at `0.05em`, `display` at `-0.01em`, nothing else.',
231
+ 'Sizes come from seven steps, `t0` to `t6`. A role takes one step.',
232
+ ],
254
233
 
255
234
  typeScale: [
256
235
  { step: 't0', size: '3.175rem' },
@@ -312,11 +291,9 @@ export const TOKENS: DesignTokens = {
312
291
  },
313
292
  ],
314
293
 
315
- spacingNote: [
316
- 'Seven steps run from a quarter rem to six, at 4, 8, 14, 24, 40, 64 and 96 pixels on a 16 pixel root. The scale is not a grid of one base, since the 14 between the 8 and the 24 is the step a control needs and no multiple of four supplies it, so the multiplier column counts quarter rems rather than claiming a unit.',
317
- '',
318
- 'The outer window padding of the capture frames, which the previous record carried as a three-value frame register, is not part of the scale. Those frames set their own padding in the template and read no step, so the register is retired here rather than mapped onto steps no frame uses.',
319
- ].join('\n'),
294
+ spacingRules: [
295
+ 'Seven steps. `md` sits at 3.5 because a control needs 14 pixels.',
296
+ ],
320
297
 
321
298
  spacing: [
322
299
  { step: 'xs', multiplier: '1', value: '0.25rem' },
@@ -328,9 +305,9 @@ export const TOKENS: DesignTokens = {
328
305
  { step: '3xl', multiplier: '24', value: '6rem' },
329
306
  ],
330
307
 
331
- bordersNote: [
332
- 'Every border is one pixel solid at the `border` role, and that value appears in no text role. Three radii appear, and the two blocks carrying the largest and smallest have no border at all, so radius and width are independent here rather than paired. Nothing renders a pill, so both of its cells stay tagged.',
333
- ].join('\n'),
308
+ bordersRules: [
309
+ 'Every border is one pixel solid in `border`. Radius and width are independent.',
310
+ ],
334
311
 
335
312
  borders: [
336
313
  {
@@ -372,17 +349,19 @@ export const TOKENS: DesignTokens = {
372
349
  },
373
350
  ],
374
351
 
375
- layout:
376
- 'Capture widths are 320, 768, 1280 and 1536 pixels, declared once in `tooling/web/configs/e2e/screenshot.ts`, with 320 and 1280 committed as evidence. 320 is the reflow floor. Every range the width queries in the generated base and teach stylesheets lay out holds at least one of these widths, so a capture photographs every layout state those stylesheets have, and a design test fails when a query moves or is added so that it opens a range none of them reaches. The landing page under `web/` sets its own `max-width` media queries between 430 and 1100 pixels per component rather than from a shared scale.',
352
+ layout: [
353
+ 'Capture widths: 320, 768, 1280, 1536. 320 is the reflow floor.',
354
+ 'Every width query in the generated stylesheets opens a range one of these widths reaches.',
355
+ ],
377
356
 
378
- motion:
379
- 'Motion is not used. No transition, animation, or keyframe declaration appears on any rendered surface, and the capture pipeline screenshots a static frame.',
357
+ motion: [
358
+ 'None. No transition, animation, or keyframe on any rendered surface.',
359
+ ],
380
360
 
381
361
  iconography: [
382
- 'No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing.',
383
- '',
384
- 'The same mark ships in five independently-maintained copies that carry different colors by design rather than by drift, each fitted to the chrome it renders on: the dark accent for a dark-chrome surface, the light accent for a light-chrome one, and the pair the web favicon takes from `src/design/favicon.ts`, which teach pages also take by rendering the live mark. Unifying them or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design/tokens.md` carries which file holds each copy.',
385
- ].join('\n'),
362
+ 'No icon library. `assets/brand/mark.svg` is the one authored icon.',
363
+ 'The terminal draws glyphs: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯`.',
364
+ ],
386
365
  }
387
366
 
388
367
  /** A role's value, or `undefined` where the record declares no such role. */
@@ -22,6 +22,8 @@ import {
22
22
  PRACTICE_SKILLS,
23
23
  SHIPPED_CORPUS,
24
24
  } from '@/claude/skills-audit'
25
+ import { measureDesignProse } from '@/design/budget'
26
+ import { DESIGN_DOCUMENT } from '@/design/regen'
25
27
  import { listRepositoryFiles } from '@/git-files'
26
28
  import { ceilingFindings } from '@/markdown/ceiling'
27
29
  import { resolveMarkdown } from '@/markdown/files'
@@ -325,6 +327,53 @@ export const canonicalRecords: Measure = async (ctx) => {
325
327
  return { emissions }
326
328
  }
327
329
 
330
+ /**
331
+ * The design records this repository holds, each read against the prose
332
+ * budget in `@/design/budget`. The worked example is read beside the record,
333
+ * since it is the one a target copies its shape from.
334
+ */
335
+ const DESIGN_RECORDS = [
336
+ DESIGN_DOCUMENT,
337
+ join('examples', 'design', 'DESIGN.md'),
338
+ ] as const
339
+
340
+ export const designProse: Measure = async (ctx) => {
341
+ const failures: string[] = []
342
+ const emissions: Emission[] = []
343
+
344
+ for (const rel of DESIGN_RECORDS) {
345
+ const path = join(ctx.root, rel)
346
+ if (!existsSync(path)) continue
347
+ let source: string
348
+ try {
349
+ source = readFileSync(path, 'utf8')
350
+ } catch {
351
+ return {
352
+ emissions: [],
353
+ unmeasured: `${rel} could not be read, so its prose was not counted.`,
354
+ }
355
+ }
356
+ const findings = measureDesignProse(source)
357
+ if (findings.length > 0) {
358
+ failures.push(
359
+ `${findings.map((finding) => `${finding} in ${rel}`).join('. ')}.`,
360
+ )
361
+ } else {
362
+ emissions.push(info(`${rel} within the prose budget`))
363
+ }
364
+ }
365
+
366
+ if (failures.length > 0) {
367
+ return {
368
+ emissions: [],
369
+ failure: `${failures.join(' ')} Cut to the budget in standards/design.md, moving the why to the context entry.`,
370
+ }
371
+ }
372
+ if (emissions.length === 0)
373
+ emissions.push(info('No design record to measure'))
374
+ return { emissions }
375
+ }
376
+
328
377
  /**
329
378
  * Every skill body and reference against the no-dated-provenance rule, across
330
379
  * both corpora `auditSkills` reads.
@@ -10,6 +10,7 @@ import {
10
10
  auditSet,
11
11
  captureStamps,
12
12
  clientCommandCitations,
13
+ designProse,
13
14
  documentCeiling,
14
15
  folderEchoedNames,
15
16
  markdownBans,
@@ -234,7 +235,9 @@ export const STAGES: readonly Stage[] = [
234
235
  // from `src/design/tokens.ts`, the base stylesheet from
235
236
  // `src/design/neutral.ts`, and none is edited by hand. Four artifacts
236
237
  // from two token sources is the cost of the token move, and a render step that has to run is only safe while something fails
237
- // when it did not, which is this.
238
+ // when it did not, which is this. The record and the worked example are
239
+ // then counted against the prose budget, which the drift asserts cannot
240
+ // see since a regenerated paragraph agrees with its source.
238
241
  id: 'design',
239
242
  label: 'Design',
240
243
  checks: [
@@ -277,6 +280,7 @@ export const STAGES: readonly Stage[] = [
277
280
  failure:
278
281
  'The tab icon drifted from the token source. Run bun run check and commit web/public/favicon.svg.',
279
282
  },
283
+ { kind: 'measure', measure: designProse },
280
284
  ],
281
285
  success: 'Design source clean',
282
286
  },
package/src/help.ts CHANGED
@@ -36,7 +36,7 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
36
36
  {
37
37
  title: 'Author and render',
38
38
  rows: [
39
- ['design [cmd]', 'Design system (render, board)'],
39
+ ['design [cmd]', 'Design system (render, regen)'],
40
40
  ['slides [cmd]', 'Slide decks (render, list)'],
41
41
  ['capture [source]', 'Render HTML capture sources to PNG'],
42
42
  ['serve [dir]', 'Serve a directory on localhost, print the link'],
@@ -102,7 +102,7 @@ const EXAMPLES = [
102
102
  'canon docs list --json',
103
103
  'canon docs agents',
104
104
  'canon design render',
105
- 'canon design board',
105
+ 'canon canvas list --json',
106
106
  'canon slides render',
107
107
  'canon slides list --json',
108
108
  'canon capture assets/captures/install.html --selector .window --out assets/evidence',
@@ -40,6 +40,11 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
40
40
  * from where the document lands. Every retired name lands on the one skill, so
41
41
  * a target citing any of them resolves after a single sweep.
42
42
  *
43
+ * `draft-diagram` merged into `draft-figure` the same way, its architecture
44
+ * views becoming one reference the figure skill reads. `claude-diagram` is
45
+ * retargeted onto `draft-figure` rather than left on the retired name, for the
46
+ * same reason `claude-ui-test` was.
47
+ *
43
48
  * Every name takes two words. Ten of these would have landed as a bare single
44
49
  * word under a plain strip, and a bare word such as `review` or `docs` is a
45
50
  * substring of ordinary prose with no token left for a later sweep to find.
@@ -58,7 +63,7 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
58
63
  'claude-address-review': 'review-address',
59
64
  'claude-autoship': 'auto-ship',
60
65
  'claude-design-extract': 'design-extract',
61
- 'claude-diagram': 'draft-diagram',
66
+ 'claude-diagram': 'draft-figure',
62
67
  'claude-docs': 'context-fold',
63
68
  'docs-fold': 'context-fold',
64
69
  'claude-feature': 'plan-feature',
@@ -91,6 +96,7 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
91
96
  'draft-wireframes': 'draft-doc',
92
97
  'draft-readme': 'draft-doc',
93
98
  'draft-wiki': 'draft-doc',
99
+ 'draft-diagram': 'draft-figure',
94
100
  }
95
101
 
96
102
  /**
@@ -1,6 +1,12 @@
1
1
  /** The path segment that marks an image as evidence a pull request should compare. */
2
2
  const EVIDENCE_SEGMENT = 'evidence'
3
3
 
4
+ /** The most images a comment carries and still opens its states, since past it the page is a wall of screenshots. */
5
+ const EVIDENCE_OPEN_LIMIT = 6
6
+
7
+ /** Length of the commit names the visible line shows, the width `git` abbreviates to. */
8
+ const SHORT_SHA_LENGTH = 7
9
+
4
10
  /** Prefix of the trailing marker this module writes and reads back. */
5
11
  const MARKER_PREFIX = '<!-- pr-evidence:'
6
12
 
@@ -120,10 +126,14 @@ export function evidenceMarker(head: string): string {
120
126
  }
121
127
 
122
128
  /**
123
- * Renders the whole comment body: one collapsed `<details>` block per state,
124
- * a Base/Head row per case, and the trailing marker naming the head this body
125
- * describes. Every image URL is pinned to a commit sha rather than a branch,
126
- * so the comment keeps showing what it claimed even after the branch moves.
129
+ * Renders the whole comment body: one `<details>` block per state, a Base/Head
130
+ * row per case, and the trailing marker naming the head this body describes.
131
+ * Every state opens while the comment carries at most `EVIDENCE_OPEN_LIMIT`
132
+ * images, one per row for the head plus one per row not added, and every state
133
+ * closes past it, so a comment is never half open. A visible line under
134
+ * `## Evidence` names the base and head as short shas. Every image URL is
135
+ * pinned to a commit sha rather than a branch, so the comment keeps showing
136
+ * what it claimed even after the branch moves.
127
137
  *
128
138
  * A preview address opens the body, and with no states it is the whole body
129
139
  * apart from the marker, so a pull request whose screenshots did not change
@@ -157,6 +167,14 @@ export function renderEvidenceBody(
157
167
  return [...opening, ...closing, evidenceMarker(head)].join('\n')
158
168
  }
159
169
 
170
+ const imageCount = states.reduce(
171
+ (total, entry) =>
172
+ total + entry.items.reduce((sum, item) => sum + (item.added ? 1 : 2), 0),
173
+ 0,
174
+ )
175
+ const opener =
176
+ imageCount <= EVIDENCE_OPEN_LIMIT ? '<details open>' : '<details>'
177
+
160
178
  const sections = states.map((entry) => {
161
179
  const rows = entry.items.map((item) => {
162
180
  const before = item.added
@@ -167,7 +185,7 @@ export function renderEvidenceBody(
167
185
  })
168
186
 
169
187
  return [
170
- '<details>',
188
+ opener,
171
189
  `<summary>${entry.state === '' ? 'evidence' : entry.state} (${entry.items.length})</summary>`,
172
190
  '',
173
191
  '| Case | Base | Head |',
@@ -182,6 +200,8 @@ export function renderEvidenceBody(
182
200
  ...opening,
183
201
  '## Evidence',
184
202
  '',
203
+ `**Base:** \`${base.slice(0, SHORT_SHA_LENGTH)}\` · **Head:** \`${head.slice(0, SHORT_SHA_LENGTH)}\``,
204
+ '',
185
205
  ...sections,
186
206
  '',
187
207
  ...closing,