@erclx/canon 4.7.0 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.7.0",
4
+ "version": "4.8.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -1,28 +1,30 @@
1
1
  ---
2
2
  title: Capture
3
- description: Rendering committed HTML sources to PNG, what the command asserts about fonts, and why it is toolkit-only
3
+ description: Rendering HTML sources to PNG, what the command asserts about fonts, and why the selector has no default
4
4
  ---
5
5
 
6
6
  # Capture
7
7
 
8
- `canon capture [source]` renders HTML capture sources to PNG, which is how a committed documentation image regenerates from its committed source. The source defaults to `assets/`, where a directory expands to every `.html` directly inside it, so adding a capture means dropping a file beside the first one and running the same bare command.
8
+ `canon capture [source] --selector <sel>` renders HTML sources to PNG, which is how a generated documentation image is rebuilt from the markup it was generated out of. The source defaults to `assets/`, where a directory expands to every `.html` directly inside it, so adding a capture means dropping a file beside the first one and running the same command.
9
9
 
10
10
  ```bash
11
- canon capture
12
- canon capture assets/install.html
13
- canon capture assets --out .canon/review/captures
11
+ canon capture --selector .window
12
+ canon capture assets/install.html --selector .window
13
+ canon capture assets --selector .window --out .canon/review/captures
14
14
  ```
15
15
 
16
- `assets/` now holds two sources, so a bare `canon capture` rebuilds both. `install.html` is hand-authored and its text came from a real run. `hero.html` is generated by `scripts/core/regen-hero.sh` from the CLI catalogs and must not be edited by hand, since `bun run check` regenerates it and fails on the difference.
16
+ `--selector` is required and every example above passes it. The element a capture crops to belongs to the page, not to the command, so there is no value that could be right for an arbitrary project's markup. `.window` is what this repository's own two sources declare, and a project renders its own pages by naming whatever theirs declare.
17
17
 
18
- Only the HTML is asserted for drift. The PNG is a chromium render whose bytes move with the browser version, so rebuild it with `canon capture assets/hero.html` when the check reports the HTML changed.
18
+ `assets/` here holds two sources, so one run over the folder rebuilds both. `install.html` is hand-authored and its text came from a real run. `hero.html` is generated by `scripts/core/regen-hero.sh` from the CLI catalogs and must not be edited by hand, since `bun run check` regenerates it and fails on the difference.
19
+
20
+ Only the HTML is asserted for drift. The PNG is a chromium render whose bytes move with the browser version, so rebuild it with `canon capture assets/hero.html --selector .window` when the check reports the HTML changed.
19
21
 
20
22
  Every render writes a stamp beside its PNG, `hero.png` next to `hero.stamp`, holding the source filename, a `source-sha256` over the markup bytes it read, and an `image-sha256` over the image bytes it wrote. Both digests are what `bun run check` compares, so a markup edit committed without a capture and a PNG swapped under unchanged markup each fail. The stamp is tracked and commits alongside the pair. Nothing hand-edits it, and a capture that cannot write it reports that source as failed and exits 1, so an image whose stamp never landed is reported rather than passed over.
21
23
 
22
24
  | Option | Behavior |
23
25
  | ------------------ | ------------------------------------------------- |
24
26
  | `--out <dir>` | Write every PNG here instead of beside its source |
25
- | `--selector <sel>` | Element to capture (default: `.window`) |
27
+ | `--selector <sel>` | Element to capture, required and never defaulted |
26
28
 
27
29
  ## What the command asserts
28
30
 
@@ -30,8 +32,12 @@ Each source renders at `deviceScaleFactor` 2 with a transparent background, and
30
32
 
31
33
  What is asserted is the font. The command reads the first family the captured element declares and fails when the browser did not resolve it, because a fallback face rewraps the block and silently changes the output. Sources therefore name a real font rather than relying on `monospace`. A source that cannot render reports its own line and exits 1 without dropping the rest of the batch.
32
34
 
