@erclx/canon 4.7.0 → 4.8.1

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.
@@ -22,9 +22,13 @@ canon tasks archive --pull-request 673 --json
22
22
  | `--json` | Emit a machine-readable record on stdout |
23
23
  | `--root <path>` | Board root, defaulting to the main worktree |
24
24
 
25
- Exit codes: `0` archived, `1` refused. Every gate is a refusal rather than a warning, because `.husky/post-merge` calls this with nobody watching. The `reason` field carries which gate fired: `no-board`, `no-match`, `ambiguous`, `no-outcomes`, `open-outcomes`, `plan-unswept`, or `bad-input`.
25
+ Exit codes: `0` archived, `1` refused. Every gate is a refusal rather than a warning, because `.husky/post-merge` calls this with nobody watching. The `reason` field carries which gate fired: `no-board`, `no-match`, `ambiguous`, `no-outcomes`, `open-outcomes`, or `bad-input`.
26
26
 
27
- `plan-unswept` fires on the last task pointing at a live plan, never on every task pointing at one. The gate counts the other live tasks whose `Plan:` line resolves onto the same file, so a plan several tasks share archives its tasks freely and only the final one is held until `claude-docs` sweeps the plan. Reading the folder alone refused all of them, which deadlocked the board against a sweep correctly declining to move a plan another live task cites.
27
+ The task carries its plan with it. When the closing task is the last live one whose `Plan:` line resolves onto that file, the plan moves to `.canon/plans/archive/` under its own name and the archived task's line is rewritten as `Plan: [feature-<slug>](../../plans/archive/feature-<slug>.md)`, a folder deeper than the live task wrote it. The `plan` field on the success record carries that `from` and `to`, and is `null` when nothing moved.
28
+
29
+ A plan several tasks share stays where it is, and the task archives anyway. Moving it on the first task to close strands every sibling's pointer at a path that has gone, and `.canon/plans/` is gitignored so no history recovers the target. A `Plan:` line resolving to no file leaves the plan alone too, since a pointer somebody typed wrong is not a plan to move and holding the whole archive over it would park the board behind a repair the merge cannot make.
30
+
31
+ The merge is what settles a plan. `.husky/post-merge` reads the pull request number off the squash subject and calls this verb, so both halves close in one act with nobody naming a file. That is why the move sits inside this verb rather than in a second call the hook would make after it, which could leave the task archived and the plan live.
28
32
 
29
33
  `bad-input` covers a malformed command line, which all three task verbs answer the same way. It is separate from `ambiguous` and `no-match` because those describe the board, and a caller that passed two selectors would otherwise be sent to repair a task citation that is fine.
30
34
 
@@ -53,7 +57,7 @@ The record carries `location`, one of `unstated`, `live`, `archived`, or `outsid
53
57
 
54
58
  The target resolves against the board folder and against the project root both, so `../plans/x.md` and `.canon/plans/x.md` land on the same file and one plan two tasks spelled differently counts once. Containment is tested at both record roots rather than at the one this tree resolves at, since a line somebody wrote against a root the tree has since left is still a path into the plans folder, and reading it as outside would report a shipped plan as still live. `docs/agents/records.md` states the read order.
55
59
 
56
- `canon tasks archive` gates on this same answer, so a caller wanting the count reads it here rather than scanning the board. The `claude-docs` plans sweep is the exception and still states the rule in its own body, because a plugin skill reaches a target on merge while the CLI reaches one on release, so a sweep calling a verb the installed `canon` predates gets no record back and archives nothing.
60
+ `canon tasks archive` decides its plan move on this same answer, so a caller wanting the count reads it here rather than scanning the board.
57
61
 
58
62
  Branch on `reason` rather than on the exit code, which is the rule the archive section above already states and which this verb needs for a second reason. An operator's shell profile may wrap `canon` in a function that runs the binary and then another command and takes the second status, which masks every non-zero exit rather than only an absent verb. The binary exits 1 for an unknown subcommand and 1 for an ordinary refusal alike, so the record is the only signal that survives the wrapper.
59
63
 
@@ -159,7 +163,7 @@ Seven checks run. Plan and Collisions reach one half each of the `## Run now` te
159
163
  | Check | What it reports |
160
164
  | ---------- | -------------------------------------------------------------------------------------------------------------------- |
161
165
  | Shape | A row whose cell count disagrees with its table's header, or one stranded behind a table a blank line already closed |
162
- | Plan | A `## Run now` row whose Plan column carries no link, or one resolving to no file |
166
+ | Plan | A `## Run now` row whose Plan column carries no link, resolves to no file, or disagrees with the task's own line |
163
167
  | Mapping | A row or backlog line naming no task file, and a task file neither surface names |
164
168
  | Grouping | A task carrying a row in more than one readiness group, or on both surfaces |
