@erclx/canon 4.75.0 → 4.76.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.75.0",
4
+ "version": "4.76.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: draft-figure
3
+ description: Why a figure decides between Mermaid and freehand SVG before drawing, and why only the Mermaid path carries a render-verification loop
4
+ ---
5
+
6
+ # Draft figure requirement
7
+
8
+ ## Gap
9
+
10
+ Without this skill, a session asked for a figure reaches for whichever path it thought of first. A graph-shaped subject gets model-authored SVG coordinates nobody checked against a render, drifting from the hand-drawn look every other figure in the project carries. A subject that is not graph-shaped gets forced through Mermaid's node-and-edge model, producing a diagram that answers a different question than the one asked. Either failure ships a figure a reader cannot trust, and a figure exists only to be trusted at a glance.
11
+
12
+ The color failure is the quiet one. Mermaid's own theme writes literal hex values, and a figure carrying one stops re-coloring itself the moment the host page switches its palette, which the figure standard's own accessibility bar rules out.
13
+
14
+ ## Must
15
+
16
+ - Apply the standard's earn-its-place bar before drawing anything
17
+ - Decide between the Mermaid path and the freehand path by whether the subject reduces to a graph, not by which is faster to produce
18
+ - Render the Mermaid path with the hand-drawn look and the figure font stack, never with Mermaid's default look
19
+ - Rewrite every literal color the renderer wrote into the custom property the host stylesheet defines
20
+ - Read the Mermaid render back and judge it against what the figure means to say, the same verification `draft-diagram` runs for the architecture surface
21
+ - Wrap either path in a `<figure>` and `<figcaption>` and give it an accessible name
22
+
23
+ ## Must not
24
+
25
+ - Force a non-graph-shaped subject through Mermaid, or route a graph-shaped one through freehand SVG coordinates a model authored by hand
26
+ - Ship a literal hex color on either path
27
+ - Report a clean verification when the Mermaid render was skipped or a defect survived
28
+ - Loop past two correction passes on a surviving defect
29
+ - Commit a scratch render. Only the SVG that ships in the destination document survives
30
+
31
+ ## Guards
32
+
33
+ - No subject given: stop, since nothing states what the figure has to show
34
+ - No destination document given: stop, since the figure has nowhere to land
35
+ - Subject does not clear the earn-its-place bar: stop rather than drawing decoration
36
+
37
+ ## Out of scope
38
+
39
+ - The toolkit's own `.canon/diagrams/` architecture surface, which shares no code, font, or visual language with this convention: `draft-diagram`
40
+ - Mermaid's own layout, budgets, and label rules for the fence itself: the mermaid standard, cited rather than restated here
41
+ - A UI wireframe or screen mockup: `draft-wireframes`
42
+ - `teach-workspace`'s own lesson-figure authoring, which stays inline rather than calling this skill
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: draft-figure
3
+ description: Drafts one hand-drawn figure for a document, deciding whether the subject is Mermaid-shaped or needs freehand SVG, then draws it. Renders the Mermaid path through Mermaid's own hand-drawn look, reads the render back, and fixes what it exposes. Wraps either path in the figure standard's caption, color, and accessibility rules. Use when asked to "draw a figure", "add a hand-drawn diagram", "draft a figure for this doc", or "draw this relationship as a figure". Do NOT use for the toolkit's own `.canon/diagrams/` architecture surface, which is `draft-diagram`, or for a UI wireframe, which is `draft-wireframes`.
4
+ ---
5
+
6
+ # Draft figure
7
+
8
+ Decide whether a requested figure reduces to a graph Mermaid can draw, or needs freehand SVG, then draw it and verify what a reader will actually see.
9
+
10
+ ## Guards
11
+
12
+ - If no subject is given, stop: `❌ No subject given. Name the relationship, boundary, path, or before-and-after this figure should show.`
13
+ - If no destination document is given, stop: `❌ No destination given. Name the file this figure belongs in.`
14
+
15
+ ## Step 1: read sources
16
+
17
+ Read these in parallel:
18
+
19
+ - `${CLAUDE_SKILL_DIR}/../../standards/figures.md`: when a figure earns its place, the render-first policy, wrapping and captioning, and the color and accessibility rules
20
+ - `${CLAUDE_SKILL_DIR}/../../standards/mermaid.md`: direction and layout, node and edge budgets, the accessibility fields, and how a render is verified, for the Mermaid path
21
+ - `${CLAUDE_SKILL_DIR}/../../standards/markdown.md`: banned words and formatting for the caption prose
22
+ - The `write-human` skill: voice and rhythm for the caption prose
23
+
24
+ ## Step 2: does it earn its place
25
+
26
+ Apply the standard's bar: a relationship, a boundary, a path, or a before-and-after that prose or a list cannot carry as well. Stop when it does not: `❌ <subject> reads fine as prose. A figure adds nothing here.`
27
+
28
+ ## Step 3: decide the render path
29
+
30
+ - **Graph-shaped**, meaning the relationship, boundary, or path is already what a flowchart or sequence diagram expresses: draw it through Step 4.
31
+ - **Not graph-shaped**, meaning the subject is a spatial or physical arrangement no flowchart reduces to: draw it through Step 5.
32
+
33
+ State which path was picked and why in one sentence. The standard states this as a render-first policy rather than a ban on any tool-exported drawing, so a graph-shaped subject always routes through Mermaid even though that is itself an external renderer.
34
+
35
+ ## Step 4: the Mermaid path
36
+
37
+ - Draft the fence against `mermaid.md`: direction, node and edge budgets, `accTitle`/`accDescr`, and quoted labels.
38
+ - Render it with the hand-drawn look and the figure font stack, writing scratch files under `.canon/tmp/figures/`:
39
+
40
+ ```bash
41
+ mkdir -p .canon/tmp/figures
42
+ cat <<'EOF' >.canon/tmp/figures/<slug>.json
43
+ {"look": "handDrawn", "themeVariables": {"fontFamily": "Virgil, Excalifont, cursive"}}
44
+ EOF
45
+ bunx -y @mermaid-js/mermaid-cli -i .canon/tmp/figures/<slug>.mmd -o .canon/tmp/figures/<slug>.svg -c .canon/tmp/figures/<slug>.json
46
+ bunx -y @mermaid-js/mermaid-cli -i .canon/tmp/figures/<slug>.mmd -o .canon/tmp/figures/<slug>.png -c .canon/tmp/figures/<slug>.json
47
+ ```
48
+
49
+ Render both formats in one pass: the SVG is what ships inside the `<figure>`, and the PNG is what Step 6 reads back, since an SVG's markup carries no recoverable spatial meaning the way `draft-diagram` Step 5 already states for the architecture surface.
50
+
51
+ Use `bunx` when bun is available, falling back to `npx -y @mermaid-js/mermaid-cli ...` otherwise.
52
+
53
+ - Rewrite every stroke, fill, and text color the renderer wrote as a literal hex value into the custom property the host stylesheet defines, per the standard's color rule. Mermaid's own theme has no notion of a custom property, so this is a source edit made to the rendered SVG, not a config option.
54
+ - Check whether the project ran `canon design css --figures`. The figure and figcaption styling ships unconditionally either way, but that flag alone embeds the Virgil and Excalifont font files. Without it, the figure falls back to the browser's own generic `cursive` face rather than the intended hand-drawn font, and Step 7's output names that gap when the flag was not passed.
55
+
56
+ ## Step 5: the freehand path
57
+
58
+ - Author the SVG by hand: plain shapes and lines authored directly in the markup.
59
+ - Follow the standard's `## Color and accessibility` section for every fill, stroke, and accessible name.
60
+
61
+ There is no external render to verify a freehand drawing against. The check here is the stroke-and-fill audit above and a read of the markup against what the subject means to show, done once rather than looped.
62
+
63
+ ## Step 6: wrap, caption, and verify
64
+
65
+ - Wrap the drawing in a `<figure>` element with a `<figcaption>` naming what to take from the figure, never what it shows. Let it run wider than the surrounding prose column.
66
+ - On the Mermaid path, read the rendered PNG back and judge it against what the figure means to say, the same check `draft-diagram` Steps 5 and 6 run for the architecture surface. Fix the mermaid source and re-render on a defect. Stop after two correction passes. When a defect survives, keep the figure and name the defect in the output rather than reporting a false verification.
67
+ - On either path, confirm the figure still reads once every color and font it depends on is stripped to its fallback, per the standard's own working-figure bar.
68
+ - When the render fails for any reason, no browser engine, no network, no package manager, continue to Step 7 and name the skipped check. A missing renderer degrades the loop rather than failing it.
69
+
70
+ ## Step 7: confirm and write
71
+
72
+ Show the destination path, the decided render path, and the full `<figure>` markup before writing. Confirm with the user, since the graph-shaped decision and the render verdict are judgment calls with no diff to preview either against.
73
+
74
+ Write the figure into the destination document at the location the user named. Renders under `.canon/tmp/figures/` are verification artifacts, and the scratch PNG and JSON config are deleted after Step 6 confirms, since only the SVG ships.
75
+
76
+ ## Output
77
+
78
+ ### Preview
79
+
80
+ **Destination:** `<path>`
81
+ **Render path:** `<mermaid | freehand>`, `<one-sentence reason>`
82
+
83
+ ```html
84
+ <drafted figure markup></drafted>
85
+ ```
86
+
87
+ ### After confirmation
88
+
89
+ ```plaintext
90
+ ✅ Drafted figure in <path>
91
+ Verified render. <defect or skipped check, one line, omitted when clean>
92
+ <Font not embedded: run `canon design css --figures` to ship Virgil and Excalifont. Omitted when the project already ran it.>
93
+ ```
@@ -24,6 +24,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
24
24
  | `canon docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
25
25
  | `canon design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
