@erclx/canon 5.23.0 → 5.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
4
- "version": "5.23.0",
4
+ "version": "5.24.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -27,6 +27,7 @@ The last failure is a section no route reaches. `## Route` maps an intent or a d
27
27
  - Run the CLI non-interactively and report the command run, what changed, and the full path of anything written
28
28
  - Read the overwrite contract from the body before any install or sync, and name each surface it lists as overwritten before running one
29
29
  - Read the reverse walk beside the forward sections, so a folder the toolkit stopped shipping reaches the user
30
+ - Read `unmovedSurfaces` from the report itself, since the drift status ignores it by design, and name `canon migrate surface-roots` without running either form
30
31
  - Name an unmigrated domain's root and install path for the user to move themselves, since no skill covers the move
31
32
  - Route a measurement intent to the audit offers from the route table itself, rather than leaving the section reachable only by reading past the table
32
33
  - Rank a lifecycle row against the audit offers where the route table itself states it, so a session acting on the lifecycle row reads the ranking without opening the section being ranked
@@ -22,10 +22,11 @@ Load a domain doc with `canon docs <topic>` only when the intent touches that do
22
22
 
23
23
  Run `canon sync --check . --json` before routing. It reports what a target is behind on across every surface, so the intent comes from the project state rather than from the user having to know it already. Skip only when the user named a single operation to run.
24
24
 
25
- Read seven fields off the report and carry each to `## Route`:
25
+ Read eight fields off the report and carry each to `## Route`:
26
26
 
27
27
  - `unmigrated`: a domain sitting at the root layout with nothing under `.claude/`. The most urgent finding, because that domain reports no drift of its own while being entirely behind
28
28
  - `superseded`: a file a newer seed folder replaced. No command moves it, since the content is the project's own, so this routes to `canon:target-check` for the full per-domain reading rather than ending at the report
29
+ - `unmovedSurfaces`: a surface held only at its `.claude/` spelling, each entry carrying `rel` and `movesTo`. Every other field reads clean for such a target, and the report's drift status ignores it by design, so read the array itself rather than an exit status
29
30
  - `seeds`: entries are `matching`, `stale`, `drifted`, or `missing`. Anything but `matching` needs the seed handoff
30
31
  - `domains[].entries`: per-file `stale`, `customized`, `stranded`, and `orphaned` as before, plus `retired` for a rule the toolkit no longer ships, which the next sync deletes
31
32
  - `reverse`: what the target holds that no live catalog claims. `unclaimed` lists folders at roots the toolkit stopped shipping, each with an `attribution` of `dropped`, `unattributed`, or `project`. Act on the first two and leave `project` alone, which history proved the project owns
@@ -34,6 +35,8 @@ Read seven fields off the report and carry each to `## Route`:
34
35
 
35
36
  State what the report found in one line per finding before acting on any of it.
36
37
 
38
+ An `unmovedSurfaces` key the report does not carry at all is likewise a CLI predating the field rather than a target with nothing to move. Say the section was unread and name the installed CLI version as the cause.
39
+
37
40
  A `reverse` key the report does not carry at all is a CLI predating the field rather than a target with nothing unclaimed. Say the walk did not run and name the CLI version as the cause. Reading the absent key as an empty answer reports a clean target to a project nobody has looked at, and answering it from a filesystem walk of your own is worse, since that reaches a verdict the attribution the field carries is the only thing entitled to make.
38
41
 
39
42
  ## Route
@@ -44,6 +47,7 @@ The two can name different rows, and a reply answers both rather than picking on
44
47
 
45
48
  - A domain in `unmigrated`: name it, the root path, and the install path. No command moves it, so the user runs the relocation themselves
46
49
  - Anything in `superseded`: name which files and what replaced them, then hand off to `canon:target-check`, which reports and never moves or deletes the file
50
+ - Anything in `unmovedSurfaces`: name `canon migrate surface-roots`, which only reports its plan until `--write` is passed, with each entry's `rel` and `movesTo`, and run neither form, since the move rewrites tracked paths
47
51
  - A folder in `unclaimed`: name it and the attribution it carries. No command moves it and the content may be the project's own, so the decision is the user's
48
52
 
49
53
  - First-time scaffold of a fresh project: hand off to `target-setup`
@@ -19,6 +19,7 @@ The same procedure ran as five skills, one per kind, each restating the read, th
19
19
  - Check the catalog by exact name and by title and description, since a subject can be covered under a name the request does not guess
20
20
  - Confirm the resolved path, the kind's detections, and the full content with the user before writing, since placement and detection are judgment calls with no diff to preview them against
21
21
  - Regenerate the catalog of the folder the document landed in, where the kind keeps one, so the next run's collision check sees it
22
+ - Draft a README from the template for every detected project type and combine them, never the closest one, since a project that is a CLI and a plugin owes its reader both
22
23
  - Carry each kind's own musts in its reference: the root check for context and wireframes, tier detection and the two modes for wireframes, both placement tests and a fetched source for the wiki, and project detection and rendered-value badges for the readme
23
24
 
24
25
  ## Must not