33
- The browser binary installs separately from the package. A fresh clone runs `bunx playwright install chromium` once, and a run that cannot launch one reports the engine's own remediation inside the frame and exits 1 rather than escaping as a stack trace.
35
+ A run refuses before it reads anything else when `--selector` is absent, naming the flag and pointing at `--help`. Ordering it first is what keeps the message about the invocation: the source defaults to `assets`, so checking that first would answer `assets not found` from whatever directory the caller happened to be in and say nothing about the flag that was actually missing.
36
+
37
+ The browser binary installs separately from the package. A first run does `bunx playwright install chromium` once, and a run that cannot launch one reports the engine's own remediation inside the frame and exits 1 rather than escaping as a stack trace.
38
+
39
+ The command ships to targets, alongside `demo`, `inventory`, and `drive`. It was held back while its only caller was this repository regenerating its own committed images, which described the caller rather than the render. A project has generated pages of its own, and the command that would show them rendering and prove their fonts resolved was the one it could not run.
34
40
 
35
- The command is toolkit-only. Its render module holds every browser reference capture makes and `files` in `package.json` excludes it, so an installed `canon` carries the command, reports it as absent on one line, and exits 1. Every other command is unaffected, which is the reason the browser import sits behind a dynamic import rather than at a command's top level.
41
+ Shipping it also fixed what the exclusion was hiding. The render module imported the `@playwright/test` development dependency, which no published tarball carries. Every browser reference still sits behind a dynamic import, so a browser loads for this command rather than in front of every other one.
36
42
 
37
- `canon demo` is the other browser command and it ships, because the reason capture stays here is that it regenerates images committed to this repository and a target has nothing to regenerate. See `demo.md`.
43
+ `demo.md` and `driver.md` cover two of the other three browser commands. What separates this one is that a capture renders a single state from a file on disk, where the rest drive a running application.
@@ -72,11 +72,11 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
72
72
  | `canon gate run` | Run every stage that guards a branch here, scoping shell, types, and tests to the changed set (`--all`, `--no-write`, `--nested`, `--json`) |
73
73
  | `canon inventory [subject]` | Walk every route a project declares and group its elements by the property each computes, as a listing rather than a gate (`--json`) |
74
74
  | `canon drive <url> <run>` | Walk a page through named interactions and measure each state it reaches, reporting findings rather than gating (`--json`) |
75
- | `canon capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
75
+ | `canon capture [source]` | Render HTML sources to PNG and prove the font each one declares resolved (`--selector` required, `--out`) |
76
76
  | `canon serve [dir]` | Serve a directory on the loopback interface and print the link that opens it, running until interrupted (`--port`, `--entry`, `--json`) |
77
77
  | `canon upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
78
78
 
79
- `canon serve` ships and drives no browser, which is what separates it from the two that do. A generated page loses its stylesheet and its script to an editor preview and to a `file://` open, so the link is the delivery rather than a convenience, and every generated surface here reaches a reader through one. It binds `127.0.0.1` and never a wildcard, because what it is pointed at is routinely a gitignored record tree. It sends `cache-control: no-store`, since a preview exists to be edited and reloaded and a cached stylesheet reads as a fix that did not work.
79
+ `canon serve` drives no browser, which is what separates it from the four that do. All four ship now that `capture` does, so the line between them is the engine rather than the package. A generated page loses its stylesheet and its script to an editor preview and to a `file://` open, so the link is the delivery rather than a convenience, and every generated surface here reaches a reader through one. It binds `127.0.0.1` and never a wildcard, because what it is pointed at is routinely a gitignored record tree. It sends `cache-control: no-store`, since a preview exists to be edited and reloaded and a cached stylesheet reads as a fix that did not work.
80
80
 
81
81
  A port already in use is the ordinary case rather than a refusal, so it walks forward to the next free one and reports which it took. That is why a caller reads `url` off the `--json` record instead of composing one from the port it asked for. Only contention is walked past. Any other bind failure refuses as `bind-failed` carrying the error's code, rather than being retried twenty times and reported as a range being full, which names a cause nothing checked.
