@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/design-extract/SKILL.md +5 -1
- package/claude/skills/sketch-design/REQUIREMENT.md +38 -0
- package/claude/skills/sketch-design/SKILL.md +92 -0
- package/claude/skills/teach-workspace/SKILL.md +23 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +11 -0
- package/docs/agents/commands.md +4 -2
- package/docs/agents/design-board.md +13 -11
- package/docs/agents/index.md +1 -1
- package/docs/agents/teach.md +16 -0
- package/docs/workflow/ai-workflow.md +1 -0
- package/docs/workflow/visual-design-workflow.md +1 -0
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +5 -0
- package/src/commands/design.ts +24 -8
- package/src/commands/teach.ts +118 -0
- package/src/design/board.ts +130 -47
- package/src/project-root.ts +16 -0
- package/src/teach/render.ts +126 -0
|
@@ -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
|
|
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
|
-
|
|
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.
|
package/docs/agents/commands.md
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
24
|
-
- **Surfaces** iframes the built landing page from
|
|
25
|
-
- **Wireframes**
|
|
26
|
-
- **Past candidates** lists an arm capture image per folder under
|
|
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
|
|
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
|
|
package/docs/agents/index.md
CHANGED
|
@@ -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
|
|
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
|
package/docs/agents/teach.md
CHANGED
|
@@ -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
package/src/claude/cases/misc.ts
CHANGED
|
@@ -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',
|
package/src/commands/design.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
'
|
|
148
|
-
'
|
|
149
|
-
'
|
|
150
|
-
'
|
|
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(
|
|
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`,
|
package/src/commands/teach.ts
CHANGED
|
@@ -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,
|
package/src/design/board.ts
CHANGED
|
@@ -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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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>.
|
|
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 =
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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(
|
|
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 =
|
|
331
|
-
?
|
|
332
|
-
|
|
333
|
-
|
|
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(
|
|
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 =
|
|
356
|
-
?
|
|
357
|
-
|
|
358
|
-
|
|
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))
|
package/src/project-root.ts
CHANGED
|
@@ -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
|
+
}
|