@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-operator/REQUIREMENT.md +1 -0
- package/claude/skills/canon-operator/SKILL.md +5 -1
- package/claude/skills/draft-doc/REQUIREMENT.md +1 -0
- package/claude/skills/draft-doc/references/readme/agent-facing.md +59 -0
- package/claude/skills/draft-doc/references/readme/application.md +66 -0
- package/claude/skills/draft-doc/references/readme/cli.md +64 -0
- package/claude/skills/draft-doc/references/readme/library.md +57 -0
- package/claude/skills/draft-doc/references/readme/plugin.md +66 -0
- package/claude/skills/draft-doc/references/readme.md +33 -4
- package/docs/agents/canvas.md +5 -1
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/package.json +1 -1
- package/src/canvas/client/inspector.tsx +46 -4
- package/src/canvas/client/shell.css +7 -0
- package/src/canvas/tokens.ts +9 -1
- package/src/commands/canvas.ts +29 -0
- package/src/design/budget.ts +149 -0
- package/src/design/document.ts +22 -10
- package/src/design/render.ts +11 -1
- package/src/design/tokens.ts +40 -61
- package/src/gate/measures.ts +49 -0
- package/src/gate/stages.ts +5 -1
- package/standards/design.md +13 -3
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +1 -1
|
@@ -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
|
|
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
|
+

|
|
2
|
+
|
|
3
|
+
# <Project name>
|
|
4
|
+
|
|
5
|
+
[](<run history URL>)
|
|
6
|
+
[](<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
|
+

|
|
2
|
+
|
|
3
|
+
# <Project name>
|
|
4
|
+
|
|
5
|
+
[](<run history URL>)
|
|
6
|
+
[](<license URL>)
|
|
7
|
+
|
|
8
|
+
<One-line claim taken from the manifest or repository description.>
|
|
9
|
+
|
|
10
|
+
[<Live site name>](<live URL>)
|
|
11
|
+
|
|
12
|
+

|
|
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
|
+

|
|
2
|
+
|
|
3
|
+
# <Project name>
|
|
4
|
+
|
|
5
|
+
[](<registry page URL>)
|
|
6
|
+
[](<run history URL>)
|
|
7
|
+
[](<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
|
+

|
|
2
|
+
|
|
3
|
+
# <Project name>
|
|
4
|
+
|
|
5
|
+
[](<registry page URL>)
|
|
6
|
+
[](<run history URL>)
|
|
7
|
+
[](<license URL>)
|
|
8
|
+
|
|
9
|
+
<One-line claim taken from the manifest description.>
|
|
10
|
+
|
|
11
|
+
[<Live site name>](<live URL>)
|
|
12
|
+
|
|
13
|
+

|
|
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
|
+

|
|
2
|
+
|
|
3
|
+
# <Project name>
|
|
4
|
+
|
|
5
|
+
[](<run history URL>)
|
|
6
|
+
[](<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
|
-
|
|
28
|
-
|
|
29
|
-
-
|
|
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
|
|
package/docs/agents/canvas.md
CHANGED
|
@@ -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
|
|
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
|
@@ -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}
|
package/src/canvas/tokens.ts
CHANGED
|
@@ -138,7 +138,15 @@ export function resolveFrameTokens(
|
|
|
138
138
|
options: TokenOptions = {},
|
|
139
139
|
): FrameTokens {
|
|
140
140
|
if (options.isOwnCheckout ?? isOwnCheckout(root)) {
|
|
141
|
-
|
|
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)
|
package/src/commands/canvas.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/design/document.ts
CHANGED
|
@@ -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
|
-
|
|
102
|
-
'',
|
|
103
|
-
tokens.preamble,
|
|
115
|
+
LEAD,
|
|
104
116
|
'',
|
|
105
117
|
section('Personality', tokens.personality),
|
|
106
118
|
'',
|
|
107
119
|
section(
|
|
108
120
|
'Color',
|
|
109
|
-
tokens.
|
|
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.
|
|
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.
|
|
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.
|
|
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`
|
package/src/design/render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/design/tokens.ts
CHANGED
|
@@ -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
|
|
70
|
+
readonly colorRules: readonly string[]
|
|
66
71
|
readonly typeScale: readonly TypeStep[]
|
|
67
72
|
readonly typography: readonly TypeToken[]
|
|
68
|
-
readonly
|
|
73
|
+
readonly typographyRules: readonly string[]
|
|
69
74
|
readonly spacing: readonly SpaceToken[]
|
|
70
|
-
readonly
|
|
75
|
+
readonly spacingRules: readonly string[]
|
|
71
76
|
readonly borders: readonly BorderToken[]
|
|
72
|
-
readonly
|
|
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
|
-
|
|
98
|
-
'
|
|
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
|
-
|
|
114
|
-
'
|
|
115
|
-
'',
|
|
116
|
-
'
|
|
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
|
-
|
|
246
|
-
'
|
|
247
|
-
'',
|
|
248
|
-
'
|
|
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
|
-
|
|
316
|
-
'Seven steps
|
|
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
|
-
|
|
332
|
-
'Every border is one pixel solid
|
|
333
|
-
]
|
|
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
|
|
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
|
-
'
|
|
357
|
+
motion: [
|
|
358
|
+
'None. No transition, animation, or keyframe on any rendered surface.',
|
|
359
|
+
],
|
|
380
360
|
|
|
381
361
|
iconography: [
|
|
382
|
-
'No icon library
|
|
383
|
-
'',
|
|
384
|
-
|
|
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. */
|
package/src/gate/measures.ts
CHANGED
|
@@ -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.
|
package/src/gate/stages.ts
CHANGED
|
@@ -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
|
},
|
package/standards/design.md
CHANGED
|
@@ -33,10 +33,20 @@ Does not govern:
|
|
|
33
33
|
|
|
34
34
|
## Format
|
|
35
35
|
|
|
36
|
-
- Use tables for token systems, one row per token.
|
|
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,
|
|
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}}'
|