82
82
 
@@ -96,7 +96,7 @@ routes = ["/", "/pricing", "/docs"]
96
96
  query = "button, a[href], input, select, textarea, [tabindex]"
97
97
  ```
98
98
 
99
- `canon drive` is the fourth and ships for the same reason the two before it do. What separates it from all three is the axis rather than the destination: `capture` and `inventory` each answer about a page as it loads, and every defect that exists only after a menu opens or the page scrolls is invisible to both. It takes a JSON run file naming the viewport, the probes, and the interaction sequence, since a route catalog is state a project holds and an interaction sequence is a script written for one question. Viewport heights are never defaulted, because the heights a defect hides at belong to the layout rather than to this command. It reports findings and never gates, since every probe it ships carries a class of false finding a throwaway version already produced. See `driver.md`.
99
+ `canon drive` is the fourth and ships for the same reason the three before it do. What separates it from all three is the axis rather than the destination: `capture` and `inventory` each answer about a page as it loads, and every defect that exists only after a menu opens or the page scrolls is invisible to both. It takes a JSON run file naming the viewport, the probes, and the interaction sequence, since a route catalog is state a project holds and an interaction sequence is a script written for one question. Viewport heights are never defaulted, because the heights a defect hides at belong to the layout rather than to this command. It reports findings and never gates, since every probe it ships carries a class of false finding a throwaway version already produced. See `driver.md`.
100
100
 
101
101
  ## Domain commands
102
102
 
@@ -75,7 +75,7 @@ Every refusal exits 1 and names its reason in the `--json` record, so a skill br
75
75
 
76
76
  ## The browser reaches every target
77
77
 
78
- Unlike `canon capture`, this command ships. Capture is toolkit-only because it regenerates images committed to this repository, and that reason does not transfer to a command whose whole purpose is running in someone else's project.
78
+ This command was the first browser command to ship, and `canon capture` has since joined it. Capture was held back because its only caller was this repository regenerating its own committed images, which was a fact about that caller rather than about the render.
79
79
 
80
80
  The cost is stated rather than hidden: the browser binary installs separately, so a target runs `bunx playwright install chromium` once before a recording works at all. A run that cannot launch reports that command inside the frame and exits 1.
81
81
 
@@ -49,7 +49,7 @@ Every step names what it did, and that name is carried onto each finding it prod
49
49
 
50
50
  Probes run after a step and never on arrival, so a run reaches the load state by opening with a `wait` step of its own, as the example above does. Nothing probes before the first step runs, which makes that leading step the only way to measure the page as it first painted, and naming it is what puts the load state on its own findings rather than under whatever ran next.
51
51
 
52
- Write one in any project that does not have `canon capture`, which is toolkit-only and renders a single state from a committed source. Where capture runs, it already answers about arrival and a leading `wait` duplicates it. Where it does not, this command is the only thing measuring the page at all, and a run without that step reports every driven state and nothing about the one a visitor sees first.
52
+ Write one wherever no capture runs against the page. `canon capture` renders a single state from a source on disk, so where it runs it already answers about arrival and a leading `wait` duplicates it. Where it does not, this command is the only thing measuring the page at all, and a run without that step reports every driven state and nothing about the one a visitor sees first.
53
53
 
54
54
  Each height is driven in a context of its own from a fresh navigation, rather than by resizing the page the previous height already drove, since a sweep asks the same question of each height rather than a later question of an already-driven page.
55
55
 
@@ -98,6 +98,6 @@ It reports findings and never gates, and the exit code says only whether the dri
98
98
 
99
99
  An unreachable page refuses rather than returning an empty report, since nothing measured and nothing found read the same to anything counting findings.
100
100
 
101
- The browser binary installs separately from the package, once, with `bunx playwright install chromium`. The command ships to targets like `demo` and `inventory` rather than staying toolkit-only like `capture`, because its whole purpose is measuring a page inside someone else's project. It reaches nothing under `src/capture/`, which the package excludes, so the two surfaces move independently.
101
+ The browser binary installs separately from the package, once, with `bunx playwright install chromium`. The command ships to targets like `demo`, `inventory`, and `capture`, because its whole purpose is measuring a page inside someone else's project. It reaches nothing under `src/capture/`, so the two surfaces move independently.
102
102
 
103
103
  See `commands.md` for where this sits among the browser commands, and `capture.md` for the single-state render it adds an axis to.
@@ -9,7 +9,7 @@ category: Agent surface
9
9
  CLI catalog and invocation rules for agents, split by command domain. Start with overview.
10
10
 
11
11
  - [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
12
- - [Capture](capture.md): Rendering committed HTML sources to PNG, what the command asserts about fonts, and why it is toolkit-only
12
+ - [Capture](capture.md): Rendering HTML sources to PNG, what the command asserts about fonts, and why the selector has no default
13
13
  - [Census](census.md): Tracked-plus-untracked file count, a breakdown by extension, and a line total that skips whatever reads as binary
14
14
  - [Command catalog](commands.md): Every project-level command and every domain subcommand, plus the shape each domain exposes
15
15
  - [Comments](comments.md): Comment density by language and kind, the two structural exclusions, and how the degradation sweep finds its vocabulary
@@ -30,7 +30,7 @@ Three root files are read whether or not the field names them, being `package.js
30
30
 