@@ -0,0 +1,59 @@
1
+ ![<Project name> logo](<committed logo path>)
2
+
3
+ # <Project name>
4
+
5
+ [![<build status>](<badge image URL>)](<run history URL>)
6
+ [![<license>](<badge image URL>)](<license URL>)
7
+
8
+ <One-line claim taken from the repository description.>
9
+
10
+ <Two or three sentences expanding the claim: the conventions or tooling an agent working here picks up, and what a session gains from them.>
11
+
12
+ ## Installation
13
+
14
+ ### Requirements
15
+
16
+ - <Agent tool and minimum version>
17
+ - <Runtime the listing command needs>
18
+
19
+ ### Set up
20
+
21
+ ```bash
22
+ <clone command>
23
+ <setup command, when a session needs one>
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### What an agent loads first
29
+
30
+ `<file an agent reads at session start>` carries <what that file holds>. It points at <the files it imports or links>.
31
+
32
+ ### List what the project exposes
33
+
34
+ ```bash
35
+ <command that lists skills, rules, or commands>
36
+ ```
37
+
38
+ ### Where things live
39
+
40
+ | Path | Holds |
41
+ | ---------- | ------------ |
42
+ | `<folder>` | <What it is> |
43
+
44
+ ## Documentation
45
+
46
+ <Link to the full conventions reference.>
47
+
48
+ ## Support
49
+
50
+ - <Where to open an issue>
51
+ - <Where to ask a question>
52
+
53
+ ## Contributing
54
+
55
+ See the [contributing guidelines](<CONTRIBUTING.md path>).
56
+
57
+ ## License
58
+
59
+ [<License name>](<LICENSE path>)
@@ -0,0 +1,66 @@
1
+ ![<Project name> logo](<committed logo path>)
2
+
3
+ # <Project name>
4
+
5
+ [![<build status>](<badge image URL>)](<run history URL>)
6
+ [![<license>](<badge image URL>)](<license URL>)
7
+
8
+ <One-line claim taken from the manifest or repository description.>
9
+
10
+ [<Live site name>](<live URL>)
11
+
12
+ ![<What the screenshot depicts>](<committed image path>)
13
+
14
+ <Two or three sentences expanding the claim: who the product is for, then what they do with it.>
15
+
16
+ ## Features
17
+
18
+ - <Benefit the user gets>
19
+ - <Benefit the user gets>
20
+
21
+ ## Installation
22
+
23
+ ### Requirements
24
+
25
+ - <Runtime and version>
26
+ - <Service the app depends on>
27
+
28
+ ### Set up
29
+
30
+ ```bash
31
+ <clone command>
32
+ <dependency install command>
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ### Run locally
38
+
39
+ ```bash
40
+ <dev server command>
41
+ ```
42
+
43
+ <Address the app serves on.>
44
+
45
+ ### Configuration
46
+
47
+ | Variable | Purpose | Default |
48
+ | -------- | ------------------ | ----------- |
49
+ | `<NAME>` | <What it controls> | `<default>` |
50
+
51
+ ### Deploy
52
+
53
+ <Where the app deploys and the command or workflow that does it.>
54
+
55
+ ## Support
56
+
57
+ - <Where to open an issue>
58
+ - <Where to ask a question>
59
+
60
+ ## Contributing
61
+
62
+ See the [contributing guidelines](<CONTRIBUTING.md path>).
63
+
64
+ ## License
65
+
66
+ [<License name>](<LICENSE path>)
@@ -0,0 +1,64 @@
1
+ ![<Project name> logo](<committed logo path>)
2
+
3
+ # <Project name>
4
+
5
+ [![<package version>](<badge image URL>)](<registry page URL>)
6
+ [![<build status>](<badge image URL>)](<run history URL>)
7
+ [![<license>](<badge image URL>)](<license URL>)
8
+
9
+ <One-line claim taken from the manifest description.>
10
+
11
+ <Two or three sentences expanding the claim: the task a reader repeats by hand, then what the command does about it.>
12
+
13
+ ## Features
14
+
15
+ - <Benefit the reader gets>
16
+ - <Benefit the reader gets>
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ <global install or one-shot run command>
22
+ ```
23
+
24
+ <Runtime or platform requirement, when one exists.>
25
+
26
+ ## Usage
27
+
28
+ ### Run a command
29
+
30
+ ```bash
31
+ <command> <argument>
32
+ ```
33
+
34
+ ### Flags
35
+
36
+ ```bash
37
+ <command> --<flag> <value>
38
+ <command> --<flag>
39
+ ```
40
+
41
+ | Flag | Effect |
42
+ | ---------- | -------------- |
43
+ | `--<flag>` | <What it does> |
44
+
45
+ ### Exit codes
46
+
47
+ <What each non-zero exit means, when a script depends on it.>
48
+
49
+ ## Documentation
50
+
51
+ <Link to the full command reference.>
52
+
53
+ ## Support
54
+
55
+ - <Where to open an issue>
56
+ - <Where to ask a question>
57
+
58
+ ## Contributing
59
+
60
+ See the [contributing guidelines](<CONTRIBUTING.md path>).
61
+
62
+ ## License
63
+
64
+ [<License name>](<LICENSE path>)
@@ -0,0 +1,57 @@
1
+ ![<Project name> logo](<committed logo path>)
2
+
3
+ # <Project name>
4
+
5
+ [![<package version>](<badge image URL>)](<registry page URL>)
6
+ [![<build status>](<badge image URL>)](<run history URL>)
7
+ [![<license>](<badge image URL>)](<license URL>)
8
+
9
+ <One-line claim taken from the manifest description.>
10
+
11
+ [<Live site name>](<live URL>)
12
+
13
+ ![<What the screenshot depicts>](<committed image path>)
14
+
15
+ <Two or three sentences expanding the claim: the problem a reader recognizes, then what the library does about it.>
16
+
17
+ ## Features
18
+
19
+ - <Benefit the reader gets>
20
+ - <Benefit the reader gets>
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ <install command for the package manager the project uses>
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### Quickstart
31
+
32
+ ```<language>
33
+ <Import and one call that shows the core behavior>
34
+ ```
35
+
36
+ ### <Second common task>
37
+
38
+ ```<language>
39
+ <Snippet for the task>
40
+ ```
41
+
42
+ ## Documentation
43
+
44
+ <Link to the full API reference.>
45
+
46
+ ## Support
47
+
48
+ - <Where to open an issue>
49
+ - <Where to ask a question>
50
+
51
+ ## Contributing
52
+
53
+ See the [contributing guidelines](<CONTRIBUTING.md path>).
54
+
55
+ ## License
56
+
57
+ [<License name>](<LICENSE path>)
@@ -0,0 +1,66 @@
1
+ ![<Project name> logo](<committed logo path>)
2
+
3
+ # <Project name>
4
+
5
+ [![<build status>](<badge image URL>)](<run history URL>)
6
+ [![<license>](<badge image URL>)](<license URL>)
7
+
8
+ <One-line claim taken from the plugin manifest description.>
9
+
10
+ <Two or three sentences expanding the claim: the work a session repeats, then what the plugin adds to it.>
11
+
12
+ ## Features
13
+
14
+ - <Skill, command, or hook and what it gives the user>
15
+ - <Skill, command, or hook and what it gives the user>
16
+
17
+ ## Installation
18
+
19
+ ### <First channel name>
20
+
21
+ ```bash
22
+ <install command for this channel>
23
+ ```
24
+
25
+ ### <Second channel name>
26
+
27
+ ```bash
28
+ <install command for this channel>
29
+ ```
30
+
31
+ ### Update
32
+
33
+ ```bash
34
+ <update command>
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### Invoke a skill
40
+
41
+ ```text
42
+ /<plugin name>:<skill name> <argument>
43
+ ```
44
+
45
+ ### What the plugin adds
46
+
47
+ | Name | Kind | Purpose |
48
+ | -------- | ------------------------- | -------------- |
49
+ | `<name>` | <skill, command, or hook> | <What it does> |
50
+
51
+ ### Configuration
52
+
53
+ <Setting the plugin reads and where the user sets it, when one exists.>
54
+
55
+ ## Support
56
+
57
+ - <Where to open an issue>
58
+ - <Where to ask a question>
59
+
60
+ ## Contributing
61
+
62
+ See the [contributing guidelines](<CONTRIBUTING.md path>).
63
+
64
+ ## License
65
+
66
+ [<License name>](<LICENSE path>)
@@ -24,16 +24,44 @@ The target path is the placement. A README keeps no catalog, so there is no coll
24
24
 
25
25
  ## Detect
26
26
 