26
26
  | `canon design regen` | Rewrite this repository's `.claude/DESIGN.md` and `src/design/base.css` from `src/design/tokens.ts` |
27
+ | `canon design board` | Generate a static page set indexing tokens, surfaces, wireframes, and past candidates, against the toolkit checkout like `regen` (`--out`) |
27
28
  | `canon design css` | Emit the design tokens and components as CSS on stdout (`--no-components` for properties alone) |
28
29
  | `canon design install` | Install the base stylesheet into a project at `.claude/design/base.css` |
29
30
  | `canon design sync` | Update a base stylesheet already installed under `.claude/design/` |
@@ -0,0 +1,30 @@
1
+ ---
2
+ title: Design board
3
+ description: Generating a static index over the toolkit checkout's own design surfaces, what each panel reads, and why it never runs against a target
4
+ ---
5
+
6
+ # Design board
7
+
8
+ `canon design board [--out <path>]` generates a static page set indexing four design surfaces and reports the path a reader opens with `canon serve`. Like `canon design regen`, it runs against the toolkit checkout the CLI resolves its own root from, never against a target project's own files: a target holds none of the sources below, since none of `.claude/DESIGN.md`, `.claude/wireframes/`, `web/`, or the gitignored `.canon/` records ship with the published package. Running the installed CLI from inside a checkout of this project prints a mismatch warning when the caller's working directory disagrees with the root it resolved.
9
+
10
+ Each panel reads what is already on disk and reports its own missing source rather than failing the run, so an absent build or an empty corpus renders an empty-state message instead of a broken frame.
11
+
12
+ ```bash
13
+ canon design board
14
+ canon serve .canon/review/board
15
+ ```
16
+
17
+ | Option | Default | Behavior |
18
+ | ------------------ | --------------------- | ---------------- |
19
+ | `-o, --out <path>` | `.canon/review/board` | Output directory |
20
+
21
+ ## Panels
22
+
23
+ - **Tokens** renders `.claude/DESIGN.md` through the same renderer `canon design render` uses, rather than a second one.
24
+ - **Surfaces** iframes the built landing page from `web/dist/` and a teach workspace from `.canon/teach/`, copying each whole into the board's own tree. Either reports its own missing build or absent workspace rather than rendering a broken frame.
25
+ - **Wireframes** renders each of the six files under `.claude/wireframes/` as-is inside a `<pre>`, beside a line naming the surface it describes.
26
+ - **Past candidates** lists an arm capture image per folder under `.canon/review/evidence/`, and states the corpus carries none rather than rendering an empty grid.
27
+
28
+ ## What it does not do
29
+
30
+ The board never writes outside its own output directory and never builds `web/dist/` on a caller's behalf. `web/dist/` and `.canon/teach/` are read, not written, and copied into the board's tree only for that run: the generator clears and recreates its output directory on every call, so a caller relying on it to persist between runs is relying on an accident. It refuses rather than clearing a directory that is or contains the project root or the caller's own working directory, which is what an `--out .` typo would otherwise do, including when a global install resolves the project root to a different checkout than the one the caller stands in.
@@ -17,6 +17,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
17
17
  - [Context audit](context-audit.md): Running the audit, its flags and folder scope, the exit codes, the citation gate, and the widened gate the seed stage runs