31
31
  ### What the corpus leaves out
32
32
 
33
- The corpus answers what the package publishes, which is narrower than what the repository holds. Measured against this repository on 2026-08-21, the scan read 544 files and left 593 of the 1139 git lists unread. Those include everything under `.claude/`, `wiki/`, and `internal/`, the workflow definitions under `.github/`, and the trees the publish negations remove, being `scripts/sandbox/`, `scripts/eval/`, `src/capture/`, and every test file.
33
+ The corpus answers what the package publishes, which is narrower than what the repository holds. Measured against this repository on 2026-08-21, the scan read 544 files and left 593 of the 1139 git lists unread. Those include everything under `.claude/`, `wiki/`, and `internal/`, the workflow definitions under `.github/`, and the trees the publish negations remove, being `scripts/sandbox/`, `scripts/eval/`, and every test file. `src/capture/` was a fourth negation when that reading was taken and is not one now. The folder holds 5 tracked files, 2 of them tests the publish still excludes, so the corpus is 3 wider than the numbers above describe and the unread count is 3 smaller.
34
34
 
35
35
  A public repository makes that gap readable by anyone, so a clean run means no credential in the published tree rather than none in the repository. The run states the number on every pass, including a clean one, so the bound travels with the verdict. Widening the corpus to every tracked file is a separate decision, since the row this implements puts the shipped tree first on the record's rule that content leaving the repository gates harder than content that stays.
