@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
- package/claude/skills/backlog-triage/SKILL.md +19 -6
- package/claude/skills/canon-operator/REQUIREMENT.md +1 -0
- package/claude/skills/canon-operator/SKILL.md +5 -1
- package/claude/skills/canvas/REQUIREMENT.md +49 -0
- package/claude/skills/canvas/SKILL.md +106 -0
- package/claude/skills/design-extract/REQUIREMENT.md +1 -1
- package/claude/skills/draft-doc/REQUIREMENT.md +2 -1
- package/claude/skills/draft-doc/references/readme/agent-facing.md +59 -0
- package/claude/skills/draft-doc/references/readme/application.md +66 -0
- package/claude/skills/draft-doc/references/readme/cli.md +64 -0
- package/claude/skills/draft-doc/references/readme/library.md +57 -0
- package/claude/skills/draft-doc/references/readme/plugin.md +66 -0
- package/claude/skills/draft-doc/references/readme.md +33 -4
- package/claude/skills/draft-figure/REQUIREMENT.md +14 -3
- package/claude/skills/draft-figure/SKILL.md +24 -51
- package/claude/skills/draft-figure/references/architecture-set.md +35 -0
- package/claude/skills/draft-figure/references/hand-drawn.md +31 -0
- package/claude/skills/draft-figure/references/mermaid.md +55 -0
- package/claude/skills/git-followup/SKILL.md +17 -6
- package/claude/skills/git-pr/references/evidence.md +4 -2
- package/claude/skills/git-split/SKILL.md +1 -1
- package/claude/skills/index-lookup/SKILL.md +2 -2
- package/claude/skills/review-address/SKILL.md +6 -13
- package/claude/skills/review-address/references/rest-reads.md +43 -0
- package/claude/skills/review-craft/references/ui.md +7 -1
- package/claude/skills/review-pr/REQUIREMENT.md +1 -0
- package/claude/skills/review-pr/SKILL.md +9 -3
- package/claude/skills/review-pr/references/close-out.md +2 -2
- package/claude/skills/review-pr/references/post.md +17 -5
- package/claude/skills/review-pr/references/scope.md +10 -8
- package/claude/skills/review-ui/SKILL.md +12 -4
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +30 -1
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +4 -0
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +6 -0
- package/claude/skills/role-orchestrator/scripts/poll.ts +90 -27
- package/claude/skills/role-orchestrator/scripts/repo.ts +3 -5
- package/claude/skills/role-orchestrator/scripts/watch.ts +4 -7
- package/docs/agents/canvas.md +6 -2
- package/docs/agents/commands.md +45 -45
- package/docs/agents/context-audit-checks.md +2 -2
- package/docs/agents/index.md +0 -1
- package/docs/agents/key-changes.md +3 -3
- package/docs/agents/pr-evidence.md +11 -2
- package/docs/agents/pr-reads.md +5 -4
- package/docs/target/projects.md +4 -4
- package/docs/workflow/ai-workflow.md +0 -1
- package/docs/workflow/skill-map.md +3 -3
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/renames.toml +0 -4
- package/governance/rules/standards/markdown.md +1 -0
- package/package.json +1 -2
- package/scripts/core/install-check.sh +1 -1
- package/src/audits/catalog.ts +2 -2
- package/src/canvas/client/inspector/field.tsx +200 -0
- package/src/canvas/client/inspector/fields.css +151 -0
- package/src/canvas/client/inspector/section.tsx +27 -0
- package/src/canvas/client/inspector/values.ts +60 -0
- package/src/canvas/client/inspector.tsx +192 -111
- package/src/canvas/client/shell.css +0 -94
- package/src/canvas/tokens.ts +9 -1
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/cases/workflow.ts +1 -1
- package/src/claude/seeds.ts +0 -1
- package/src/commands/canvas.ts +29 -0
- package/src/commands/design.ts +2 -64
- package/src/commands/pr.ts +219 -202
- package/src/context/audit.ts +7 -6
- package/src/design/budget.ts +149 -0
- package/src/design/document.ts +22 -10
- package/src/design/render.ts +11 -1
- package/src/design/tokens.ts +40 -61
- package/src/gate/measures.ts +49 -0
- package/src/gate/stages.ts +5 -1
- package/src/help.ts +2 -2
- package/src/migrate/skill-names.ts +7 -1
- package/src/pr/evidence.ts +25 -5
- package/src/pr/rest.ts +137 -0
- package/standards/canvas.md +112 -0
- package/standards/context.md +2 -1
- package/standards/design.md +13 -3
- package/standards/figures.md +0 -1
- package/standards/index.md +1 -1
- package/standards/markdown.md +1 -0
- package/standards/mermaid.md +1 -7
- package/tooling/claude/reference.md +0 -5
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +1 -1
- package/claude/skills/draft-diagram/REQUIREMENT.md +0 -45
- package/claude/skills/draft-diagram/SKILL.md +0 -177
- package/docs/agents/design-board.md +0 -33
- package/governance/rules/standards/diagrams.md +0 -14
- package/governance/rules/standards/mermaid.md +0 -15
- package/scripts/core/check-gallery-exclusion.sh +0 -50
- package/src/design/board.ts +0 -576
- package/standards/diagrams.md +0 -132
- 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"
|
package/src/design/board.ts
DELETED
|
@@ -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, '&')
|
|
85
|
-
.replace(/</g, '<')
|
|
86
|
-
.replace(/>/g, '>')
|
|
87
|
-
.replace(/"/g, '"')
|
|
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
|
-
}
|