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