@erclx/canon 5.15.0 → 5.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-wiki/SKILL.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/search-craft/REQUIREMENT.md +57 -0
- package/claude/skills/search-craft/SKILL.md +83 -0
- package/docs/agents/canvas.md +36 -0
- package/docs/agents/commands.md +3 -3
- package/docs/agents/index.md +1 -0
- package/docs/workflow/skill-map.md +1 -0
- package/package.json +1 -1
- package/scripts/lib/sandbox-git.sh +2 -2
- package/src/canvas/address.ts +184 -0
- package/src/canvas/capture.ts +172 -0
- package/src/canvas/client/app.tsx +2 -0
- package/src/canvas/client/inspector.tsx +121 -0
- package/src/canvas/client/layers.tsx +118 -0
- package/src/canvas/client/pages-panel.tsx +47 -13
- package/src/canvas/client/shell.css +131 -1
- package/src/canvas/client/state.ts +186 -0
- package/src/canvas/client/surface.tsx +272 -20
- package/src/canvas/content.ts +307 -16
- package/src/canvas/server.ts +182 -13
- package/src/claude/cases/misc.ts +5 -0
- package/src/commands/canvas.ts +200 -3
- package/src/commands/migrate.ts +1 -1
- package/src/migrate/rename.ts +5 -4
- package/src/teach/browser/compile.ts +44 -0
- package/src/teach/browser/dismiss.ts +76 -0
- package/src/teach/browser/focus-line.ts +19 -0
- package/src/teach/browser/glossary-filter.ts +44 -0
- package/src/teach/browser/head.ts +30 -0
- package/src/teach/browser/quiz.ts +19 -0
- package/src/teach/browser/sidebar.ts +233 -0
- package/src/teach/browser/theme.ts +18 -0
- package/src/teach/nav.ts +19 -374
|
@@ -40,7 +40,7 @@ Do not widen the split to admit a subject. Adding a sibling folder is a change t
|
|
|
40
40
|
The standard forbids working from training knowledge, so the draft is built on a read rather than on recall.
|
|
41
41
|
|
|
42
42
|
- Fetch current information through the `claude-code-guide` agent for any subject that agent covers. Its answer is the source the page is written from.
|
|
43
|
-
- Reach a subject the agent does not cover through the owner's own published documentation.
|
|
43
|
+
- Reach a subject the agent does not cover through the owner's own published documentation. Invoke `canon:search-craft` with the skill tool before searching for it, since finding the owner's documentation is the field-and-authority step that skill carries, and report it rather than proceeding silently when the skill does not resolve.
|
|
44
44
|
- Say the fetch failed and stop rather than substituting recall. A page sourced from memory is the failure the rule exists to prevent, and it ships looking identical to one that was checked.
|
|
45
45
|
- Close the intro with the `Source:` sentence the standard requires, linking the canonical page where one exists and naming the owner alone where the subject has no single URL.
|
|
46
46
|
- Stamp the read date on a source revised without notice, as `read <date>`. A claim traced to an undated read cannot be checked against what was actually read.
|
|
@@ -24,7 +24,7 @@ Read `${CLAUDE_SKILL_DIR}/../../standards/groundwork.md` before writing any file
|
|
|
24
24
|
- A second exception, for what a spike reads as bulk input: write a large fixture under `.canon/tmp/runs/groundwork-fixtures/<slug>/`. Keep it out of `.canon/groundwork/` so mode detection never matches a fixture as a track. A fixture a headless run is pointed at goes outside the repository instead, per the rule in `## Running a spike`. An arm script and a checkout a claim rests on are not fixtures, and go inside the track under `scripts/` and `clones/`, per the standard.
|
|
25
25
|
- What a spike produces stays inside the track rather than joining the two exceptions above: write evidence a spike file cites under `.canon/groundwork/<nn>-<slug>/evidence/`, which the first rule already permits. Mode detection matches entries at the top level of the tracks directory, so the sibling the fixtures rule guards against cannot be a folder nested inside a track, and the scratch path holds only what can be deleted without loss, which a recording a finding rests on is not.
|
|
26
26
|
- A `draft-and-pick` run invoked from inside a live track follows its own branch for this, writing to `evidence/` here rather than `.canon/tmp/<slug>/`.
|
|
27
|
-
- Reading is not restricted. External research is in scope, so read documentation, comparable projects, and papers whenever a live question needs them.
|
|
27
|
+
- Reading is not restricted. External research is in scope, so read documentation, comparable projects, and papers whenever a live question needs them. Invoke `canon:search-craft` with the skill tool before an outside search, and report it rather than proceeding silently when the skill does not resolve.
|
|
28
28
|
- Every claim about a source outside the project carries a link to it. A source found and not read is listed as a lead and is never cited.
|
|
29
29
|
- Treat the folder as gitignored, and backed only where a records remote is configured: `canon records push` and `canon records pull` protect it against the machine being lost there and refuse with `no-remote` otherwise, and neither protects against a compaction dropping this session's own reasoning before anyone has pushed, so `07-next-session.md` repeats what it needs instead of pointing at its siblings.
|
|
30
30
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: search-craft
|
|
3
|
+
description: Why a session needs a thinking step before an outside search, deriving where a field's authorities live rather than taking the top-ranked result, and why that step is a method rather than a catalog of sites
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Search craft requirement
|
|
7
|
+
|
|
8
|
+
## Gap
|
|
9
|
+
|
|
10
|
+
Without this skill, a session reaching outside the project runs one generic `WebSearch` and takes what ranks highest. Nothing in the toolkit says how to search, and the two procedures that require an outside source, `plan-groundwork` and `draft-wiki`, give no route to one.
|
|
11
|
+
|
|
12
|
+
The miss is measured. On 2026-10-02 two queries ran twice each with identical text, once open and once with `allowed_domains` set to authorities the session derived itself with no list to consult.
|
|
13
|
+
|
|
14
|
+
- `css container queries browser support`: 1 of 9 open results came from MDN, and that one was a blog post rather than the reference page. The rest were third-party pages, three of them thin guides. Scoped to MDN, web.dev, and caniuse, 10 of 10 results were primary sources.
|
|
15
|
+
- The two summaries disagreed on a fact. The open one said Firefox shipped style queries in version 128, and the scoped one, reading caniuse, said 151. A direct fetch of the caniuse table returned 151, so a session that searched once would have reported the wrong version.
|
|
16
|
+
- `effect of minimum wage increase on employment evidence`: the open set mixed journals with advocacy groups and its summary leaned one way. Scoped to five authorities the session named, being a research bureau's working papers, the economists' professional association, a working-paper index, a policy institute, and a legislature's budget office, 7 of 9 results were working papers or official analysis, and the summary carried both the near-zero and the negative estimates.
|
|
17
|
+
- 2 of the 9 scoped economics results came from a domain outside the allow list, so the filter is not strict.
|
|
18
|
+
|
|
19
|
+
The authorities in both pairs were named by the session unprompted once it was asked to, which is the argument for a method over a list. Two queries at one run each show the effect exists and do not size it.
|
|
20
|
+
|
|
21
|
+
Access is a second gap a session cannot derive. Probed on 2026-10-02, Reddit refused its JSON search with a 403 and refused `WebFetch`, while its RSS feed answered 200. X redirected `curl` to a login page and returned 402 to `WebFetch`, so no free route reaches it.
|
|
22
|
+
|
|
23
|
+
## Must
|
|
24
|
+
|
|
25
|
+
- Name the field, or two, before a query is written
|
|
26
|
+
- Derive the field's authorities from the kind of body it trusts, and search for who they are first when unsure
|
|
27
|
+
- Run the query scoped to those authorities and open beside it, prefer the scoped result on a conflict, and say in the output that the two disagreed
|
|
28
|
+
- Use the open result for practitioner write-ups and news the authorities do not publish
|
|
29
|
+
- Warn that the domain filter is not strict, so a scoped result has its domain checked before it is cited
|
|
30
|
+
- Prefer a public API over a fetched and summarized page where one exists, since the API returns the dates, authors, and identifiers a citation needs
|
|
31
|
+
- Carry a dated access note grown only from a failure a session hit, and stop to ask the operator before any paid or logged-in route
|
|
32
|
+
- Close with the practice skill's excuses, red flags, and closing checklist, answering "the model already knows the good sites" and "one search was enough"
|
|
33
|
+
- Be loaded from the sourcing steps of `plan-groundwork` and `draft-wiki`, each reporting when the skill does not resolve
|
|
34
|
+
|
|
35
|
+
## Must not
|
|
36
|
+
|
|
37
|
+
- Name a site as the destination for a category of question. An example authority appears only inside the method as an illustration of deriving one, marked as such. A line reading "for economics, use X" is the catalog the operator ruled out and a reviewer reads against this line.
|
|
38
|
+
- Ship code. Access code a session needs repeatedly becomes a `canon` verb, never a script in the skill.
|
|
39
|
+
- Restate citation format, which belongs to the surface writing the citation
|
|
40
|
+
- Suggest scraping behind a login or a paywall
|
|
41
|
+
|
|
42
|
+
## Out of scope
|
|
43
|
+
|
|
44
|
+
- The form a citation takes: the procedure or standard writing it
|
|
45
|
+
- A Claude Code subject: the `claude-code-guide` agent, which `draft-wiki` already routes to
|
|
46
|
+
- Image generation, which makes a picture rather than finding a source
|
|
47
|
+
- Loading on every web search rather than on a research-shaped request: a hook matching `WebSearch`, planned on its own
|
|
48
|
+
- Whether a session already scopes searches without the skill, which only a run on a fresh research request without it would show
|
|
49
|
+
|
|
50
|
+
## Measured
|
|
51
|
+
|
|
52
|
+
2026-10-03, one Sonnet run of the toolkit's own sandbox scenario for this skill, a housing brief asking for linked notes and a prompt for the strongest evidence on whether rent control reduces housing supply. The skill sat in the project's skill folder and the run was allowed the skill, search, and fetch tools.
|
|
53
|
+
|
|
54
|
+
- No `Skill` call fired, so the description did not route a plain research request to the body.
|
|
55
|
+
- The run searched four times with no domain filter, naming authors and journals from recall, then fetched the papers' own pages. It never named the field, so the scenario's field pattern failed and the arm went red on one of its assertions.
|
|
56
|
+
|
|
57
|
+
The arm shows the trigger is the gap rather than the body: a session that never loads the skill cannot follow it. The pointers from `plan-groundwork` and `draft-wiki` reach only those procedures, and the hook matching `WebSearch` is the route for every other search. One run on one model shows the miss exists and does not size it.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: search-craft
|
|
3
|
+
description: Carries the thinking step a session takes before it searches outside the project, naming the field, deriving who that field treats as authoritative, and running a search scoped to those sources beside an open one, plus a dated note on which sites refuse access. Use before any outside search for evidence, papers, documentation, statistics, or opinion, when a procedure loads it at its sourcing step, or when asked "find me the strongest evidence on X", "where should I look for this", "what does the research say", "find papers on", "look up the docs for", or "what are people saying about". Do NOT use to format a citation, which belongs to the surface writing it, to source a Claude Code subject, which is the `claude-code-guide` agent, or to generate an image, which is not search.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Search craft
|
|
7
|
+
|
|
8
|
+
A session reaching outside the project tends to run one generic search and take whatever ranks highest. Ranking rewards pages written to rank, so a field's thin "complete guide" pages and its advocates crowd out its reference material, and one search gives nothing to check a wrong fact against. This skill carries the step before the search: work out where the field's authoritative material lives, search there, and search openly beside it.
|
|
9
|
+
|
|
10
|
+
The procedure that loaded this skill owns what the result is for and where it is written. Nothing here formats a citation or decides what a finding means.
|
|
11
|
+
|
|
12
|
+
## Name the field
|
|
13
|
+
|
|
14
|
+
- Name the field the question sits in before writing a query. Name two when the question straddles them, such as a pricing question that is both law and economics.
|
|
15
|
+
- Name it at the level a practitioner would, being the discipline that argues about the answer, rather than the topic word in the question.
|
|
16
|
+
- Say the field in the output, so a reader can tell a wrong answer from a search run in the wrong place.
|
|
17
|
+
|
|
18
|
+
## Derive who the field trusts
|
|
19
|
+
|
|
20
|
+
- Ask what kind of body the field treats as settling a question, then name the bodies of that kind. The kinds recur across fields:
|
|
21
|
+
- the standards body or specification that defines the thing
|
|
22
|
+
- the reference documentation its practitioners cite
|
|
23
|
+
- the peer-reviewed papers and working papers of its professional society
|
|
24
|
+
- the official statistics office or regulator that publishes the numbers
|
|
25
|
+
- the project's own repository, changelog, or issue tracker, for a piece of software
|
|
26
|
+
- Derive the names. Never reach for a remembered list of sites per category, since a list rots with nobody noticing and the field's own kind of authority is what the derivation tests.
|
|
27
|
+
- Search for the authorities first when unsure who they are, such as asking where the field's practitioners publish or what its practitioners cite, and scope the real search only after that answer is read.
|
|
28
|
+
- As an illustration of deriving, not a list to reuse: a question on a browser feature's support sits in web platform engineering, whose authorities are the specification, the reference docs practitioners cite, and the compatibility tables built from browser releases. Each new question derives its own.
|
|
29
|
+
|
|
30
|
+
## Search twice
|
|
31
|
+
|
|
32
|
+
- Run the same query twice: once scoped to the derived authorities through `WebSearch`'s `allowed_domains`, and once open.
|
|
33
|
+
- Prefer the scoped result when the two disagree on a fact, and say in the output that they disagreed and which one was kept. Never pick one silently.
|
|
34
|
+
- Use the open result for what the authorities do not publish: practitioner write-ups, field reports, and news.
|
|
35
|
+
- Check the domain of every scoped result before citing it. The filter is not strict, and a scoped run can return results from outside its list.
|
|
36
|
+
- Fetch the authority's page itself to confirm a fact both searches summarized differently, since a search summary is a paraphrase and can carry the error.
|
|
37
|
+
|
|
38
|
+
## Prefer a structured source
|
|
39
|
+
|
|
40
|
+
- Prefer a source's public API over a fetched and summarized HTML page where one exists. An API returns the dates, authors, and identifiers a citation needs, and a summary of the page can drop or blur them.
|
|
41
|
+
- Reach the API with a plain request when it needs no key, rather than writing a wrapper for one search.
|
|
42
|
+
|
|
43
|
+
## Access note
|
|
44
|
+
|
|
45
|
+
Each line is a claim measured once, and `${CLAUDE_SKILL_DIR}/REQUIREMENT.md` records the date under its Gap. Sites change their blocking without notice, so re-check a line before relying on it. Add a line only for a failure a session actually hit, and record the date it hit in the requirement beside it.
|
|
46
|
+
|
|
47
|
+
- Reddit answers its RSS feed and refuses its JSON search with a 403. `WebFetch` is refused for the site.
|
|
48
|
+
- X answers nothing without paying or a login. `curl` redirects to a login page and `WebFetch` returns 402.
|
|
49
|
+
- Stop and ask the operator before any route that pays, logs in, or works around a refusal. Never suggest scraping behind a login.
|
|
50
|
+
|
|
51
|
+
## Excuses and rebuttals
|
|
52
|
+
|
|
53
|
+
| Excuse | Rebuttal |
|
|
54
|
+
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| The model already knows the good sites | It does, and it does not use that knowledge unprompted. Left to rank, a search puts guides written to rank above a field's reference pages. |
|
|
56
|
+
| One search was enough | One search gives nothing to check a wrong fact against. The scoped search is what catches the open summary's error. |
|
|
57
|
+
| The open results look authoritative | Ranking rewards pages written to rank, so advocates and thin guides sit beside the field's own record and tilt the summary. |
|
|
58
|
+
| I scoped it, so every result is in scope | The domain filter is not strict, so a scoped search can return results from outside its list. |
|
|
59
|
+
| The summary already answers it | A summary paraphrases. Fetch the page when two summaries disagree, since that is where the error sits. |
|
|
60
|
+
|
|
61
|
+
## Red flags
|
|
62
|
+
|
|
63
|
+
- You are about to write a query and have not named the field.
|
|
64
|
+
- Every source you are about to cite came from one open search.
|
|
65
|
+
- You named an authority because it is a site you remember, not because it is the kind of body the field trusts.
|
|
66
|
+
- The scoped and open results disagree and the output does not say so.
|
|
67
|
+
- You are citing a scoped result whose domain you have not checked.
|
|
68
|
+
- You are about to pay, log in, or scrape to reach a source and have not asked the operator.
|
|
69
|
+
|
|
70
|
+
## Before handing over
|
|
71
|
+
|
|
72
|
+
- The output names the field, or both fields, the question was searched in.
|
|
73
|
+
- Every authority searched was derived from the kind of body the field trusts.
|
|
74
|
+
- The query ran scoped and open, and every disagreement between them is stated with the side kept.
|
|
75
|
+
- Every cited source's domain was checked, and a fact the summaries disagreed on was confirmed on the page itself.
|
|
76
|
+
- No paid, logged-in, or scraped route was taken without the operator's answer.
|
|
77
|
+
|
|
78
|
+
## What this delegates
|
|
79
|
+
|
|
80
|
+
- The form a citation takes: the surface writing it, such as the standard the procedure writes to
|
|
81
|
+
- A Claude Code subject: the `claude-code-guide` agent, which `draft-wiki` routes to
|
|
82
|
+
- Generating an image: whatever draws the surface, since making one is not search
|
|
83
|
+
- Access code a session needs repeatedly: a `canon` verb, never a script in this skill
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Canvas
|
|
3
|
+
description: The canvas server, its content format, and the verbs that list, arrange, select, and capture frames
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Canvas
|
|
7
|
+
|
|
8
|
+
`canon canvas` serves a local canvas of pages and HTML frames and gives an agent the verbs the operator's mouse has. A page is a folder under `.canon/canvas/`, a frame is one HTML file in it, and `layout.json` beside them holds each frame's box.
|
|
9
|
+
|
|
10
|
+
## Serve
|
|
11
|
+
|
|
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
|
+
|
|
14
|
+
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
|
+
|
|
16
|
+
## List and add
|
|
17
|
+
|
|
18
|
+
`canvas list --json` emits every page with its frames. `page add`, `page rename`, and `frame add` (`--width`, `--height`) each emit one record under `--json`, refusing a name that is not one path segment, a page that already exists, or a root that is not a directory, with the reason on stderr.
|
|
19
|
+
|
|
20
|
+
## Arrange and select
|
|
21
|
+
|
|
22
|
+
`frame move <page> <name> --x <px> --y <px>` rewrites one frame's position in `layout.json` and keeps its size. An omitted axis stays where the frame is. It refuses a frame or page that does not exist and a layout that does not parse.
|
|
23
|
+
|
|
24
|
+
The shell writes through the same writer when the operator drags a frame. Both take a lock file beside the layout, read it, merge their change, and replace it, so a drag released while a verb writes loses neither position. A writer that cannot get the lock within two seconds refuses with reason `busy`.
|
|
25
|
+
|
|
26
|
+
`canvas selection --json` emits `{ ok, selection }` with the page, frame, file, box, and path of the frame the operator selected, or `selection: null` when nothing is selected or the selected frame has since been removed. Read it when the operator says "this one".
|
|
27
|
+
|
|
28
|
+
The operator can also pick one element inside a frame, by clicking it on the surface or in the frame's layers tree. The selection then carries `element`, recording the `index` in document order, `tag`, `classes`, and `text` excerpt as they were at the pick. An edit to the frame since can shift that index onto another element, so `stale` turns true once the file's content no longer matches what the pick was made against. Check it before acting on the index.
|
|
29
|
+
|
|
30
|
+
A pick is refused as `address-mismatch` when the browser builds elements the file never states, such as a `tbody` a table leaves out or the document wrapper around a fragment. The shell sends its own element count beside the index, so a frame the two sides count differently is reported rather than recorded against the wrong element. A pick is refused as `stale-address` when the frame file changed between the frame being served and the pick arriving, since every served frame carries the hash of its file and the shell sends that hash back.
|
|
31
|
+
|
|
32
|
+
## Capture
|
|
33
|
+
|
|
34
|
+
`canvas capture <page>/<frame>` renders one frame as a PNG, and `canvas capture <page>` renders every frame on the page. Each goes through a server started for the call, so the injected tokens are in the image, and renders at the frame's own width from the layout, so a media query resolves there. It never captures the file on disk.
|
|
35
|
+
|
|
36
|
+
`--out` is the PNG for a frame and a folder for a page, defaulting to session scratch. `--json` emits `{ ok, captures }` with the path and pixel size of each frame. Each PNG is the frame's whole document, so its height follows the document and not the frame's box. The font check `canon capture` makes applies, so a frame naming a font the machine lacks fails with that reason. A refused capture reports `no-page` or `no-frame` for a target that does not exist, `no-server` when the server for the call cannot start, and `capture-failed` for an error the engine throws, such as a browser that will not launch.
|
package/docs/agents/commands.md
CHANGED
|
@@ -34,7 +34,7 @@ One row per top-level command, in the order `canon --help` prints them. A domain
|
|
|
34
34
|
| `canon drive <url> <run>` | Walk a page through named interactions and measure each state |
|
|
35
35
|
| `canon transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
|
|
36
36
|
| `canon teach <verb>` | Open and author learning workspaces |
|
|
37
|
-
| `canon canvas <verb>` | Serve a local canvas of pages and HTML frames,
|
|
37
|
+
| `canon canvas <verb>` | Serve a local canvas of pages and HTML frames, arrange them, and capture them |
|
|
38
38
|
| `canon sandbox [cat:cmd]` | Run sandbox scenarios, toolkit-only like the tree it reads |
|
|
39
39
|
| `canon tasks <verb>` | Read and write the task board, the plans it cites, and its archive |
|
|
40
40
|
| `canon intake <verb>` | Report intake folders and write answers into them |
|
|
@@ -96,7 +96,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
|
|
|
96
96
|
- `demo`, in `demo.md`: `compile`, `run`, `frames`
|
|
97
97
|
- `inventory`, described above: `run`
|
|
98
98
|
- `teach`, in `teach.md`: `list`, `open`, `resource`, `glossary`, `lesson`, `stylesheet`, `nav`, `render`
|
|
99
|
-
- `canvas`, described below: `serve`, `list`, `page add`, `page rename`, `frame add`
|
|
99
|
+
- `canvas`, described below: `serve`, `list`, `page add`, `page rename`, `frame add`, `frame move`, `selection`, `capture`
|
|
100
100
|
- `sandbox`, in `sandbox.md`: `reset`, `clean`, `check`, `coverage`
|
|
101
101
|
- `tasks`, in `tasks.md`: `next-label`, `archive`, `decline`, `plan-citations`, `plan-answers`, `plan-branch`, `plan-reach`, `plan-link`, `pull-request`, `outcome`, `list`, `validate`
|
|
102
102
|
- `intake`, in `intake.md`: `list`, `answer`
|
|
@@ -129,7 +129,7 @@ Subcommands no other page describes:
|
|
|
129
129
|
- `claude skills rank` scores either skill corpus's descriptions against a case corpus by TF-IDF cosine similarity, reporting rank-one and top-three (`--cases <path>`).
|
|
130
130
|
- `design regen` rewrites this repository's `canon/DESIGN.md` from `src/design/tokens.ts` and `src/design/base.css` from `src/design/neutral.ts`. `design css` emits the tokens and components as CSS on stdout, with `--no-components` for properties alone, and `design render` declares each vendored face the typography table names under `--embed-fonts`.
|
|
131
131
|
- `slides render` renders a `.claude/SLIDES.md` source into a PowerPoint deck and reports any unrecognized layout name on stderr, and `slides list --json` emits the layout catalog.
|
|
132
|
-
- `canvas
|
|
132
|
+
- `canvas`, with its verbs, content format, and write routes, is described in `canvas.md`.
|
|
133
133
|
- `labels scan` fails a pull request or a posted review whose title, body, or review comment carries a phase label, a label a code span quotes, a gitignored record path, a session link, a title word no dictionary holds, or a title breaking `standards/pr.md`'s format, casing, or length rule (`--event`, `--body-file`, `--json`).
|
|
134
134
|
- `repo metadata propose` compares a description, homepage, and topic set computed from the README and `package.json` against what the remote carries, writing nothing, and `repo metadata apply` writes an explicitly supplied set through `gh repo edit` (`--description`, `--homepage`, `--topics`).
|
|
135
135
|
|
package/docs/agents/index.md
CHANGED
|
@@ -10,6 +10,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
10
10
|
|
|
11
11
|
- [Architecture record](architecture-record.md): The five findings canon context audit reads off the architecture record, being the entry cap, revisit sentence, and length gates the record states for itself, the claim coverage report, and the word counts
|
|
12
12
|
- [Audits](audits.md): Running every health check as one set, what the single verdict means, the exit code each outcome takes, the retained baseline and the delta it reports, and which corpora are kept out of the record
|
|
13
|
+
- [Canvas](canvas.md): The canvas server, its content format, and the verbs that list, arrange, select, and capture frames
|
|
13
14
|
- [Capture](capture.md): Rendering HTML sources to PNG, what the command asserts about fonts, and why the selector has no default
|
|
14
15
|
- [Census](census.md): Tracked-plus-untracked file count, a breakdown by extension, and a line total that skips whatever reads as binary
|
|
15
16
|
- [Command catalog](commands.md): Every top-level command with its purpose, each domain's subcommands and the page describing them, the browser commands, and the version skew report
|
|
@@ -50,6 +50,7 @@ This page is the corpus the coverage claim is measured against: every name `cano
|
|
|
50
50
|
| `canon:deprecation-migration` | When deleting or replacing code something may still call, to migrate its callers and prove none remain |
|
|
51
51
|
| `canon:code-craft` | Before writing a function, class, or module, to pick the shape the next change needs |
|
|
52
52
|
| `canon:review-craft` | When reviewing a change, for what to look for and how much evidence a finding needs |
|
|
53
|
+
| `canon:search-craft` | Before searching outside the project, to derive where the field's authorities publish and search there too |
|
|
53
54
|
| `canon:systematic-debugging` | When a test fails or a bug surfaces, to force root cause first |
|
|
54
55
|
| `canon:design-taste` | When drafting or judging an interface, or when output reads generic, to settle which decision comes first |
|
|
55
56
|
| `canon:ui-checklist` | After a UI change, to write what to look at and name what ships untested |
|
package/package.json
CHANGED
|
@@ -43,7 +43,7 @@ resolve_sandbox_git_identity() {
|
|
|
43
43
|
SANDBOX_GIT_NAME="${SANDBOX_GIT_NAME:-$(git config --global user.name 2>/dev/null || true)}"
|
|
44
44
|
SANDBOX_GIT_EMAIL="${SANDBOX_GIT_EMAIL:-$(git config --global user.email 2>/dev/null || true)}"
|
|
45
45
|
|
|
46
|
-
: "${SANDBOX_GIT_NAME:=
|
|
46
|
+
: "${SANDBOX_GIT_NAME:=canon-sandbox}"
|
|
47
47
|
: "${SANDBOX_GIT_EMAIL:=sandbox@example.com}"
|
|
48
48
|
|
|
49
49
|
export SANDBOX_GIT_NAME SANDBOX_GIT_EMAIL
|
|
@@ -57,7 +57,7 @@ configure_sandbox_git_identity() {
|
|
|
57
57
|
|
|
58
58
|
# Every scenario that needs a remote points at the same throwaway repository, so
|
|
59
59
|
# the name lives here rather than in each one.
|
|
60
|
-
SANDBOX_ANCHOR_REPO="
|
|
60
|
+
SANDBOX_ANCHOR_REPO="canon-sandbox"
|
|
61
61
|
|
|
62
62
|
# A scenario calls this from its own use_anchor hook rather than this file
|
|
63
63
|
# defining the hook. manage-sandbox.sh keys off `type -t use_anchor`, so
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The element address: a frame plus the element's index in document order.
|
|
3
|
+
* The shell counts it over the frame's parsed DOM and the server counts it over
|
|
4
|
+
* the file through `HTMLRewriter`, so the two have to count the same elements.
|
|
5
|
+
* This module imports nothing, which keeps it bundling into the shell, and the
|
|
6
|
+
* server half reaches `HTMLRewriter` as a Bun global only when called.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Marks the token stylesheet the server injects, which the file never holds. */
|
|
10
|
+
export const TOKENS_ATTRIBUTE = 'data-canvas-tokens'
|
|
11
|
+
|
|
12
|
+
/** On that same element, the hash of the file the frame was served from. */
|
|
13
|
+
export const HASH_ATTRIBUTE = 'data-canvas-hash'
|
|
14
|
+
|
|
15
|
+
const EXCERPT_LENGTH = 80
|
|
16
|
+
|
|
17
|
+
/** Elements whose content is raw text, which a browser never parses as markup. */
|
|
18
|
+
const RAW_TEXT = new Set(['script', 'style', 'template', 'textarea', 'title'])
|
|
19
|
+
|
|
20
|
+
export interface ElementAddress {
|
|
21
|
+
readonly index: number
|
|
22
|
+
readonly tag: string
|
|
23
|
+
/**
|
|
24
|
+
* Every element the shell counted. A browser implies elements the file never
|
|
25
|
+
* states, such as a `tbody` or the document wrapper, and each one shifts the
|
|
26
|
+
* count, so a total that differs from the server's is what exposes it.
|
|
27
|
+
*/
|
|
28
|
+
readonly count: number
|
|
29
|
+
/**
|
|
30
|
+
* The served file's hash, so the server can refuse a pick made against a
|
|
31
|
+
* version of the frame the file has since moved past.
|
|
32
|
+
*/
|
|
33
|
+
readonly hash?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SourceElement {
|
|
37
|
+
readonly tag: string
|
|
38
|
+
readonly classes: readonly string[]
|
|
39
|
+
readonly text: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type AddressCheck =
|
|
43
|
+
| { readonly ok: true; readonly element: SourceElement }
|
|
44
|
+
| {
|
|
45
|
+
readonly ok: false
|
|
46
|
+
readonly reason: 'address-mismatch'
|
|
47
|
+
readonly detail: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function excerpt(text: string): string {
|
|
51
|
+
return text.replace(/\s+/g, ' ').trim().slice(0, EXCERPT_LENGTH)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function documentElements(doc: Document): Element[] {
|
|
55
|
+
return [...doc.querySelectorAll('*')].filter(
|
|
56
|
+
(element) => !element.hasAttribute(TOKENS_ATTRIBUTE),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function addressOf(
|
|
61
|
+
doc: Document,
|
|
62
|
+
element: Element,
|
|
63
|
+
): ElementAddress | undefined {
|
|
64
|
+
const all = documentElements(doc)
|
|
65
|
+
const index = all.indexOf(element)
|
|
66
|
+
if (index === -1) return undefined
|
|
67
|
+
const address = {
|
|
68
|
+
index,
|
|
69
|
+
tag: element.tagName.toLowerCase(),
|
|
70
|
+
count: all.length,
|
|
71
|
+
}
|
|
72
|
+
const hash = doc
|
|
73
|
+
.querySelector(`[${TOKENS_ATTRIBUTE}]`)
|
|
74
|
+
?.getAttribute(HASH_ATTRIBUTE)
|
|
75
|
+
return hash ? { ...address, hash } : address
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The element an address names, or nothing once the tag there differs. */
|
|
79
|
+
export function elementAt(
|
|
80
|
+
doc: Document,
|
|
81
|
+
address: Pick<ElementAddress, 'index' | 'tag'>,
|
|
82
|
+
): Element | undefined {
|
|
83
|
+
const element = documentElements(doc)[address.index]
|
|
84
|
+
return element?.tagName.toLowerCase() === address.tag ? element : undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface Collected {
|
|
88
|
+
readonly tag: string
|
|
89
|
+
readonly classes: readonly string[]
|
|
90
|
+
text: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every element the file states, in document order. Text is gathered into
|
|
95
|
+
* each open element, which an element closed only by implication keeps open to
|
|
96
|
+
* the end, so its excerpt can run past where a browser would end it.
|
|
97
|
+
*/
|
|
98
|
+
export function sourceElements(html: string): SourceElement[] {
|
|
99
|
+
const elements: Collected[] = []
|
|
100
|
+
const open: number[] = []
|
|
101
|
+
let rawDepth = 0
|
|
102
|
+
/* A template's content is a fragment `querySelectorAll` never reaches. */
|
|
103
|
+
let templateDepth = 0
|
|
104
|
+
|
|
105
|
+
const appendText = (text: string) => {
|
|
106
|
+
for (const index of open) {
|
|
107
|
+
const element = elements[index]
|
|
108
|
+
if (element && element.text.length < EXCERPT_LENGTH * 4) {
|
|
109
|
+
element.text += text
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
new HTMLRewriter()
|
|
115
|
+
.on('*', {
|
|
116
|
+
element(element) {
|
|
117
|
+
const tag = element.tagName.toLowerCase()
|
|
118
|
+
if (templateDepth > 0) {
|
|
119
|
+
if (tag === 'template') {
|
|
120
|
+
templateDepth += 1
|
|
121
|
+
element.onEndTag(() => {
|
|
122
|
+
templateDepth -= 1
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
const index = elements.length
|
|
128
|
+
/* A tag boundary separates words the way a rendered block would. */
|
|
129
|
+
appendText(' ')
|
|
130
|
+
elements.push({
|
|
131
|
+
tag,
|
|
132
|
+
classes: (element.getAttribute('class') ?? '')
|
|
133
|
+
.split(/\s+/)
|
|
134
|
+
.filter(Boolean),
|
|
135
|
+
text: '',
|
|
136
|
+
})
|
|
137
|
+
if (!element.canHaveContent || element.selfClosing) return
|
|
138
|
+
const isRaw = RAW_TEXT.has(tag)
|
|
139
|
+
const isTemplate = tag === 'template'
|
|
140
|
+
open.push(index)
|
|
141
|
+
if (isRaw) rawDepth += 1
|
|
142
|
+
if (isTemplate) templateDepth += 1
|
|
143
|
+
element.onEndTag(() => {
|
|
144
|
+
const at = open.lastIndexOf(index)
|
|
145
|
+
if (at !== -1) open.splice(at, 1)
|
|
146
|
+
if (isRaw) rawDepth -= 1
|
|
147
|
+
if (isTemplate) templateDepth -= 1
|
|
148
|
+
appendText(' ')
|
|
149
|
+
})
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
.onDocument({
|
|
153
|
+
text(chunk) {
|
|
154
|
+
if (rawDepth === 0) appendText(chunk.text)
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
.transform(html)
|
|
158
|
+
|
|
159
|
+
return elements.map(({ tag, classes, text }) => ({
|
|
160
|
+
tag,
|
|
161
|
+
classes,
|
|
162
|
+
text: excerpt(text),
|
|
163
|
+
}))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Finds the element the shell addressed in the file, and refuses when the two
|
|
168
|
+
* counts disagree rather than naming whatever sits at that index on disk.
|
|
169
|
+
*/
|
|
170
|
+
export function resolveAddress(
|
|
171
|
+
html: string,
|
|
172
|
+
address: ElementAddress,
|
|
173
|
+
): AddressCheck {
|
|
174
|
+
const elements = sourceElements(html)
|
|
175
|
+
const element = elements[address.index]
|
|
176
|
+
if (elements.length !== address.count || element?.tag !== address.tag) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
reason: 'address-mismatch',
|
|
180
|
+
detail: `the browser counted ${address.count} elements and the file states ${elements.length}, so element ${address.index} cannot be matched`,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { ok: true, element }
|
|
184
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import type { CaptureResult } from '@/capture/render'
|
|
3
|
+
import { type ContentRefused, type Frame, readPage } from '@/canvas/content'
|
|
4
|
+
import {
|
|
5
|
+
type CanvasOptions,
|
|
6
|
+
type CanvasOutcome,
|
|
7
|
+
FRAMES_PREFIX,
|
|
8
|
+
startCanvas,
|
|
9
|
+
} from '@/canvas/server'
|
|
10
|
+
import { recordDir } from '@/record-root'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The element a frame is captured by. The document rather than its body, so a
|
|
14
|
+
* frame that paints its background on the root is not cropped to the body's
|
|
15
|
+
* box.
|
|
16
|
+
*/
|
|
17
|
+
export const CAPTURE_SELECTOR = 'html'
|
|
18
|
+
|
|
19
|
+
export interface CaptureTarget {
|
|
20
|
+
readonly page: string
|
|
21
|
+
readonly frame: string
|
|
22
|
+
/** The served address, which is what carries the injected tokens. */
|
|
23
|
+
readonly url: string
|
|
24
|
+
/** The viewport width, so a media query resolves at the frame's own width. */
|
|
25
|
+
readonly width: number
|
|
26
|
+
readonly pngPath: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type TargetsOutcome =
|
|
30
|
+
| { readonly ok: true; readonly targets: readonly CaptureTarget[] }
|
|
31
|
+
| ContentRefused
|
|
32
|
+
|
|
33
|
+
function refuse(
|
|
34
|
+
reason: ContentRefused['reason'],
|
|
35
|
+
detail: string,
|
|
36
|
+
): ContentRefused {
|
|
37
|
+
return { ok: false, reason, detail }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function frameUrl(base: string, page: string, frame: Frame): string {
|
|
41
|
+
return `${base}${FRAMES_PREFIX.slice(1)}${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Turns `<page>` or `<page>/<frame>` into what to capture. Every target is a
|
|
46
|
+
* served address, never the file on disk, so a capture can only show what the
|
|
47
|
+
* canvas shows. The width comes from the layout and not from the window, so a
|
|
48
|
+
* frame narrower than its own breakpoint renders its narrow layout.
|
|
49
|
+
*
|
|
50
|
+
* `out` is the PNG for a frame and a folder for a page, the way a URL source
|
|
51
|
+
* reads `--out` in `canon capture`.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveCaptureTargets(
|
|
54
|
+
root: string,
|
|
55
|
+
spec: string,
|
|
56
|
+
base: string,
|
|
57
|
+
out?: string,
|
|
58
|
+
): TargetsOutcome {
|
|
59
|
+
const [pageName, frameName, ...extra] = spec.split('/')
|
|
60
|
+
if (!pageName || extra.length > 0 || frameName === '') {
|
|
61
|
+
return refuse('invalid-name', `${spec} is not <page> or <page>/<frame>`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const page = readPage(root, pageName)
|
|
65
|
+
if (!page) return refuse('no-page', `page ${pageName} does not exist`)
|
|
66
|
+
|
|
67
|
+
const frames =
|
|
68
|
+
frameName === undefined
|
|
69
|
+
? page.frames
|
|
70
|
+
: page.frames.filter((frame) => frame.name === frameName)
|
|
71
|
+
if (frames.length === 0) {
|
|
72
|
+
return frameName === undefined
|
|
73
|
+
? refuse('no-frame', `page ${pageName} has no frames`)
|
|
74
|
+
: refuse('no-frame', `frame ${frameName} does not exist on ${pageName}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const scratch = recordDir(root, 'tmp', 'canvas-capture', pageName)
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
targets: frames.map((frame) => ({
|
|
81
|
+
page: pageName,
|
|
82
|
+
frame: frame.name,
|
|
83
|
+
url: frameUrl(base, pageName, frame),
|
|
84
|
+
width: frame.width,
|
|
85
|
+
pngPath:
|
|
86
|
+
frameName !== undefined && out !== undefined
|
|
87
|
+
? out
|
|
88
|
+
: join(out ?? scratch, `${frame.name}.png`),
|
|
89
|
+
})),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type CaptureOutcome =
|
|
94
|
+
| {
|
|
95
|
+
readonly ok: true
|
|
96
|
+
readonly captures: readonly {
|
|
97
|
+
readonly target: CaptureTarget
|
|
98
|
+
readonly result: CaptureResult
|
|
99
|
+
}[]
|
|
100
|
+
}
|
|
101
|
+
| ContentRefused
|
|
102
|
+
| {
|
|
103
|
+
readonly ok: false
|
|
104
|
+
readonly reason: 'no-server' | 'capture-failed'
|
|
105
|
+
readonly detail: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type CaptureEngine = (typeof import('@/capture/render'))['captureSources']
|
|
109
|
+
|
|
110
|
+
/** What a test stands in for, which the command never passes. */
|
|
111
|
+
export interface CaptureDeps {
|
|
112
|
+
readonly start?: (root: string, options: CanvasOptions) => CanvasOutcome
|
|
113
|
+
readonly capture?: CaptureEngine
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Serves the canvas for the length of the call and captures through it, so the
|
|
118
|
+
* run needs no `canon canvas serve` and still never reads the raw file. The
|
|
119
|
+
* port is whatever is free, which keeps it clear of a canvas already running.
|
|
120
|
+
* An error the engine throws, such as a browser that will not launch, comes
|
|
121
|
+
* back as `capture-failed` rather than escaping, so a caller reading the record
|
|
122
|
+
* can tell it from a root that is wrong.
|
|
123
|
+
*/
|
|
124
|
+
export async function captureCanvas(
|
|
125
|
+
root: string,
|
|
126
|
+
spec: string,
|
|
127
|
+
out?: string,
|
|
128
|
+
deps: CaptureDeps = {},
|
|
129
|
+
): Promise<CaptureOutcome> {
|
|
130
|
+
/*
|
|
131
|
+
* Refused before the server starts, since starting it creates the canvas
|
|
132
|
+
* folder and a refused capture should leave the project as it found it.
|
|
133
|
+
*/
|
|
134
|
+
const early = resolveCaptureTargets(root, spec, '')
|
|
135
|
+
if (!early.ok) return early
|
|
136
|
+
|
|
137
|
+
const start = deps.start ?? startCanvas
|
|
138
|
+
const server = start(root, { port: 0, shell: new Response('') })
|
|
139
|
+
if (!server.ok) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
reason: 'no-server',
|
|
143
|
+
detail: `${server.detail}, so run canon canvas serve and capture again`,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const resolved = resolveCaptureTargets(root, spec, server.url, out)
|
|
149
|
+
if (!resolved.ok) return resolved
|
|
150
|
+
|
|
151
|
+
const capture =
|
|
152
|
+
deps.capture ?? (await import('@/capture/render')).captureSources
|
|
153
|
+
const captures: { target: CaptureTarget; result: CaptureResult }[] = []
|
|
154
|
+
for (const target of resolved.targets) {
|
|
155
|
+
const [result] = await capture(target.url, {
|
|
156
|
+
selector: CAPTURE_SELECTOR,
|
|
157
|
+
width: target.width,
|
|
158
|
+
outDir: target.pngPath,
|
|
159
|
+
})
|
|
160
|
+
if (result) captures.push({ target, result })
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, captures }
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: 'capture-failed',
|
|
167
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
168
|
+
}
|
|
169
|
+
} finally {
|
|
170
|
+
await server.stop()
|
|
171
|
+
}
|
|
172
|
+
}
|