165
169
  | Ordering | A `## Needs a plan` row whose stated position disagrees with where it actually sits |
@@ -168,6 +172,8 @@ Seven checks run. Plan and Collisions reach one half each of the `## Run now` te
168
172
 
169
173
  Shape runs before any other check reads a row, since a row failing it carries no dependable fields for the rest to check. A blank or prose line closes the table above it, so the walk treats the next pipe line as a fresh header candidate rather than as a continuation. That candidate counts as a header only when the line behind it is a separator carrying the same cell count, and one that fails is `row-untabled`, stranded behind a table that already closed. Cell count still has to match the header on every row that clears that test, and a row whose count disagrees is `row-misshapen`, the shape a dropped pipe or a merged column produces.
170
174
 
175
+ The Plan check reads the row and the task file both, because the two are written by different hands and only the task's own `Plan:` line reaches the archive. A row carrying a plan whose task states none is `plan-uncited`, and a pair naming two different plans is `plan-mismatched`. Both sides resolve against the board and against the project root before they compare, so a row writing `../plans/x.md` and a task writing `.canon/plans/x.md` name one file rather than two.
176
+
171
177
  Mapping spans two surfaces, because a task sits on `priority.md` when it would plausibly be planned soon and on `backlog.md` otherwise. A task file either surface names is accounted for, a file neither names is `row-missing`, and a file both name is `row-duplicated` for the reason a task in two groups is: it claims two things about itself and only one can hold. One check across both is what lets a task move between them without the move reading as a dropped file.
172
178
 
173
179
  A backlog line is a bullet carrying a link to a sibling task, since the backlog is a flat unordered list rather than a table. A bullet holding prose is skipped rather than reported, which keeps the file's own intro out of the findings, and the task that bullet meant to name is still reported as reaching neither surface. A project carrying no `backlog.md` reads as an empty backlog rather than a refusal, which leaves the one-to-one mapping this check ran before the second surface existed.
@@ -108,12 +108,10 @@ A person points it at a private repository once and both verbs refuse until they
108
108
 
109
109
  `canon records size` reports what each of these folders holds, heaviest first, along with `.canon/tmp`. Each row carries the file count, the bytes, how many files were written in the last 7 and 30 days, and the dates of the least and most recently written one. Nothing fails on a number, because a record folder has no correct size. What the verb replaces is a reading somebody had to remember to take: the memory pen went from 44 entries to 236 between two counts made by hand a fortnight apart, and nothing reported the rate in between.
110
110
 
111
- A plan that ships is archived, never deleted. `canon:claude-docs` moves it to `.canon/plans/archive/` and retargets the task file's `Plan:` line at the new location, so a completed task still leads to the reasoning behind it. An archive sits inside the record folder it archives rather than beside it, so one ignore entry and one backed-folder entry cover a record and everything it has retired. The folder is gitignored, which is why a deleted plan had no recovery path. A plan cited by more than one task stays put until the last of them closes, since moving it early would strand every other pointer.
111
+ A plan that ships is archived, never deleted. `canon tasks archive` moves it to `.canon/plans/archive/` alongside the task it belonged to and retargets that task's `Plan:` line at the new location, so a completed task still leads to the reasoning behind it. An archive sits inside the record folder it archives rather than beside it, so one ignore entry and one backed-folder entry cover a record and everything it has retired. The folder is gitignored, which is why a deleted plan had no recovery path. A plan cited by more than one task stays put until the last of them closes, since moving it early would strand every other pointer.
112
112
 
113
113
  A branch review report takes the other route and is swept rather than archived. `claude-review` writes it to `.canon/review/branch/`, the session addressing it reads it once, and the durable record of what a review found is the comment `claude-pr-review` posts on the pull request, so `claude-docs` deletes any report whose branch is gone. The body that writes a report owns how long it lives, which leaves the shipping branch's own report on disk through the run that cites it and collects it a branch later. What that loses is a local-only review on a branch that never opened a pull request, which is why the report says so where a reader meets it.
114
114
 
115
- The plans sweep reads the whole board rather than the tasks the session touched. It is the one place the skill reaches past its own rule against editing a task file the session did not change, because a task that closed while an earlier run missed its archive is exactly what the sweep exists to clear. Reaching it is safe: the archive moves the plan and retargets the pointer in the same pass, so an untouched task ends up with a working link rather than a broken one.
116
-
117
115
  `canon:claude-docs` decides which task closed by reading the diff rather than the conversation. It resolves a merge base against `origin/main`, unions the committed diff with the working tree and untracked files, then matches unchecked outcomes on the board against what shipped. A task that shipped without ever being discussed still gets marked. Requirements, architecture, and design stay session-sourced, because a diff cannot carry a judgment.
118
116
 
