@erclx/canon 4.76.0 → 4.78.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-and-pick/SKILL.md +3 -3
- package/claude/skills/teach-workspace/references/promotion.md +5 -7
- package/docs/agents/capture.md +6 -5
- package/docs/agents/commands.md +2 -2
- package/docs/agents/design-board.md +2 -1
- package/docs/agents/driver.md +1 -1
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/visual-design-workflow.md +12 -13
- package/package.json +2 -1
- package/scripts/core/check-gallery-exclusion.sh +50 -0
- package/src/capture/render.ts +26 -9
- package/src/capture/sources.ts +29 -3
- package/src/commands/capture.ts +17 -6
- package/src/design/board.ts +24 -0
- package/src/gate/measures.ts +4 -2
- package/src/shipped/references.ts +41 -7
- package/src/teach/components/heading.tsx +11 -0
- package/src/teach/components/list.tsx +17 -0
- package/src/teach/components/paragraph.tsx +12 -0
- package/src/teach/html/jsx-dev-runtime.ts +13 -0
- package/src/teach/html/jsx-runtime.ts +113 -0
- package/src/teach/html/raw.ts +28 -0
- package/src/teach/render-fixture.tsx +95 -0
- package/standards/wiki.md +2 -4
- package/tsconfig.json +1 -0
|
@@ -16,7 +16,7 @@ Some decisions are settled by looking rather than by reasoning, and no draft is
|
|
|
16
16
|
## Step 1: name the decision and the arms
|
|
17
17
|
|
|
18
18
|
1. State the decision in one sentence, naming what changes between arms and what stays fixed.
|
|
19
|
-
2. Derive a kebab slug from that sentence. Call the folder every file this run writes to `<dest>` below. `<dest>` is `.canon/tmp/<slug>/`,
|
|
19
|
+
2. Derive a kebab slug from that sentence. Call the folder every file this run writes to `<dest>` below. `<dest>` is `.canon/tmp/<slug>/`, a nested `<slug>/` folder rather than a flat `<slug>-<file>.md`, which is the shape every temporary write in this project takes. Running inside a live `plan-groundwork` track is the one exception: `<dest>` is the track's own `evidence/<slug>/` instead, since a candidate render is evidence the track's decision file cites rather than spike input.
|
|
20
20
|
3. Write one arm per candidate, each carrying an id, a label, and what the arm costs. An arm with no stated cost is not an option.
|
|
21
21
|
4. Make the current state arm `0`, so the baseline is a candidate rather than an absence. A decision with nothing shipped yet says so and starts at arm `1`.
|
|
22
22
|
5. Stop at three to five arms. Two is a comparison the operator can hold in prose, and past five the pick stops being a look and becomes a sort.
|
|
@@ -48,7 +48,7 @@ canon capture <dest>/candidates.html --selector <element>
|
|
|
48
48
|
|
|
49
49
|
## Step 4: take the pick
|
|
50
50
|
|
|
51
|
-
Put the choice to the operator through the structured question surface,
|
|
51
|
+
Put the choice to the operator through the structured question surface, since a call the operator's preference decides always routes through it rather than through prose.
|
|
52
52
|
|
|
53
53
|
- One option per arm, labeled with the arm's id and carrying its cost as the description.
|
|
54
54
|
- Rank the recommendation first and mark it `(Recommended)`.
|
|
@@ -68,7 +68,7 @@ Put the choice to the operator through the structured question surface, per `.cl
|
|
|
68
68
|
1. Apply the winning arm to the real surface, in one change.
|
|
69
69
|
2. Close out whatever document stated the decision as open, in the same change, naming the arm that won and the ones that stayed defensible. A pick that changes a surface and records nothing about why leaves the next reader to re-derive it from a diff. Skip this where nothing stated the decision.
|
|
70
70
|
3. Batch-capture the final round's arm files, when `<dest>` is the scratch path: `canon capture <dest>/arms --selector <wrapper-class> --out <archive-dir>`, naming Step 2's chosen class. This is the directory-batch convention `draft-identity` Step 6 already uses.
|
|
71
|
-
4. Resolve `<archive-dir>` as `.canon/review/evidence/<slug>/` against the main worktree root,
|
|
71
|
+
4. Resolve `<archive-dir>` as `.canon/review/evidence/<slug>/` against the main worktree root, since shared session scratch resolves there rather than against a linked worktree this run happens to be building in. The capture is what keeps every arm past the pick, the losing ones included, as a durable revert record distinct from the live comparison page.
|
|
72
72
|
5. Delete `<dest>` and every file inside it, when `<dest>` is the scratch path, now that every arm sits at the durable path above. A variant left behind there is a second design nobody maintains.
|
|
73
73
|
6. Leave `<dest>` in place when it is a live track's `evidence/<slug>/`: `plan-groundwork`'s write scope treats evidence as durable rather than as scratch a session may delete, and the arms already sit at a durable path there.
|
|
74
74
|
7. Report `<dest>` as still standing when the scratch-path delete is refused, naming the path for the operator to remove, rather than closing on a report the tree contradicts. The pick is applied either way, so the run has done its work and the folder is what outlives it.
|
|
@@ -14,16 +14,14 @@ A reference page that reads like a lesson is a lesson wearing the wrong extensio
|
|
|
14
14
|
|
|
15
15
|
Sort by who owns the subject, which is the test the wiki already runs. The reader's activity decided the workspace and decides nothing here.
|
|
16
16
|
|
|
17
|
-
| The page's subject
|
|
18
|
-
|
|
|
19
|
-
| Owned by
|
|
20
|
-
|
|
|
21
|
-
|
|
|
17
|
+
| The page's subject | Destination |
|
|
18
|
+
| -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
19
|
+
| Owned by Anthropic | the project's wiki |
|
|
20
|
+
| Owned by another tool or vendor, owned by no single vendor, or written for someone consuming what this project ships | the project's public docs |
|
|
21
|
+
| How this project itself works, for the people who build it | `.claude/context/<domain>.md`, or the domain's own nested `index.md` when it is already split into a folder |
|
|
22
22
|
|
|
23
23
|
A page a reader cannot sort by that question is a page whose subject has not been settled. Say which two destinations it sits between and let the operator choose, rather than picking the nearer one.
|
|
24
24
|
|
|
25
|
-
Within the wiki, place the page by the same owner test that decides the folder: a vendor's own subject, another tool's subject, and a subject no single vendor owns each take the folder the project's wiki index names for them.
|
|
26
|
-
|
|
27
25
|
## Where the wiki sits
|
|
28
26
|
|
|
29
27
|
The folder has two spellings and a project carries whichever its scaffold wrote:
|
package/docs/agents/capture.md
CHANGED
|
@@ -11,6 +11,7 @@ description: Rendering HTML sources to PNG, what the command asserts about fonts
|
|
|
11
11
|
canon capture --selector .window
|
|
12
12
|
canon capture assets/captures/install.html --selector .window --out assets
|
|
13
13
|
canon capture assets/captures --selector .window --out .canon/review/captures
|
|
14
|
+
canon capture https://example.com --selector .window --out preview.png
|
|
14
15
|
```
|
|
15
16
|
|
|
16
17
|
`--selector` is required and every example above passes it. The element a capture crops to belongs to the page, not to the command, so there is no value that could be right for an arbitrary project's markup. `.window` is what this repository's own five sources declare, and a project renders its own pages by naming whatever theirs declare.
|
|
@@ -29,10 +30,10 @@ Every render writes a stamp beside its PNG, `hero.png` next to `hero.stamp`, hol
|
|
|
29
30
|
|
|
30
31
|
Neither digest is ever written by hand. A digest is what the gate compares, so a hand-set one asserts agreement the tool never checked, and the way to move it is a capture. The `source:` line above them is the one field a rename may correct in place, since nothing reads it and the alternative is a stamp naming a file that no longer exists. Renaming a frame is the case that comes up, and the correction is the same basename the next capture would have written anyway.
|
|
31
32
|
|
|
32
|
-
| Option | Behavior
|
|
33
|
-
| ------------------ |
|
|
34
|
-
| `--out <dir>` | Write every PNG here instead of beside its source |
|
|
35
|
-
| `--selector <sel>` | Element to capture, required and never defaulted
|
|
33
|
+
| Option | Behavior |
|
|
34
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------ |
|
|
35
|
+
| `--out <dir>` | Write every PNG here instead of beside its source. For a URL source, names the destination PNG itself. |
|
|
36
|
+
| `--selector <sel>` | Element to capture, required and never defaulted |
|
|
36
37
|
|
|
37
38
|
## What the command asserts
|
|
38
39
|
|
|
@@ -48,4 +49,4 @@ The command ships to targets, alongside `demo`, `inventory`, and `drive`. It was
|
|
|
48
49
|
|
|
49
50
|
Shipping it also fixed what the exclusion was hiding. The render module imported the `@playwright/test` development dependency, which no published tarball carries. Every browser reference still sits behind a dynamic import, so a browser loads for this command rather than in front of every other one.
|
|
50
51
|
|
|
51
|
-
`demo.md` and `driver.md` cover two of the other three browser commands. What separates this one is that a capture renders a single state from a file on disk, where the rest drive a running application.
|
|
52
|
+
`demo.md` and `driver.md` cover two of the other three browser commands. What separates this one is that a capture renders a single state, from a file on disk or a named `http(s)://` URL, where the rest drive a running application.
|
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 `.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,
|
|
27
|
+
| `canon design board` | Generate a static page set indexing tokens, surfaces, wireframes, past candidates, and components, against the toolkit checkout like `regen` (`--out`) |
|
|
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/` |
|
|
@@ -94,7 +94,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
94
94
|
| `canon gate run` | Run every stage that guards a branch here, scoping shell, types, and tests to the changed set (`--all`, `--no-write`, `--nested`, `--json`) |
|
|
95
95
|
| `canon inventory [subject]` | Walk every route a project declares and group its elements by the property each computes, as a listing rather than a gate (`--json`) |
|
|
96
96
|
| `canon drive <url> <run>` | Walk a page through named interactions and measure each state it reaches, reporting findings rather than gating (`--json`) |
|
|
97
|
-
| `canon capture [source]` | Render HTML
|
|
97
|
+
| `canon capture [source]` | Render an HTML source or an `http(s)://` URL to PNG and prove the font each one declares resolved (`--selector` required, `--out`) |
|
|
98
98
|
| `canon serve [dir]` | Serve a directory on the loopback interface and print the link that opens it, running until interrupted (`--port`, `--entry`, `--json`) |
|
|
99
99
|
| `canon upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
|
|
100
100
|
|
|
@@ -5,7 +5,7 @@ description: Generating a static index over the toolkit checkout's own design su
|
|
|
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>]` generates a static page set indexing five design surfaces and reports the path a reader opens with `canon serve`. Like `canon design regen`, it runs against the toolkit checkout the CLI resolves its own root from, never against a target project's own files: a target holds none of the sources below, since none of `.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
9
|
|
|
10
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
11
|
|
|
@@ -24,6 +24,7 @@ canon serve .canon/review/board
|
|
|
24
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
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
26
|
- **Past candidates** lists an arm capture image per folder under `.canon/review/evidence/`, and states the corpus carries none rather than rendering an empty grid.
|
|
27
|
+
- **Components** iframes the gallery built by `bun run web:gallery`, a second Astro config at `web/gallery.config.mjs` reading `web/gallery-src/` and writing `web/gallery-dist/`. That config's `srcDir` is never read by `web:build`'s own config, so the gallery never reaches the published `web/dist/`. The gallery page renders every component under `web/src/components/` except two whose props carry no defaults, which it names rather than filling with invented data. Reports a missing gallery build rather than rendering a broken frame.
|
|
27
28
|
|
|
28
29
|
## What it does not do
|
|
29
30
|
|
package/docs/agents/driver.md
CHANGED
|
@@ -49,7 +49,7 @@ Every step names what it did, and that name is carried onto each finding it prod
|
|
|
49
49
|
|
|
50
50
|
Probes run after a step and never on arrival, so a run reaches the load state by opening with a `wait` step of its own, as the example above does. Nothing probes before the first step runs, which makes that leading step the only way to measure the page as it first painted, and naming it is what puts the load state on its own findings rather than under whatever ran next.
|
|
51
51
|
|
|
52
|
-
Write one wherever no capture runs against the page. `canon capture` renders a single state from a source on disk, so where it runs it already answers about arrival and a leading `wait` duplicates it. Where it does not, this command is the only thing measuring the page at all, and a run without that step reports every driven state and nothing about the one a visitor sees first.
|
|
52
|
+
Write one wherever no capture runs against the page. `canon capture` renders a single state from a source on disk or a URL, so where it runs it already answers about arrival and a leading `wait` duplicates it. Where it does not, this command is the only thing measuring the page at all, and a run without that step reports every driven state and nothing about the one a visitor sees first.
|
|
53
53
|
|
|
54
54
|
Each height is driven in a context of its own from a fresh navigation, rather than by resizing the page the previous height already drove, since a sweep asks the same question of each height rather than a later question of an already-driven page.
|
|
55
55
|
|
|
@@ -323,7 +323,7 @@ This section is the corpus the coverage claim is measured against: every name `c
|
|
|
323
323
|
|
|
324
324
|
Every row answers a question rather than marking a point in a project's life, so a phase above would send a reader to the wrong group.
|
|
325
325
|
|
|
326
|
-
A learning workspace produces two halves and only one of them leaves. A lesson is worked through once and stays in the workspace, and a reference page or a glossary carries no learner, so it belongs wherever the project already keeps prose on that subject. Asking `canon:teach-workspace` to promote sorts each durable page by who owns its subject, sending an
|
|
326
|
+
A learning workspace produces two halves and only one of them leaves. A lesson is worked through once and stays in the workspace, and a reference page or a glossary carries no learner, so it belongs wherever the project already keeps prose on that subject. Asking `canon:teach-workspace` to promote sorts each durable page by who owns its subject, sending an Anthropic-owned subject to the wiki, an internal one to the matching context entry, and everything else, subject-neutral material included, to the public docs. It proposes and waits, because a promoted page is public prose that needs a line naming who owns the subject, and it writes nothing to a destination: each page the operator confirms goes to a handoff file that `canon:docs-fold` folds in from a branch. A project with no wiki folder gets a refusal naming `canon wiki init` rather than a folder it never asked for.
|
|
327
327
|
|
|
328
328
|
## Feedback routing
|
|
329
329
|
|
|
@@ -8,9 +8,9 @@ category: Workflow
|
|
|
8
8
|
|
|
9
9
|
Three tiers cover the range from prose-only design docs to a fully graphical design source of truth. Pick one per project based on how UI-heavy the work is, whether stakeholders review visuals, and whether a designer is involved. Tiers stack, so moving up does not invalidate work done at a lower tier.
|
|
10
10
|
|
|
11
|
-
The tier framework sits alongside [Claude Design](../../wiki/claude/claude-design.md),
|
|
11
|
+
The tier framework sits alongside [Claude Design](../../wiki/claude/claude-design.md), the one catalog page in this range. Stitch, Excalidraw, and the surrounding MCP and skill ecosystem are named inline below rather than catalogued separately. This page decides when to reach for what.
|
|
12
12
|
|
|
13
|
-
Two tools anchor tier 1 and tier 2.
|
|
13
|
+
Two tools anchor tier 1 and tier 2. Stitch is the agent-addressable default through its MCP server at `stitch.googleapis.com/mcp`, with a free tier of 400 daily credits that covers daily iteration. [Claude Design](../../wiki/claude/claude-design.md), released 2026-04-17 and priced inside Claude subscriptions, is the ceiling tool reserved for codebase extraction and the richly annotated handoff bundle. Each covers a different job, they are not swappable.
|
|
14
14
|
|
|
15
15
|
## Tier 0: prose only
|
|
16
16
|
|
|
@@ -68,16 +68,16 @@ Impeccable, if installed, keeps its own root `DESIGN.md` and `PRODUCT.md` in the
|
|
|
68
68
|
|
|
69
69
|
### Tools
|
|
70
70
|
|
|
71
|
-
- Stitch via MCP at `stitch.googleapis.com/mcp`. Default pick for agent-driven visual generation. Free tier of 400 daily credits.
|
|
72
|
-
- Excalidraw canvas server on localhost plus the `yctimlin/mcp_excalidraw` MCP shim, for projects that need an agent to draw, read back, and revise a canvas.
|
|
73
|
-
- Playwright MCP for browser-side verification
|
|
74
|
-
- Chrome DevTools MCP for live frontend debugging
|
|
71
|
+
- Stitch via MCP at `stitch.googleapis.com/mcp`, Google's Gemini-powered design product. Agent-addressable through `generate_screen_from_text`, `edit_screens`, and `generate_variants`. Default pick for agent-driven visual generation. Free tier of 400 daily credits.
|
|
72
|
+
- Excalidraw canvas server on localhost plus the community [`yctimlin/mcp_excalidraw`](https://github.com/yctimlin/mcp_excalidraw) MCP shim, for projects that need an agent to draw, read back, and revise a canvas. MIT-licensed, plaintext JSON scene format. `describe_scene` and `get_canvas_screenshot` let the agent verify its own layout before claiming it is correct.
|
|
73
|
+
- Playwright MCP ([`microsoft/playwright-mcp`](https://github.com/microsoft/playwright-mcp)) for browser-side verification, driven off the accessibility tree rather than screenshots.
|
|
74
|
+
- Chrome DevTools MCP ([`ChromeDevTools/chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp)) for live frontend debugging: performance traces, network inspection, and DOM and CSS inspection.
|
|
75
75
|
- Claude Design as the ceiling option for codebase extraction or polished handoff bundles. See [Claude Design](../../wiki/claude/claude-design.md).
|
|
76
76
|
|
|
77
77
|
### Skills
|
|
78
78
|
|
|
79
79
|
- Everything from tier 0
|
|
80
|
-
- A frontend design skill to steer visual quality. Pick one of [
|
|
80
|
+
- A frontend design skill to steer visual quality. Pick one of [pbakaus/impeccable](https://github.com/pbakaus/impeccable), which bundles curated anti-pattern references and is the strongest default, [nextlevelbuilder/ui-ux-pro-max-skill](https://github.com/nextlevelbuilder/ui-ux-pro-max-skill), which maps product type to UI rules through a reasoning engine, or Anthropic's `frontend-design` plugin.
|
|
81
81
|
|
|
82
82
|
### When to pick
|
|
83
83
|
|
|
@@ -108,9 +108,9 @@ Design happens in a graphical tool. `.claude/DESIGN.md` either regenerates from
|
|
|
108
108
|
|
|
109
109
|
### Tools
|
|
110
110
|
|
|
111
|
-
- Figma desktop app with the [Figma Dev Mode MCP](
|
|
111
|
+
- Figma desktop app with the official [Figma Dev Mode MCP](https://www.figma.com/blog/introducing-claude-code-to-figma/) for teams with a dedicated designer already on Figma. Bidirectional sync and Code to Canvas capture of a running Claude Code UI into editable Figma frames.
|
|
112
112
|
- Claude Design with its Claude Code handoff bundle for teams without an existing Figma investment and for solo founders or PMs driving design themselves. One-way handoff, no bidirectional sync. See [Claude Design](../../wiki/claude/claude-design.md).
|
|
113
|
-
- Stitch via MCP as a low-cost complement to either, used for bulk screen generation driven by Claude Code.
|
|
113
|
+
- Stitch via MCP as a low-cost complement to either, used for bulk screen generation driven by Claude Code.
|
|
114
114
|
- Playwright and Chrome DevTools MCPs as in tier 1
|
|
115
115
|
|
|
116
116
|
### Skills
|
|
@@ -147,9 +147,8 @@ Resist over-tiering early. Moving up is cheap because tiers stack. Moving down m
|
|
|
147
147
|
|
|
148
148
|
## References
|
|
149
149
|
|
|
150
|
-
- [Stitch](../../wiki/tools/stitch.md): Gemini-powered design product with the MCP server that anchors tier 1
|
|
151
150
|
- [Claude Design](../../wiki/claude/claude-design.md): first-party hosted design product and handoff bundle
|
|
152
151
|
- `.claude/context/claude-plugin/skill-strategy.md`: how to decide between workflow and domain-knowledge skills
|
|
153
|
-
- [
|
|
154
|
-
- [
|
|
155
|
-
- [
|
|
152
|
+
- [`microsoft/playwright-mcp`](https://github.com/microsoft/playwright-mcp): browser automation MCP used in tier 1 and tier 2
|
|
153
|
+
- [`ChromeDevTools/chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp): live frontend debugging MCP used in tier 1 and tier 2
|
|
154
|
+
- [`yctimlin/mcp_excalidraw`](https://github.com/yctimlin/mcp_excalidraw): community MCP server behind the tier 1 wireframe companion
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.78.0",
|
|
5
5
|
"description": "Infrastructure and quality tooling for developer workflows",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"web:favicon": "bun scripts/core/regen-web-favicon.ts",
|
|
47
47
|
"web:previews": "bun scripts/core/regen-web-previews.ts",
|
|
48
48
|
"web:build": "bun run web:tokens && bun run web:favicon && bun run web:previews && cd web && astro check && astro build",
|
|
49
|
+
"web:gallery": "cd web && astro build --config gallery.config.mjs",
|
|
49
50
|
"web:preview": "cd web && astro preview",
|
|
50
51
|
"web:e2e": "cd web && playwright test"
|
|
51
52
|
},
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -e
|
|
3
|
+
set -o pipefail
|
|
4
|
+
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
7
|
+
|
|
8
|
+
WEB_DIST="$PROJECT_ROOT/web/dist"
|
|
9
|
+
GALLERY_DIST="$PROJECT_ROOT/web/gallery-dist"
|
|
10
|
+
MARKER='name="canon-gallery-marker"'
|
|
11
|
+
|
|
12
|
+
# Nothing to grep without a build. Callers run this after `bun run web:build`
|
|
13
|
+
# and `bun run web:gallery`, so an absent dist here means a build step itself
|
|
14
|
+
# failed rather than something this check can verify.
|
|
15
|
+
if [ ! -d "$WEB_DIST" ]; then
|
|
16
|
+
echo "No $WEB_DIST. Run bun run web:build first." >&2
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
if [ ! -d "$GALLERY_DIST" ]; then
|
|
21
|
+
echo "No $GALLERY_DIST. Run bun run web:gallery first." >&2
|
|
22
|
+
exit 1
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# A prose notice is not load-bearing: rewording it silently retires the leak
|
|
26
|
+
# check it backed with every workflow still green. The marker is a fixed
|
|
27
|
+
# `<meta>` tag `web/gallery-src/pages/index.astro` carries for exactly this,
|
|
28
|
+
# so asserting it survives in the gallery's own build is what turns a dropped
|
|
29
|
+
# marker into a loud failure here rather than a blind spot in the check below.
|
|
30
|
+
if ! grep -rl "$MARKER" "$GALLERY_DIST" >/dev/null 2>&1; then
|
|
31
|
+
echo "The gallery build at $GALLERY_DIST carries no $MARKER. The gallery page dropped its own marker, which is what the leak check below reads for." >&2
|
|
32
|
+
exit 1
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
# `web/gallery.config.mjs` keeps the components-panel gallery out of the
|
|
36
|
+
# published build by never pointing its srcDir at it. A later config change
|
|
37
|
+
# that widens srcDir, or a gallery file moving under web/src/, would publish
|
|
38
|
+
# this repository's component internals to a live domain with every other
|
|
39
|
+
# check still green, which is what this guards against.
|
|
40
|
+
if find "$WEB_DIST" -iname '*gallery*' | grep -q .; then
|
|
41
|
+
echo "A file named for the gallery reached $WEB_DIST. See web/gallery.config.mjs's srcDir." >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
if grep -rl "$MARKER" "$WEB_DIST" >/dev/null 2>&1; then
|
|
46
|
+
echo "The gallery's own marker ($MARKER) reached $WEB_DIST. See web/gallery.config.mjs's srcDir." >&2
|
|
47
|
+
exit 1
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
echo "check-gallery-exclusion: gallery marker present in $GALLERY_DIST, absent from $WEB_DIST"
|
package/src/capture/render.ts
CHANGED
|
@@ -37,12 +37,12 @@ export interface CaptureOptions {
|
|
|
37
37
|
export type CaptureResult =
|
|
38
38
|
| {
|
|
39
39
|
status: 'rendered'
|
|
40
|
-
|
|
40
|
+
source: string
|
|
41
41
|
pngPath: string
|
|
42
42
|
width: number
|
|
43
43
|
height: number
|
|
44
44
|
}
|
|
45
|
-
| { status: 'failed';
|
|
45
|
+
| { status: 'failed'; source: string; reason: string }
|
|
46
46
|
|
|
47
47
|
export async function captureSources(
|
|
48
48
|
sourcePath: string,
|
|
@@ -74,7 +74,9 @@ async function captureOne(
|
|
|
74
74
|
deviceScaleFactor: DEVICE_SCALE_FACTOR,
|
|
75
75
|
})
|
|
76
76
|
try {
|
|
77
|
-
await page.goto(
|
|
77
|
+
await page.goto(
|
|
78
|
+
source.kind === 'url' ? source.url : pathToFileURL(source.htmlPath).href,
|
|
79
|
+
)
|
|
78
80
|
const element = page.locator(selector).first()
|
|
79
81
|
if ((await element.count()) === 0) {
|
|
80
82
|
return failed(source, `no element matched ${selector}`)
|
|
@@ -97,7 +99,7 @@ async function captureOne(
|
|
|
97
99
|
writeStamp(source, png)
|
|
98
100
|
return {
|
|
99
101
|
status: 'rendered',
|
|
100
|
-
|
|
102
|
+
source: sourceIdentifier(source),
|
|
101
103
|
pngPath: source.pngPath,
|
|
102
104
|
width: png.readUInt32BE(16),
|
|
103
105
|
height: png.readUInt32BE(20),
|
|
@@ -118,8 +120,11 @@ async function captureOne(
|
|
|
118
120
|
* caller's catch and reports the source as failed, which is correct: a PNG
|
|
119
121
|
* whose stamp never landed is the state the verify stage exists to reject.
|
|
120
122
|
*
|
|
121
|
-
*
|
|
122
|
-
* machine that ran the capture into a tracked file and differ per
|
|
123
|
+
* A file source is stored as a bare filename. An absolute path would record
|
|
124
|
+
* the machine that ran the capture into a tracked file and differ per
|
|
125
|
+
* checkout. A URL source is stored as the URL itself, and its digest hashes
|
|
126
|
+
* the URL string's UTF-8 bytes rather than any local file, since there is
|
|
127
|
+
* none to hash.
|
|
123
128
|
*
|
|
124
129
|
* The image digest is taken over the buffer the screenshot returned rather than
|
|
125
130
|
* by reading the file back, so the stamp describes the bytes this run wrote.
|
|
@@ -128,15 +133,27 @@ function writeStamp(source: CaptureSource, png: Uint8Array): void {
|
|
|
128
133
|
writeFileSync(
|
|
129
134
|
stampPath(source.pngPath),
|
|
130
135
|
formatStamp({
|
|
131
|
-
source: basename(source.htmlPath),
|
|
132
|
-
sourceSha256:
|
|
136
|
+
source: source.kind === 'url' ? source.url : basename(source.htmlPath),
|
|
137
|
+
sourceSha256:
|
|
138
|
+
source.kind === 'url'
|
|
139
|
+
? hashSource(Buffer.from(source.url, 'utf8'))
|
|
140
|
+
: hashSource(readFileSync(source.htmlPath)),
|
|
133
141
|
imageSha256: hashSource(png),
|
|
134
142
|
}),
|
|
135
143
|
)
|
|
136
144
|
}
|
|
137
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The identifier a `CaptureResult` reports, kept as the full path for a file
|
|
148
|
+
* source so `displayPath` in `src/commands/capture.ts` can still show it
|
|
149
|
+
* relative to the working directory.
|
|
150
|
+
*/
|
|
151
|
+
function sourceIdentifier(source: CaptureSource): string {
|
|
152
|
+
return source.kind === 'url' ? source.url : source.htmlPath
|
|
153
|
+
}
|
|
154
|
+
|
|
138
155
|
function failed(source: CaptureSource, reason: string): CaptureResult {
|
|
139
|
-
return { status: 'failed',
|
|
156
|
+
return { status: 'failed', source: sourceIdentifier(source), reason }
|
|
140
157
|
}
|
|
141
158
|
|
|
142
159
|
/**
|
package/src/capture/sources.ts
CHANGED
|
@@ -1,20 +1,45 @@
|
|
|
1
1
|
import { readdirSync, statSync } from 'node:fs'
|
|
2
2
|
import { basename, dirname, extname, join } from 'node:path'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
const URL_SOURCE = /^https?:\/\//
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one test for whether a source string names a URL rather than a
|
|
8
|
+
* filesystem path, shared with `src/commands/capture.ts` so the two never
|
|
9
|
+
* drift apart on what counts as a URL source.
|
|
10
|
+
*/
|
|
11
|
+
export function isUrlSource(source: string): boolean {
|
|
12
|
+
return URL_SOURCE.test(source)
|
|
7
13
|
}
|
|
8
14
|
|
|
15
|
+
export type CaptureSource =
|
|
16
|
+
| {
|
|
17
|
+
readonly kind: 'file'
|
|
18
|
+
readonly htmlPath: string
|
|
19
|
+
readonly pngPath: string
|
|
20
|
+
}
|
|
21
|
+
| { readonly kind: 'url'; readonly url: string; readonly pngPath: string }
|
|
22
|
+
|
|
9
23
|
/**
|
|
10
24
|
* Pairs each HTML source with the PNG it renders to. A directory expands to
|
|
11
25
|
* every `.html` directly inside it, so adding a capture is a file drop rather
|
|
12
26
|
* than a flag. Omitting `outDir` writes the PNG beside its source.
|
|
27
|
+
*
|
|
28
|
+
* A `http(s)://` source has no directory to walk and no basename on disk to
|
|
29
|
+
* name the PNG from, so `outDir` there is read as the destination file
|
|
30
|
+
* itself rather than a directory, and the caller is what requires it.
|
|
13
31
|
*/
|
|
14
32
|
export function resolveCaptureSources(
|
|
15
33
|
sourcePath: string,
|
|
16
34
|
outDir?: string,
|
|
17
35
|
): CaptureSource[] {
|
|
36
|
+
if (isUrlSource(sourcePath)) {
|
|
37
|
+
if (!outDir) {
|
|
38
|
+
throw new Error('a URL source needs --out naming the destination PNG')
|
|
39
|
+
}
|
|
40
|
+
return [{ kind: 'url', url: sourcePath, pngPath: outDir }]
|
|
41
|
+
}
|
|
42
|
+
|
|
18
43
|
const htmlPaths = statSync(sourcePath).isDirectory()
|
|
19
44
|
? readdirSync(sourcePath)
|
|
20
45
|
.filter((name) => extname(name) === '.html')
|
|
@@ -23,6 +48,7 @@ export function resolveCaptureSources(
|
|
|
23
48
|
: [sourcePath]
|
|
24
49
|
|
|
25
50
|
return htmlPaths.map((htmlPath) => ({
|
|
51
|
+
kind: 'file',
|
|
26
52
|
htmlPath,
|
|
27
53
|
pngPath: join(
|
|
28
54
|
outDir ?? dirname(htmlPath),
|
package/src/commands/capture.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
isBrowserMissing,
|
|
7
7
|
isEngineMissing,
|
|
8
8
|
} from '@/browser/engine'
|
|
9
|
+
import { isUrlSource } from '@/capture/sources'
|
|
9
10
|
import {
|
|
10
11
|
frameError,
|
|
11
12
|
intro,
|
|
@@ -42,8 +43,15 @@ export function register(program: Command): void {
|
|
|
42
43
|
program
|
|
43
44
|
.command('capture')
|
|
44
45
|
.description('Render HTML capture sources to PNG')
|
|
45
|
-
.argument(
|
|
46
|
-
|
|
46
|
+
.argument(
|
|
47
|
+
'[source]',
|
|
48
|
+
'HTML file, a directory of them, or an http(s):// URL',
|
|
49
|
+
DEFAULT_SOURCE,
|
|
50
|
+
)
|
|
51
|
+
.option(
|
|
52
|
+
'-o, --out <path>',
|
|
53
|
+
'Output directory for a file source, or the destination PNG for a URL source; defaults beside the source',
|
|
54
|
+
)
|
|
47
55
|
.option('-s, --selector <selector>', 'Element to capture')
|
|
48
56
|
.action(
|
|
49
57
|
async (
|
|
@@ -66,8 +74,9 @@ export function register(program: Command): void {
|
|
|
66
74
|
}
|
|
67
75
|
const selector = opts.selector
|
|
68
76
|
|
|
69
|
-
const
|
|
70
|
-
|
|
77
|
+
const isUrl = isUrlSource(source)
|
|
78
|
+
const sourcePath = isUrl ? source : resolve(process.cwd(), source)
|
|
79
|
+
if (!isUrl && !existsSync(sourcePath)) {
|
|
71
80
|
frameError(`${source} not found`)
|
|
72
81
|
process.exitCode = 1
|
|
73
82
|
return
|
|
@@ -100,7 +109,7 @@ export function register(program: Command): void {
|
|
|
100
109
|
`${displayPath(result.pngPath)} ${result.width}x${result.height}`,
|
|
101
110
|
)
|
|
102
111
|
} else {
|
|
103
|
-
logError(`${displayPath(result.
|
|
112
|
+
logError(`${displayPath(result.source)}: ${result.reason}`)
|
|
104
113
|
}
|
|
105
114
|
}
|
|
106
115
|
outro()
|
|
@@ -141,9 +150,11 @@ function reportInFrame(error: unknown): void {
|
|
|
141
150
|
/**
|
|
142
151
|
* Keeps a path clickable in the operator's terminal. A source outside the
|
|
143
152
|
* project reports absolute, since a relative path to it is a run of `..`
|
|
144
|
-
* segments no editor resolves.
|
|
153
|
+
* segments no editor resolves. A URL is not a filesystem path at all, so it
|
|
154
|
+
* reports as given.
|
|
145
155
|
*/
|
|
146
156
|
function displayPath(path: string): string {
|
|
157
|
+
if (isUrlSource(path)) return path
|
|
147
158
|
const fromCwd = relative(process.cwd(), path)
|
|
148
159
|
return fromCwd.startsWith('..') ? path : fromCwd
|
|
149
160
|
}
|
package/src/design/board.ts
CHANGED
|
@@ -25,6 +25,15 @@ export const WIREFRAME_DIR = join('.claude', 'wireframes')
|
|
|
25
25
|
const WEB_DIST = join('web', 'dist')
|
|
26
26
|
const WEB_DIST_ENTRY = 'index.html'
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The gallery build, generated by `bun run web:gallery` through
|
|
30
|
+
* `web/gallery.config.mjs`. That config's own `srcDir` is `web/gallery-src`,
|
|
31
|
+
* which `web:build`'s config never reads, so this output never reaches
|
|
32
|
+
* `web/dist`.
|
|
33
|
+
*/
|
|
34
|
+
const WEB_GALLERY_DIST = join('web', 'gallery-dist')
|
|
35
|
+
const WEB_GALLERY_DIST_ENTRY = 'index.html'
|
|
36
|
+
|
|
28
37
|
interface WireframeEntry {
|
|
29
38
|
readonly path: string
|
|
30
39
|
readonly describes: string
|
|
@@ -338,6 +347,19 @@ function writeSurfacesPanel(root: string, outDir: string): void {
|
|
|
338
347
|
)
|
|
339
348
|
}
|
|
340
349
|
|
|
350
|
+
function writeComponentsPanel(root: string, outDir: string): void {
|
|
351
|
+
const dir = join(outDir, 'components')
|
|
352
|
+
mkdirSync(dir, { recursive: true })
|
|
353
|
+
|
|
354
|
+
const gallerySource = join(root, WEB_GALLERY_DIST)
|
|
355
|
+
const body = existsSync(join(gallerySource, WEB_GALLERY_DIST_ENTRY))
|
|
356
|
+
? (copyBuilt(gallerySource, join(dir, 'gallery')),
|
|
357
|
+
'<iframe src="gallery/index.html" loading="lazy"></iframe>')
|
|
358
|
+
: `<p class="empty">No ${WEB_GALLERY_DIST}/ build. Run bun run web:gallery, then regenerate the board.</p>`
|
|
359
|
+
|
|
360
|
+
writeFileSync(join(dir, 'index.html'), panelPage('Components', body))
|
|
361
|
+
}
|
|
362
|
+
|
|
341
363
|
function isImage(name: string): boolean {
|
|
342
364
|
return IMAGE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext))
|
|
343
365
|
}
|
|
@@ -437,12 +459,14 @@ export function generateBoard(
|
|
|
437
459
|
title: 'Past candidates',
|
|
438
460
|
path: 'candidates/index.html',
|
|
439
461
|
},
|
|
462
|
+
{ id: 'components', title: 'Components', path: 'components/index.html' },
|
|
440
463
|
]
|
|
441
464
|
|
|
442
465
|
writeTokensPanel(root, outDir)
|
|
443
466
|
writeSurfacesPanel(root, outDir)
|
|
444
467
|
writeWireframesPanel(root, outDir)
|
|
445
468
|
writeCandidatesPanel(root, outDir)
|
|
469
|
+
writeComponentsPanel(root, outDir)
|
|
446
470
|
|
|
447
471
|
const indexPath = join(outDir, 'index.html')
|
|
448
472
|
writeFileSync(indexPath, shellHtml(panels))
|
package/src/gate/measures.ts
CHANGED
|
@@ -523,6 +523,8 @@ function describeShippedReference(reference: ShippedReference): string {
|
|
|
523
523
|
return 'a bare standards/ path that has nothing to expand it in an installed plugin cache'
|
|
524
524
|
case 'phase-label':
|
|
525
525
|
return 'a phase label that names a board no target holds'
|
|
526
|
+
case 'rule-path':
|
|
527
|
+
return 'a numbered rule path that reaches a target only through a separate canon gov sync, and only where governance was installed at all'
|
|
526
528
|
}
|
|
527
529
|
}
|
|
528
530
|
|
|
@@ -579,8 +581,8 @@ export const shippedReferences: Measure = async (ctx) => {
|
|
|
579
581
|
),
|
|
580
582
|
failure:
|
|
581
583
|
found.length === 1
|
|
582
|
-
? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
|
|
583
|
-
: `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
|
|
584
|
+
? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, state the fact a shipped skill body's cited rule enforces instead of its path, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
|
|
585
|
+
: `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, state the fact a shipped skill body's cited rule enforces instead of its path, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
|
|
584
586
|
}
|
|
585
587
|
}
|
|
586
588
|
|
|
@@ -203,6 +203,22 @@ const STANDARDS_PATH = /(?<![\w./-])standards\/[^\s`)\]]*\.md\b/g
|
|
|
203
203
|
*/
|
|
204
204
|
const PHASE_LABEL = /\bv\d+\.\d+(?!\.\d)\b/g
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* A `.claude/rules/<segments>/<nnn>-<slug>.md` citation from a body under
|
|
208
|
+
* `claude/skills/`, the installed-path spelling `598-authoring-layout.md`
|
|
209
|
+
* bans a shipped skill body from citing as authority for its own behavior.
|
|
210
|
+
*
|
|
211
|
+
* Anchored on the trailing `\d{3}-[\w-]+\.md` rather than on the bare
|
|
212
|
+
* `.claude/rules/` prefix, which is what keeps a folder mention carrying no
|
|
213
|
+
* number, such as `create-rule`, `memory-review`, and `setup-gov` already
|
|
214
|
+
* write correctly, from matching. The segment group between `rules/` and the
|
|
215
|
+
* numbered file admits both a governance-namespace path
|
|
216
|
+
* (`canon/core/055-scratch.md`) and a project-namespace one
|
|
217
|
+
* (`project/<subdir>/<n>-<slug>.md`) without distinguishing them, since
|
|
218
|
+
* either shape is the same broken citation.
|
|
219
|
+
*/
|
|
220
|
+
const RULE_PATH = /(?<![\w./-])\.claude\/rules\/[^\s`)\]]*\/\d{3}-[\w-]+\.md\b/g
|
|
221
|
+
|
|
206
222
|
export interface ShippedReference {
|
|
207
223
|
readonly file: string
|
|
208
224
|
/** One-based, matching the `file:line` form a reader clicks. */
|
|
@@ -213,6 +229,7 @@ export interface ShippedReference {
|
|
|
213
229
|
| 'docs-path'
|
|
214
230
|
| 'standards-path'
|
|
215
231
|
| 'phase-label'
|
|
232
|
+
| 'rule-path'
|
|
216
233
|
/** The reference as written, so a report names the token to qualify. */
|
|
217
234
|
readonly text: string
|
|
218
235
|
/**
|
|
@@ -259,14 +276,19 @@ function isDocsPathReportable(
|
|
|
259
276
|
}
|
|
260
277
|
|
|
261
278
|
/**
|
|
262
|
-
* Whether `file` sits in the one corpus `STANDARDS_PATH`
|
|
279
|
+
* Whether `file` sits in the one corpus `STANDARDS_PATH` and `RULE_PATH`
|
|
280
|
+
* both gate: a shipped skill body, minus its own `REQUIREMENT.md`.
|
|
263
281
|
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
282
|
+
* Both patterns share this scope because both bans live in
|
|
283
|
+
* `598-authoring-layout.md`, stated for the same reader: a session loading
|
|
284
|
+
* the `SKILL.md` body a target actually receives. `REQUIREMENT.md` is
|
|
285
|
+
* excluded for the reason that file states there, since a maintainer or an
|
|
286
|
+
* audit command reads it rather than a session loading it, so neither
|
|
287
|
+
* resolver rule this pair enforces ever applies to it. One predicate serves
|
|
288
|
+
* both call sites rather than two copies drifting apart with nothing
|
|
289
|
+
* comparing them.
|
|
268
290
|
*/
|
|
269
|
-
function
|
|
291
|
+
function isSkillBodyScope(file: string): boolean {
|
|
270
292
|
return file.startsWith('claude/skills/') && !file.endsWith('/REQUIREMENT.md')
|
|
271
293
|
}
|
|
272
294
|
|
|
@@ -357,7 +379,7 @@ export function referencesIn(
|
|
|
357
379
|
})
|
|
358
380
|
}
|
|
359
381
|
|
|
360
|
-
if (
|
|
382
|
+
if (isSkillBodyScope(file)) {
|
|
361
383
|
for (const match of line.matchAll(STANDARDS_PATH)) {
|
|
362
384
|
if (!isStandardsPathReportable(match[0])) continue
|
|
363
385
|
references.push({
|
|
@@ -377,6 +399,18 @@ export function referencesIn(
|
|
|
377
399
|
text: match[0],
|
|
378
400
|
})
|
|
379
401
|
}
|
|
402
|
+
|
|
403
|
+
if (isSkillBodyScope(file)) {
|
|
404
|
+
for (const match of line.matchAll(RULE_PATH)) {
|
|
405
|
+
if (isPlaceholderPath(match[0])) continue
|
|
406
|
+
references.push({
|
|
407
|
+
file,
|
|
408
|
+
line: index + 1,
|
|
409
|
+
kind: 'rule-path',
|
|
410
|
+
text: match[0],
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
}
|
|
380
414
|
}
|
|
381
415
|
|
|
382
416
|
return references
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** @jsxImportSource ../html */
|
|
2
|
+
import type { Child, Element } from '@/teach/html/jsx-runtime'
|
|
3
|
+
|
|
4
|
+
export interface HeadingProps {
|
|
5
|
+
readonly level: 1 | 2
|
|
6
|
+
readonly children: Child
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Heading({ level, children }: HeadingProps): Element {
|
|
10
|
+
return level === 1 ? <h1>{children}</h1> : <h2>{children}</h2>
|
|
11
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** @jsxImportSource ../html */
|
|
2
|
+
import type { Child, Element } from '@/teach/html/jsx-runtime'
|
|
3
|
+
|
|
4
|
+
export interface ListProps {
|
|
5
|
+
readonly ordered?: boolean
|
|
6
|
+
readonly items: readonly Child[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Composes an item per entry rather than taking pre-built `<li>` children, so
|
|
11
|
+
* every list in a lesson escapes the same way regardless of what the caller
|
|
12
|
+
* hands in.
|
|
13
|
+
*/
|
|
14
|
+
export function List({ ordered, items }: ListProps): Element {
|
|
15
|
+
const rendered = items.map((item) => <li>{item}</li>)
|
|
16
|
+
return ordered ? <ol>{rendered}</ol> : <ul>{rendered}</ul>
|
|
17
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** @jsxImportSource ../html */
|
|
2
|
+
import type { Child, Element } from '@/teach/html/jsx-runtime'
|
|
3
|
+
|
|
4
|
+
export interface ParagraphProps {
|
|
5
|
+
/** The lesson's dek, read back by `extractLessonMeta` in `@/teach/nav`. */
|
|
6
|
+
readonly lede?: boolean
|
|
7
|
+
readonly children: Child
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Paragraph({ lede, children }: ParagraphProps): Element {
|
|
11
|
+
return lede ? <p class="lede">{children}</p> : <p>{children}</p>
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun resolves this module rather than `jsx-runtime` at run time (spike 1,
|
|
3
|
+
* `.canon/groundwork/56-teach-render-layer/08-spikes.md`), so every export a
|
|
4
|
+
* caller might reach through either entry point has to exist here too.
|
|
5
|
+
*/
|
|
6
|
+
export {
|
|
7
|
+
Fragment,
|
|
8
|
+
jsx,
|
|
9
|
+
jsx as jsxDEV,
|
|
10
|
+
jsxs,
|
|
11
|
+
render,
|
|
12
|
+
type JSX,
|
|
13
|
+
} from './jsx-runtime'
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { escape, isRaw, raw, type RawMarkup } from './raw'
|
|
2
|
+
|
|
3
|
+
export type Child =
|
|
4
|
+
| string
|
|
5
|
+
| number
|
|
6
|
+
| boolean
|
|
7
|
+
| null
|
|
8
|
+
| undefined
|
|
9
|
+
| RawMarkup
|
|
10
|
+
| readonly Child[]
|
|
11
|
+
|
|
12
|
+
export interface Props {
|
|
13
|
+
readonly children?: Child
|
|
14
|
+
readonly [key: string]: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What a component returns and what `jsx` produces: markup already rendered,
|
|
19
|
+
* carrying the same brand `raw` gives an author's own escape hatch. One brand
|
|
20
|
+
* for both is what lets a parent's `{children}` compose a child's output
|
|
21
|
+
* without escaping it a second time, since a plain string cannot say whether
|
|
22
|
+
* it is author text or already-rendered markup.
|
|
23
|
+
*/
|
|
24
|
+
export type Element = RawMarkup
|
|
25
|
+
|
|
26
|
+
export type Component<P extends Props = Props> = (props: P) => Element
|
|
27
|
+
|
|
28
|
+
export const Fragment = Symbol('Fragment')
|
|
29
|
+
|
|
30
|
+
const ARIA_PREFIX = 'aria-'
|
|
31
|
+
|
|
32
|
+
const VOID_ELEMENTS = new Set([
|
|
33
|
+
'area',
|
|
34
|
+
'base',
|
|
35
|
+
'br',
|
|
36
|
+
'col',
|
|
37
|
+
'embed',
|
|
38
|
+
'hr',
|
|
39
|
+
'img',
|
|
40
|
+
'input',
|
|
41
|
+
'link',
|
|
42
|
+
'meta',
|
|
43
|
+
'source',
|
|
44
|
+
'track',
|
|
45
|
+
'wbr',
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
function renderChild(child: Child): string {
|
|
49
|
+
if (child === null || child === undefined || typeof child === 'boolean') {
|
|
50
|
+
return ''
|
|
51
|
+
}
|
|
52
|
+
if (typeof child === 'number') return String(child)
|
|
53
|
+
if (typeof child === 'string') return escape(child)
|
|
54
|
+
if (isRaw(child)) return child.html
|
|
55
|
+
return child.map(renderChild).join('')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ARIA takes the literal word for a boolean, where HTML boolean attributes
|
|
60
|
+
* take presence or absence. Spike 1's first draft rendered a bare
|
|
61
|
+
* `aria-disabled` for `true` and dropped `aria-disabled={false}` entirely,
|
|
62
|
+
* silently removing state a screen reader reads.
|
|
63
|
+
*/
|
|
64
|
+
function renderAttribute(key: string, value: unknown): string {
|
|
65
|
+
if (key.startsWith(ARIA_PREFIX) && typeof value === 'boolean') {
|
|
66
|
+
return ` ${key}="${value}"`
|
|
67
|
+
}
|
|
68
|
+
if (value === undefined || value === null || value === false) return ''
|
|
69
|
+
if (value === true) return ` ${key}`
|
|
70
|
+
if (isRaw(value)) return ` ${key}="${escape(value.html)}"`
|
|
71
|
+
return ` ${key}="${escape(String(value))}"`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function renderAttributes(props: Props): string {
|
|
75
|
+
return Object.entries(props)
|
|
76
|
+
.filter(([key]) => key !== 'children')
|
|
77
|
+
.map(([key, value]) => renderAttribute(key, value))
|
|
78
|
+
.join('')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function jsx(
|
|
82
|
+
type: string | Component | typeof Fragment,
|
|
83
|
+
props: Props,
|
|
84
|
+
): Element {
|
|
85
|
+
if (type === Fragment) return raw(renderChild(props.children ?? null))
|
|
86
|
+
if (typeof type === 'function') return type(props)
|
|
87
|
+
|
|
88
|
+
const attrs = renderAttributes(props)
|
|
89
|
+
if (VOID_ELEMENTS.has(type)) return raw(`<${type}${attrs}>`)
|
|
90
|
+
|
|
91
|
+
return raw(
|
|
92
|
+
`<${type}${attrs}>${renderChild(props.children ?? null)}</${type}>`,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const jsxs = jsx
|
|
97
|
+
|
|
98
|
+
/** Unwraps a rendered element to the plain string a file writes to disk. */
|
|
99
|
+
export function render(element: Element): string {
|
|
100
|
+
return element.html
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export namespace JSX {
|
|
104
|
+
export type Element = RawMarkup
|
|
105
|
+
|
|
106
|
+
export interface ElementChildrenAttribute {
|
|
107
|
+
children: Record<string, never>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface IntrinsicElements {
|
|
111
|
+
[name: string]: Props
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const RAW = Symbol('raw')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Markup already rendered, exempt from the escaping every other string
|
|
5
|
+
* receives. `raw` is the only way to produce one, so a lesson has to opt in
|
|
6
|
+
* explicitly rather than an author text string accidentally passing through
|
|
7
|
+
* unescaped.
|
|
8
|
+
*/
|
|
9
|
+
export interface RawMarkup {
|
|
10
|
+
readonly [RAW]: true
|
|
11
|
+
readonly html: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function raw(html: string): RawMarkup {
|
|
15
|
+
return { [RAW]: true, html }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isRaw(value: unknown): value is RawMarkup {
|
|
19
|
+
return typeof value === 'object' && value !== null && RAW in value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function escape(value: string): string {
|
|
23
|
+
return value
|
|
24
|
+
.replace(/&/g, '&')
|
|
25
|
+
.replace(/</g, '<')
|
|
26
|
+
.replace(/>/g, '>')
|
|
27
|
+
.replace(/"/g, '"')
|
|
28
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** @jsxImportSource ./html */
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { TEACH_STYLESHEET_COMPONENTS } from '@/design/components'
|
|
5
|
+
import { buildDesignCss } from '@/design/css'
|
|
6
|
+
import { TEACH_FONT_FACES } from '@/teach/fonts'
|
|
7
|
+
import { Heading } from '@/teach/components/heading'
|
|
8
|
+
import { List } from '@/teach/components/list'
|
|
9
|
+
import { Paragraph } from '@/teach/components/paragraph'
|
|
10
|
+
import { render } from '@/teach/html/jsx-runtime'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Regenerates the committed fixture lesson under
|
|
14
|
+
* `examples/teach/00-fixture/`, the same shape `examples/slides/showcase.md`
|
|
15
|
+
* takes against its own hand-rendered snapshot. Run with
|
|
16
|
+
* `bun src/teach/render-fixture.tsx` after a component changes shape.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const FIXTURE_ROOT = join(
|
|
20
|
+
import.meta.dir,
|
|
21
|
+
'..',
|
|
22
|
+
'..',
|
|
23
|
+
'examples',
|
|
24
|
+
'teach',
|
|
25
|
+
'00-fixture',
|
|
26
|
+
)
|
|
27
|
+
const LESSON_TITLE = 'Compass bearings'
|
|
28
|
+
const LESSON_FILE = '0001-compass-bearings.html'
|
|
29
|
+
|
|
30
|
+
const STEPS = [
|
|
31
|
+
'Point the direction-of-travel arrow at the landmark.',
|
|
32
|
+
'Rotate the bezel until the orienting arrow lines up with the needle.',
|
|
33
|
+
'Read the bearing where the direction-of-travel arrow meets the bezel.',
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const body = (
|
|
37
|
+
<>
|
|
38
|
+
<Heading level={1}>{LESSON_TITLE}</Heading>
|
|
39
|
+
<Paragraph lede>
|
|
40
|
+
A bearing is the compass direction from where you stand to whatever you
|
|
41
|
+
are aiming at, measured clockwise from north.
|
|
42
|
+
</Paragraph>
|
|
43
|
+
<Paragraph>
|
|
44
|
+
Hold the compass level and let the needle settle before reading anything
|
|
45
|
+
off it. A bearing taken while walking or tilted reads confidently and
|
|
46
|
+
wrong.
|
|
47
|
+
</Paragraph>
|
|
48
|
+
<Paragraph>
|
|
49
|
+
Three steps turn a sighted landmark into a number you can act on:
|
|
50
|
+
</Paragraph>
|
|
51
|
+
<List ordered items={STEPS} />
|
|
52
|
+
<Paragraph>
|
|
53
|
+
The same three steps run in reverse turn a bearing on a map into a
|
|
54
|
+
direction to walk, which is the only reason to learn them in this order.
|
|
55
|
+
</Paragraph>
|
|
56
|
+
</>
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
function page(title: string, main: string): string {
|
|
60
|
+
return `<!doctype html>
|
|
61
|
+
<html lang="en">
|
|
62
|
+
<head>
|
|
63
|
+
<meta charset="utf-8">
|
|
64
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
65
|
+
<title>${title}</title>
|
|
66
|
+
<link rel="stylesheet" href="../assets/course.css">
|
|
67
|
+
</head>
|
|
68
|
+
<body>
|
|
69
|
+
<main class="wide-body">
|
|
70
|
+
${main}
|
|
71
|
+
</main>
|
|
72
|
+
</body>
|
|
73
|
+
</html>
|
|
74
|
+
`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function main(): Promise<void> {
|
|
78
|
+
await mkdir(join(FIXTURE_ROOT, 'lessons'), { recursive: true })
|
|
79
|
+
await mkdir(join(FIXTURE_ROOT, 'assets'), { recursive: true })
|
|
80
|
+
|
|
81
|
+
await writeFile(
|
|
82
|
+
join(FIXTURE_ROOT, 'assets', 'course.css'),
|
|
83
|
+
buildDesignCss(undefined, {
|
|
84
|
+
embedFonts: TEACH_FONT_FACES,
|
|
85
|
+
components: TEACH_STYLESHEET_COMPONENTS,
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
await writeFile(
|
|
90
|
+
join(FIXTURE_ROOT, 'lessons', LESSON_FILE),
|
|
91
|
+
page(LESSON_TITLE, render(body)),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await main()
|
package/standards/wiki.md
CHANGED
|
@@ -28,8 +28,7 @@ A page failing either is non-conforming even when it satisfies every shape rule
|
|
|
28
28
|
## Placement
|
|
29
29
|
|
|
30
30
|
- Write a page here only when its subject is owned outside this repository. Route anything about how this repository works to `docs/`, `.claude/context/`, or a skill body instead.
|
|
31
|
-
- File the page
|
|
32
|
-
- Treat `wiki/concepts/rule-writing-vocabulary.md` as the one recorded exception to the owner test, stated in the page itself.
|
|
31
|
+
- File the page under `wiki/claude/`. A subject Anthropic does not own, whether a third-party tool or a vendor-neutral concept, is out of scope for this folder split. Route it to `docs/` or a skill body instead of adding a second wiki folder for it.
|
|
33
32
|
|
|
34
33
|
## Frontmatter
|
|
35
34
|
|
|
@@ -38,8 +37,7 @@ A page failing either is non-conforming even when it satisfies every shape rule
|
|
|
38
37
|
|
|
39
38
|
## Naming
|
|
40
39
|
|
|
41
|
-
- Name a
|
|
42
|
-
- Name a `wiki/tools/` or `wiki/concepts/` page as a bare kebab subject, with no prefix.
|
|
40
|
+
- Name a page with a `claude-` prefix before the kebab subject.
|
|
43
41
|
|
|
44
42
|
## Sourcing
|
|
45
43
|
|