@erclx/canon 4.80.0 → 4.81.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.80.0",
4
+ "version": "4.81.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -17,7 +17,7 @@ Step 1 carries two more stops that apply to one path only. Do not evaluate them
17
17
  Check the project root for UI surfaces: `src/ui.ts`, `src/ui.tsx`, `src/components/**`, `scripts/lib/ui.sh`, and any `*.css`, `tailwind.config.*`, or `theme.*`.
18
18
 
19
19
  - Any match takes the source path. Existing code defines the system, so every filled cell traces to a value already in the tree.
20
- - No match takes the greenfield path. Nothing anchors a value, so every filled cell is a proposal and carries the tag that says so.
20
+ - No match takes the greenfield path. Nothing anchors a value there unless a picked reference does, so name `sketch-design` as the first move: a picked reference anchors a cell where a paragraph alone cannot. A direct run of this skill still falls through to the proposal-from-personality rules in Step 4, tag included, so this skill stays runnable on its own with nothing rendered yet.
21
21
 
22
22
  The project decides this, never a user flag or an argument. Announce which path ran in one line before Step 2, since the two produce different-looking output from the same skill.
23
23
 
@@ -39,6 +39,8 @@ On the source path, also read the UI surfaces matched in Step 1 plus `canon docs
39
39
 
40
40
  On the greenfield path, also read `canon/ARCHITECTURE.md` for platform, tech stack, and surface type. Do not scan `src/`, stylesheets, or UI modules. Step 1 already established they hold nothing.
41
41
 
42
+ On the greenfield path, also check `.canon/review/evidence/*/design-handoff.md` for a file the `sketch-design` skill wrote, taking the most recently modified match when more than one exists. Read it when found. Its presence is what Step 4 traces cells from instead of proposing them.
43
+
42
44
  Run these reads in parallel. Do not speculatively recurse into every directory.
43
45
 
44
46
  ## Step 3: fetch the seed template
@@ -72,6 +74,8 @@ On the source path, the tag marks the exception. On the greenfield path it marks
72
74
 
73
75
  Anchor every proposal to a signal, never to a default. "Calm and dense" pins muted grays and tight spacing. A requirements non-goal of "no motion" makes Motion read `No animation.` with no tag. A CLI-only surface leans Typography monospaced and keeps Borders minimal.
74
76
 
77
+ - **Traced cells**: when Step 2 matched a `design-handoff.md` file, fill only the fields it actually states, no tag, the same traced-versus-proposed distinction the source path draws for a stylesheet value.
78
+ - The handoff carries hex and family and size, never Weight, Line height, or a color's Intent. Those cells, every field of a role the handoff never names, and Motion and Iconography regardless, still follow the proposal rules below, tag included.
75
79
  - **Personality**: transcribe the `## Personality` paragraph from `canon/REQUIREMENTS.md` verbatim. This is the one section that is not a proposal. No tag.
76
80
  - **Color**: one row per role. Rewrite the Intent cell in personality language, for example `warm off-white page canvas` instead of the seed default `page canvas`. Propose hex values matching the personality. Dense and calm gives low saturation and high text contrast. Playful gives saturated accents. Every Intent and Value cell gets `? verify`.
77
81
  - **Typography**: one row per role. Propose families fitting the platform, system UI for web, monospaced for CLI tools, serif for editorial, and a harmonious scale. Every cell gets `? verify`.
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: sketch-design
3
+ description: Why a greenfield design value needs a render behind it before it reaches DESIGN.md, and where the reference loop stops short of writing that file itself
4
+ ---
5
+
6
+ # Sketch design requirement
7
+
8
+ ## Gap
9
+
10
+ Without this skill, a session asked to match a look an operator has in mind either invents token values from a personality paragraph alone, which is what `design-extract`'s greenfield path already does and states plainly as a proposal rather than a fact, or builds a one-off render-and-pick comparison by hand for each request, restating `draft-and-pick`'s candidate page, hand-off, and pick mechanics from scratch with nothing comparing the copy to the shipped one. Neither path lets an operator point at a reference image or a live page and have the values that follow trace back to what was actually looked at and picked.
11
+
12
+ ## Must
13
+
14
+ - Take at least two operator-supplied references, each a path to an image on disk or a URL to render, and gather them through this skill's own Step 1 prompt rather than a caller-supplied argument
15
+ - Render every URL reference before drafting, and report a capture refusal naming an unrecognized source as a version gap rather than a broken reference
16
+ - Follow `draft-and-pick`'s candidate page, render, hand-off, structured pick, and archival capture mechanics rather than restating them
17
+ - Trace the picked reference's color, type, and spacing by reading its render, and carry the operator's own stated reason for the pick forward as written
18
+ - Write the handoff one field per line, grouped under the section names `standards/design.md` already fixes for `canon/DESIGN.md`, so `design-extract` can read it mechanically
19
+ - Resolve the handoff file and the archival capture against the main worktree root, never against a linked worktree this run happens to be building in
20
+
21
+ ## Must not
22
+
23
+ - Write to `canon/DESIGN.md`. Tracing values into the handoff is this skill's whole output. Filling the design document from them is `design-extract`'s Step 2, kept as the one place that file is written.
24
+ - Take the pick on the operator's behalf. That call is theirs on every arm that is defensible, the same boundary `draft-and-pick` states for itself.
25
+ - Restate `draft-and-pick`'s render, hand-off, pick, or loop mechanics, or `canon capture`'s render mechanics
26
+ - Assume this skill's own invocation frequency needs no check. `design-extract` Step 1 points to it as the first move on the greenfield path, and an operator can also type its name directly, so whether anything else reaches for it has an answer only after a review pass reads usage back, rather than at creation time.
27
+
28
+ ## Guards
29
+
30
+ - `canon` not on PATH: stop, since the render loop and the capture calls both need it
31
+ - Fires on a direct request or on `design-extract`'s own pointer, never on the model's own read that a project could use a design direction
32
+
33
+ ## Out of scope
34
+
35
+ - `design-extract` reads the handoff this skill writes and fills `canon/DESIGN.md` from it. That fill, and the seed template it starts from, stay entirely that skill's own.
36
+ - `draft-and-pick` owns the render, hand-off, pick, and loop mechanics this skill invokes rather than reimplements
37
+ - `canon capture` and `canon serve` own the render and the address mechanics
38
+ - Auditing an implemented UI against its tokens, which `ux-audit` owns
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: sketch-design
3
+ description: Runs a reference-and-pick loop against operator-supplied images and URLs, then writes the picked reference's traced color, type, and spacing values to a handoff file `design-extract` reads. Use when asked to "sketch a design direction", "compare these reference sites", "pick a look before we build DESIGN.md", "show me a few design directions", or as the first move on `design-extract`'s greenfield path when nothing has been rendered yet. Do NOT use when a value is already anchored to running code or a stylesheet, which is `design-extract`'s source path, or to draft the design system document itself, which stays `design-extract`'s job.
4
+ ---
5
+
6
+ # Sketch design
7
+
8
+ `design-extract`'s greenfield path proposes color, type, and spacing from a personality paragraph alone, with no render behind any value. This skill puts operator-supplied references in front of a render instead, so the values `design-extract` fills are traced to a picked reference rather than invented from prose.
9
+
10
+ ## Guards
11
+
12
+ - If `canon` is not on PATH, stop: `❌ canon CLI not found.`
13
+ - Draft no comparison for a reference set the operator has not supplied. This skill fires on a direct request or on `design-extract` pointing at it, never on the model's own read that a project could use one.
14
+
15
+ ## Step 1: gather the references and render what needs it
16
+
17
+ Ask the operator, in this step's own prompt, for the references to compare: a path to an image already on disk for each one already rendered somewhere, and a full URL for each live page to render. Take at least two.
18
+
19
+ Follow `${CLAUDE_SKILL_DIR}/../draft-and-pick/SKILL.md` Step 1 items 2 through 5 to name the decision and derive `<dest>`, with the decision fixed rather than derived: "which of these references the project's design should draw from." Arm 0 is the current state when `canon/DESIGN.md` already carries filled Color, Typography, or Spacing cells. Arms start at 1 otherwise, since a greenfield project asking for this loop has nothing shipped yet to anchor a baseline arm against.
20
+
21
+ Render every URL reference before drafting, one call per reference since each targets its own file:
22
+
23
+ ```bash
24
+ canon capture <url> --selector body --out <dest>/refs/arm-<id>.png
25
+ ```
26
+
27
+ `--selector body` is the right choice here, unlike inside `draft-and-pick`'s own arms: the page belongs to someone else, so nothing on it is a wrapper this skill can name, and a full-page render is what a reference comparison needs. An operator-supplied image needs no render. Reference it directly from `<dest>/refs/` by copying it there under the same `arm-<id>` naming, so every arm resolves through one path shape regardless of source.
28
+
29
+ `canon capture <url>` reaches a target only once a release ships the URL-source capability. Report a refusal naming an unrecognized source as that gap rather than a broken reference, and name upgrading the installed `canon` as the repair.
30
+
31
+ ## Step 2: author the candidate set as one page
32
+
33
+ Follow `${CLAUDE_SKILL_DIR}/../draft-and-pick/SKILL.md` Step 2, with one substitution: each arm is an `<img>` of the file `<dest>/refs/arm-<id>.png` produced in Step 1, not markup drafted from scratch. Wrap each in the shared class that skill's Step 2 fixes, label it with its id and its cost, and inline everything else the page needs, per that step's rules.
34
+
35
+ ## Step 3: render, hand off, and take the pick
36
+
37
+ Follow `${CLAUDE_SKILL_DIR}/../draft-and-pick/SKILL.md` Steps 3 and 4 unchanged: render the combined page, hand over the address, and put the choice to the operator through the structured question surface. Take whatever reason the operator's answer already states, whether through the surface's own free-text escape or in the surrounding turn, as the reason Step 5 records. The surface takes the pick and the reason in one turn when the operator gives both together, rather than needing a second one.
38
+
39
+ ## Step 4: loop, when the operator asks for a change
40
+
41
+ The arms here are fixed references rather than drafted markup, so nothing about a picked arm's own content can be revised. A loop here narrows the set instead: drop an arm the operator rules out, or add a new reference by returning to Step 1 for it alone. Return to Step 2 with the adjusted set and repeat Step 3. Stop when the operator says the pick is right, the same rule `draft-and-pick` Step 5 states, never when the set stops changing.
42
+
43
+ ## Step 5: trace and write the handoff
44
+
45
+ 1. Carry forward the reason Step 3's pick already stated, as written rather than paraphrased. Ask the operator to state, in their own words, why the picked reference won only when the pick arrived with no reason attached.
46
+ 2. Read the picked arm's render at `<dest>/refs/arm-<id>.png` and trace its color, type, and spacing by looking, the same reading a person does against a screenshot. Name a role only where the reference actually shows it. Leave one out rather than guessing.
47
+ 3. Write `.canon/review/evidence/<slug>/design-handoff.md`, resolved against the main worktree root rather than the linked worktree this run may be building in, since shared session scratch always resolves there. One field per line, grouped under the section names `${CLAUDE_SKILL_DIR}/../../standards/design.md` fixes, so `design-extract` reads it mechanically:
48
+
49
+ ```markdown
50
+ # Design handoff
51
+
52
+ Picked: arm-<id> (<label>)
53
+ Reason: <the operator's own sentence from item 1>
54
+
55
+ ## Color
56
+
57
+ - <role>: <hex>
58
+
59
+ ## Typography
60
+
61
+ - <role>: <family>, <size>
62
+
63
+ ## Spacing
64
+
65
+ - base unit: <value>
66
+
67
+ ## Borders
68
+
69
+ - <role>: <radius>, <width>
70
+ ```
71
+
72
+ Write only the sections and roles the trace actually names. A role or a whole section with nothing traced is left out rather than written empty, since `design-extract` Step 2 treats an absent line as untraced and an empty one as a claim about the source.
73
+
74
+ 4. Batch-capture the final round's arm files, the same directory-batch convention `draft-and-pick` Step 6 uses: `canon capture <dest>/arms --selector <wrapper-class> --out .canon/review/evidence/<slug>/`, naming Step 2's chosen class, so every arm considered, not only the winner, survives past the pick as a revert record.
75
+ 5. Delete `<dest>` and everything in it, now that the handoff and the arms both sit at the durable path above. Report the path as still standing when the delete is refused, rather than closing on a report the tree contradicts.
76
+
77
+ ## Response format
78
+
79
+ ```plaintext
80
+ 📝 Wrote .canon/review/evidence/<slug>/design-handoff.md
81
+ 📝 Wrote .canon/review/evidence/<slug>/arm-<id>.png (and every other arm considered)
82
+
83
+ Picked arm-<id>: <label>. Run /canon:design-extract to fill canon/DESIGN.md from the trace.
84
+ ```
85
+
86
+ ## What this delegates
87
+
88
+ Cite these rather than restating them. A step reimplemented here rots against the skill that owns it.
89
+
90
+ - `draft-and-pick` owns the candidate page, the render and hand-off, the structured pick, and the archival capture convention, cited above
91
+ - `design-extract` reads the handoff file this skill writes and fills `canon/DESIGN.md` from it. This skill never writes that file itself.
92
+ - `canon capture` owns the render mechanics, including the URL source and its font refusal
@@ -113,7 +113,29 @@ Write each option as a `<label class="opt" data-k="<letter>">` holding a radio `
113
113
 
114
114
  Then carry a teach-back block beside the quiz. A quiz is recognition and the pedagogy prefers production, so a lesson offering only a quiz tests the weak form. Ask for an explanation to a named audience, and carry a `<details>` listing what a complete explanation covers, closed by default, so a learner reading with no session in the room can grade themselves. The reference states both shapes in full.
115
115
 
116
- Write the chrome as four empty marker pairs rather than composing it by hand: `<!-- canon:teach:style -->`/`<!-- /canon:teach:style -->` inside `<head>`, and `<!-- canon:teach:header -->`, `<!-- canon:teach:footnav -->`, and `<!-- canon:teach:scripts -->` each with its own close marker, in that order in `<body>`. Write the authored `<h1>`, lede, body, and quiz between the header's close marker and the footnav's open marker, and nothing else anywhere in the file. Then run:
116
+ Compose the quiz and the teach-back block as one string. Below, that string is the block list's trailing `raw` entry, rather than markup written into the file by hand.
117
+
118
+ ### Building the lesson body
119
+
120
+ Write the chrome as four empty marker pairs rather than composing it by hand: `<!-- canon:teach:style -->`/`<!-- /canon:teach:style -->` inside `<head>`, and `<!-- canon:teach:header -->`, `<!-- canon:teach:footnav -->`, and `<!-- canon:teach:scripts -->` each with its own close marker, in that order in `<body>`.
121
+
122
+ Build the authored `<h1>`, lede, body, and quiz as a JSON array of blocks rather than composing markup by hand, and render it through the verb rather than through a component import:
123
+
124
+ ```bash
125
+ echo '[
126
+ {"type":"heading","level":1,"text":"<title>"},
127
+ {"type":"paragraph","lede":true,"text":"<the dek>"},
128
+ {"type":"paragraph","text":"<a body paragraph>"},
129
+ {"type":"list","ordered":true,"items":["<step one>","<step two>"]},
130
+ {"type":"raw","html":"<the quiz and teach-back block composed above>"}
131
+ ]' | canon teach render --json
132
+ ```
133
+
134
+ A `heading`, a `paragraph` (`lede: true` for the dek), and a `list` cover the structural body. Reach for `raw` only where none of the three can carry the content, never as a shortcut around composing one, and give the quiz and teach-back block the array's trailing `raw` entry every time, since their fixed contract is not a components concern. Take the call's `html` field and write it between the header's close marker and the footnav's open marker, and nothing else anywhere in the file.
135
+
136
+ Report it rather than proceeding silently when the verb does not resolve, which is an installed CLI predating it, and never compose the lesson body by hand as a fallback. That is the state this section exists to end, and a target holds this skill body before it holds the verb, since a plugin skill reaches a target the moment it merges while the CLI reaches one only when a release publishes.
137
+
138
+ Then run:
117
139
 
118
140
  ```bash
119
141
  canon teach nav <topic> --json
@@ -71,6 +71,17 @@ The quiz shape is a contract rather than a convention. `canon teach nav` splices
71
71
 
72
72
  The stepper hides and shows and sets nothing else, so how a selected option looks, and whether a wrong one is marked as wrong, is the workspace stylesheet's to grow. Name the correct option in the feedback text for that reason: it is what a learner reads to find out whether they were right.
73
73
 
74
+ ## The block list
75
+
76
+ `canon teach render` takes a JSON array of blocks and renders it through the same components the fixture lesson is generated from. Four types, and every structural body composes from them:
77
+
78
+ - `{"type":"heading","level":1|2,"text":"<text>"}`: an `<h1>` or `<h2>`.
79
+ - `{"type":"paragraph","text":"<text>","lede":true}`: a `<p>`, marked `lede` for the dek that opens the lesson.
80
+ - `{"type":"list","items":["<text>", ...],"ordered":true}`: a `<ul>` or, with `ordered`, an `<ol>`.
81
+ - `{"type":"raw","html":"<markup>"}`: the escape hatch, passed through unescaped.
82
+
83
+ The quiz and the teach-back block above are the one case `raw` is always needed for, since their fixed contract is not a components concern. Give them the array's trailing entry rather than reaching for `raw` anywhere the other three could carry the content instead.
84
+
74
85
  ## Teach back
75
86
 
76
87
  A quiz is recognition and a teach-back is production, which is the form retrieval practice prefers. Carry both: the quiz places the learner against options someone could hold, and the teach-back is where the learner finds out what they cannot say without help.
@@ -24,7 +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 `canon/DESIGN.md` tokens to HTML and CSS |
26
26
  | `canon design regen` | Rewrite this repository's `canon/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, past candidates, and components, against the toolkit checkout like `regen` (`--out`) |
27
+ | `canon design board` | Generate a static page set indexing tokens, surfaces, wireframes, past candidates, components, and references against a caller-resolved `--root` (`--out`), defaulting to the main worktree |
28
28
  | `canon design css` | Emit the design tokens and components as CSS on stdout (`--no-components` for properties alone) |
29
29
  | `canon design install` | Install the base stylesheet into a project at `.claude/design/base.css` |
30
30
  | `canon design sync` | Update a base stylesheet already installed under `.claude/design/` |
@@ -45,8 +45,10 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
45
45
  | `canon teach open` | Open a workspace at the next ordinal with its mission, resources, and glossary files (`--json`) |
46
46
  | `canon teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
47
47
  | `canon teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
48
+ | `canon teach lesson` | Resolve the numbered path, shared stylesheet, mission success lines, and one quiz option order per question for the next lesson, writing nothing (`--json`) |
48
49
  | `canon teach stylesheet` | Seed a workspace stylesheet from the design source, leaving an existing one alone without `--force` (`--json`) |
49
50
  | `canon teach nav` | Rewrite the teach-root listing, a workspace's contents page, and each lesson's chrome from its four marker regions (`--json`) |
51
+ | `canon teach render` | Render a JSON block list (heading, paragraph, list, raw) from stdin to HTML through the shared components, refusing `bad-input` by block index (`--json`) |
50
52
  | `canon records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
51
53
  | `canon records migrate` | Rewrite the records a validate finding names a recoverable transform for (`--write`, `--json`) |
52
54
  | `canon records ordinal` | Report or claim the next ordinal `intake` and `groundwork` share, creating the folder atomically (`--claim`, `--json`) |
@@ -139,7 +141,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
139
141
  | `slides` | `render`, `list` |
140
142
  | `tasks` | `archive`, `validate` |
141
143
  | `intake` | `list`, `answer` |
142
- | `teach` | `list`, `open`, `resource`, `glossary`, `lesson`, `stylesheet`, `nav` |
144
+ | `teach` | `list`, `open`, `resource`, `glossary`, `lesson`, `stylesheet`, `nav`, `render` |
143
145
  | `comments` | `scan` |
144
146
  | `context` | `audit` |
145
147
  | `markdown` | `audit` |
@@ -1,30 +1,32 @@
1
1
  ---
2
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
3
+ description: Generating a static index over a project's design surfaces, what each panel reads, and which two stay toolkit-checkout-only
4
4
  ---
5
5
 
6
6
  # Design board
7
7
 
8
- `canon design board [--out <path>]` generates a static page set indexing five 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 `canon/DESIGN.md`, `canon/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.
8
+ `canon design board [--out <path>] [--root <path>]` generates a static page set indexing six design surfaces and reports the path a reader opens with `canon serve`. `--root` defaults to the main worktree the same way `canon teach`'s verbs resolve their own root. The surfaces panel's landing-page half and the whole components panel render only when that root is this toolkit's own checkout, since both read this repository's own build output rather than anything a target project produces. 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
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.
10
+ Each panel reads what is already on disk and reports its own missing source rather than failing the run, so an absent build, an empty corpus, or a toolkit-only gate renders an empty-state message instead of a broken frame.
11
11
 
12
12
  ```bash
13
13
  canon design board
14
14
  canon serve .canon/review/board
15
15
  ```
16
16
 
17
- | Option | Default | Behavior |
18
- | ------------------ | --------------------- | ---------------- |
19
- | `-o, --out <path>` | `.canon/review/board` | Output directory |
17
+ | Option | Default | Behavior |
18
+ | ------------------ | --------------------- | --------------------------------------------- |
19
+ | `-o, --out <path>` | `.canon/review/board` | Output directory |
20
+ | `--root <path>` | The main worktree | Project root the board reads its sources from |
20
21
 
21
22
  ## Panels
22
23
 
23
- - **Tokens** renders `canon/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 `canon/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
- - **Components** iframes the gallery built by `bun run web:gallery`, a second Astro config at `web/gallery.config.mjs` reading `web/gallery-src/` and writing `web/gallery-dist/`. That config's `srcDir` is never read by `web:build`'s own config, so the gallery never reaches the published `web/dist/`. The gallery page renders every component under `web/src/components/` except two whose props carry no defaults, which it names rather than filling with invented data. Reports a missing gallery build rather than rendering a broken frame.
24
+ - **Tokens** renders `DESIGN.md` through the same renderer `canon design render` uses, rather than a second one, reading `<root>/canon/DESIGN.md` or `<root>/.claude/DESIGN.md` for a target that has not run `canon migrate surface-roots`.
25
+ - **Surfaces** iframes the built landing page from `<root>/web/dist/` and a teach workspace from `<root>/.canon/teach/`, copying each whole into the board's own tree. The landing-page half reports a toolkit-only notice outside this toolkit's own checkout, and either half reports its own missing build or absent workspace rather than rendering a broken frame.
26
+ - **Wireframes** reads every `**/*.md` under `<root>/canon/wireframes/` or `<root>/.claude/wireframes/`, excluding `index.md` at any depth, and renders each file as-is inside a `<pre>`, labeled from its own `description` frontmatter field. Reports the whole panel empty rather than per file when the directory is absent or holds nothing to render.
27
+ - **Past candidates** lists an arm capture image per folder under `<root>/.canon/review/evidence/`, and states the corpus carries none rather than rendering an empty grid.
28
+ - **Components** iframes the gallery built by `bun run web:gallery`, a second Astro config at `web/gallery.config.mjs` reading `web/gallery-src/` and writing `web/gallery-dist/`. That config's `srcDir` is never read by `web:build`'s own config, so the gallery never reaches the published `web/dist/`. The gallery page renders every component under `web/src/components/` except two whose props carry no defaults, which it names rather than filling with invented data. Reports a toolkit-only notice outside this toolkit's own checkout, and a missing gallery build otherwise.
29
+ - **References** lists whatever image an operator has dropped flat under `<root>/.canon/review/references/`, with no fetching and no second token source: a reference is an image, nothing more. Reports the folder as absent or as present but carrying no image rather than rendering an empty grid.
28
30
 
29
31
  ## What it does not do
30
32
 
@@ -17,7 +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
+ - [Design board](design-board.md): Generating a static index over a project's design surfaces, what each panel reads, and which two stay toolkit-checkout-only
21
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
22
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
23
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
@@ -137,6 +137,22 @@ Each `quiz` entry carries `order`, the authored option indices in presentation o
137
137
 
138
138
  The order is drawn here rather than instructed, and that is the point of the verb. An author told to vary the position still varies it by judgment, and the judgment settles on the first slot, which is the defect this design departs from. The draw is uniform over the options, so the position carries no information about which answer is correct.
139
139
 
140
+ ## Render
141
+
142
+ `canon teach render` renders a lesson body's structural blocks to HTML, through the same components the fixture lesson under `examples/teach/00-fixture/` is generated from. It takes no topic and no `--root`, since the verb is a stateless transform reading nothing off a workspace on disk.
143
+
144
+ ```bash
145
+ echo '[{"type":"heading","level":1,"text":"Compass bearings"}]' | canon teach render --json
146
+ ```
147
+
148
+ | Option | Behavior |
149
+ | -------- | ---------------------------------------- |
150
+ | `--json` | Emit a machine-readable record on stdout |
151
+
152
+ It reads a JSON array of blocks from stdin, each a `heading`, `paragraph`, `list`, or `raw` block, and reports `{ ok: true, html }` on `--json` or the bare rendered HTML on stdout otherwise. Content none of the three components can express takes a `raw` block, carrying its own HTML verbatim and unescaped, which is the shape the quiz and the teach-back block travel in.
153
+
154
+ It refuses `bad-input` on empty stdin, on malformed JSON, on stdin that does not parse to an array, and on a block carrying an unrecognized `type` or a field of the wrong shape for its type, naming the block's index in the message.
155
+
140
156
  ## Nav
141
157
 
142
158
  `canon teach nav` rewrites the teach-root listing, a workspace's contents page, and each of its lessons' chrome, from what the workspace holds on disk.
@@ -204,6 +204,7 @@ This section is the corpus the coverage claim is measured against: every name `c
204
204
  | `canon:setup-plugins` | On a new machine, to install the community and official plugins user-scoped |