27
- - Read the project's manifest (`package.json`, `pyproject.toml`, `Cargo.toml`, or equivalent) for a `bin` field or CLI entry point, an installable package name, and its declared dependencies.
28
- - Check for a `claude/skills/` or `.claude/skills/` folder, a `plugin.json`, or a marketplace manifest, each naming an agent-facing or marketplace-distributed surface.
29
- - Note every type that applies rather than stopping at the first match, since a project is often several at once and the draft covers each one.
27
+ Read the project's manifest (`package.json`, `pyproject.toml`, `Cargo.toml`, or equivalent) and the tree, then test each type on its own signal. Note every type that matches rather than stopping at the first, since a project is often several at once.
28
+
29
+ - Library: an installable package name, counted only when no application, CLI, or plugin signal matches, since a named package is also what a site, a CLI, or a plugin carries. An agent-facing match leaves it in place, so a library with a `CLAUDE.md` still gets its quickstart.
30
+ - CLI: a `bin` field, a `[project.scripts]` table in `pyproject.toml`, a `[[bin]]` table in `Cargo.toml`, or a CLI entry point.
31
+ - Application: a site framework dependency, a site config file, or an app entry point.
32
+ - Agent-facing: a `.claude/skills/` folder, or a file an agent loads such as `CLAUDE.md`.
33
+ - Plugin: a `plugin.json` or a marketplace manifest.
34
+
35
+ A match on no type falls back to the standard's generic template. Say so in the preview, and never force the nearest type.
36
+
30
37
  - Check for a page. A dependency on a site framework or a site config file in the manifest or the tree marks a project as having one. Read a live URL separately, from a `homepage` field or a deploy config, since a project can have a page and no known URL.
31
38
  - A monorepo can carry a page in one package and a CLI in another, so both signals may fire. Report each rather than picking one.
32
39
  - Look for the mark and the screenshot among images the project already commits: scan the existing README for image references, then the asset and public folders.
33
40
 
41
+ ## Pick
42
+
43
+ Read the template for each detected type from `readme/`, beside this file:
44
+
45
+ - `readme/library.md`
46
+ - `readme/cli.md`
47
+ - `readme/application.md`
48
+ - `readme/agent-facing.md`
49
+ - `readme/plugin.md`
50
+
51
+ Each is a whole page of slots and structure, so fill it from the manifest and the tree rather than from memory. The standard still owns voice and content, and a template carries no rule of its own.
52
+
53
+ A project matching one type drafts from that template. A project matching several combines them:
54
+
55
+ - Keep one H1, one header block, and one description.
56
+ - Order the sections as the standard orders them.
57
+ - Add each extra type's usage or install subsection under the shared `## Installation` or `## Usage` H2, never as a second H2 of the same name.
58
+ - Drop a template section the page already carries rather than repeating it.
59
+
60
+ Between agent-facing and plugin alone, a repository with no plugin manifest takes only the agent-facing template, and a plugin that is not agent-facing in its own repository takes only the plugin template. A CLI or an application that also matches agent-facing keeps its own template beside it.
61
+
34
62
  ## Draft
35
63
 
36
- - Draft the H1, a 2-3 sentence description in plain text, then the required sections, then whichever optional sections and per-type content Detect found. A README takes no frontmatter.
64
+ - Start from the picked or combined template. Draft the H1, a 2-3 sentence description in plain text, then the required sections, then whichever optional sections and per-type content Detect found. A README takes no frontmatter.
37
65
  - Open the page with the standard's header block, filling each slot from what Detect found: mark, title, badges, a one-line claim from the manifest description, the live link, then the product screenshot.
38
66
  - Fill the screenshot slot only with an image the project already commits, referenced with alt text naming what it depicts. This kind cannot capture one. Omit the slot when no such image exists and say so in the preview. Never write a placeholder path or invent a mark.
39
67
  - Omit the link and the screenshot for a project with no page, and the mark for a project with none, without a note in the drafted page. A project with a page and no known URL gets the screenshot slot, and the Confirm step asks for the link.
@@ -48,6 +76,7 @@ Add these lines, then confirm them with the path:
48
76
 
49
77
  - `**Target:** <root | nested>`
50
78
  - `**Detected:** <project types>`
79
+ - `**Templates:** <each template read, or the generic fallback>`
51
80
  - `**Header:** <each slot as filled (source) or skipped (reason)>`
52
81
  - `**Badges:** <candidates and what backs each one, or none>`
53
82
 
@@ -11,6 +11,10 @@ description: The canvas server, its content format, and the verbs that list, arr
11
11
 
12
12
  `canvas serve` serves the canvas on `127.0.0.1`, walks ports like `canon serve`, refuses a `Host` other than loopback on its own port, and runs until interrupted. Each frame gets the project's token stylesheet injected, being this toolkit's tokens in its own checkout, else `.claude/design/base.css` plus anything under `.claude/design/project/`, else none with a notice. An open canvas reloads a frame whose file changes.
13
13
 
14
+ The toolkit's own tokens arrive with the font faces they name embedded, so a frame renders in them on a machine that never installed them. An installed base owns its fonts and gets no faces added.
15
+
16
+ Before it binds a port, `canvas serve` checks that `preact` and `@preact/signals` resolve from the package the CLI runs from, never from the served project. When either is missing it refuses with reason `missing-client-deps` and names `bun install` in the detail, rather than serving a shell that loads as a blank page.
17
+
14
18
  The write routes the shell posts to also refuse a request carrying an `Origin` other than the server's own, and a body that is not JSON.
15
19
 
16
20
  ## List and add
@@ -37,7 +41,7 @@ A pick is refused as `address-mismatch` when the browser builds elements the fil
37
41
 
38
42
  The operator edits the same set from the inspector, which posts to the same writer with the hash the frame was served with. An edit made against a version of the file Claude has since rewritten is refused as `stale-address` and the shell reloads the frame, so nothing lands on a shifted element. A color picked from the project's tokens is written as `var(--<name>)`. Both writers take the frame file's lock, and an edit that keeps the selected element where it was keeps the selection fresh.
39
43
 
40
- Inline style beats a class, so an operator's edit masks a class change Claude makes later. Read a frame's inline styles before restyling it, and remove one the change should replace. A property outside the set, or a value carrying `;`, `{`, `}`, `<`, `>`, or a line break, is refused as `invalid-edit`.
44
+ Inline style beats a class, so an operator's edit masks a class change Claude makes later. Read a frame's inline styles before restyling it, and remove one the change should replace. The inspector marks a color or background set inline with the word `raw` when its value names no `var()` and is not `currentColor` or a CSS-wide keyword, since that value stays fixed when the theme changes. A property outside the set, or a value carrying `;`, `{`, `}`, `<`, `>`, or a line break, is refused as `invalid-edit`.
41
45
 
42
46
  The Theme tab in the shell lists what the token stylesheet defines, grouped as color, spacing, radius, font family, font size, and other. The page record at `/api/pages` carries the same list as `tokens.groups`.
43
47
 
@@ -14,7 +14,7 @@ The tier framework sits alongside [Claude Design](../../wiki/claude/design.md),
14
14
 
15
15
  ## Tier 0: prose only
16
16
 
17
- The default. `canon/DESIGN.md` holds visual intent as prose plus token tables for color, typography, spacing, borders, motion, and iconography. `.claude/WIREFRAMES.md` holds each surface's regions and states.
17
+ The default. `canon/DESIGN.md` holds visual intent as token tables for color, typography, spacing, and borders, with one Personality paragraph and at most three one-line rules a section, motion and iconography included. `.claude/WIREFRAMES.md` holds each surface's regions and states.
18
18
 
