@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/capture.md +17 -11
- package/docs/agents/commands.md +3 -3
- package/docs/agents/demo.md +1 -1
- package/docs/agents/driver.md +2 -2
- package/docs/agents/index.md +1 -1
- package/docs/agents/state-scoped-risk.md +1 -1
- package/package.json +1 -2
- package/scripts/core/regen-hero.sh +6 -3
- package/src/capture/render.ts +166 -0
- package/src/capture/sources.ts +42 -0
- package/src/capture/stamp.ts +52 -0
- package/src/cli.ts +1 -1
- package/src/commands/capture.ts +52 -41
- package/src/demo/drive.ts +5 -8
- package/src/driver/drive.ts +7 -8
- package/src/gate/measures.ts +1 -1
- package/src/gate/stages.ts +1 -1
- package/src/inventory/walk.ts +3 -3
package/docs/agents/capture.md
CHANGED
|
@@ -1,28 +1,30 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Capture
|
|
3
|
-
description: Rendering
|
|
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]
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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.
|
package/docs/agents/commands.md
CHANGED
|
@@ -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
|
|
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`
|
|
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
|
|
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
|
|
package/docs/agents/demo.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/docs/agents/driver.md
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
package/docs/agents/index.md
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
|
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. `
|
|
15
|
-
#
|
|
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`,
|
package/src/commands/capture.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
11
|
-
*
|
|
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
|
|
18
|
-
* because `src/cli.ts` imports this module at startup and the
|
|
19
|
-
*
|
|
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'
|
|
42
|
+
.option('-s, --selector <selector>', 'Element to capture')
|
|
28
43
|
.action(
|
|
29
44
|
async (
|
|
30
45
|
source: string,
|
|
31
|
-
opts: { out?: string; selector
|
|
46
|
+
opts: { out?: string; selector?: string },
|
|
32
47
|
): Promise<void> => {
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
'
|
|
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
|
|
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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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
|
|
30
|
-
* every target's dependency tree, and a target needs the driver
|
|
31
|
-
* test runner and an assertion library.
|
|
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.
|
package/src/driver/drive.ts
CHANGED
|
@@ -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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
38
|
-
*
|
|
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. */
|
package/src/gate/measures.ts
CHANGED
|
@@ -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 {
|
package/src/gate/stages.ts
CHANGED
|
@@ -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
|
],
|
package/src/inventory/walk.ts
CHANGED
|
@@ -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
|
|
19
|
-
* a command whose whole purpose is running inside someone else's
|
|
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
|
/**
|