205
205
  | `canon:setup-verify` | After the agent generates configs, to run the installed scripts and report pass or fail |
206
206
  | `canon:setup-smoke` | After `setup-verify` passes, to check the dev and preview servers, end-to-end tests, and the screenshot harness |
207
+ | `canon:sketch-design` | Before `design-extract`'s greenfield path, to trace a design direction from reference images or URLs |
207
208
  | `canon:design-extract` | Before the first UI feature, to draft `canon/DESIGN.md` |
208
209
  | `canon:draft-diagram` | Once the architecture is written, to draft per-kind entries under `.canon/diagrams/` |
209
210
  | `canon:repo-metadata` | When the GitHub About text, homepage, or topics may have drifted, to reconcile them against the README |
@@ -35,6 +35,7 @@ A cell no source anchors ends in `? verify`, and the preview shows that marker b
35
35
 
36
36
  ### Skills
37
37
 
38
+ - `canon:sketch-design` to trace a design direction from reference images or URLs, before `design-extract`'s greenfield path proposes from a personality paragraph alone
38
39
  - `canon:design-extract` to draft `canon/DESIGN.md`, from existing project signals or from requirements alone on day one
39
40
  - `canon:ui-test` for e2e test generation after UI changes
40
41
  - `canon:ux-audit` for UX gap detection on existing surfaces
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.80.0",
4
+ "version": "4.81.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -17,6 +17,11 @@ export const MISC_CASES: readonly SkillCase[] = [
17
17
  'This project has no logo yet. Draft one and give me a social card to go with it.',