19
19
  Claude Code reads both and writes the implementation. Works for CLI tools, internal dashboards, admin panels, and backend-focused projects.
20
20
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.23.0",
4
+ "version": "5.24.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -76,15 +76,49 @@ function positionRows(element: Element): Row[] {
76
76
  ]
77
77
  }
78
78
 
79
+ function inlineValue(element: Element, property: string): string {
80
+ return 'style' in element
81
+ ? (element as HTMLElement).style.getPropertyValue(property)
82
+ : ''
83
+ }
84
+
85
+ /** Values that defer to something else, so they follow the theme as it does. */
86
+ const DEFERRING = new Set([
87
+ 'currentcolor',
88
+ 'inherit',
89
+ 'initial',
90
+ 'unset',
91
+ 'revert',
92
+ 'revert-layer',
93
+ ])
94
+
95
+ /**
96
+ * A color that stays put when the theme changes: one naming no token anywhere
97
+ * in it and deferring to nothing, so `color-mix()` over a `var()` is not raw.
98
+ */
99
+ export function isRawValue(value: string): boolean {
100
+ const normalized = value.trim().toLowerCase()
101
+ return (
102
+ normalized !== '' &&
103
+ !normalized.includes('var(') &&
104
+ !DEFERRING.has(normalized)
105
+ )
106
+ }
107
+
108
+ /**
109
+ * Inherited and stylesheet values are not the element's own, so only an
110
+ * inline value counts.
111
+ */
112
+ function isRawInline(element: Element, property: string): boolean {
113
+ return isRawValue(inlineValue(element, property))
114
+ }
115
+
79
116
  /**
80
117
  * What the element states inline, else the value the browser settled on, so
81
118
  * an edit starts from what the operator sees.
82
119
  */