119
117
  `.canon/tasks/` is gitignored and resolves at the main worktree root, so every session shares one board. One file per task is what keeps concurrent sessions from overwriting each other, since a gitignored board has no history to recover a lost write from. Its `index.md` is generated by a hook rather than by `bun run check`, because the whole-repo index walk skips gitignored folders.
@@ -126,7 +124,7 @@ Past that shape, it checks what a surviving row claims against what the tree hol
126
124
 
127
125
  `canon:claude-tasks` owns the two operations that bracket a task's life. It creates the file, holding the filename convention and the frontmatter contract so a malformed write cannot break the index for every sibling, and it moves a shipped task to `.canon/tasks/archive/`. Creation is where the origin invariant is enforced: every task names a plan, a groundwork folder, an intake folder, or an issue, since a task with no origin is either lost context or work nobody decided to do.
128
126
 
129
- Archiving a task leaves its plan alone, because `canon:claude-docs` owns the plans sweep and already holds the last-live-citation rule. That makes the order load-bearing, so the archive verb refuses the last task pointing at a live plan. The sweep only reaches tasks still in the live folder, and archiving that task first would strand the plan there with nothing citing it. A task whose plan a sibling still cites archives freely, because the sweep is correct to leave that plan where it is and a gate reading the folder instead would park every task sharing one plan behind a sweep that will never move it.
127
+ Archiving a task carries its plan with it, in the same act. The merge is what settles a plan, and the hook below reaches the archive with nobody watching, so a second call after it would be a second failure point leaving the task archived and the plan live. A task whose plan a sibling still cites archives on its own and leaves the plan live, since moving it on the first task to close strands every other pointer at a path that has gone.
130
128
 
131
129
  Nothing chained that archive until the `post-merge` git hook landed. Every earlier step fires from `canon:claude-autoship` or `canon:git-ship`, both of which finish while the pull request is still open, so a task archived there would close for work that may be abandoned. The board is gitignored, which rules out reading it from anywhere but the machine that pulled. The hook names the board's archive candidates and stays silent otherwise, including on a project with no board.
132
130
 
@@ -12,6 +12,8 @@ Claude Code auto-discovers the toolkit plugin from `claude/.claude-plugin/plugin
12
12
 
13
13
  `bun run bootstrap` installs this block. It owns the canonical copy in `scripts/core/bootstrap.sh`, appends it to `~/.zshrc` wrapped in the two marker comments below, and sets `TOOLKIT` to the cloned path. The markers are how a re-run recognizes its own block and skips.
14
14
 
15
+ Skipping means a re-run never updates a block already installed, so a shell that ran the bootstrap before the canonical block grew keeps whatever it got that day. To pick up an alias added since, delete the marked block and re-run the bootstrap, or paste the new lines into the block by hand.
16
+
15
17
  A block installed by hand before the managed one has no markers. Bootstrap detects that case through the `alias clp=` line instead, warns, and leaves the block untouched rather than appending a second copy. To switch over, delete the hand-rolled block and re-run the bootstrap.
16
18
 
17
19
  ```zsh
@@ -27,26 +29,34 @@ alias cls='cl --model sonnet'
27
29
  alias clp='claude --plugin-dir $TOOLKIT/claude'
28
30
  alias clpc='clp -c'
29
31
  alias clps='clp --model sonnet'
32
+ alias clpa='clp agents'
33
+ alias clpac='clpa --cwd .'
30
34
  # <<< canon aliases <<<