18
18
  expect: 'draft-identity',
19
19
  },
20
+ {
21
+ prompt:
22
+ "I found two sites whose look I want us to draw from. Put them side by side and let's settle which one the design should follow before anyone writes a token.",
23
+ expect: 'sketch-design',
24
+ },
20
25
  {
21
26
  prompt: 'Fire up the dev server the way this project documents it.',
22
27
  expect: 'project-commands',
@@ -11,12 +11,17 @@ import { buildDesignCss } from '@/design/css'
11
11
  import { HAND_DRAWN_FONT_FACES } from '@/design/fonts'
12
12
  import { renderDesignDoc } from '@/design/render'
13
13
  import { DESIGN_BASE_CSS, DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
14
- import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
14
+ import {
15
+ checkoutMismatchWarning,
16
+ isOwnCheckout,
17
+ PROJECT_ROOT,
18
+ } from '@/project-root'
15
19
  import { creationRel } from '@/record-root'
16
20
  import { surfaceDir } from '@/surface-root'
17
21
  import { recordStamp, runDomainSync } from '@/sync/engine'
18
22
  import { resolveTarget } from '@/target'
19
23
  import { intro, logAdd, logError, logInfo, logWarn, outro, palette } from '@/ui'
24
+ import { mainWorktreeRoot } from '@/worktree'
20
25
 
21
26
  export function register(program: Command): void {
22
27
  const design = program
@@ -133,33 +138,44 @@ export function register(program: Command): void {
133
138
  design
134
139
  .command('board')
135
140
  .description(
136
- 'Generate the design board, an index over this repository’s own design surfaces',
141
+ 'Generate the design board, an index over a project’s design surfaces',
137
142
  )
138
143
  .option(
139
144
  '-o, --out <path>',
140
145
  'Output directory',
141
146
  creationRel(process.cwd(), 'review', 'board'),
142
147
  )
148
+ .option('--root <path>', 'Project root, defaulting to the main worktree')
143
149
  .addHelpText(
144
150
  'after',
145
151
  [
146
152
  '',
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>.',
153
+ 'Reads its sources from --root, defaulting to the main worktree of',
154
+ 'whatever project the caller stands in, and writes a static page',
155
+ 'set, never installed or synced. The surfaces panel’s landing-page',
156
+ 'half and the whole components panel render only when that root is',
157
+ 'this toolkit’s own checkout. Open the result with canon serve <out>.',
151
158
  '',
152
159
  ].join('\n'),
153
160
  )
154
- .action((opts: { out: string }) => {
161
+ .action(async (opts: { out: string; root?: string }) => {
155
162
  const outDir = resolve(process.cwd(), opts.out)
163
+ const root = opts.root
164
+ ? resolve(process.cwd(), opts.root)
165
+ : await mainWorktreeRoot()
166
+ const isToolkitCheckout = isOwnCheckout(root)
156
167
  const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
157
168
  const mismatch = checkoutMismatchWarning(process.cwd())
158
169
  process.stderr.write(
159
170
  `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}Generate design board${NC}\n`,
160
171
  )
161
172
  if (mismatch !== undefined) logWarn(mismatch)
162
- const result = generateBoard(PROJECT_ROOT, outDir, process.cwd())
173
+ const result = generateBoard(
174
+ root,
175
+ outDir,
176
+ process.cwd(),
177
+ isToolkitCheckout,
178
+ )
163
179
  if (!result.ok) {
164
180
  process.stderr.write(
165
181
  `${GREY}│${NC} ${RED}✗${NC} ${result.detail}\n${GREY}└${NC}\n`,
@@ -2,6 +2,7 @@ import { relative } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { type LessonOutcome, planLesson } from '@/teach/lesson'
4
4
  import { type NavOutcome, generateNav } from '@/teach/nav'
5
+ import { type RenderOutcome, renderLessonBody } from '@/teach/render'
5
6
  import {
6
7
  defineTerms,
7
8
  type ListOutcome,
@@ -81,6 +82,10 @@ interface NavCommandOptions {
81
82
  readonly root?: string
82
83
  }
83
84
 
85
+ interface RenderCommandOptions {
86
+ readonly json?: boolean
87
+ }
88
+
84
89
  export function register(program: Command): void {
85
90
  const teach = program
86
91
  .command('teach')
@@ -357,6 +362,36 @@ export function register(program: Command): void {
357
362
  .action(async (topic: string | undefined, opts: NavCommandOptions) => {
358
363
  process.exitCode = await runNav(topic, opts)
359
364
  })
365
+
366
+ teach
367
+ .command('render')
368
+ .description('Render a lesson body block list to HTML')
369
+ .helpOption('-h, --help', 'Show this help message')
370
+ .option('--json', 'Emit a machine-readable record on stdout')
371
+ .addHelpText(
372
+ 'after',
373
+ [
374
+ '',
375
+ 'Exit codes:',
376
+ ' 0 the block list rendered',
377
+ ' 1 refused, with the reason on stderr or in the JSON record',
378
+ '',
379
+ 'Reads a JSON array of blocks from stdin, each a heading, paragraph,',
380
+ 'list, or raw block, and renders it through the same components the',
381
+ 'fixture lesson is generated from. Content the three cannot express',
382
+ 'takes type raw, carrying its own html verbatim, unescaped.',
383
+ '',
384
+ 'Takes no topic and no --root: the verb is a stateless transform,',
385
+ 'reading nothing off a workspace on disk.',
386
+ '',
387
+ 'Examples:',
388
+ ' echo \'[{"type":"heading","level":1,"text":"Compass bearings"}]\' | canon teach render --json',
389
+ '',
390
+ ].join('\n'),
391
+ )
392
+ .action(async (opts: RenderCommandOptions) => {
393
+ process.exitCode = await runRender(opts)
394
+ })
360
395
  }
361
396
 
362
397
  interface StylesheetCommandOptions {
@@ -403,6 +438,17 @@ function collect(value: string, previous: string[]): string[] {
403
438
  return [...previous, value]
404
439
  }
405
440
 
441
+ function readStdin(): Promise<string> {
442
+ return new Promise((resolveStream, rejectStream) => {
443
+ const chunks: Buffer[] = []
444
+ process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk))
445
+ process.stdin.on('end', () =>
446
+ resolveStream(Buffer.concat(chunks).toString('utf8')),
447
+ )
448
+ process.stdin.on('error', rejectStream)
449
+ })
450
+ }
451
+
406
452
  /**
407
453
  * Splits every pair or reports the ones that carry no separator. Both halves
408
454
  * are reported together, so a caller passing four pairs learns about all the
@@ -648,6 +694,78 @@ async function runNav(
648
694
  return reportNav(await generateNav(root, topic), emitJson, root)
649
695
  }
650
696
 
697
+ async function runRender(opts: RenderCommandOptions): Promise<number> {
698
+ const emitJson = opts.json ?? false
699
+
700
+ if (process.stdin.isTTY) {
701
+ return reportRenderRefusal(
702
+ badInput(
703
+ "No blocks on stdin. Pipe a JSON array: echo '[...]' | canon teach render",
704
+ ),
705
+ emitJson,
706
+ )
707
+ }
708
+
709
+ const body = (await readStdin()).trim()
710
+
711
+ if (!body) {
712
+ return reportRenderRefusal(
713
+ badInput('Empty stdin. Pipe a JSON array of blocks.'),
714
+ emitJson,
715
+ )
716
+ }
717
+
718
+ let parsed: unknown
719
+ try {
720
+ parsed = JSON.parse(body)
721
+ } catch {
722
+ return reportRenderRefusal(badInput('Malformed JSON on stdin.'), emitJson)
723
+ }
724
+
725
+ if (!Array.isArray(parsed)) {
726
+ return reportRenderRefusal(
727
+ badInput('Stdin must be a JSON array of blocks.'),
728
+ emitJson,
729
+ )
730
+ }
731
+
732
+ return reportRender(renderLessonBody(parsed), emitJson)
733
+ }
734
+
735
+ function reportRenderRefusal(refused: TeachRefused, emitJson: boolean): number {
736
+ if (emitJson) {
737
+ process.stderr.write(`${refused.message}\n`)
738
+ process.stdout.write(
739
+ `${JSON.stringify({
740
+ ok: false,
741
+ reason: refused.reason,
742
+ message: refused.message,
743
+ })}\n`,
744
+ )
745
+ return 1
746
+ }
747
+
748
+ intro('canon teach render')
749
+ logStep('Refused')
750
+ logError(refused.message)
751
+ outro()
752
+ return 1
753
+ }
754
+
755
+ function reportRender(outcome: RenderOutcome, emitJson: boolean): number {
756
+ if (!outcome.ok) return reportRenderRefusal(outcome, emitJson)
757
+
758
+ if (emitJson) {
759
+ process.stdout.write(
760
+ `${JSON.stringify({ ok: true, html: outcome.html })}\n`,
761
+ )
762
+ return 0
763
+ }
764
+
765
+ process.stdout.write(`${outcome.html}\n`)
766
+ return 0
767
+ }
768
+
651
769
  function reportNav(
652
770
  outcome: NavOutcome,
653
771
  emitJson: boolean,
@@ -11,13 +11,17 @@ import { join, relative, sep } from 'node:path'
11
11
  import { DESIGN_DOCUMENT } from '@/design/regen'
12
12
  import { renderDesignDoc } from '@/design/render'
13
13
  import { colorValue } from '@/design/tokens'
14
+ import { parseFrontmatter, readField } from '@/indexes/frontmatter'
14
15
  import { recordDir } from '@/record-root'
16
+ import { surfaceDir } from '@/surface-root'
15
17
 
16
18
  /**
17
- * The one named site for this repository's wireframe corpus, so the surface
18
- * move retargets a single constant rather than a literal repeated per panel.
19
- * The board reads this checkout alone, which has moved, so the new root is
20
- * spelled directly rather than resolved.
19
+ * The wireframes panel's creation-root spelling, used in its own empty-state
20
+ * message and by this module's tests to seed a fixture. The panel itself
21
+ * resolves its actual read path through `surfaceDir`, which also reads
22
+ * `.claude/wireframes/` for a target that has not run the surface-roots
23
+ * migration, so this constant names where a fresh file lands rather than
24
+ * where every root's corpus necessarily sits.
21
25
  */
22
26
  export const WIREFRAME_DIR = join('canon', 'wireframes')
23
27
 
@@ -34,26 +38,6 @@ const WEB_DIST_ENTRY = 'index.html'
34
38
  const WEB_GALLERY_DIST = join('web', 'gallery-dist')
35
39
  const WEB_GALLERY_DIST_ENTRY = 'index.html'
36
40
 
37
- interface WireframeEntry {
38
- readonly path: string
39
- readonly describes: string
40
- }
41
-
42
- /**
43
- * The six files the wireframes panel renders, each beside the surface it
44
- * describes. `index.md` at either level is a catalog rather than a wireframe
45
- * and is excluded, which is why this list holds six rather than the eight
46
- * files the corpus carries today.
47
- */
48
- const WIREFRAMES: readonly WireframeEntry[] = [
49
- { path: 'landing-page.md', describes: 'The canon.erclx.dev landing page' },
50
- { path: 'slides.md', describes: 'The SLIDES.md render' },
51
- { path: 'teach/root.md', describes: 'A teach workspace root listing' },
52
- { path: 'teach/contents.md', describes: 'A workspace contents page' },
53
- { path: 'teach/lesson.md', describes: 'A lesson page and quiz stepper' },
54
- { path: 'teach/chrome.md', describes: 'The shared teach chrome' },
55
- ]
56
-
57
41
  const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.svg', '.webp']
58
42
 
59
43
  export interface BoardPanel {
@@ -233,7 +217,8 @@ iframe { width: 100%; height: 480px; border: 1px solid var(--board-border); bord
233
217
  <body>
234
218
  <header>
235
219
  <h1>Design board</h1>
236
- <p class="meta">Generated by <code>canon design board</code>. Repository-local, never installed into a target.</p>
220
+ <p class="meta">Generated by <code>canon design board</code>. Reads from a caller-resolved root, never installed or synced.</p>
221
+ <p class="meta">Drafting a new candidate: <code>canon:draft-and-pick</code>. Auditing a rendered surface: <code>canon:ux-audit</code>.</p>
237
222
  </header>
238
223
  <nav>${navLinks}${THEME_TOGGLE_BUTTON}</nav>
239
224
  <main>
@@ -282,7 +267,7 @@ ${body}
282
267
  }
283
268
 
284
269
  function writeTokensPanel(root: string, outDir: string): void {
285
- const sourcePath = join(root, DESIGN_DOCUMENT)
270
+ const sourcePath = surfaceDir(root, 'DESIGN.md')
286
271
  const dir = join(outDir, 'tokens')
287
272
 
288
273
  if (!existsSync(sourcePath)) {
@@ -300,18 +285,56 @@ function writeTokensPanel(root: string, outDir: string): void {
300
285
  renderDesignDoc(sourcePath, dir)
301
286
  }
302
287
 
288
+ interface WireframeFile {
289
+ readonly relPath: string
290
+ readonly text: string
291
+ readonly description: string | undefined
292
+ }
293
+
294
+ /**
295
+ * Every wireframe under whichever root the corpus resolves at, sorted for a
296
+ * stable render order across runs. `surfaceDir` reads `.claude/wireframes/`
297
+ * for a target that has not run the surface-roots migration, the same
298
+ * fallback `recordDir` already gives the surfaces panel's teach half.
299
+ * `index.md` at any depth is a catalog rather than a wireframe and is
300
+ * excluded. A glob emits OS-native separators, and the corpus nests one
301
+ * level deep, so the label always reads with forward slashes regardless of
302
+ * platform.
303
+ */
304
+ function listWireframes(root: string): readonly WireframeFile[] {
305
+ const dir = surfaceDir(root, 'wireframes')
306
+ if (!existsSync(dir)) return []
307
+
308
+ const glob = new Bun.Glob('**/*.md')
309
+ const relPaths = [...glob.scanSync({ cwd: dir })]
310
+ .filter((relPath) => relPath.split(sep).pop() !== 'index.md')
311
+ .map((relPath) => relPath.split(sep).join('/'))
312
+ .sort()
313
+
314
+ return relPaths.map((relPath) => {
315
+ const text = readFileSync(join(dir, relPath), 'utf8')
316
+ return {
317
+ relPath,
318
+ text,
319
+ description: readField(parseFrontmatter(text), 'description'),
320
+ }
321
+ })
322
+ }
323
+
303
324
  function writeWireframesPanel(root: string, outDir: string): void {
304
325
  const dir = join(outDir, 'wireframes')
305
326
  mkdirSync(dir, { recursive: true })
306
327
 
307
- const sections = WIREFRAMES.map((entry) => {
308
- const sourcePath = join(root, WIREFRAME_DIR, entry.path)
309
- if (!existsSync(sourcePath)) {
310
- return `<h2>${escapeHtml(entry.path)}</h2>\n<p class="empty">Missing from ${WIREFRAME_DIR}/.</p>`
311
- }
312
- const text = readFileSync(sourcePath, 'utf8')
313
- return `<h2>${escapeHtml(entry.path)}</h2>\n<p>${escapeHtml(entry.describes)}</p>\n<pre>${escapeHtml(text)}</pre>`
314
- }).join('\n')
328
+ const files = listWireframes(root)
329
+ const sections =
330
+ files.length === 0
331
+ ? `<p class="empty">No wireframe files under ${WIREFRAME_DIR}/.</p>`
332
+ : files
333
+ .map((file) => {
334
+ const description = file.description ?? file.relPath
335
+ return `<h2>${escapeHtml(file.relPath)}</h2>\n<p>${escapeHtml(description)}</p>\n<pre>${escapeHtml(file.text)}</pre>`
336
+ })
337
+ .join('\n')
315
338
 
316
339
  writeFileSync(join(dir, 'index.html'), panelPage('Wireframes', sections))
317
340
  }
@@ -322,15 +345,21 @@ function copyBuilt(source: string, dest: string): void {
322
345
  cpSync(source, dest, { recursive: true })
323
346
  }
324
347
 
325
- function writeSurfacesPanel(root: string, outDir: string): void {
348
+ function writeSurfacesPanel(
349
+ root: string,
350
+ outDir: string,
351
+ isToolkitCheckout: boolean,
352
+ ): void {
326
353
  const dir = join(outDir, 'surfaces')
327
354
  mkdirSync(dir, { recursive: true })
328
355
 
329
356
  const distSource = join(root, WEB_DIST)
330
- const landingBody = existsSync(join(distSource, WEB_DIST_ENTRY))
331
- ? (copyBuilt(distSource, join(dir, 'landing')),
332
- '<iframe src="landing/index.html" loading="lazy"></iframe>')
333
- : `<p class="empty">No ${WEB_DIST}/ build. Run bun run web:build, then regenerate the board.</p>`
357
+ const landingBody = !isToolkitCheckout
358
+ ? `<p class="empty">The landing page panel only renders in this toolkit's own checkout.</p>`
359
+ : existsSync(join(distSource, WEB_DIST_ENTRY))
360
+ ? (copyBuilt(distSource, join(dir, 'landing')),
361
+ '<iframe src="landing/index.html" loading="lazy"></iframe>')
362
+ : `<p class="empty">No ${WEB_DIST}/ build. Run bun run web:build, then regenerate the board.</p>`
334
363
 
335
364
  const teachSource = recordDir(root, 'teach')
336
365
  const teachBody = existsSync(join(teachSource, 'index.html'))
@@ -347,15 +376,21 @@ function writeSurfacesPanel(root: string, outDir: string): void {
347
376
  )
348
377
  }
349
378
 
350
- function writeComponentsPanel(root: string, outDir: string): void {
379
+ function writeComponentsPanel(
380
+ root: string,
381
+ outDir: string,
382
+ isToolkitCheckout: boolean,
383
+ ): void {
351
384
  const dir = join(outDir, 'components')
352
385
  mkdirSync(dir, { recursive: true })
353
386
 
354
387
  const gallerySource = join(root, WEB_GALLERY_DIST)
355
- const body = existsSync(join(gallerySource, WEB_GALLERY_DIST_ENTRY))
356
- ? (copyBuilt(gallerySource, join(dir, 'gallery')),
357
- '<iframe src="gallery/index.html" loading="lazy"></iframe>')
358
- : `<p class="empty">No ${WEB_GALLERY_DIST}/ build. Run bun run web:gallery, then regenerate the board.</p>`
388
+ const body = !isToolkitCheckout
389
+ ? `<p class="empty">The components panel only renders in this toolkit's own checkout.</p>`
390
+ : existsSync(join(gallerySource, WEB_GALLERY_DIST_ENTRY))
391
+ ? (copyBuilt(gallerySource, join(dir, 'gallery')),
392
+ '<iframe src="gallery/index.html" loading="lazy"></iframe>')
393
+ : `<p class="empty">No ${WEB_GALLERY_DIST}/ build. Run bun run web:gallery, then regenerate the board.</p>`
359
394
 
360
395
  writeFileSync(join(dir, 'index.html'), panelPage('Components', body))
361
396
  }
@@ -416,13 +451,54 @@ function writeCandidatesPanel(root: string, outDir: string): void {
416
451
  const sections = found
417
452
  .map(
418
453
  ({ folder, images }) =>
419
- `<h2>${escapeHtml(folder)}</h2>\n${images.map((image) => `<img src="${folder}/${image}" alt="${escapeHtml(image)}">`).join('\n')}`,
454
+ `<h2>${escapeHtml(folder)}</h2>\n${images.map((image) => `<img src="${escapeHtml(`${folder}/${image}`)}" alt="${escapeHtml(image)}">`).join('\n')}`,
420
455
  )
421
456
  .join('\n')
422
457
 
423
458
  writeFileSync(join(dir, 'index.html'), panelPage('Past candidates', sections))
424
459
  }
425
460
 
461
+ function writeReferencesPanel(root: string, outDir: string): void {
462
+ const dir = join(outDir, 'references')
463
+ mkdirSync(dir, { recursive: true })
464
+
465
+ const referencesDir = recordDir(root, 'review', 'references')
466
+ if (!existsSync(referencesDir)) {
467
+ writeFileSync(
468
+ join(dir, 'index.html'),
469
+ panelPage(
470
+ 'References',
471
+ `<p class="empty">No ${relative(root, referencesDir)} folder yet.</p>`,
472
+ ),
473
+ )
474
+ return
475
+ }
476
+
477
+ const images = imagesIn(referencesDir)
478
+ if (images.length === 0) {
479
+ writeFileSync(
480
+ join(dir, 'index.html'),
481
+ panelPage(
482
+ 'References',
483
+ `<p class="empty">${relative(root, referencesDir)} exists but carries no image yet.</p>`,
484
+ ),
485
+ )
486
+ return
487
+ }
488
+
489
+ for (const image of images) {
490
+ cpSync(join(referencesDir, image), join(dir, image))
491
+ }
492
+
493
+ const sections = images
494
+ .map(
495
+ (image) => `<img src="${escapeHtml(image)}" alt="${escapeHtml(image)}">`,
496
+ )
497
+ .join('\n')
498
+
499
+ writeFileSync(join(dir, 'index.html'), panelPage('References', sections))
500
+ }
501
+
426
502
  /**
427
503
  * Generates the board's page set into `outDir`, clearing whatever was there.
428
504
  *
@@ -433,11 +509,16 @@ function writeCandidatesPanel(root: string, outDir: string): void {
433
509
  * `cwd` is the caller's own working directory, resolved and passed in
434
510
  * explicitly rather than read here, so a test can exercise the checkout-
435
511
  * mismatch case without touching the process's real cwd.
512
+ *
513
+ * `isToolkitCheckout` gates the surfaces panel's landing-page half and the
514
+ * whole components panel, both of which read this repository's own build
515
+ * output rather than anything a target project produces.
436
516
  */
437
517
  export function generateBoard(
438
518
  root: string,
439
519
  outDir: string,
440
520
  cwd: string,
521
+ isToolkitCheckout: boolean,
441
522
  ): BoardOutcome {
442
523
  if (wouldDeleteRoot([root, cwd], outDir)) {
443
524
  return {
@@ -460,13 +541,15 @@ export function generateBoard(
460
541
  path: 'candidates/index.html',
461
542
  },
462
543
  { id: 'components', title: 'Components', path: 'components/index.html' },
544
+ { id: 'references', title: 'References', path: 'references/index.html' },
463
545
  ]
464
546
 
465
547
  writeTokensPanel(root, outDir)
466
- writeSurfacesPanel(root, outDir)
548
+ writeSurfacesPanel(root, outDir, isToolkitCheckout)
467
549
  writeWireframesPanel(root, outDir)
468
550
  writeCandidatesPanel(root, outDir)
469
- writeComponentsPanel(root, outDir)
551
+ writeComponentsPanel(root, outDir, isToolkitCheckout)
552
+ writeReferencesPanel(root, outDir)
470
553
 
471
554
  const indexPath = join(outDir, 'index.html')
472
555
  writeFileSync(indexPath, shellHtml(panels))
@@ -28,6 +28,22 @@ const readPackageName = (root: string): string | undefined => {
28
28
  }
29
29
  }
30
30
 
31
+ /**
32
+ * Whether `root` is a checkout of this same package, judged by package name
33
+ * rather than by path equality against `PROJECT_ROOT`.
34
+ *
35
+ * A linked worktree tracks the identical `package.json` the main worktree
36
+ * does, so this reads true for either. Comparing `root` against `PROJECT_ROOT`
37
+ * directly reads true only for the one directory this process happened to
38
+ * load its own source from, which is wrong for a caller whose CLI runs out of
39
+ * a linked worktree while `root` correctly points at the main one, or at
40
+ * another worktree of the same repository.
41
+ */
42
+ export function isOwnCheckout(root: string): boolean {
43
+ const ownName = readPackageName(PROJECT_ROOT)
44
+ return ownName !== undefined && readPackageName(root) === ownName
45
+ }
46
+
31
47
  /**
32
48
  * Walks upward from `startDir` for the nearest ancestor `package.json`
33
49
  * sharing this package's own `name`, and reports its path when that ancestor
@@ -0,0 +1,126 @@
1
+ import { Heading } from '@/teach/components/heading'
2
+ import { List } from '@/teach/components/list'
3
+ import { Paragraph } from '@/teach/components/paragraph'
4
+ import { render } from '@/teach/html/jsx-runtime'
5
+ import type { TeachRefused } from '@/teach/workspace'
6
+
7
+ export type LessonBlock =
8
+ | { readonly type: 'heading'; readonly level: 1 | 2; readonly text: string }
9
+ | {
10
+ readonly type: 'paragraph'
11
+ readonly text: string
12
+ readonly lede?: boolean
13
+ }
14
+ | {
15
+ readonly type: 'list'
16
+ readonly items: readonly string[]
17
+ readonly ordered?: boolean
18
+ }
19
+ | { readonly type: 'raw'; readonly html: string }
20
+
21
+ export interface RenderRendered {
22
+ readonly ok: true
23
+ readonly html: string
24
+ }
25
+
26
+ export type RenderOutcome = RenderRendered | TeachRefused
27
+
28
+ const BLOCK_TYPES = ['heading', 'paragraph', 'list', 'raw'] as const
29
+
30
+ function isBlockType(value: unknown): value is LessonBlock['type'] {
31
+ return (BLOCK_TYPES as readonly string[]).includes(value as string)
32
+ }
33
+
34
+ function badBlock(index: number, message: string): TeachRefused {
35
+ return {
36
+ ok: false,
37
+ reason: 'bad-input',
38
+ message: `Block ${index}: ${message}`,
39
+ detail: [],
40
+ }
41
+ }
42
+
43
+ function isString(value: unknown): value is string {
44
+ return typeof value === 'string'
45
+ }
46
+
47
+ function isStringArray(value: unknown): value is readonly string[] {
48
+ return Array.isArray(value) && value.every(isString)
49
+ }
50
+
51
+ function renderBlock(
52
+ block: unknown,
53
+ index: number,
54
+ ): { readonly html: string } | TeachRefused {
55
+ if (typeof block !== 'object' || block === null || !('type' in block)) {
56
+ return badBlock(index, 'not an object carrying a type')
57
+ }
58
+
59
+ const type = (block as { readonly type: unknown }).type
60
+
61
+ if (!isBlockType(type)) {
62
+ return badBlock(index, `unrecognized type ${JSON.stringify(type)}`)
63
+ }
64
+
65
+ const fields = block as Record<string, unknown>
66
+
67
+ switch (type) {
68
+ case 'heading': {
69
+ if (fields.level !== 1 && fields.level !== 2) {
70
+ return badBlock(index, 'heading needs level 1 or 2')
71
+ }
72
+ if (!isString(fields.text)) {
73
+ return badBlock(index, 'heading needs a string text')
74
+ }
75
+ return {
76
+ html: render(Heading({ level: fields.level, children: fields.text })),
77
+ }
78
+ }
79
+ case 'paragraph': {
80
+ if (!isString(fields.text)) {
81
+ return badBlock(index, 'paragraph needs a string text')
82
+ }
83
+ if (fields.lede !== undefined && typeof fields.lede !== 'boolean') {
84
+ return badBlock(index, 'paragraph lede must be a boolean')
85
+ }
86
+ return {
87
+ html: render(Paragraph({ lede: fields.lede, children: fields.text })),
88
+ }
89
+ }
90
+ case 'list': {
91
+ if (!isStringArray(fields.items)) {
92
+ return badBlock(index, 'list needs an items array of strings')
93
+ }
94
+ if (fields.ordered !== undefined && typeof fields.ordered !== 'boolean') {
95
+ return badBlock(index, 'list ordered must be a boolean')
96
+ }
97
+ return {
98
+ html: render(List({ ordered: fields.ordered, items: fields.items })),
99
+ }
100
+ }
101
+ case 'raw': {
102
+ if (!isString(fields.html)) {
103
+ return badBlock(index, 'raw needs a string html')
104
+ }
105
+ return { html: fields.html }
106
+ }
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Renders a lesson body's structural blocks through the same components
112
+ * `render-fixture.tsx` composes by hand, so a real lesson and the committed
113
+ * fixture share one rendering mechanism. Every block past the first refusal
114
+ * goes unread, matching how a malformed JSON parse refuses the whole call.
115
+ */
116
+ export function renderLessonBody(blocks: readonly unknown[]): RenderOutcome {
117
+ const rendered: string[] = []
118
+
119
+ for (const [index, block] of blocks.entries()) {
120
+ const result = renderBlock(block, index)
121
+ if ('ok' in result) return result
122
+ rendered.push(result.html)
123
+ }
124
+
125
+ return { ok: true, html: rendered.join('\n') }
126
+ }