83
120
  function currentValue(element: Element, property: string): string {
84
- const inline =
85
- 'style' in element
86
- ? (element as HTMLElement).style.getPropertyValue(property)
87
- : ''
121
+ const inline = inlineValue(element, property)
88
122
  if (inline) return inline
89
123
  return (
90
124
  element.ownerDocument.defaultView
@@ -223,6 +257,14 @@ function ElementFields({
223
257
  isBusy={isBusy}
224
258
  onCommit={commit(field.property)}
225
259
  />
260
+ {field.isColor && isRawInline(node, field.property) ? (
261
+ <span
262
+ class="raw"
263
+ title="Set as a raw value, so it will not follow the theme. Pick a token to fix it"
264
+ >
265
+ raw
266
+ </span>
267
+ ) : null}
226
268
  {field.isColor ? (
227
269
  <TokenPicker
228
270
  label={field.label}
@@ -490,6 +490,13 @@ code {
490
490
  cursor: pointer;
491
491
  }
492
492
 
493
+ .raw {
494
+ flex: none;
495
+ align-self: center;
496
+ color: var(--color-text-secondary);
497
+ cursor: help;
498
+ }
499
+
493
500
  .field:hover,
494
501
  .token-picker:hover {
495
502
  border-color: var(--color-muted);
@@ -138,7 +138,15 @@ export function resolveFrameTokens(
138
138
  options: TokenOptions = {},
139
139
  ): FrameTokens {
140
140
  if (options.isOwnCheckout ?? isOwnCheckout(root)) {
141
- return { source: 'toolkit', css: buildDesignCss() }
141
+ /*
142
+ * Faces embedded, since the type tokens name `Geist Variable` and a
143
+ * machine with only static Geist installed renders every frame in the
144
+ * fallback. An installed base owns its fonts, so that branch gets none.
145
+ */
146
+ return {
147
+ source: 'toolkit',
148
+ css: buildDesignCss(undefined, { embedFonts: true }),
149
+ }
142
150
  }
143
151
 
144
152
  const files = installedFiles(root)
@@ -20,6 +20,7 @@ import {
20
20
  STYLE_PROPERTIES,
21
21
  } from '@/canvas/edit'
22
22
  import { CANVAS_PORT, startCanvas } from '@/canvas/server'
23
+ import { PROJECT_ROOT } from '@/project-root'
23
24
  import { resolveFrameTokens } from '@/canvas/tokens'
24
25
  import { displayPath, parsePort, waitForInterrupt } from '@/serve/report'
25
26
  import {
@@ -49,9 +50,35 @@ interface Refused {
49
50
  | 'no-root'
50
51
  | 'no-server'
51
52
  | 'capture-failed'
53
+ | 'missing-client-deps'
52
54
  readonly detail: string
53
55
  }
54
56
 
57
+ const CLIENT_DEPS = ['preact', '@preact/signals'] as const
58
+
59
+ /**
60
+ * Refuses when the shell's client packages do not resolve from `from`, since
61
+ * the bundler then answers `/` with an empty page rather than an error. The
62
+ * caller passes the package root the binary runs from, never the served
63
+ * project's, so a global install serving a target finds its own copies.
64
+ */
65
+ export function missingClientDeps(from: string): Refused | undefined {
66
+ const missing = CLIENT_DEPS.filter((name) => {
67
+ try {
68
+ Bun.resolveSync(name, from)
69
+ return false
70
+ } catch {
71
+ return true
72
+ }
73
+ })
74
+ if (missing.length === 0) return undefined
75
+ return {
76
+ ok: false,
77
+ reason: 'missing-client-deps',
78
+ detail: `${missing.join(', ')} not installed in ${from}, so the canvas shell cannot load. Run bun install there`,
79
+ }
80
+ }
81
+
55
82
  type RootOutcome = { readonly ok: true; readonly root: string } | Refused
56
83
 
57
84
  /**
@@ -637,6 +664,8 @@ async function runServe(
637
664
  return refuseServe(resolved.reason, resolved.detail, emitJson)
638
665
  }
639
666
  const { root } = resolved
667
+ const missing = missingClientDeps(PROJECT_ROOT)
668
+ if (missing) return refuseServe(missing.reason, missing.detail, emitJson)
640
669
  /*
641
670
  * Imported here rather than at the top, so a command module the test runner
642
671
  * loads never asks it for an HTML module it cannot parse.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The prose budget `standards/design.md` sets for a design record, and the
3
+ * counter the `design` gate stage reads it through.
4
+ *
5
+ * The limits live here and in the standard, never in the stage, so the stage
6
+ * names a failure without restating what it measured against.
7
+ *
8
+ * A row is excluded on the same test `table()` in `@/design/parse` reads one
9
+ * by, a line opening with a pipe, so a code span carrying a pipe or a cell
10
+ * carrying the uncertainty tag never reaches the count. A line opening with
11
+ * `<` is dropped the way `prose()` drops it, and so is a fenced block or an
12
+ * HTML comment spanning several lines, which `prose()` would read in part. A
13
+ * `###` subsection counts toward the `##` section holding it, since the
14
+ * standard fixes the sections and a subheading is no way past the cap.
15
+ */
16
+ export const DESIGN_BUDGET = {
17
+ /** Words before the first `##` section, the title excluded. */
18
+ leadWords: 40,
19
+ /** Words in `## Personality`, held to one paragraph. */
20
+ personalityWords: 60,
21
+ personalityParagraphs: 1,
22
+ /** Bullets or paragraphs in any other section, beside its table. */
23
+ rules: 3,
24
+ /** Words in one of those rules, the proxy for one line. */
25
+ ruleWords: 25,
26
+ } as const
27
+
28
+ const PERSONALITY = 'Personality'
29
+
30
+ /** A list item at any depth, which starts a rule of its own. */
31
+ const BULLET = /^\s*(?:[-*+]|\d+[.)])\s+/
32
+
33
+ interface Section {
34
+ readonly heading: string | undefined
35
+ readonly lines: readonly string[]
36
+ }
37
+
38
+ /**
39
+ * Every place the record exceeds the budget, one sentence each, in document
40
+ * order. An empty list is a record within budget, including an empty one.
41
+ */
42
+ export function measureDesignProse(source: string): string[] {
43
+ const findings: string[] = []
44
+
45
+ for (const section of splitSections(source)) {
46
+ const rules = readRules(section.lines)
47
+ const total = rules.reduce((sum, rule) => sum + rule, 0)
48
+
49
+ if (section.heading === undefined) {
50
+ if (total > DESIGN_BUDGET.leadWords) {
51
+ findings.push(
52
+ `The lead holds ${total} words against a cap of ${DESIGN_BUDGET.leadWords}`,
53
+ )
54
+ }
55
+ } else if (section.heading === PERSONALITY) {
56
+ if (rules.length > DESIGN_BUDGET.personalityParagraphs) {
57
+ findings.push(
58
+ `${PERSONALITY} holds ${rules.length} paragraphs against a cap of ${DESIGN_BUDGET.personalityParagraphs}`,
59
+ )
60
+ }
61
+ if (total > DESIGN_BUDGET.personalityWords) {
62
+ findings.push(
63
+ `${PERSONALITY} holds ${total} words against a cap of ${DESIGN_BUDGET.personalityWords}`,
64
+ )
65
+ }
66
+ } else {
67
+ if (rules.length > DESIGN_BUDGET.rules) {
68
+ findings.push(
69
+ `${section.heading} holds ${rules.length} rules against a cap of ${DESIGN_BUDGET.rules}`,
70
+ )
71
+ }
72
+ rules.forEach((words, index) => {
73
+ if (words > DESIGN_BUDGET.ruleWords) {
74
+ findings.push(
75
+ `${section.heading} rule ${index + 1} holds ${words} words against a cap of ${DESIGN_BUDGET.ruleWords}`,
76
+ )
77
+ }
78
+ })
79
+ }
80
+ }
81
+
82
+ return findings
83
+ }
84
+
85
+ /** The lead under an undefined heading, then each `##` section in order. */
86
+ function splitSections(source: string): Section[] {
87
+ const sections: { heading: string | undefined; lines: string[] }[] = [
88
+ { heading: undefined, lines: [] },
89
+ ]
90
+ for (const line of source.split('\n')) {
91
+ const match = line.match(/^##\s+(.+?)\s*$/)
92
+ if (match) {
93
+ sections.push({ heading: match[1], lines: [] })
94
+ } else if (!/^#\s/.test(line)) {
95
+ sections[sections.length - 1].lines.push(line)
96
+ }
97
+ }
98
+ return sections
99
+ }
100
+
101
+ /**
102
+ * The word count of each rule in a section. A rule opens on a bullet or on
103
+ * the first prose line after a break, and every following prose line joins it
104
+ * until a blank line, a table row, a heading, or the next bullet.
105
+ */
106
+ function readRules(lines: readonly string[]): number[] {
107
+ const rules: number[] = []
108
+ let isOpen = false
109
+ let isFenced = false
110
+ let isCommented = false
111
+
112
+ for (const line of lines) {
113
+ const trimmed = line.trim()
114
+ if (isCommented) {
115
+ isCommented = !trimmed.includes('-->')
116
+ continue
117
+ }
118
+ if (trimmed.startsWith('<!--')) {
119
+ isCommented = !trimmed.includes('-->', 4)
120
+ isOpen = false
121
+ continue
122
+ }
123
+ if (trimmed.startsWith('```')) {
124
+ isFenced = !isFenced
125
+ isOpen = false
126
+ continue
127
+ }
128
+ if (
129
+ isFenced ||
130
+ trimmed === '' ||
131
+ trimmed.startsWith('|') ||
132
+ trimmed.startsWith('#') ||
133
+ trimmed.startsWith('<')
134
+ ) {
135
+ isOpen = false
136
+ continue
137
+ }
138
+ const text = trimmed.replace(BULLET, '')
139
+ const count = text.split(/\s+/).filter(Boolean).length
140
+ if (BULLET.test(line) || !isOpen) {
141
+ rules.push(count)
142
+ isOpen = true
143
+ } else {
144
+ rules[rules.length - 1] += count
145
+ }
146
+ }
147
+
148
+ return rules
149
+ }
@@ -88,6 +88,20 @@ function borderRows(tokens: readonly BorderToken[]): string[][] {
88
88
  })
89
89
  }
90
90
 
91
+ /**
92
+ * The lead a reader meets before the first section. It points at the module
93
+ * and at the context entry, since the record carries values and one-line rules
94
+ * and every reason behind them lives in the entry.
95
+ */
96
+ const LEAD = [
97
+ 'Authoring guidance: `standards/design.md`. Rendered from `src/design/tokens.ts` by `canon design regen`, so edit the module and never this file.',
98
+ 'Why each value was chosen lives in `canon/context/design/tokens.md`.',
99
+ ].join(' ')
100
+
101
+ function bullets(rules: readonly string[]): string {
102
+ return rules.map((rule) => `- ${rule}`).join('\n')
103
+ }
104
+
91
105
  function section(heading: string, ...blocks: string[]): string {
92
106
  return [`## ${heading}`, ...blocks.filter((block) => block !== '')].join(
93
107
  '\n\n',
@@ -98,21 +112,19 @@ export function renderDesignDocument(tokens: DesignTokens = TOKENS): string {
98
112
  const body = [
99
113
  '# Design',
100
114
  '',
101
- 'Authoring guidance: `standards/design.md`.',
102
- '',
103
- tokens.preamble,
115
+ LEAD,
104
116
  '',
105
117
  section('Personality', tokens.personality),
106
118
  '',
107
119
  section(
108
120
  'Color',
109
- tokens.colorNote,
121
+ bullets(tokens.colorRules),
110
122
  formatTable(['Role', 'Intent', 'Value'], colorRows(tokens.color)),
111
123
  ),
112
124
  '',
113
125
  section(
114
126
  'Typography',
115
- tokens.typographyNote,
127
+ bullets(tokens.typographyRules),
116
128
  formatTable(
117
129
  ['Role', 'Family', 'Weight', 'Size', 'Line height'],
118
130
  typeRows(tokens.typography),
@@ -121,24 +133,24 @@ export function renderDesignDocument(tokens: DesignTokens = TOKENS): string {
121
133
  '',
122
134
  section(
123
135
  'Spacing',
124
- tokens.spacingNote,
136
+ bullets(tokens.spacingRules),
125
137
  formatTable(['Step', 'Multiplier', 'Value'], spaceRows(tokens.spacing)),
126
138
  ),
127
139
  '',
128
140
  section(
129
141
  'Borders',
130
- tokens.bordersNote,
142
+ bullets(tokens.bordersRules),
131
143
  formatTable(
132
144
  ['Role', 'Radius', 'Width', 'When used'],
133
145
  borderRows(tokens.borders),
134
146
  ),
135
147
  ),
136
148
  '',
137
- section('Layout', tokens.layout),
149
+ section('Layout', bullets(tokens.layout)),
138
150
  '',
139
- section('Motion', tokens.motion),
151
+ section('Motion', bullets(tokens.motion)),
140
152
  '',
141
- section('Iconography', tokens.iconography),
153
+ section('Iconography', bullets(tokens.iconography)),
142
154
  ]
143
155
 
144
156
  return `${body.join('\n')}\n`
@@ -328,7 +328,17 @@ function sectionBorders(rows: Row[]): string {
328
328
  return `<h2>Borders</h2>\n<table><thead><tr><th>Role</th><th>Radius</th><th>Width</th><th>When used</th><th>Sample</th></tr></thead><tbody>${body}</tbody></table>`
329
329
  }
330
330
 
331
+ /** A section the record writes as one-line rules draws them as a list. */
332
+ const BULLET = /^[-*+]\s+/
333
+
331
334
  function sectionLine(title: string, text: string): string {
332
335
  if (!text) return ''
333
- return `<h2>${title}</h2>\n<p>${escape(text)}</p>`
336
+ const lines = text.split('\n')
337
+ if (!lines.every((line) => BULLET.test(line))) {
338
+ return `<h2>${title}</h2>\n<p>${escape(text)}</p>`
339
+ }
340
+ const items = lines
341
+ .map((line) => `<li>${escape(line.replace(BULLET, ''))}</li>`)
342
+ .join('')
343
+ return `<h2>${title}</h2>\n<ul>${items}</ul>`
334
344
  }
@@ -59,21 +59,25 @@ export interface BorderToken {
59
59
  readonly verify?: readonly ('radius' | 'width')[]
60
60
  }
61
61
 
62
+ /**
63
+ * Every prose slot is a list of one-line rules rather than a string, so a
64
+ * bullet is the unit the renderer emits and the prose budget counts, and a
65
+ * value needing a paragraph of why goes to `canon/context/design/tokens.md`.
66
+ */
62
67
  export interface DesignTokens {
63
68
  readonly personality: string
64
69
  readonly color: readonly ColorToken[]
65
- readonly colorNote: string
70
+ readonly colorRules: readonly string[]
66
71
  readonly typeScale: readonly TypeStep[]
67
72
  readonly typography: readonly TypeToken[]
68
- readonly typographyNote: string
73
+ readonly typographyRules: readonly string[]
69
74
  readonly spacing: readonly SpaceToken[]
70
- readonly spacingNote: string
75
+ readonly spacingRules: readonly string[]
71
76
  readonly borders: readonly BorderToken[]
72
- readonly bordersNote: string
73
- readonly layout: string
74
- readonly motion: string
75
- readonly iconography: string
76
- readonly preamble: string
77
+ readonly bordersRules: readonly string[]
78
+ readonly layout: readonly string[]
79
+ readonly motion: readonly string[]
80
+ readonly iconography: readonly string[]
77
81
  }
78
82
 
79
83
  /** The monospace stack, taken by code and by the terminal frames a page embeds. */
@@ -94,35 +98,14 @@ const DARK_GROUNDS = ['background', 'surface'] as const
94
98
  const LIGHT_GROUNDS = ['light-background', 'light-surface'] as const
95
99
 
96
100
  export const TOKENS: DesignTokens = {
97
- preamble: [
98
- 'This document is rendered from `src/design/tokens.ts` by `canon design regen`, and the `design` stage of `bun run check` fails when the two disagree. Edit the module, never this file.',
99
- '',
100
- 'The values below are the system rather than a reading of one. Until 2026-09-01 this record transcribed two surfaces and agreed with nothing else, which is what made a change to it reach nobody. The slide theme, the token preview, and a teach workspace stylesheet now read the module this file is rendered from, so a value changed there changes what all three render.',
101
- '',
102
- 'The two rendered captures read it as well. `scripts/core/regen-hero.ts` fills `assets/captures/hero.html.tmpl` and `assets/captures/install.html.tmpl` with what `canon design css --no-components` emits, so both frames now carry the custom properties rather than their own copies of the hex, and a value moved here moves what the next capture renders.',
103
- '',
104
- 'The terminal framing is the one surface left holding its own values, and that is a decision rather than a gap. `scripts/lib/ui.sh` and `src/ui.ts` each spell six escape constants, and `canon/context/scripts/framing.md` records one color source per language with a check behind each, so generating a third spelling from here would break the rule those two checks enforce. What the record is still incomplete about is the other half of those six: `WHITE` and `GREY` name no role below, so the terminal palette is described here in part rather than in whole.',
105
- ].join('\n'),
106
-
107
- personality: [
108
- 'Warm neutrals carry the frame under a single rust accent, set in Geist. The subject is a toolkit for people who read documents and diffs as much as they run commands, so the voice is a proportional one and the page reads as prose. One accent carries every count, link, and primary action. Promoting a second and third into structural roles is what reads as a generated interface, so the palette stays at one.',
109
- '',
110
- 'Monospace is a role the page uses rather than its voice. The `code` role takes it, and so does any terminal frame a surface embeds, since those show what the shell printed and matching the shell is what makes them legible. Every other role is Geist.',
111
- ].join('\n'),
101
+ personality:
102
+ 'Warm neutrals under one rust accent, set in Geist. The page reads as prose, and monospace appears only where a surface shows what a shell printed. One accent carries every count, link, and primary action.',
112
103
 
113
- colorNote: [
114
- 'Every role clears WCAG AA at 4.5:1 against each ground it declares, asserted in `src/design/contrast.test.ts`.',
115
- '',
116
- 'Warning and error hold ANSI codes because that is what `scripts/lib/ui.sh` writes and no rendered surface implements an equivalent. Giving either a hex value would invent a mapping no file has, so they carry no contrast reading either.',
117
- '',
118
- 'Success is the one of the three that does have a rendered equivalent, which is why it carries a hex. `assets/captures/install.html` marks every confirmed step with it, and the shell writes `ANSI 32` for the same role, so the two are one role in two registers rather than one value in two spellings. The hex is what the rendered surface picked and no reading claims the terminal renders that value. It declares `background` alone as its ground, since that is the only role it is drawn on, where every other dark text role is drawn on both. It is the one role below that is not derived.',
119
- '',
120
- "Every other role is derived rather than picked, solved in OKLCH by binary search for the lightness that hits a target contrast against its ground, using this module's own anchors. Six anchors are the whole system: ground lightness 0.985 light and 0.165 dark, neutral chroma 0.003 light and 0.004 dark, neutral hue 90 both, accent hue 22 light and 28 dark, accent chroma 0.13 light and 0.12 dark for a mark and 0.095 light and 0.09 dark for a fill. Only the mark step is rendered below, in `accent`. That token already stands in for a fill in practice: `assets/captures/hero.html`'s `.cmd` rule paints its whole background with `var(--color-accent)` and sets text on top, which is a fill use rather than a mark one. The fill chroma step has no token of its own yet, so a lower-saturation `accent-fill` would recolor that button rather than introduce a new consumer. Targets, which are inputs rather than results: text 13.5, body 8.6, secondary 5.6, muted 4.6, accent 5.2. Dark is its own anchor set rather than an inversion of light, and every arm measured in the groundwork behind this landed on identical neutral ratios, which is what made the accent choice a question about hue alone.",
121
- '',
122
- '`muted` and `light-muted` solve against `surface` rather than `background`. Both declare two grounds and the groundwork solved its target against one, so the recorded hex cleared 4.6 against `background` and read 4.40 dark, 4.24 light against `surface`, the tighter of the two since surface sits a step closer to its text color. The values here are re-solved for the same 4.6 target read against `surface` instead, which clears both: 4.58 dark, 4.56 light against `surface`, and 4.80 dark, 4.95 light against `background`.',
123
- '',
124
- 'The accent is a quiet red at hue 22 light, 28 dark, chosen over a vivid red that read as an error state against a page reporting success, over indigo which carries less distinctiveness at hue 280 in tooling already dominated by that hue, and over rust at hue 42, a larger temperature shift than the problem required. The debt this accepts: `error` and `warning` now share a register with the most repeated element on every rendered surface, so both need differentiating by lightness or by an icon rather than by hue, and that work is unscheduled.',
125
- ].join('\n'),
104
+ colorRules: [
105
+ 'One accent. Never promote a second color into a structural role.',
106
+ 'Every text role clears WCAG AA at 4.5:1 against each ground it is drawn on.',
107
+ '`warning` and `error` exist only in the terminal, as ANSI codes.',
108
+ ],
126
109
 
127
110
  color: [
128
111
  {
@@ -242,15 +225,11 @@ export const TOKENS: DesignTokens = {
242
225
  },
243
226
  ],
244
227
 
245
- typographyNote: [
246
- 'Seven sizes are on offer, named `t0` through `t6` and emitted as custom properties beside the roles. They are 3.175, 2.375, 1.375, 1.125, 0.9375, 0.8125 and 0.6875 rem, which paint at 50.8, 38, 22, 18, 15, 13 and 11 pixels, and each role takes one. A step answers what sizes exist and a role answers what a stylesheet asks for, so the two stay separate: a role whose step did not move renders as it did, and a role that moved is the only thing that changes what a surface paints. Two steps, `t3` at 18 pixels and `t6` at 11, are offered with no role pointing at them yet.',
247
- '',
248
- 'Every role but `code` is Geist and `code` is monospace. The hero headline takes the largest step at 50.8 pixels, which no other role reaches, since a headline set at the display cap reads as an opening rather than as a section heading.',
249
- '',
250
- 'A tagged cell is one no rendering surface exercises yet, which is a declaration the system has not tested rather than one it has.',
251
- '',
252
- 'Two rules set tracking and no others touch it. The label role carries `0.05em`, and the display role tightens to `-0.01em`.',
253
- ].join('\n'),
228
+ typographyRules: [
229
+ 'Geist for every role except `code`, which is monospace.',
230
+ 'Tracking: `label` at `0.05em`, `display` at `-0.01em`, nothing else.',
231
+ 'Sizes come from seven steps, `t0` to `t6`. A role takes one step.',
232
+ ],
254
233
 
255
234
  typeScale: [
256
235
  { step: 't0', size: '3.175rem' },
@@ -312,11 +291,9 @@ export const TOKENS: DesignTokens = {
312
291
  },
313
292
  ],
314
293
 
315
- spacingNote: [
316
- 'Seven steps run from a quarter rem to six, at 4, 8, 14, 24, 40, 64 and 96 pixels on a 16 pixel root. The scale is not a grid of one base, since the 14 between the 8 and the 24 is the step a control needs and no multiple of four supplies it, so the multiplier column counts quarter rems rather than claiming a unit.',
317
- '',
318
- 'The outer window padding of the capture frames, which the previous record carried as a three-value frame register, is not part of the scale. Those frames set their own padding in the template and read no step, so the register is retired here rather than mapped onto steps no frame uses.',
319
- ].join('\n'),
294
+ spacingRules: [
295
+ 'Seven steps. `md` sits at 3.5 because a control needs 14 pixels.',
296
+ ],
320
297
 
321
298
  spacing: [
322
299
  { step: 'xs', multiplier: '1', value: '0.25rem' },
@@ -328,9 +305,9 @@ export const TOKENS: DesignTokens = {
328
305
  { step: '3xl', multiplier: '24', value: '6rem' },
329
306
  ],
330
307
 
331
- bordersNote: [
332
- 'Every border is one pixel solid at the `border` role, and that value appears in no text role. Three radii appear, and the two blocks carrying the largest and smallest have no border at all, so radius and width are independent here rather than paired. Nothing renders a pill, so both of its cells stay tagged.',
333
- ].join('\n'),
308
+ bordersRules: [
309
+ 'Every border is one pixel solid in `border`. Radius and width are independent.',
310
+ ],
334
311
 
335
312
  borders: [
336
313
  {
@@ -372,17 +349,19 @@ export const TOKENS: DesignTokens = {
372
349
  },
373
350
  ],
374
351
 
375
- layout:
376
- 'Capture widths are 320, 768, 1280 and 1536 pixels, declared once in `tooling/web/configs/e2e/screenshot.ts`, with 320 and 1280 committed as evidence. 320 is the reflow floor. Every range the width queries in the generated base and teach stylesheets lay out holds at least one of these widths, so a capture photographs every layout state those stylesheets have, and a design test fails when a query moves or is added so that it opens a range none of them reaches. The landing page under `web/` sets its own `max-width` media queries between 430 and 1100 pixels per component rather than from a shared scale.',
352
+ layout: [
353
+ 'Capture widths: 320, 768, 1280, 1536. 320 is the reflow floor.',
354
+ 'Every width query in the generated stylesheets opens a range one of these widths reaches.',
355
+ ],
377
356
 
378
- motion:
379
- 'Motion is not used. No transition, animation, or keyframe declaration appears on any rendered surface, and the capture pipeline screenshots a static frame.',
357
+ motion: [
358
+ 'None. No transition, animation, or keyframe on any rendered surface.',
359
+ ],
380
360
 
381
361
  iconography: [
382
- 'No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing.',
383
- '',
384
- 'The same mark ships in five independently-maintained copies that carry different colors by design rather than by drift, each fitted to the chrome it renders on: the dark accent for a dark-chrome surface, the light accent for a light-chrome one, and the pair the web favicon takes from `src/design/favicon.ts`, which teach pages also take by rendering the live mark. Unifying them or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design/tokens.md` carries which file holds each copy.',
385
- ].join('\n'),
362
+ 'No icon library. `assets/brand/mark.svg` is the one authored icon.',
363
+ 'The terminal draws glyphs: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯`.',
364
+ ],
386
365
  }
387
366
 
388
367
  /** A role's value, or `undefined` where the record declares no such role. */
@@ -22,6 +22,8 @@ import {
22
22
  PRACTICE_SKILLS,
23
23
  SHIPPED_CORPUS,
24
24
  } from '@/claude/skills-audit'
25
+ import { measureDesignProse } from '@/design/budget'
26
+ import { DESIGN_DOCUMENT } from '@/design/regen'
25
27
  import { listRepositoryFiles } from '@/git-files'
26
28
  import { ceilingFindings } from '@/markdown/ceiling'
27
29
  import { resolveMarkdown } from '@/markdown/files'
@@ -325,6 +327,53 @@ export const canonicalRecords: Measure = async (ctx) => {
325
327
  return { emissions }
326
328
  }
327
329
 
330
+ /**
331
+ * The design records this repository holds, each read against the prose
332
+ * budget in `@/design/budget`. The worked example is read beside the record,
333
+ * since it is the one a target copies its shape from.
334
+ */
335
+ const DESIGN_RECORDS = [
336
+ DESIGN_DOCUMENT,
337
+ join('examples', 'design', 'DESIGN.md'),
338
+ ] as const
339
+
340
+ export const designProse: Measure = async (ctx) => {
341
+ const failures: string[] = []
342
+ const emissions: Emission[] = []
343
+
344
+ for (const rel of DESIGN_RECORDS) {
345
+ const path = join(ctx.root, rel)
346
+ if (!existsSync(path)) continue
347
+ let source: string
348
+ try {
349
+ source = readFileSync(path, 'utf8')
350
+ } catch {
351
+ return {
352
+ emissions: [],
353
+ unmeasured: `${rel} could not be read, so its prose was not counted.`,
354
+ }
355
+ }
356
+ const findings = measureDesignProse(source)
357
+ if (findings.length > 0) {
358
+ failures.push(
359
+ `${findings.map((finding) => `${finding} in ${rel}`).join('. ')}.`,
360
+ )
361
+ } else {
362
+ emissions.push(info(`${rel} within the prose budget`))
363
+ }
364
+ }
365
+
366
+ if (failures.length > 0) {
367
+ return {
368
+ emissions: [],
369
+ failure: `${failures.join(' ')} Cut to the budget in standards/design.md, moving the why to the context entry.`,
370
+ }
371
+ }
372
+ if (emissions.length === 0)
373
+ emissions.push(info('No design record to measure'))
374
+ return { emissions }
375
+ }
376
+
328
377
  /**
329
378
  * Every skill body and reference against the no-dated-provenance rule, across
330
379
  * both corpora `auditSkills` reads.
@@ -10,6 +10,7 @@ import {
10
10
  auditSet,
11
11
  captureStamps,
12
12
  clientCommandCitations,
13
+ designProse,
13
14
  documentCeiling,
14
15
  folderEchoedNames,
15
16
  markdownBans,
@@ -234,7 +235,9 @@ export const STAGES: readonly Stage[] = [
234
235
  // from `src/design/tokens.ts`, the base stylesheet from
235
236
  // `src/design/neutral.ts`, and none is edited by hand. Four artifacts
236
237
  // from two token sources is the cost of the token move, and a render step that has to run is only safe while something fails
237
- // when it did not, which is this.
238
+ // when it did not, which is this. The record and the worked example are
239
+ // then counted against the prose budget, which the drift asserts cannot
240
+ // see since a regenerated paragraph agrees with its source.
238
241
  id: 'design',
239
242
  label: 'Design',
240
243
  checks: [
@@ -277,6 +280,7 @@ export const STAGES: readonly Stage[] = [
277
280
  failure:
278
281
  'The tab icon drifted from the token source. Run bun run check and commit web/public/favicon.svg.',
279
282
  },
283
+ { kind: 'measure', measure: designProse },
280
284
  ],
281
285
  success: 'Design source clean',
282
286
  },
@@ -33,10 +33,20 @@ Does not govern:
33
33
 
34
34
  ## Format
35
35
 
36
- - Use tables for token systems, one row per token. Use short bullets for component rules, one decision per line.
36
+ - Use tables for token systems, one row per token. Write every other rule as a short bullet, one decision per line, within `## Prose budget`.
37
37
  - Plain English over technical notation. If a section could be removed and the developer would still build correctly from wireframes and code alone, remove it.
38
38
  - Keep table headers and role names intact so the render tooling can parse the token tables.
39
39
 
40
+ ## Prose budget
41
+
42
+ The record is a lookup, so its prose stays short enough to scan beside the tables. Why a value was chosen belongs in the project's context entry for design, never here.
43
+
44
+ - Personality: one paragraph of at most 60 words
45
+ - Any other section: at most 3 rules beside its table, each at most 25 words. A bullet is one rule, and so is a paragraph.
46
+ - The lead before the first section: at most 40 words
47
+
48
+ Headings and table rows are never counted, and a section holding a table and no rules is within budget. A bullet wrapping over several lines counts as one rule, and a nested bullet counts as its own.
49
+
40
50
  ## The uncertainty tag
41
51
 
42
52
  A cell no source anchors ends in ` ? verify`, written inside the cell rather than as a trailing column, since a trailing marker breaks the table parse. A cell wrapping itself in a code span carries the tag inside the span, as in `` `#ffffff ? verify` ``. Both spellings parse.
@@ -93,9 +103,9 @@ The column headers are the strings the renderer parses, read by exact key, so th
93
103
 
94
104
  ## Motion
95
105
 
96
- <whether motion is used at all, and if so the default duration and easing>
106
+ - <whether motion is used at all, and if so the default duration and easing>
97
107
 
98
108
  ## Iconography
99
109
 
100
- <style, source library, and whether custom icons are allowed>
110
+ - <style, source library, and whether custom icons are allowed>
101
111
  ```
@@ -57,5 +57,5 @@ marker="$marker_dir/$key"
57
57
  mkdir -p "$marker_dir"
58
58
  : >"$marker"
59
59
 
60
- msg='Temporary file write outside the project scratch folder. Write temp files to .canon/tmp/<slug>/ in the project root, or .claude/.tmp/<slug>/ where the project carries no .canon/ root, not system temp. See the Scratch rule (604-scratch, under canon/ in your installed governance rules).'
60
+ msg='Temporary file write outside the project scratch folder. Write temp files to .canon/tmp/<slug>/ in the project root, not system temp. See the Scratch rule (604-scratch, under canon/ in your installed governance rules).'
61
61
  jq -nc --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$msg}}'