31
35
  ```
32
36
 
33
- The block sits after any `PATH` mutations and the `claude` CLI install. Zsh expands aliases recursively on the first word, so `clr`, `clc`, `clw`, and `cls` inherit their base through `cl`, and `clpc` and `clps` inherit `--plugin-dir` through `clp`. `$TOOLKIT` expands at invocation time, so updating the variable and re-sourcing reroutes all `clp` calls without touching the alias definitions.
37
+ The block sits after any `PATH` mutations and the `claude` CLI install. Zsh expands aliases recursively on the first word, so `clr`, `clc`, `clw`, and `cls` inherit their base through `cl`, and `clpc`, `clps`, and `clpa` inherit `--plugin-dir` through `clp`. `clpac` inherits it one level further, through `clpa`. `$TOOLKIT` expands at invocation time, so updating the variable and re-sourcing reroutes all `clp` calls without touching the alias definitions.
38
+
39
+ To opt out, delete the block between the two markers. Bootstrap re-adds it on the next run, so skip that step by running `bun install` and `bun link` yourself instead.
34
40
 
35
41
  ## What each one does
36
42
 
37
- `cl` through `cls` carry no explicit plugin dir. Use them inside the toolkit repository, where Claude Code auto-discovers the plugin from `claude/.claude-plugin/plugin.json`. Loading `--plugin-dir` on top of auto-discovery registers every skill twice and produces duplicate entries in the slash command list.
43
+ `cl`, `clw`, and `cls` carry no explicit plugin dir and start a fresh session. Use them inside the toolkit repository, where Claude Code auto-discovers the plugin from `claude/.claude-plugin/plugin.json`. Loading `--plugin-dir` on top of auto-discovery registers every skill twice and produces duplicate entries in the slash command list.
44
+
45
+ `clr` and `clc` resume a session rather than launching one, so auto-discovery never fires for them. A resume restores whatever plugin registration the session started with and recomputes nothing, which a bare `claude --resume` demonstrated by holding a stale registration through a full process restart until `--plugin-dir` was passed on the resume itself.
38
46
 
39
47
  - `cl`: plain session in the current directory
40
- - `clr`: opens the `/resume` picker scoped to the current directory. Trailing arguments filter by name. `clr auth` limits results to sessions containing "auth".
41
- - `clc`: jumps straight into the most recent session for the current directory. No picker. Faster than `clr` when the terminal closed and you want back into the same session.
48
+ - `clr`: opens the `/resume` picker scoped to the current directory. Trailing arguments filter by name. `clr auth` limits results to sessions containing "auth". Resuming does not reload the plugin, so reach for `clpc` when the session needs it.
49
+ - `clc`: jumps straight into the most recent session for the current directory. No picker. Faster than `clr` when the terminal closed and you want back into the same session. Resuming does not reload the plugin here either.
42
50
  - `clw`: creates a worktree under `.claude/worktrees/<name>/` on a fresh branch and starts a Claude Code session in it. Pass the worktree name as the trailing arg: `clw feat-auth`.
43
51
  - `cls`: pins the session to Sonnet instead of the default Opus. Use for routine work where Opus cost is not justified.
44
52
 
45
- `clp`, `clpc`, and `clps` bake in `--plugin-dir`. Use them outside the toolkit repository, where auto-discovery does not fire.
53
+ The `clp` family bakes in `--plugin-dir`. Use it outside the toolkit repository, where auto-discovery does not fire, and for any resume that needs the plugin.
46
54
 
47
55
  - `clp`: session with the toolkit plugin loaded explicitly
48
- - `clpc`: jumps straight into the most recent session for the current directory with the plugin loaded. The `clp` mirror of `clc`.
56
+ - `clpc`: jumps straight into the most recent session for the current directory with the plugin loaded. The `clp` mirror of `clc`, and the one resume that carries the plugin.
49
57
  - `clps`: `clp` pinned to Sonnet
58
+ - `clpa`: opens the agent view with the plugin loaded, covering background sessions from every directory
59
+ - `clpac`: the same view filtered to background sessions started under the current directory
50
60
 
51
61
  ## When to use which
52
62
 
@@ -58,7 +68,9 @@ Use `cls` or `clps` to save Opus usage on routine sessions. Switch mid-session w
58
68
 
59
69
  Use `clw <name>` for features that will take more than one session. A worktree isolates the branch, the transcripts, and the `/resume` history. See [Claude Code and git worktrees](../wiki/claude/claude-worktrees.md) for fan-out rules.
60
70
 
61
- Use `clc` to resume the last session without a picker. Use `clr` when you have several sessions and need to pick by name or recency. Outside the toolkit repo, `clpc` is the same shortcut as `clc` with the plugin loaded.
71
+ Use `clc` to resume the last session without a picker. Use `clr` when you have several sessions and need to pick by name or recency. Reach for `clpc` over `clc` wherever the resumed session needs the plugin, inside the toolkit repository as well as outside it, since neither resume re-runs discovery.
72
+
73
+ Use `clpa` to see what is running across every directory, and `clpac` when only the current project matters.
62
74
 
63
75
  ## Why not a function
64
76
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.7.0",
4
+ "version": "4.8.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -19,7 +19,6 @@
19
19
  "tsconfig.json",
20
20
  "!scripts/sandbox",
21
21
  "!scripts/eval",
22
- "!src/capture",
23
22
  "!**/*.test.ts"
24
23
  ],
25
24
  "publishConfig": {
@@ -36,12 +36,16 @@ alias cls='cl --model sonnet'
36
36
  alias clp='claude --plugin-dir \$TOOLKIT/claude'
37
37
  alias clpc='clp -c'
38
38
  alias clps='clp --model sonnet'
39
+ alias clpa='clp agents'
40
+ alias clpac='clpa --cwd .'
39
41
  $MARKER_CLOSE
40
42
  EOF
41
43
  }
42
44
 
43
45
  install_aliases() {
44
46
  log_step "Installing Claude Code shell aliases"
47
+ log_info "This step manages a marked alias block in $ZSHRC, the only file outside the repo this script writes."
48
+ log_info "Delete the block and its two marker comments to remove the aliases. Re-running never adds a second copy."
45
49
 
46
50
  if [ -f "$ZSHRC" ] && grep -qF "$MARKER_OPEN" "$ZSHRC"; then
47
51
  log_info "Aliases already present in $ZSHRC, skipping"
@@ -4,15 +4,18 @@
4
4
  # Only the HTML regenerates here. The PNG beside it is a chromium render whose
5
5
  # bytes move with the browser version, so asserting it in verify.sh would fail
6
6
  # on a machine whose chromium differs rather than on a stale count. Rebuild the
7
- # image with `canon capture assets/hero.html` after this script reports a change.
7
+ # image with `canon capture assets/hero.html --selector .window` after this
8
+ # script reports a change. The selector has no default, since the element a
9
+ # capture crops to belongs to the page rather than to the command, and `.window`
10
+ # is the class this repository's own two sources declare.
8
11
  # That capture also writes assets/hero.stamp, which records the digest of the
9
12
  # markup it rendered and is what the Hero stage compares, so all three files
10
13
  # commit together. The frame carries no version. `package.json` is bumped on main by the release
11
14
  # tooling, so embedding it drifts every open branch on the next release and the
12
15
  # stage then fails for work that touched nothing.
13
16
  #
14
- # Clone-only. `src/capture` is excluded from the published tarball, and this
15
- # script reads the repository's own catalogs, so a registry install has neither.
17
+ # Clone-only. `canon capture` ships now, but this script reads the repository's
18
+ # own catalogs, which a registry install does not carry.
16
19
  set -e
17
20
  set -o pipefail
18
21
 
@@ -0,0 +1,166 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { basename, dirname } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { chromium } from 'playwright-core'
5
+ import type { Browser, Page } from 'playwright-core'
6
+ import type { CaptureSource } from '@/capture/sources'
7
+ import { primaryFontFamily, resolveCaptureSources } from '@/capture/sources'
8
+ import { formatStamp, hashSource, stampPath } from '@/capture/stamp'
9
+
10
+ /**
11
+ * Every browser reference the capture command makes lives in this module, and
12
+ * `src/commands/capture.ts` reaches it through a dynamic import so `src/cli.ts`
13
+ * never resolves the engine at startup.
14
+ *
15
+ * It ships, like `@/demo/drive`, `@/inventory/walk`, and `@/driver/drive`.
16
+ * Regenerating this repository's own committed images was the reason it stayed
17
+ * behind, and it was a reason about one caller rather than about the mechanism:
18
+ * a target renders its own generated pages and proves its own fonts resolved,
19
+ * which is the whole of what this module does.
20
+ *
21
+ * It imports `playwright-core` rather than `@playwright/test` for the same
22
+ * reason the three siblings do. The test runner is a development dependency the
23
+ * published tarball never carries, so the earlier import resolved here and
24
+ * threw `ERR_MODULE_NOT_FOUND` in every target install.
25
+ */
26
+
27
+ const DEVICE_SCALE_FACTOR = 2
28
+ const FONT_PROBE_SIZE = 72
29
+ const FONT_PROBE_TEXT = 'canon capture 0123456789'
30
+ const ABSENT_FAMILY = '__canon_absent_family__'
31
+
32
+ export interface CaptureOptions {
33
+ selector: string
34
+ outDir?: string
35
+ }
36
+
37
+ export type CaptureResult =
38
+ | {
39
+ status: 'rendered'
40
+ htmlPath: string
41
+ pngPath: string
42
+ width: number
43
+ height: number
44
+ }
45
+ | { status: 'failed'; htmlPath: string; reason: string }
46
+
47
+ export async function captureSources(
48
+ sourcePath: string,
49
+ options: CaptureOptions,
50
+ ): Promise<CaptureResult[]> {
51
+ const sources = resolveCaptureSources(sourcePath, options.outDir)
52
+ if (!sources.length) return []
53
+
54
+ const browser = await chromium.launch()
55
+ try {
56
+ return await Promise.all(
57
+ sources.map((source) => captureOne(browser, source, options.selector)),
58
+ )
59
+ } finally {
60
+ await browser.close()
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Resolves to a failed result rather than throwing, so one source that cannot
66
+ * render does not drop the rest of the batch.
67
+ */
68
+ async function captureOne(
69
+ browser: Browser,
70
+ source: CaptureSource,
71
+ selector: string,
72
+ ): Promise<CaptureResult> {
73
+ const page = await browser.newPage({
74
+ deviceScaleFactor: DEVICE_SCALE_FACTOR,
75
+ })
76
+ try {
77
+ await page.goto(pathToFileURL(source.htmlPath).href)
78
+ const element = page.locator(selector).first()
79
+ if ((await element.count()) === 0) {
80
+ return failed(source, `no element matched ${selector}`)
81
+ }
82
+
83
+ await page.evaluate(() => document.fonts.ready.then(() => undefined))
84
+ const family = primaryFontFamily(
85
+ await element.evaluate((node) => getComputedStyle(node).fontFamily),
86
+ )
87
+ if (family && !(await resolvesFont(page, family))) {
88
+ return failed(
89
+ source,
90
+ `${family} is not installed, so the capture would rewrap against a fallback`,
91
+ )
92
+ }
93
+
94
+ mkdirSync(dirname(source.pngPath), { recursive: true })
95
+ const png = await element.screenshot({ omitBackground: true })
96
+ writeFileSync(source.pngPath, png)
97
+ writeStamp(source, png)
98
+ return {
99
+ status: 'rendered',
100
+ htmlPath: source.htmlPath,
101
+ pngPath: source.pngPath,
102
+ width: png.readUInt32BE(16),
103
+ height: png.readUInt32BE(20),
104
+ }
105
+ } catch (error) {
106
+ return failed(
107
+ source,
108
+ error instanceof Error ? error.message : String(error),
109
+ )
110
+ } finally {
111
+ await page.close()
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Runs inside the render rather than in a wrapper around it, so a capture
117
+ * cannot succeed and leave the provenance unrecorded. A throw here reaches the
118
+ * caller's catch and reports the source as failed, which is correct: a PNG
119
+ * whose stamp never landed is the state the verify stage exists to reject.
120
+ *
121
+ * The source is stored as a bare filename. An absolute path would record the
122
+ * machine that ran the capture into a tracked file and differ per checkout.
123
+ *
124
+ * The image digest is taken over the buffer the screenshot returned rather than
125
+ * by reading the file back, so the stamp describes the bytes this run wrote.
126
+ */
127
+ function writeStamp(source: CaptureSource, png: Uint8Array): void {
128
+ writeFileSync(
129
+ stampPath(source.pngPath),
130
+ formatStamp({
131
+ source: basename(source.htmlPath),
132
+ sourceSha256: hashSource(readFileSync(source.htmlPath)),
133
+ imageSha256: hashSource(png),
134
+ }),
135
+ )
136
+ }
137
+
138
+ function failed(source: CaptureSource, reason: string): CaptureResult {
139
+ return { status: 'failed', htmlPath: source.htmlPath, reason }
140
+ }
141
+
142
+ /**
143
+ * Compares the family's text metrics against a family that cannot exist. Equal
144
+ * widths mean the browser fell through to its default for both, which is the
145
+ * only signal available: `document.fonts.check` reports every system family as
146
+ * present, including invented ones.
147
+ */
148
+ async function resolvesFont(page: Page, family: string): Promise<boolean> {
149
+ return page.evaluate(
150
+ ({ family, absent, size, text }) => {
151
+ const context = document.createElement('canvas').getContext('2d')
152
+ if (!context) return false
153
+ const widthOf = (name: string): number => {
154
+ context.font = `${size}px "${name}"`
155
+ return context.measureText(text).width
156
+ }
157
+ return widthOf(family) !== widthOf(absent)
158
+ },
159
+ {
160
+ family,
161
+ absent: ABSENT_FAMILY,
162
+ size: FONT_PROBE_SIZE,
163
+ text: FONT_PROBE_TEXT,
164
+ },
165
+ )
166
+ }
@@ -0,0 +1,42 @@
1
+ import { readdirSync, statSync } from 'node:fs'
2
+ import { basename, dirname, extname, join } from 'node:path'
3
+
4
+ export interface CaptureSource {
5
+ htmlPath: string
6
+ pngPath: string
7
+ }
8
+
9
+ /**
10
+ * Pairs each HTML source with the PNG it renders to. A directory expands to
11
+ * every `.html` directly inside it, so adding a capture is a file drop rather
12
+ * than a flag. Omitting `outDir` writes the PNG beside its source.
13
+ */
14
+ export function resolveCaptureSources(
15
+ sourcePath: string,
16
+ outDir?: string,
17
+ ): CaptureSource[] {
18
+ const htmlPaths = statSync(sourcePath).isDirectory()
19
+ ? readdirSync(sourcePath)
20
+ .filter((name) => extname(name) === '.html')
21
+ .sort()
22
+ .map((name) => join(sourcePath, name))
23
+ : [sourcePath]
24
+
25
+ return htmlPaths.map((htmlPath) => ({
26
+ htmlPath,
27
+ pngPath: join(
28
+ outDir ?? dirname(htmlPath),
29
+ `${basename(htmlPath, '.html')}.png`,
30
+ ),
31
+ }))
32
+ }
33
+
34
+ /**
35
+ * Reads the first family from a computed `font-family` value, which is the font
36
+ * the source asks for and the one a capture has to prove resolved. Quoting is
37
+ * the source's choice, so both spellings arrive here.
38
+ */
39
+ export function primaryFontFamily(declaration: string): string {
40
+ const first = declaration.split(',')[0]?.trim() ?? ''
41
+ return first.replace(/^['"]|['"]$/g, '')
42
+ }
@@ -0,0 +1,52 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { basename, dirname, extname, join } from 'node:path'
3
+
4
+ const HASH_ALGORITHM = 'sha256'
5
+ const STAMP_EXTENSION = '.stamp'
6
+
7
+ /**
8
+ * Provenance for one rendered PNG: the markup it came from and a digest over
9
+ * each side of the pair. `assertStampField` in `src/gate/measures.ts` reads both
10
+ * digests back off the first whitespace-separated token, so the field names, the
11
+ * colon, and the space after it are a contract between the writer and the gate
12
+ * rather than formatting.
13
+ *
14
+ * Both sides are recorded because either can move alone. The markup digest
15
+ * catches an edit committed with no capture, and the image digest catches a PNG
16
+ * replaced under markup that never changed.
17
+ */
18
+ export interface CaptureStamp {
19
+ readonly source: string
20
+ readonly sourceSha256: string
21
+ readonly imageSha256: string
22
+ }
23
+
24
+ /**
25
+ * A capture writes its stamp beside its PNG rather than beside its HTML,
26
+ * because `--out` moves the image away from the source and the stamp answers a
27
+ * question about the image.
28
+ */
29
+ export function stampPath(pngPath: string): string {
30
+ return join(
31
+ dirname(pngPath),
32
+ `${basename(pngPath, extname(pngPath))}${STAMP_EXTENSION}`,
33
+ )
34
+ }
35
+
36
+ /**
37
+ * Digests the whole file rather than the counts inside it. A template edit
38
+ * changes what the image shows without moving any count, and the whole point of
39
+ * the stamp is that it needs no knowledge of what the markup renders.
40
+ */
41
+ export function hashSource(bytes: Uint8Array): string {
42
+ return createHash(HASH_ALGORITHM).update(bytes).digest('hex')
43
+ }
44
+
45
+ export function formatStamp(stamp: CaptureStamp): string {
46
+ return [
47
+ `source: ${stamp.source}`,
48
+ `source-sha256: ${stamp.sourceSha256}`,
49
+ `image-sha256: ${stamp.imageSha256}`,
50
+ '',
51
+ ].join('\n')
52
+ }
package/src/cli.ts CHANGED
@@ -120,7 +120,7 @@ function showHelp(): void {
120
120
  `${GREY}│${NC} canon design render`,
121
121
  `${GREY}│${NC} canon slides render`,
122
122
  `${GREY}│${NC} canon slides list --json`,
123
- `${GREY}│${NC} canon capture assets/install.html`,
123
+ `${GREY}│${NC} canon capture assets/install.html --selector .window`,
124
124
  `${GREY}│${NC} canon serve .canon/teach`,
125
125
  `${GREY}│${NC} canon inventory focus --json`,
126
126
  `${GREY}│${NC} canon drive http://localhost:4173 run.json --json`,
