@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
@@ -1,14 +0,0 @@
1
- ---
2
- # Generated by `canon gov regen` from standards/diagrams.md. Edit that frontmatter, not this file.
3
- description: Shape and content rules for .canon/diagrams/<kind>.md files
4
- paths:
5
- - '.canon/diagrams/**'
6
- ---
7
-
8
- # Diagram standards
9
-
10
- ## Authority
11
-
12
- - Follow the diagrams standard for shape and content rules for .canon/diagrams/<kind>.md files. It is the single source. Read it with `canon standards diagrams`.
13
- - Layout, budgets, accessibility, labels, and render verification inside the fence are a separate topic. The mermaid standard routes them.
14
- - A diagram entry carries structure and flow, not implementation. Read the standard before adding or revising a kind.
@@ -1,15 +0,0 @@
1
- ---
2
- # Generated by `canon gov regen` from standards/mermaid.md. Edit that frontmatter, not this file.
3
- description: Direction and layout, node and edge budgets, accessibility fields, label punctuation, and render verification for a Mermaid diagram
4
- paths:
5
- - '**/*.md'
6
- ---
7
-
8
- # Mermaid standards
9
-
10
- ## Authority
11
-
12
- - Follow the mermaid standard for direction and layout, node and edge budgets, accessibility fields, label punctuation, and render verification for a Mermaid diagram. It is the single source. Read it with `canon standards mermaid`.
13
- - Apply this rule only when the edit drafts or revises a Mermaid fence. Skip it on a markdown edit carrying no diagram.
14
- - Read it before drafting the fence. Do not work the layout or the budgets from memory.
15
- - Which diagram a document carries and what question it answers is a separate topic. The diagrams standard routes it.
@@ -1,50 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -e
3
- set -o pipefail
4
-
5
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
- PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
7
-
8
- WEB_DIST="$PROJECT_ROOT/web/dist"
9
- GALLERY_DIST="$PROJECT_ROOT/web/gallery-dist"
10
- MARKER='name="canon-gallery-marker"'
11
-
12
- # Nothing to grep without a build. Callers run this after `bun run web:build`
13
- # and `bun run web:gallery`, so an absent dist here means a build step itself
14
- # failed rather than something this check can verify.
15
- if [ ! -d "$WEB_DIST" ]; then
16
- echo "No $WEB_DIST. Run bun run web:build first." >&2
17
- exit 1
18
- fi
19
-
20
- if [ ! -d "$GALLERY_DIST" ]; then
21
- echo "No $GALLERY_DIST. Run bun run web:gallery first." >&2
22
- exit 1
23
- fi
24
-
25
- # A prose notice is not load-bearing: rewording it silently retires the leak
26
- # check it backed with every workflow still green. The marker is a fixed
27
- # `<meta>` tag `web/gallery-src/pages/index.astro` carries for exactly this,
28
- # so asserting it survives in the gallery's own build is what turns a dropped
29
- # marker into a loud failure here rather than a blind spot in the check below.
30
- if ! grep -rl "$MARKER" "$GALLERY_DIST" >/dev/null 2>&1; then
31
- echo "The gallery build at $GALLERY_DIST carries no $MARKER. The gallery page dropped its own marker, which is what the leak check below reads for." >&2
32
- exit 1
33
- fi
34
-
35
- # `web/gallery.config.mjs` keeps the components-panel gallery out of the
36
- # published build by never pointing its srcDir at it. A later config change
37
- # that widens srcDir, or a gallery file moving under web/src/, would publish
38
- # this repository's component internals to a live domain with every other
39
- # check still green, which is what this guards against.
40
- if find "$WEB_DIST" -iname '*gallery*' | grep -q .; then
41
- echo "A file named for the gallery reached $WEB_DIST. See web/gallery.config.mjs's srcDir." >&2
42
- exit 1
43
- fi
44
-
45
- if grep -rl "$MARKER" "$WEB_DIST" >/dev/null 2>&1; then
46
- echo "The gallery's own marker ($MARKER) reached $WEB_DIST. See web/gallery.config.mjs's srcDir." >&2
47
- exit 1
48
- fi
49
-
50
- echo "check-gallery-exclusion: gallery marker present in $GALLERY_DIST, absent from $WEB_DIST"
@@ -1,576 +0,0 @@
1
- import {
2
- cpSync,
3
- existsSync,
4
- mkdirSync,
5
- readdirSync,
6
- readFileSync,
7
- rmSync,
8
- writeFileSync,
9
- } from 'node:fs'
10
- import { join, relative, sep } from 'node:path'
11
- import { DESIGN_DOCUMENT } from '@/design/regen'
12
- import { renderDesignDoc } from '@/design/render'
13
- import { NEUTRAL_TOKENS } from '@/design/neutral'
14
- import { colorValue } from '@/design/tokens'
15
- import { parseFrontmatter, readField } from '@/indexes/frontmatter'
16
- import { recordDir } from '@/record-root'
17
- import { surfaceDir } from '@/surface-root'
18
-
19
- /**
20
- * The wireframes panel's creation-root spelling, used in its own empty-state
21
- * message and by this module's tests to seed a fixture. The panel itself
22
- * resolves its actual read path through `surfaceDir`, which also reads
23
- * `.claude/wireframes/` for a target that has not run the surface-roots
24
- * migration, so this constant names where a fresh file lands rather than
25
- * where every root's corpus necessarily sits.
26
- */
27
- export const WIREFRAME_DIR = join('canon', 'wireframes')
28
-
29
- /** Landing page for the built site the surfaces panel iframes when present. */
30
- const WEB_DIST = join('web', 'dist')
31
- const WEB_DIST_ENTRY = 'index.html'
32
-
33
- /**
34
- * The gallery build, generated by `bun run web:gallery` through
35
- * `web/gallery.config.mjs`. That config's own `srcDir` is `web/gallery-src`,
36
- * which `web:build`'s config never reads, so this output never reaches
37
- * `web/dist`.
38
- */
39
- const WEB_GALLERY_DIST = join('web', 'gallery-dist')
40
- const WEB_GALLERY_DIST_ENTRY = 'index.html'
41
-
42
- const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.svg', '.webp']
43
-
44
- export interface BoardPanel {
45
- readonly id: string
46
- readonly title: string
47
- /** Relative to the board's own output directory. */
48
- readonly path: string
49
- }
50
-
51
- export interface BoardResult {
52
- readonly ok: true
53
- readonly outDir: string
54
- readonly indexPath: string
55
- readonly panels: readonly BoardPanel[]
56
- }
57
-
58
- export interface BoardRefused {
59
- readonly ok: false
60
- readonly reason: 'unsafe-out'
61
- readonly detail: string
62
- }
63
-
64
- export type BoardOutcome = BoardResult | BoardRefused
65
-
66
- /**
67
- * Whether clearing `outDir` would take a protected directory down with it:
68
- * the directory equals one of them, or contains one.
69
- *
70
- * `generateBoard` clears its output directory on every run, and `--out` is
71
- * resolved against the caller's cwd while the panels read from `PROJECT_ROOT`.
72
- * Those agree in the ordinary case and diverge in exactly one: a second
73
- * checkout, where a global `canon` resolves `PROJECT_ROOT` to a different
74
- * tree than the one the caller stands in. Guarding `PROJECT_ROOT` alone misses
75
- * that case, since `--out .` then resolves under the caller's own cwd, which
76
- * shares no containment with the unrelated root the guard compared it to.
77
- */
78
- function wouldDeleteRoot(protect: readonly string[], outDir: string): boolean {
79
- return protect.some((dir) => dir === outDir || dir.startsWith(outDir + sep))
80
- }
81
-
82
- function escapeHtml(value: string): string {
83
- return value
84
- .replace(/&/g, '&amp;')
85
- .replace(/</g, '&lt;')
86
- .replace(/>/g, '&gt;')
87
- .replace(/"/g, '&quot;')
88
- }
89
-
90
- const THEME_TOGGLE_BUTTON = `<button class="theme-toggle" type="button" aria-label="Switch between light and dark"><svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg><svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>`
91
-
92
- const THEME_TOGGLE_SCRIPT = [
93
- '<script>(function(){',
94
- 'var r=document.documentElement;',
95
- 'try{var s=localStorage.getItem("board-theme");if(s)r.dataset.theme=s;}catch(e){}',
96
- 'document.addEventListener("click",function(e){',
97
- 'var b=e.target.closest(".theme-toggle");if(!b)return;',
98
- 'var dark=r.dataset.theme==="dark"||(!r.dataset.theme&&matchMedia("(prefers-color-scheme: dark)").matches);',
99
- 'r.dataset.theme=dark?"light":"dark";',
100
- 'try{localStorage.setItem("board-theme",r.dataset.theme);}catch(e){}',
101
- '});',
102
- '})();</script>',
103
- ].join('')
104
-
105
- /**
106
- * Highlights the nav link for whichever section fills the most of the
107
- * viewport, following the same largest-intersection-ratio rule the landing
108
- * page's own floating pill uses, so a reader scrolling through the four
109
- * panels sees the pill track their position rather than sitting static.
110
- */
111
- const SCROLLSPY_SCRIPT = [
112
- '<script>(function(){',
113
- 'var links={};',
114
- 'document.querySelectorAll("nav a[href^=\\"#\\"]").forEach(function(a){links[a.getAttribute("href").slice(1)]=a;});',
115
- 'var ratios={};',
116
- 'var current="";',
117
- 'var observer=new IntersectionObserver(function(entries){',
118
- 'entries.forEach(function(entry){ratios[entry.target.id]=entry.isIntersecting?entry.intersectionRatio:0;});',
119
- 'var bestId="";',
120
- 'var bestRatio=0;',
121
- 'Object.keys(ratios).forEach(function(id){if(ratios[id]>bestRatio){bestRatio=ratios[id];bestId=id;}});',
122
- 'if(bestId&&bestId!==current){',
123
- 'if(links[current])links[current].classList.remove("active");',
124
- 'current=bestId;',
125
- 'if(links[current])links[current].classList.add("active");',
126
- '}',
127
- '},{threshold:[0,0.25,0.5,0.75,1]});',
128
- 'Object.keys(links).forEach(function(id){',
129
- 'var section=document.getElementById(id);',
130
- 'if(section)observer.observe(section);',
131
- '});',
132
- '})();</script>',
133
- ].join('')
134
-
135
- /**
136
- * A role's toolkit value, falling back to the neutral install base's where the
137
- * toolkit record drops the role, so the board never restates a hex of its own.
138
- */
139
- function roleColor(role: string): string {
140
- const value = colorValue(role) ?? colorValue(role, NEUTRAL_TOKENS)
141
- if (value === undefined) throw new Error(`No design token declares ${role}`)
142
- return value
143
- }
144
-
145
- /**
146
- * The same brand mark `src/design/render.ts` embeds in its own preview,
147
- * colored with the dark accent since the board's default chrome is dark
148
- * where that preview's is light. Duplicated here rather than imported,
149
- * following that module's own precedent of three independently-colored
150
- * copies rather than a shared icon helper for the first second caller.
151
- */
152
- function faviconLink(): string {
153
- const color = roleColor('accent')
154
- const href = `data:image/svg+xml,${encodeURIComponent(
155
- `<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="${color}" /><rect x="44" y="15" width="12" height="70" rx="2" fill="${color}" /></svg>`,
156
- )}`
157
- return `<link rel="icon" href="${href}">`
158
- }
159
-
160
- /** The board shell's own chrome, read off the toolkit's design source. */
161
- function shellChrome(): string {
162
- const roles: ReadonlyArray<readonly [string, string]> = [
163
- ['background', 'background'],
164
- ['surface', 'surface'],
165
- ['text', 'text'],
166
- ['muted', 'muted'],
167
- ['border', 'border'],
168
- ['accent', 'accent'],
169
- ]
170
- const dark = roles
171
- .map(([name, role]) => {
172
- const value = colorValue(role)
173
- return value === undefined ? '' : ` --board-${name}: ${value};`
174
- })
175
- .filter((line) => line !== '')
176
- const light = roles
177
- .map(([name, role]) => {
178
- const value = colorValue(`light-${role}`)
179
- return value === undefined ? '' : ` --board-${name}: ${value};`
180
- })
181
- .filter((line) => line !== '')
182
-
183
- return `:root {\n${dark.join('\n')}\n}\n[data-theme='light'] {\n${light.join('\n')}\n}`
184
- }
185
-
186
- function shellHtml(panels: readonly BoardPanel[]): string {
187
- const navLinks = panels
188
- .map((panel) => `<a href="#${panel.id}">${escapeHtml(panel.title)}</a>`)
189
- .join('')
190
-
191
- const sections = panels
192
- .map(
193
- (panel, index) =>
194
- `<section id="${panel.id}">\n<div class="eyebrow">Panel 0${index + 1}</div>\n<h2>${escapeHtml(panel.title)}</h2>\n<iframe src="${panel.path}" loading="lazy"></iframe>\n</section>`,
195
- )
196
- .join('\n')
197
-
198
- return `<!doctype html>
199
- <html lang="en">
200
- <head>
201
- <meta charset="utf-8">
202
- ${faviconLink()}
203
- <title>Design board</title>
204
- <style>
205
- ${shellChrome()}
206
- * { box-sizing: border-box; }
207
- body { font-family: 'Noto Sans Mono', 'DejaVu Sans Mono', monospace; margin: 0; color: var(--board-text); background: var(--board-background); }
208
- header { padding: 2.5rem 3rem 1.5rem; }
209
- h1 { margin: 0 0 0.4rem; font-size: 1.6rem; font-weight: 600; letter-spacing: -0.01em; }
210
- .meta { color: var(--board-muted); font-size: 0.85rem; margin: 0; }
211
- nav { position: fixed; top: 1.25rem; left: 50%; transform: translateX(-50%); z-index: 1; display: flex; align-items: center; gap: 1.25rem; padding: 0.5rem 0.6rem 0.5rem 1.1rem; background: var(--board-surface); border: 1px solid var(--board-border); border-radius: 999px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); white-space: nowrap; }
212
- nav a { color: var(--board-muted); text-decoration: none; font-size: 0.8rem; }
213
- nav a:hover { color: var(--board-accent); }
214
- nav a.active { color: var(--board-accent); }
215
- .theme-toggle { display: flex; align-items: center; justify-content: center; width: 1.8rem; height: 1.8rem; background: var(--board-background); color: var(--board-text); border: 1px solid var(--board-border); border-radius: 50%; padding: 0; cursor: pointer; }
216
- .theme-toggle .sun { display: none; }
217
- .theme-toggle .moon { display: block; }
218
- [data-theme='light'] .theme-toggle .sun { display: block; }
219
- [data-theme='light'] .theme-toggle .moon { display: none; }
220
- .theme-toggle svg { width: 0.95rem; height: 0.95rem; }
221
- main { padding: 0 3rem 4.5rem; }
222
- section { margin-top: 2.5rem; padding-top: 1.25rem; border-top: 1px solid var(--board-border); scroll-margin-top: 4.5rem; }
223
- .eyebrow { color: var(--board-accent); font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 0.35rem; }
224
- section h2 { margin: 0 0 0.75rem; font-size: 1.1rem; font-weight: 600; }
225
- iframe { width: 100%; height: 480px; border: 1px solid var(--board-border); border-radius: 4px; background: var(--board-surface); }
226
- </style>
227
- </head>
228
- <body>
229
- <header>
230
- <h1>Design board</h1>
231
- <p class="meta">Generated by <code>canon design board</code>. Reads from a caller-resolved root, never installed or synced.</p>
232
- <p class="meta">Drafting a new candidate: <code>canon:draft-and-pick</code>. Auditing a rendered surface: <code>canon:ux-audit</code>.</p>
233
- </header>
234
- <nav>${navLinks}${THEME_TOGGLE_BUTTON}</nav>
235
- <main>
236
- ${sections}
237
- </main>
238
- ${THEME_TOGGLE_SCRIPT}
239
- ${SCROLLSPY_SCRIPT}
240
- </body>
241
- </html>
242
- `
243
- }
244
-
245
- function panelPage(title: string, body: string): string {
246
- const accent = roleColor('light-accent')
247
- const surface = roleColor('light-surface')
248
- const border = roleColor('light-border')
249
- const muted = roleColor('light-muted')
250
- const text = roleColor('light-text')
251
- const background = roleColor('light-background')
252
-
253
- return `<!doctype html>
254
- <html lang="en">
255
- <head>
256
- <meta charset="utf-8">
257
- ${faviconLink()}
258
- <title>${escapeHtml(title)}</title>
259
- <style>
260
- * { box-sizing: border-box; }
261
- body { font-family: 'Noto Sans Mono', 'DejaVu Sans Mono', monospace; margin: 0; padding: 1.75rem 2.25rem; color: ${text}; background: ${background}; }
262
- h2 { margin: 2rem 0 0.6rem; padding-top: 1.1rem; border-top: 1px solid ${border}; font-size: 1rem; font-weight: 600; }
263
- h2:first-of-type { margin-top: 0; padding-top: 0; border-top: none; }
264
- p { color: ${muted}; font-size: 0.85rem; }
265
- table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; font-size: 0.85rem; }
266
- td, th { padding: 0.4rem 0.6rem; border-bottom: 1px solid ${border}; text-align: left; }
267
- pre { white-space: pre-wrap; background: ${surface}; border-radius: 4px; padding: 1rem; font-size: 0.8rem; }
268
- img { border-radius: 4px; border: 1px solid ${border}; margin-top: 0.5rem; }
269
- a { color: ${accent}; }
270
- .empty { color: ${muted}; font-style: italic; background: ${surface}; border-radius: 4px; padding: 0.9rem 1rem; }
271
- </style>
272
- </head>
273
- <body>
274
- ${body}
275
- </body>
276
- </html>
277
- `
278
- }
279
-
280
- function writeTokensPanel(root: string, outDir: string): void {
281
- const sourcePath = surfaceDir(root, 'DESIGN.md')
282
- const dir = join(outDir, 'tokens')
283
-
284
- if (!existsSync(sourcePath)) {
285
- mkdirSync(dir, { recursive: true })
286
- writeFileSync(
287
- join(dir, 'index.html'),
288
- panelPage(
289
- 'Tokens',
290
- `<p class="empty">No ${DESIGN_DOCUMENT} at the repository root.</p>`,
291
- ),
292
- )
293
- return
294
- }
295
-
296
- renderDesignDoc(sourcePath, dir)
297
- }
298
-
299
- interface WireframeFile {
300
- readonly relPath: string
301
- readonly text: string
302
- readonly description: string | undefined
303
- }
304
-
305
- /**
306
- * Every wireframe under whichever root the corpus resolves at, sorted for a
307
- * stable render order across runs. `surfaceDir` reads `.claude/wireframes/`
308
- * for a target that has not run the surface-roots migration, the same
309
- * fallback `recordDir` already gives the surfaces panel's teach half.
310
- * `index.md` at any depth is a catalog rather than a wireframe and is
311
- * excluded. A glob emits OS-native separators, and the corpus nests one
312
- * level deep, so the label always reads with forward slashes regardless of
313
- * platform.
314
- */
315
- function listWireframes(root: string): readonly WireframeFile[] {
316
- const dir = surfaceDir(root, 'wireframes')
317
- if (!existsSync(dir)) return []
318
-
319
- const glob = new Bun.Glob('**/*.md')
320
- const relPaths = [...glob.scanSync({ cwd: dir })]
321
- .filter((relPath) => relPath.split(sep).pop() !== 'index.md')
322
- .map((relPath) => relPath.split(sep).join('/'))
323
- .sort()
324
-
325
- return relPaths.map((relPath) => {
326
- const text = readFileSync(join(dir, relPath), 'utf8')
327
- return {
328
- relPath,
329
- text,
330
- description: readField(parseFrontmatter(text), 'description'),
331
- }
332
- })
333
- }
334
-
335
- function writeWireframesPanel(root: string, outDir: string): void {
336
- const dir = join(outDir, 'wireframes')
337
- mkdirSync(dir, { recursive: true })
338
-
339
- const files = listWireframes(root)
340
- const sections =
341
- files.length === 0
342
- ? `<p class="empty">No wireframe files under ${WIREFRAME_DIR}/.</p>`
343
- : files
344
- .map((file) => {
345
- const description = file.description ?? file.relPath
346
- return `<h2>${escapeHtml(file.relPath)}</h2>\n<p>${escapeHtml(description)}</p>\n<pre>${escapeHtml(file.text)}</pre>`
347
- })
348
- .join('\n')
349
-
350
- writeFileSync(join(dir, 'index.html'), panelPage('Wireframes', sections))
351
- }
352
-
353
- /** Copies a built directory whole, filtering nothing, into the board's own tree. */
354
- function copyBuilt(source: string, dest: string): void {
355
- rmSync(dest, { recursive: true, force: true })
356
- cpSync(source, dest, { recursive: true })
357
- }
358
-
359
- function writeSurfacesPanel(
360
- root: string,
361
- outDir: string,
362
- isToolkitCheckout: boolean,
363
- ): void {
364
- const dir = join(outDir, 'surfaces')
365
- mkdirSync(dir, { recursive: true })
366
-
367
- const distSource = join(root, WEB_DIST)
368
- const landingBody = !isToolkitCheckout
369
- ? `<p class="empty">The landing page panel only renders in this toolkit's own checkout.</p>`
370
- : existsSync(join(distSource, WEB_DIST_ENTRY))
371
- ? (copyBuilt(distSource, join(dir, 'landing')),
372
- '<iframe src="landing/index.html" loading="lazy"></iframe>')
373
- : `<p class="empty">No ${WEB_DIST}/ build. Run bun run web:build, then regenerate the board.</p>`
374
-
375
- const teachSource = recordDir(root, 'teach')
376
- const teachBody = existsSync(join(teachSource, 'index.html'))
377
- ? (copyBuilt(teachSource, join(dir, 'teach')),
378
- '<iframe src="teach/index.html" loading="lazy"></iframe>')
379
- : `<p class="empty">${relative(root, teachSource)} is gitignored and machine-local, so this panel renders empty in a fresh clone and on CI.</p>`
380
-
381
- writeFileSync(
382
- join(dir, 'index.html'),
383
- panelPage(
384
- 'Surfaces',
385
- `<h2>Landing page</h2>\n${landingBody}\n<h2>Teach workspaces</h2>\n${teachBody}`,
386
- ),
387
- )
388
- }
389
-
390
- function writeComponentsPanel(
391
- root: string,
392
- outDir: string,
393
- isToolkitCheckout: boolean,
394
- ): void {
395
- const dir = join(outDir, 'components')
396
- mkdirSync(dir, { recursive: true })
397
-
398
- const gallerySource = join(root, WEB_GALLERY_DIST)
399
- const body = !isToolkitCheckout
400
- ? `<p class="empty">The components panel only renders in this toolkit's own checkout.</p>`
401
- : existsSync(join(gallerySource, WEB_GALLERY_DIST_ENTRY))
402
- ? (copyBuilt(gallerySource, join(dir, 'gallery')),
403
- '<iframe src="gallery/index.html" loading="lazy"></iframe>')
404
- : `<p class="empty">No ${WEB_GALLERY_DIST}/ build. Run bun run web:gallery, then regenerate the board.</p>`
405
-
406
- writeFileSync(join(dir, 'index.html'), panelPage('Components', body))
407
- }
408
-
409
- function isImage(name: string): boolean {
410
- return IMAGE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext))
411
- }
412
-
413
- /** Every image file directly inside a pick folder, one level deep. */
414
- function imagesIn(dir: string): string[] {
415
- return readdirSync(dir, { withFileTypes: true })
416
- .filter((entry) => entry.isFile() && isImage(entry.name))
417
- .map((entry) => entry.name)
418
- .sort()
419
- }
420
-
421
- /**
422
- * The folder under `picks/` holding operator-supplied reference images, which
423
- * the References panel reads. It sits beside the captures of the choices it
424
- * fed, so the candidates panel steps over it rather than listing it as a pick.
425
- */
426
- const REFERENCES_FOLDER = 'references'
427
-
428
- function writeCandidatesPanel(root: string, outDir: string): void {
429
- const dir = join(outDir, 'candidates')
430
- mkdirSync(dir, { recursive: true })
431
-
432
- const picksDir = recordDir(root, 'picks')
433
- if (!existsSync(picksDir)) {
434
- writeFileSync(
435
- join(dir, 'index.html'),
436
- panelPage(
437
- 'Past candidates',
438
- `<p class="empty">No ${relative(root, picksDir)} folder yet.</p>`,
439
- ),
440
- )
441
- return
442
- }
443
-
444
- const folders = readdirSync(picksDir, { withFileTypes: true })
445
- .filter((entry) => entry.isDirectory() && entry.name !== REFERENCES_FOLDER)
446
- .map((entry) => entry.name)
447
- .sort()
448
-
449
- const found: Array<{ folder: string; images: string[] }> = []
450
- for (const folder of folders) {
451
- const images = imagesIn(join(picksDir, folder))
452
- if (images.length > 0) {
453
- cpSync(join(picksDir, folder), join(dir, folder), { recursive: true })
454
- found.push({ folder, images })
455
- }
456
- }
457
-
458
- if (found.length === 0) {
459
- writeFileSync(
460
- join(dir, 'index.html'),
461
- panelPage(
462
- 'Past candidates',
463
- `<p class="empty">${folders.length} folders under ${relative(root, picksDir)}/ and none carries a draft-and-pick arm capture. The archival capture step has not run since it shipped.</p>`,
464
- ),
465
- )
466
- return
467
- }
468
-
469
- const sections = found
470
- .map(
471
- ({ folder, images }) =>
472
- `<h2>${escapeHtml(folder)}</h2>\n${images.map((image) => `<img src="${escapeHtml(`${folder}/${image}`)}" alt="${escapeHtml(image)}">`).join('\n')}`,
473
- )
474
- .join('\n')
475
-
476
- writeFileSync(join(dir, 'index.html'), panelPage('Past candidates', sections))
477
- }
478
-
479
- function writeReferencesPanel(root: string, outDir: string): void {
480
- const dir = join(outDir, 'references')
481
- mkdirSync(dir, { recursive: true })
482
-
483
- const referencesDir = recordDir(root, 'picks', REFERENCES_FOLDER)
484
- if (!existsSync(referencesDir)) {
485
- writeFileSync(
486
- join(dir, 'index.html'),
487
- panelPage(
488
- 'References',
489
- `<p class="empty">No ${relative(root, referencesDir)} folder yet.</p>`,
490
- ),
491
- )
492
- return
493
- }
494
-
495
- const images = imagesIn(referencesDir)
496
- if (images.length === 0) {
497
- writeFileSync(
498
- join(dir, 'index.html'),
499
- panelPage(
500
- 'References',
501
- `<p class="empty">${relative(root, referencesDir)} exists but carries no image yet.</p>`,
502
- ),
503
- )
504
- return
505
- }
506
-
507
- for (const image of images) {
508
- cpSync(join(referencesDir, image), join(dir, image))
509
- }
510
-
511
- const sections = images
512
- .map(
513
- (image) => `<img src="${escapeHtml(image)}" alt="${escapeHtml(image)}">`,
514
- )
515
- .join('\n')
516
-
517
- writeFileSync(join(dir, 'index.html'), panelPage('References', sections))
518
- }
519
-
520
- /**
521
- * Generates the board's page set into `outDir`, clearing whatever was there.
522
- *
523
- * This function is the directory's only writer, per the constraint every
524
- * caller shares it under: `canon serve` and a future `canon capture` pass
525
- * both read the result and neither may assume it exists ahead of a run.
526
- *
527
- * `cwd` is the caller's own working directory, resolved and passed in
528
- * explicitly rather than read here, so a test can exercise the checkout-
529
- * mismatch case without touching the process's real cwd.
530
- *
531
- * `isToolkitCheckout` gates the surfaces panel's landing-page half and the
532
- * whole components panel, both of which read this repository's own build
533
- * output rather than anything a target project produces.
534
- */
535
- export function generateBoard(
536
- root: string,
537
- outDir: string,
538
- cwd: string,
539
- isToolkitCheckout: boolean,
540
- ): BoardOutcome {
541
- if (wouldDeleteRoot([root, cwd], outDir)) {
542
- return {
543
- ok: false,
544
- reason: 'unsafe-out',
545
- detail: `${outDir} is or contains ${root} or ${cwd}. Refusing to clear it.`,
546
- }
547
- }
548
-
549
- rmSync(outDir, { recursive: true, force: true })
550
- mkdirSync(outDir, { recursive: true })
551
-
552
- const panels: readonly BoardPanel[] = [
553
- { id: 'tokens', title: 'Tokens', path: 'tokens/index.html' },
554
- { id: 'surfaces', title: 'Surfaces', path: 'surfaces/index.html' },
555
- { id: 'wireframes', title: 'Wireframes', path: 'wireframes/index.html' },
556
- {
557
- id: 'candidates',
558
- title: 'Past candidates',
559
- path: 'candidates/index.html',
560
- },
561
- { id: 'components', title: 'Components', path: 'components/index.html' },
562
- { id: 'references', title: 'References', path: 'references/index.html' },
563
- ]
564
-
565
- writeTokensPanel(root, outDir)
566
- writeSurfacesPanel(root, outDir, isToolkitCheckout)
567
- writeWireframesPanel(root, outDir)
568
- writeCandidatesPanel(root, outDir)
569
- writeComponentsPanel(root, outDir, isToolkitCheckout)
570
- writeReferencesPanel(root, outDir)
571
-
572
- const indexPath = join(outDir, 'index.html')
573
- writeFileSync(indexPath, shellHtml(panels))
574
-
575
- return { ok: true, outDir, indexPath, panels }
576
- }