36
36
 
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.0",
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": {
@@ -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
- }
package/src/demo/drive.ts CHANGED
@@ -20,15 +20,12 @@ declare global {
20
20
  * the demo feature adds lives here, and `src/commands/demo.ts` reaches it
21
21
  * through a dynamic import so no other command resolves the engine at startup.
22
22
  *
23
- * Unlike `@/capture/render`, this module ships. The capture command is excluded
24
- * from the published package because it regenerates images committed to this
25
- * repository, and that reason does not transfer to a command whose whole
26
- * purpose is running in someone else's project.
27
- *
28
23
  * It imports `playwright-core` rather than `@playwright/test`, which stays a
29
- * development dependency for the capture module. Shipping puts the import in
30
- * every target's dependency tree, and a target needs the driver rather than a
31
- * test runner and an assertion library. Both are pinned to one version rather
24
+ * development dependency the published tarball never carries. Shipping puts the
25
+ * import in every target's dependency tree, and a target needs the driver
26
+ * rather than a test runner and an assertion library. Every browser module here
27
+ * takes the same import for that reason, `@/capture/render` included since it
28
+ * started shipping too. Both are pinned to one version rather
32
29
  * than a range, because `bunx playwright install chromium` fetches the browser
33
30
  * revision the installed engine expects and a float would leave a target
34
31
  * resolving a binary its engine cannot launch.
@@ -23,19 +23,18 @@ import type {
23
23
  * `src/commands/driver.ts` reaches it through a dynamic import so no other
24
24
  * command resolves the engine at startup.
25
25
  *
26
- * Like `@/demo/drive` and `@/inventory/walk`, and unlike `@/capture/render`,
27
- * this module ships. A command whose whole purpose is measuring someone else's
28
- * page cannot stay toolkit-only, and nothing here reaches into `@/capture/`,
29
- * which `files` in `package.json` excludes from the published package, so the
30
- * two surfaces move independently.
26
+ * Like `@/demo/drive`, `@/inventory/walk`, and `@/capture/render`, this module
27
+ * ships. A command whose whole purpose is measuring someone else's page cannot
28
+ * stay toolkit-only, and nothing here reaches into `@/capture/`, so the two
29
+ * surfaces move independently.
31
30
  *
32
31
  * What separates it from `canon capture` is the axis it adds. A render answers
33
32
  * about a page as it loads, and every defect that exists only after a menu
34
33
  * opens, an answer is chosen, or the page scrolls is invisible to one. Probes
35
34
  * therefore run after a step rather than on arrival, and a run reaches the load
36
- * state by opening with a `wait` step of its own. That matters most where
37
- * capture does not run, since it is toolkit-only and this command is then the
38
- * only thing measuring the page at all.
35
+ * state by opening with a `wait` step of its own. That matters most where no
36
+ * capture runs against the page, since this command is then the only thing
37
+ * measuring it at all.
39
38
  */
40
39
 
41
40
  /** Each probe keyed by the name a caller writes in the run. */
@@ -100,7 +100,7 @@ export const SANDBOX_UNDECLARED_CEILING = 47
100
100
  export const AUDITS_BASELINE = '.claude/canon/baseline.json'
101
101
 
102
102
  export const HERO_STAMP_FAILURE =
103
- 'The hero set disagrees with the stamp written when the image was captured. Run canon capture assets/hero.html and commit all three files together.'
103
+ 'The hero set disagrees with the stamp written when the image was captured. Run canon capture assets/hero.html --selector .window and commit all three files together.'
104
104
 
105
105
  function parseJson(payload: string): unknown {
106
106
  try {
@@ -189,7 +189,7 @@ export const STAGES: readonly Stage[] = [
189
189
  kind: 'drift',
190
190
  pathspec: 'assets/hero.html',
191
191
  failure:
192
- 'Hero counts drifted. Run bun run check, then canon capture assets/hero.html, and commit assets/hero.html with assets/hero.png and assets/hero.stamp.',
192
+ 'Hero counts drifted. Run bun run check, then canon capture assets/hero.html --selector .window, and commit assets/hero.html with assets/hero.png and assets/hero.stamp.',
193
193
  },
194
194
  { kind: 'measure', measure: heroStamp },
195
195
  ],
@@ -15,9 +15,9 @@ import type { Subject } from '@/inventory/subjects'
15
15
  * and `src/commands/inventory.ts` reaches it through a dynamic import so no
16
16
  * other command resolves the engine at startup.
17
17
  *
18
- * Like `@/demo/drive` and unlike `@/capture/render`, this module ships, because
19
- * a command whose whole purpose is running inside someone else's project cannot
20
- * stay toolkit-only.
18
+ * Like `@/demo/drive`, `@/driver/drive`, and `@/capture/render`, this module
19
+ * ships, because a command whose whole purpose is running inside someone else's
20
+ * project cannot stay toolkit-only.
21
21
  */
22
22
 
23
23
  /**