@@ -1,22 +1,37 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { relative, resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
- import { frameError, intro, logError, logInfo, outro, pipeOutput } from '@/ui'
4
+ import {
5
+ INSTALL_BROWSER,
6
+ isBrowserMissing,
7
+ isEngineMissing,
8
+ } from '@/browser/engine'
9
+ import {
10
+ frameError,
11
+ intro,
12
+ logError,
13
+ logInfo,
14
+ logWarn,
15
+ outro,
16
+ pipeOutput,
17
+ } from '@/ui'
5
18
 
6
19
  const DEFAULT_SOURCE = 'assets'
7
- const DEFAULT_SELECTOR = '.window'
8
20
 
9
21
  /**
10
- * Both aliases are type queries rather than imports, so `@/capture/render`
11
- * never appears in this module's runtime import list.
22
+ * Both aliases are type queries rather than imports, so naming the render
23
+ * module's types adds no static import of it. The only reference that survives
24
+ * to runtime is the `import()` inside the action.
12
25
  */
13
26
  type Renderer = typeof import('@/capture/render')
14
27
  type CaptureResult = Awaited<ReturnType<Renderer['captureSources']>>[number]
15
28
 
16
29
  /**
17
- * Holds wiring only. Every browser reference sits behind `loadRenderer`,
18
- * because `src/cli.ts` imports this module at startup and the render module is
19
- * excluded from the published package.
30
+ * Holds wiring only. Every browser reference sits behind a dynamic import,
31
+ * because `src/cli.ts` imports this module at startup and resolving the engine
32
+ * there would put a browser launch in front of every other command. That is the
33
+ * same reason `src/commands/demo.ts`, `src/commands/inventory.ts`, and
34
+ * `src/commands/driver.ts` state for themselves.
20
35
  */
21
36
  export function register(program: Command): void {
22
37
  program
@@ -24,20 +39,27 @@ export function register(program: Command): void {
24
39
  .description('Render HTML capture sources to PNG')
25
40
  .argument('[source]', 'HTML file or a directory of them', DEFAULT_SOURCE)
26
41
  .option('-o, --out <dir>', 'Output directory, defaults beside the source')
27
- .option('-s, --selector <selector>', 'Element to capture', DEFAULT_SELECTOR)
42
+ .option('-s, --selector <selector>', 'Element to capture')
28
43
  .action(
29
44
  async (
30
45
  source: string,
31
- opts: { out?: string; selector: string },
46
+ opts: { out?: string; selector?: string },
32
47
  ): Promise<void> => {
33
- const renderer = await loadRenderer()
34
- if (!renderer) {
48
+ /**
49
+ * Refused rather than defaulted, and refused ahead of every other
50
+ * check, so the message names the invocation rather than whatever the
51
+ * working directory happens to hold. The element a capture crops to is
52
+ * a property of the page's own markup, and the class this command used
53
+ * to assume is declared by two committed sources in one repository.
54
+ */
55
+ if (!opts.selector) {
35
56
  frameError(
36
- 'capture is toolkit-only and is absent from an installed canon',
57
+ '--selector names the element to capture and has no default. See canon capture --help.',
37
58
  )
38
59
  process.exitCode = 1
39
60
  return
40
61
  }
62
+ const selector = opts.selector
41
63
 
42
64
  const sourcePath = resolve(process.cwd(), source)
43
65
  if (!existsSync(sourcePath)) {
@@ -49,8 +71,9 @@ export function register(program: Command): void {
49
71
  intro('Capture')
50
72
  let results: CaptureResult[]
51
73
  try {
74
+ const renderer = await import('@/capture/render')
52
75
  results = await renderer.captureSources(sourcePath, {
53
- selector: opts.selector,
76
+ selector,
54
77
  outDir: opts.out ? resolve(process.cwd(), opts.out) : undefined,
55
78
  })
56
79
  } catch (error) {
@@ -85,16 +108,28 @@ export function register(program: Command): void {
85
108
  }
86
109
 
87
110
  /**
88
- * Closes an open frame around a failure the render module raised for the whole
89
- * run rather than for one source, such as a browser binary that is not
90
- * installed. The engine's own message is readable, so it is passed through
91
- * intact instead of being summarized.
111
+ * Closes an open frame around a failure that stopped the whole run rather than
112
+ * one source. Two of them are setup states rather than defects and each names
113
+ * the step that clears it: an engine package that never resolved, and a browser
114
+ * binary that was never downloaded. Every other failure passes through intact,
115
+ * since the engine's own message is readable and summarizing it loses what it
116
+ * said.
92
117
  */
93
118
  function reportInFrame(error: unknown): void {
119
+ if (isEngineMissing(error)) {
120
+ logError('the browser engine is not installed in this project')
121
+ logWarn(`Install it with: ${INSTALL_BROWSER}`)
122
+ outro()
123
+ return
124
+ }
125
+
94
126
  const message = error instanceof Error ? error.message : String(error)
95
127
  const [first, ...rest] = message.split('\n')
96
128
  logError(first ?? 'capture failed')
97
129
  if (rest.length) pipeOutput(rest.join('\n'))
130
+ if (isBrowserMissing(error)) {
131
+ logWarn(`Install the browser binary with: ${INSTALL_BROWSER}`)
132
+ }
98
133
  outro()
99
134
  }
100
135
 
@@ -107,27 +142,3 @@ function displayPath(path: string): string {
107
142
  const fromCwd = relative(process.cwd(), path)
108
143
  return fromCwd.startsWith('..') ? path : fromCwd
109
144
  }
110
-
111
- /**
112
- * Reports absence only when the module or its engine cannot be resolved, which
113
- * is the published-package case. Any other import failure is a defect inside
114
- * the render module and propagates, rather than being reported as a feature
115
- * the package left out.
116
- */
117
- async function loadRenderer(): Promise<Renderer | undefined> {
118
- try {
119
- return await import('@/capture/render')
120
- } catch (error) {
121
- if (isModuleNotFound(error)) return undefined
122
- throw error
123
- }
124
- }
125
-
126
- function isModuleNotFound(error: unknown): boolean {
127
- return (
128
- typeof error === 'object' &&
129
- error !== null &&
130
- 'code' in error &&
131
- error.code === 'ERR_MODULE_NOT_FOUND'
132
- )
133
- }