18
18
  - [Self-stated counts](counts.md): Reading a sentence that asserts a closed catalog's size, how a match is decided, the plausibility filter that keeps a generic word from matching a subset, and why the sweep reports rather than gates
19
19
  - [Demo](demo.md): Compiling a screencast draft into a runnable plan, driving a served application to a recording and a still, reading numbered frames back out of a recording, the pointer the recording paints, and what each refusal reports
20
+ - [Design board](design-board.md): Generating a static index over the toolkit checkout's own design surfaces, what each panel reads, and why it never runs against a target
20
21
  - [Docs](docs.md): How canon docs resolves the toolkit's own reference surface from an install root, and how a split domain is named
21
22
  - [Driver](driver.md): Walking a page through named interactions, the probe catalog and the false finding each one carries, why viewport heights are never defaulted, and what each refusal reports
22
23
  - [Merge gate](gate.md): Running the gate this repository verifies a branch with, what the stage table holds and what stays a script, how the changed set scopes three stages, and why a stage that cannot read its input reports rather than passing
@@ -290,23 +290,24 @@ This section is the corpus the coverage claim is measured against: every name `c
290
290
 
291
291
  ### Generate an artifact on demand
292
292
 
293
- | Skill | When to use |
294
- | ------------------------- | ------------------------------------------------------------------------------------------------- |
295
- | `canon:create-rule` | For a project-specific governance rule the toolkit does not ship |
296
- | `canon:create-skill` | For a new `SKILL.md` |
297
- | `canon:create-snippet` | For a reusable prompt |
298
- | `canon:create-standard` | For a new authoring convention |
299
- | `canon:draft-docs` | For a brand-new `docs/*.md` page, drafted against `standards/docs.md` |
300
- | `canon:draft-context` | For a brand-new `.claude/context/<domain>.md` entry, drafted against `standards/context.md` |
301
- | `canon:draft-wireframes` | For a brand-new `.claude/wireframes/<surface>.md` file, drafted against `standards/wireframes.md` |
302
- | `canon:draft-readme` | For a project's `README.md`, drafted against `standards/readme.md` |
303
- | `canon:bash-script` | For an interactive, human-facing shell tool |
304
- | `canon:bash-cli-script` | For a non-interactive automation, CI, or pipeline script |
305
- | `canon:ci-workflow` | For a GitHub Actions workflow file |
306
- | `canon:draft-slides` | For a deck, drafted as `.claude/SLIDES.md` and rendered to PowerPoint |
307
- | `canon:draft-screencast` | For a recording script with beats and defaults already seeded |
308
- | `canon:record-screencast` | For compiling and running a screencast draft into a recording and a still |
309
- | `canon:draft-identity` | For a project's logo mark and its social card, drafted through `draft-and-pick`'s pick loop |
293
+ | Skill | When to use |
294
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
295
+ | `canon:create-rule` | For a project-specific governance rule the toolkit does not ship |
296
+ | `canon:create-skill` | For a new `SKILL.md` |
297
+ | `canon:create-snippet` | For a reusable prompt |
298
+ | `canon:create-standard` | For a new authoring convention |
299
+ | `canon:draft-docs` | For a brand-new `docs/*.md` page, drafted against `standards/docs.md` |
300
+ | `canon:draft-context` | For a brand-new `.claude/context/<domain>.md` entry, drafted against `standards/context.md` |
301
+ | `canon:draft-wireframes` | For a brand-new `.claude/wireframes/<surface>.md` file, drafted against `standards/wireframes.md` |
302
+ | `canon:draft-figure` | For a hand-drawn figure inside an existing doc, drafted against `standards/figures.md` and rendered through Mermaid or freehand SVG |
303
+ | `canon:draft-readme` | For a project's `README.md`, drafted against `standards/readme.md` |
304
+ | `canon:bash-script` | For an interactive, human-facing shell tool |
305
+ | `canon:bash-cli-script` | For a non-interactive automation, CI, or pipeline script |
306
+ | `canon:ci-workflow` | For a GitHub Actions workflow file |
307
+ | `canon:draft-slides` | For a deck, drafted as `.claude/SLIDES.md` and rendered to PowerPoint |
308
+ | `canon:draft-screencast` | For a recording script with beats and defaults already seeded |
309
+ | `canon:record-screencast` | For compiling and running a screencast draft into a recording and a still |
310
+ | `canon:draft-identity` | For a project's logo mark and its social card, drafted through `draft-and-pick`'s pick loop |
310
311
 
311
312
  ### Answer a question at any point
312
313
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.75.0",
4
+ "version": "4.76.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -28,6 +28,11 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
28
28
  prompt: 'Draw me a diagram of how the pieces of this system connect.',
29
29
  expect: 'draft-diagram',
30
30
  },
31
+ {
32
+ prompt:
33
+ 'This onboarding doc needs a hand-drawn picture of the signup-to-active path, sketch it in and make sure it actually renders correctly.',
34
+ expect: 'draft-figure',
35
+ },
31
36
  {
32
37
  prompt:
33
38
  'Bring the internal planning docs under .claude up to date with what we decided this session.',
package/src/cli.ts CHANGED
@@ -65,7 +65,7 @@ function showHelp(): void {
65
65
  `${GREY}│${NC} wiki [cmd] ${GREY}# Wiki commands (init)${NC}`,
66
66
  `${GREY}│${NC} indexes [cmd] ${GREY}# Regenerate index.md files (regen)${NC}`,
67
67
  `${GREY}│${NC} docs [cmd|topic] ${GREY}# Emit toolkit reference docs (list, <topic>)${NC}`,
68
- `${GREY}│${NC} design [cmd] ${GREY}# Design system commands (render)${NC}`,
68
+ `${GREY}│${NC} design [cmd] ${GREY}# Design system commands (render, board)${NC}`,
69
69
  `${GREY}│${NC} slides [cmd] ${GREY}# Slide deck commands (render, list)${NC}`,
70
70
  `${GREY}│${NC} capture [source] ${GREY}# Render HTML capture sources to PNG${NC}`,
71
71
  `${GREY}│${NC} serve [dir] ${GREY}# Serve a directory over localhost and print the preview link${NC}`,
@@ -118,6 +118,7 @@ function showHelp(): void {
118
118
  `${GREY}│${NC} canon docs list --json`,
119
119
  `${GREY}│${NC} canon docs agents`,
120
120
  `${GREY}│${NC} canon design render`,
121
+ `${GREY}│${NC} canon design board`,
121
122
  `${GREY}│${NC} canon slides render`,
122
123
  `${GREY}│${NC} canon slides list --json`,
123
124
  `${GREY}│${NC} canon capture assets/captures/install.html --selector .window --out assets`,
@@ -6,6 +6,7 @@ import {
6
6
  DESIGN_INSTALL_DIR,
7
7
  DESIGN_PROJECT_SUBDIR,
8
8
  } from '@/design/adapter'
9
+ import { generateBoard } from '@/design/board'
9
10
  import { buildDesignCss } from '@/design/css'
10
11
  import { HAND_DRAWN_FONT_FACES } from '@/design/fonts'
11
12
  import { renderDesignDoc } from '@/design/render'
@@ -20,7 +21,9 @@ import { intro, logAdd, logError, logInfo, logWarn, outro, palette } from '@/ui'
20
21
  export function register(program: Command): void {
21
22
  const design = program
22
23
  .command('design')
23
- .description('Design system commands (regen, css, render, install, sync)')
24
+ .description(
25
+ 'Design system commands (regen, css, render, board, install, sync)',
26
+ )
24
27
 
25
28
  design
26
29
  .command('regen')
@@ -127,6 +130,48 @@ export function register(program: Command): void {
127
130
  )
128
131
  })
129
132
 
133
+ design
134
+ .command('board')
135
+ .description(
136
+ 'Generate the design board, an index over this repository’s own design surfaces',
137
+ )
138
+ .option(
139
+ '-o, --out <path>',
140
+ 'Output directory',
141
+ creationRel(process.cwd(), 'review', 'board'),
142
+ )
143
+ .addHelpText(
144
+ 'after',
145
+ [
146
+ '',
147
+ 'Like regen, this runs against the toolkit checkout rather than a',
148
+ 'target: it reads five sources already on disk relative to the',
149
+ 'project root and writes a static page set, never installed or',
150
+ 'synced. Open it with canon serve <out>.',
151
+ '',
152
+ ].join('\n'),
153
+ )
154
+ .action((opts: { out: string }) => {
155
+ const outDir = resolve(process.cwd(), opts.out)
156
+ const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
157
+ const mismatch = checkoutMismatchWarning(process.cwd())
158
+ process.stderr.write(
159
+ `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}Generate design board${NC}\n`,
160
+ )
161
+ if (mismatch !== undefined) logWarn(mismatch)
162
+ const result = generateBoard(PROJECT_ROOT, outDir, process.cwd())
163
+ if (!result.ok) {
164
+ process.stderr.write(
165
+ `${GREY}│${NC} ${RED}✗${NC} ${result.detail}\n${GREY}└${NC}\n`,
166
+ )
167
+ process.exitCode = 1
168
+ return
169
+ }
170
+ process.stderr.write(
171
+ `${GREY}│${NC} ${GREEN}✓${NC} ${relative(process.cwd(), result.indexPath)}\n${GREY}│${NC} Open with: canon serve ${relative(process.cwd(), result.outDir)}\n${GREY}└${NC}\n`,
172
+ )
173
+ })
174
+
130
175
  design
131
176
  .command('install')
132
177
  .description('Install the base stylesheet into a project')
@@ -0,0 +1,451 @@
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 { colorValue } from '@/design/tokens'
14
+ import { recordDir } from '@/record-root'
15
+
16
+ /**
17
+ * The one named site for this repository's wireframe corpus. `standards/`
18
+ * moves it to `canon/wireframes/` under the answered
19
+ * `.canon/intake/88-surface-roots-and-corpus-debt/` item 1, and that move
20
+ * retargets this constant alone rather than a literal repeated per panel.
21
+ */
22
+ export const WIREFRAME_DIR = join('.claude', 'wireframes')
23
+
24
+ /** Landing page for the built site the surfaces panel iframes when present. */
25
+ const WEB_DIST = join('web', 'dist')
26
+ const WEB_DIST_ENTRY = 'index.html'
27
+
28
+ interface WireframeEntry {
29
+ readonly path: string
30
+ readonly describes: string
31
+ }
32
+
33
+ /**
34
+ * The six files the wireframes panel renders, each beside the surface it
35
+ * describes. `index.md` at either level is a catalog rather than a wireframe
36
+ * and is excluded, which is why this list holds six rather than the eight
37
+ * files the corpus carries today.
38
+ */
39
+ const WIREFRAMES: readonly WireframeEntry[] = [
40
+ { path: 'landing-page.md', describes: 'The canon.erclx.dev landing page' },
41
+ { path: 'slides.md', describes: 'The SLIDES.md render' },
42
+ { path: 'teach/root.md', describes: 'A teach workspace root listing' },
43
+ { path: 'teach/contents.md', describes: 'A workspace contents page' },
44
+ { path: 'teach/lesson.md', describes: 'A lesson page and quiz stepper' },
45
+ { path: 'teach/chrome.md', describes: 'The shared teach chrome' },
46
+ ]
47
+
48
+ const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.svg', '.webp']
49
+
50
+ export interface BoardPanel {
51
+ readonly id: string
52
+ readonly title: string
53
+ /** Relative to the board's own output directory. */
54
+ readonly path: string
55
+ }
56
+
57
+ export interface BoardResult {
58
+ readonly ok: true
59
+ readonly outDir: string
60
+ readonly indexPath: string
61
+ readonly panels: readonly BoardPanel[]
62
+ }
63
+
64
+ export interface BoardRefused {
65
+ readonly ok: false
66
+ readonly reason: 'unsafe-out'
67
+ readonly detail: string
68
+ }
69
+
70
+ export type BoardOutcome = BoardResult | BoardRefused
71
+
72
+ /**
73
+ * Whether clearing `outDir` would take a protected directory down with it:
74
+ * the directory equals one of them, or contains one.
75
+ *
76
+ * `generateBoard` clears its output directory on every run, and `--out` is
77
+ * resolved against the caller's cwd while the panels read from `PROJECT_ROOT`.
78
+ * Those agree in the ordinary case and diverge in exactly one: a second
79
+ * checkout, where a global `canon` resolves `PROJECT_ROOT` to a different
80
+ * tree than the one the caller stands in. Guarding `PROJECT_ROOT` alone misses
81
+ * that case, since `--out .` then resolves under the caller's own cwd, which
82
+ * shares no containment with the unrelated root the guard compared it to.
83
+ */
84
+ function wouldDeleteRoot(protect: readonly string[], outDir: string): boolean {
85
+ return protect.some((dir) => dir === outDir || dir.startsWith(outDir + sep))
86
+ }
87
+
88
+ function escapeHtml(value: string): string {
89
+ return value
90
+ .replace(/&/g, '&amp;')
91
+ .replace(/</g, '&lt;')
92
+ .replace(/>/g, '&gt;')
93
+ .replace(/"/g, '&quot;')
94
+ }
95
+
96
+ 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>`
97
+
98
+ const THEME_TOGGLE_SCRIPT = [
99
+ '<script>(function(){',
100
+ 'var r=document.documentElement;',
101
+ 'try{var s=localStorage.getItem("board-theme");if(s)r.dataset.theme=s;}catch(e){}',
102
+ 'document.addEventListener("click",function(e){',
103
+ 'var b=e.target.closest(".theme-toggle");if(!b)return;',
104
+ 'var dark=r.dataset.theme==="dark"||(!r.dataset.theme&&matchMedia("(prefers-color-scheme: dark)").matches);',
105
+ 'r.dataset.theme=dark?"light":"dark";',
106
+ 'try{localStorage.setItem("board-theme",r.dataset.theme);}catch(e){}',
107
+ '});',
108
+ '})();</script>',
109
+ ].join('')
110
+
111
+ /**
112
+ * Highlights the nav link for whichever section fills the most of the
113
+ * viewport, following the same largest-intersection-ratio rule the landing
114
+ * page's own floating pill uses, so a reader scrolling through the four
115
+ * panels sees the pill track their position rather than sitting static.
116
+ */
117
+ const SCROLLSPY_SCRIPT = [
118
+ '<script>(function(){',
119
+ 'var links={};',
120
+ 'document.querySelectorAll("nav a[href^=\\"#\\"]").forEach(function(a){links[a.getAttribute("href").slice(1)]=a;});',
121
+ 'var ratios={};',
122
+ 'var current="";',
123
+ 'var observer=new IntersectionObserver(function(entries){',
124
+ 'entries.forEach(function(entry){ratios[entry.target.id]=entry.isIntersecting?entry.intersectionRatio:0;});',
125
+ 'var bestId="";',
126
+ 'var bestRatio=0;',
127
+ 'Object.keys(ratios).forEach(function(id){if(ratios[id]>bestRatio){bestRatio=ratios[id];bestId=id;}});',
128
+ 'if(bestId&&bestId!==current){',
129
+ 'if(links[current])links[current].classList.remove("active");',
130
+ 'current=bestId;',
131
+ 'if(links[current])links[current].classList.add("active");',
132
+ '}',
133
+ '},{threshold:[0,0.25,0.5,0.75,1]});',
134
+ 'Object.keys(links).forEach(function(id){',
135
+ 'var section=document.getElementById(id);',
136
+ 'if(section)observer.observe(section);',
137
+ '});',
138
+ '})();</script>',
139
+ ].join('')
140
+
141
+ /**
142
+ * The same brand mark `src/design/render.ts` embeds in its own preview,
143
+ * colored with the dark accent since the board's default chrome is dark
144
+ * where that preview's is light. Duplicated here rather than imported,
145
+ * following that module's own precedent of three independently-colored
146
+ * copies rather than a shared icon helper for the first second caller.
147
+ */
148
+ function faviconLink(): string {
149
+ const color = colorValue('accent') ?? '#e0724b'
150
+ const href = `data:image/svg+xml,${encodeURIComponent(
151
+ `<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>`,
152
+ )}`
153
+ return `<link rel="icon" href="${href}">`
154
+ }
155
+
156
+ /** The board shell's own chrome, read off the toolkit's design source. */
157
+ function shellChrome(): string {
158
+ const roles: ReadonlyArray<readonly [string, string]> = [
159
+ ['background', 'background'],
160
+ ['surface', 'surface'],
161
+ ['text', 'text'],
162
+ ['muted', 'muted'],
163
+ ['border', 'border'],
164
+ ['accent', 'accent'],
165
+ ]
166
+ const dark = roles
167
+ .map(([name, role]) => {
168
+ const value = colorValue(role)
169
+ return value === undefined ? '' : ` --board-${name}: ${value};`
170
+ })
171
+ .filter((line) => line !== '')
172
+ const light = roles
173
+ .map(([name, role]) => {
174
+ const value = colorValue(`light-${role}`)
175
+ return value === undefined ? '' : ` --board-${name}: ${value};`
176
+ })
177
+ .filter((line) => line !== '')
178
+
179
+ return `:root {\n${dark.join('\n')}\n}\n[data-theme='light'] {\n${light.join('\n')}\n}`
180
+ }
181
+
182
+ function shellHtml(panels: readonly BoardPanel[]): string {
183
+ const navLinks = panels
184
+ .map((panel) => `<a href="#${panel.id}">${escapeHtml(panel.title)}</a>`)
185
+ .join('')
186
+
187
+ const sections = panels
188
+ .map(
189
+ (panel, index) =>
190
+ `<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>`,
191
+ )
192
+ .join('\n')
193
+
194
+ return `<!doctype html>
195
+ <html lang="en">
196
+ <head>
197
+ <meta charset="utf-8">
198
+ ${faviconLink()}
199
+ <title>Design board</title>
200
+ <style>
201
+ ${shellChrome()}
202
+ * { box-sizing: border-box; }
203
+ body { font-family: 'Noto Sans Mono', 'DejaVu Sans Mono', monospace; margin: 0; color: var(--board-text); background: var(--board-background); }
204
+ header { padding: 2.5rem 3rem 1.5rem; }
205
+ h1 { margin: 0 0 0.4rem; font-size: 1.6rem; font-weight: 600; letter-spacing: -0.01em; }
206
+ .meta { color: var(--board-muted); font-size: 0.85rem; margin: 0; }
207
+ 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; }
208
+ nav a { color: var(--board-muted); text-decoration: none; font-size: 0.8rem; }
209
+ nav a:hover { color: var(--board-accent); }
210
+ nav a.active { color: var(--board-accent); }
211
+ .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; }
212
+ .theme-toggle .sun { display: none; }
213
+ .theme-toggle .moon { display: block; }
214
+ [data-theme='light'] .theme-toggle .sun { display: block; }
215
+ [data-theme='light'] .theme-toggle .moon { display: none; }
216
+ .theme-toggle svg { width: 0.95rem; height: 0.95rem; }
217
+ main { padding: 0 3rem 4.5rem; }
218
+ section { margin-top: 2.5rem; padding-top: 1.25rem; border-top: 1px solid var(--board-border); scroll-margin-top: 4.5rem; }
219
+ .eyebrow { color: var(--board-accent); font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 0.35rem; }
220
+ section h2 { margin: 0 0 0.75rem; font-size: 1.1rem; font-weight: 600; }
221
+ iframe { width: 100%; height: 480px; border: 1px solid var(--board-border); border-radius: 4px; background: var(--board-surface); }
222
+ </style>
223
+ </head>
224
+ <body>
225
+ <header>
226
+ <h1>Design board</h1>
227
+ <p class="meta">Generated by <code>canon design board</code>. Repository-local, never installed into a target.</p>
228
+ </header>
229
+ <nav>${navLinks}${THEME_TOGGLE_BUTTON}</nav>
230
+ <main>
231
+ ${sections}
232
+ </main>
233
+ ${THEME_TOGGLE_SCRIPT}
234
+ ${SCROLLSPY_SCRIPT}
235
+ </body>
236
+ </html>
237
+ `
238
+ }
239
+
240
+ function panelPage(title: string, body: string): string {
241
+ const accent = colorValue('light-accent') ?? '#a4471c'
242
+ const surface = colorValue('light-surface') ?? '#f4efe6'
243
+ const border = colorValue('light-border') ?? '#e4dcd0'
244
+ const muted = colorValue('light-muted') ?? '#726b62'
245
+ const text = colorValue('light-text') ?? '#1a1815'
246
+ const background = colorValue('light-background') ?? '#faf7f2'
247
+
248
+ return `<!doctype html>
249
+ <html lang="en">
250
+ <head>
251
+ <meta charset="utf-8">
252
+ ${faviconLink()}
253
+ <title>${escapeHtml(title)}</title>
254
+ <style>
255
+ * { box-sizing: border-box; }
256
+ body { font-family: 'Noto Sans Mono', 'DejaVu Sans Mono', monospace; margin: 0; padding: 1.75rem 2.25rem; color: ${text}; background: ${background}; }
257
+ h2 { margin: 2rem 0 0.6rem; padding-top: 1.1rem; border-top: 1px solid ${border}; font-size: 1rem; font-weight: 600; }
258
+ h2:first-of-type { margin-top: 0; padding-top: 0; border-top: none; }
259
+ p { color: ${muted}; font-size: 0.85rem; }
260
+ table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; font-size: 0.85rem; }
261
+ td, th { padding: 0.4rem 0.6rem; border-bottom: 1px solid ${border}; text-align: left; }
262
+ pre { white-space: pre-wrap; background: ${surface}; border-radius: 4px; padding: 1rem; font-size: 0.8rem; }
263
+ img { border-radius: 4px; border: 1px solid ${border}; margin-top: 0.5rem; }
264
+ a { color: ${accent}; }
265
+ .empty { color: ${muted}; font-style: italic; background: ${surface}; border-radius: 4px; padding: 0.9rem 1rem; }
266
+ </style>
267
+ </head>
268
+ <body>
269
+ ${body}
270
+ </body>
271
+ </html>
272
+ `
273
+ }
274
+
275
+ function writeTokensPanel(root: string, outDir: string): void {
276
+ const sourcePath = join(root, DESIGN_DOCUMENT)
277
+ const dir = join(outDir, 'tokens')
278
+
279
+ if (!existsSync(sourcePath)) {
280
+ mkdirSync(dir, { recursive: true })
281
+ writeFileSync(
282
+ join(dir, 'index.html'),
283
+ panelPage(
284
+ 'Tokens',
285
+ `<p class="empty">No ${DESIGN_DOCUMENT} at the repository root.</p>`,
286
+ ),
287
+ )
288
+ return
289
+ }
290
+
291
+ renderDesignDoc(sourcePath, dir)
292
+ }
293
+
294
+ function writeWireframesPanel(root: string, outDir: string): void {
295
+ const dir = join(outDir, 'wireframes')
296
+ mkdirSync(dir, { recursive: true })
297
+
298
+ const sections = WIREFRAMES.map((entry) => {
299
+ const sourcePath = join(root, WIREFRAME_DIR, entry.path)
300
+ if (!existsSync(sourcePath)) {
301
+ return `<h2>${escapeHtml(entry.path)}</h2>\n<p class="empty">Missing from ${WIREFRAME_DIR}/.</p>`
302
+ }
303
+ const text = readFileSync(sourcePath, 'utf8')
304
+ return `<h2>${escapeHtml(entry.path)}</h2>\n<p>${escapeHtml(entry.describes)}</p>\n<pre>${escapeHtml(text)}</pre>`
305
+ }).join('\n')
306
+
307
+ writeFileSync(join(dir, 'index.html'), panelPage('Wireframes', sections))
308
+ }
309
+
310
+ /** Copies a built directory whole, filtering nothing, into the board's own tree. */
311
+ function copyBuilt(source: string, dest: string): void {
312
+ rmSync(dest, { recursive: true, force: true })
313
+ cpSync(source, dest, { recursive: true })
314
+ }
315
+
316
+ function writeSurfacesPanel(root: string, outDir: string): void {
317
+ const dir = join(outDir, 'surfaces')
318
+ mkdirSync(dir, { recursive: true })
319
+
320
+ const distSource = join(root, WEB_DIST)
321
+ const landingBody = existsSync(join(distSource, WEB_DIST_ENTRY))
322
+ ? (copyBuilt(distSource, join(dir, 'landing')),
323
+ '<iframe src="landing/index.html" loading="lazy"></iframe>')
324
+ : `<p class="empty">No ${WEB_DIST}/ build. Run bun run web:build, then regenerate the board.</p>`
325
+
326
+ const teachSource = recordDir(root, 'teach')
327
+ const teachBody = existsSync(join(teachSource, 'index.html'))
328
+ ? (copyBuilt(teachSource, join(dir, 'teach')),
329
+ '<iframe src="teach/index.html" loading="lazy"></iframe>')
330
+ : `<p class="empty">${relative(root, teachSource)} is gitignored and machine-local, so this panel renders empty in a fresh clone and on CI.</p>`
331
+
332
+ writeFileSync(
333
+ join(dir, 'index.html'),
334
+ panelPage(
335
+ 'Surfaces',
336
+ `<h2>Landing page</h2>\n${landingBody}\n<h2>Teach workspaces</h2>\n${teachBody}`,
337
+ ),
338
+ )
339
+ }
340
+
341
+ function isImage(name: string): boolean {
342
+ return IMAGE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext))
343
+ }
344
+
345
+ /** Every image file directly inside an evidence arm folder, one level deep. */
346
+ function imagesIn(dir: string): string[] {
347
+ return readdirSync(dir, { withFileTypes: true })
348
+ .filter((entry) => entry.isFile() && isImage(entry.name))
349
+ .map((entry) => entry.name)
350
+ .sort()
351
+ }
352
+
353
+ function writeCandidatesPanel(root: string, outDir: string): void {
354
+ const dir = join(outDir, 'candidates')
355
+ mkdirSync(dir, { recursive: true })
356
+
357
+ const evidenceDir = recordDir(root, 'review', 'evidence')
358
+ if (!existsSync(evidenceDir)) {
359
+ writeFileSync(
360
+ join(dir, 'index.html'),
361
+ panelPage(
362
+ 'Past candidates',
363
+ `<p class="empty">No ${relative(root, evidenceDir)} folder yet.</p>`,
364
+ ),
365
+ )
366
+ return
367
+ }
368
+
369
+ const folders = readdirSync(evidenceDir, { withFileTypes: true })
370
+ .filter((entry) => entry.isDirectory())
371
+ .map((entry) => entry.name)
372
+ .sort()
373
+
374
+ const found: Array<{ folder: string; images: string[] }> = []
375
+ for (const folder of folders) {
376
+ const images = imagesIn(join(evidenceDir, folder))
377
+ if (images.length > 0) {
378
+ cpSync(join(evidenceDir, folder), join(dir, folder), { recursive: true })
379
+ found.push({ folder, images })
380
+ }
381
+ }
382
+
383
+ if (found.length === 0) {
384
+ writeFileSync(
385
+ join(dir, 'index.html'),
386
+ panelPage(
387
+ 'Past candidates',
388
+ `<p class="empty">${folders.length} folders under ${relative(root, evidenceDir)}/ and none carries a draft-and-pick arm capture. The archival capture step has not run since it shipped.</p>`,
389
+ ),
390
+ )
391
+ return
392
+ }
393
+
394
+ const sections = found
395
+ .map(
396
+ ({ folder, images }) =>
397
+ `<h2>${escapeHtml(folder)}</h2>\n${images.map((image) => `<img src="${folder}/${image}" alt="${escapeHtml(image)}">`).join('\n')}`,
398
+ )
399
+ .join('\n')
400
+
401
+ writeFileSync(join(dir, 'index.html'), panelPage('Past candidates', sections))
402
+ }
403
+
404
+ /**
405
+ * Generates the board's page set into `outDir`, clearing whatever was there.
406
+ *
407
+ * This function is the directory's only writer, per the constraint every
408
+ * caller shares it under: `canon serve` and a future `canon capture` pass
409
+ * both read the result and neither may assume it exists ahead of a run.
410
+ *
411
+ * `cwd` is the caller's own working directory, resolved and passed in
412
+ * explicitly rather than read here, so a test can exercise the checkout-
413
+ * mismatch case without touching the process's real cwd.
414
+ */
415
+ export function generateBoard(
416
+ root: string,
417
+ outDir: string,
418
+ cwd: string,
419
+ ): BoardOutcome {
420
+ if (wouldDeleteRoot([root, cwd], outDir)) {
421
+ return {
422
+ ok: false,
423
+ reason: 'unsafe-out',
424
+ detail: `${outDir} is or contains ${root} or ${cwd}. Refusing to clear it.`,
425
+ }
426
+ }
427
+
428
+ rmSync(outDir, { recursive: true, force: true })
429
+ mkdirSync(outDir, { recursive: true })
430
+
431
+ const panels: readonly BoardPanel[] = [
432
+ { id: 'tokens', title: 'Tokens', path: 'tokens/index.html' },
433
+ { id: 'surfaces', title: 'Surfaces', path: 'surfaces/index.html' },
434
+ { id: 'wireframes', title: 'Wireframes', path: 'wireframes/index.html' },
435
+ {
436
+ id: 'candidates',
437
+ title: 'Past candidates',
438
+ path: 'candidates/index.html',
439
+ },
440
+ ]
441
+
442
+ writeTokensPanel(root, outDir)
443
+ writeSurfacesPanel(root, outDir)
444
+ writeWireframesPanel(root, outDir)
445
+ writeCandidatesPanel(root, outDir)
446
+
447
+ const indexPath = join(outDir, 'index.html')
448
+ writeFileSync(indexPath, shellHtml(panels))
449
+
450
+ return { ok: true, outDir, indexPath, panels }
451
+ }