@erclx/canon 4.92.0 → 4.93.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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  canon is a CLI and Claude Code plugin that stops your AI conventions drifting apart across repositories. It keeps one authoritative copy and installs it into each project on demand.
8
8
 
9
- ![The canon catalog, listing skills, governance rules, and standards with the count each ships, the workflow skills named, and a sample of the rule and standard names](assets/hero.png)
9
+ ![The canon catalog, listing skills, governance rules, and standards with the count each ships, the workflow skills named, and a sample of the rule and standard names](assets/evidence/hero.png)
10
10
 
11
11
  The counts above are read from the catalogs when the image is built, so they're what the repo actually ships today. See it live at [canon.erclx.dev](https://canon.erclx.dev).
12
12
 
@@ -39,7 +39,7 @@ claude plugin marketplace add https://github.com/erclx/canon
39
39
  claude plugin install canon@canon
40
40
  ```
41
41
 
42
- ![Adding the canon marketplace and installing the plugin in Claude Code](assets/install.png)
42
+ ![Adding the canon marketplace and installing the plugin in Claude Code](assets/evidence/install.png)
43
43
 
44
44
  The skills land as `/canon:<name>`. If your session was already open, run `/reload-plugins` to pick them up.
45
45
 
@@ -67,19 +67,19 @@ Each domain has a canonical source in this repo and a thin install or sync CLI o
67
67
 
68
68
  Those domains split on one line: some are copied into your project and become yours to edit, and some are never copied at all.
69
69
 
70
- ![canon tooling list and canon standards list side by side, the first showing five stacks with their dependency and script counts, the second showing standards against the artifact each governs](assets/install-surface.png)
70
+ ![canon tooling list and canon standards list side by side, the first showing five stacks with their dependency and script counts, the second showing standards against the artifact each governs](assets/evidence/install-surface.png)
71
71
 
72
72
  A tooling stack lands as real files under version control, because a config is something your build reads and your project owns. A standard stays here and is opened by name, so there is no copy in your repo to drift from this one.
73
73
 
74
74
  Governance is the third shape, and it is worth seeing rather than reading about, because the glob beside each rule is what decides whether it reaches a session at all.
75
75
 
76
- ![canon gov list, showing seven stacks with the rules each carries and a sample of rules beside the path glob that loads each one](assets/governance.png)
76
+ ![canon gov list, showing seven stacks with the rules each carries and a sample of rules beside the path glob that loads each one](assets/evidence/governance.png)
77
77
 
78
78
  Stacks compose, so a react project inherits node and node inherits base. A rule with a glob loads only when a matching path is edited, and a rule with none loads every session. Both counts and every row above are read from the catalogs when the image is built.
79
79
 
80
80
  The toolkit tracks its own work the same way, in a task board no catalog can read back.
81
81
 
82
- ![the task board's Run now table, one row per task in flight, each naming the files it touches and the plan it runs under](assets/task-board.png)
82
+ ![the task board's Run now table, one row per task in flight, each naming the files it touches and the plan it runs under](assets/evidence/task-board.png)
83
83
 
84
84
  `.canon/tasks/` is gitignored session scratch, so unlike the two frames above, this one is a hand-taken snapshot rather than something the build reads live, and it goes stale the moment the board moves.
85
85
 
@@ -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.92.0",
4
+ "version": "4.93.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -73,7 +73,7 @@ The stepper hides and shows and sets nothing else, so how a selected option look
73
73
 
74
74
  ## The block list
75
75
 
76
- `canon teach render` takes a JSON array of blocks and renders it through the same components the fixture lesson is generated from. Four types, and every structural body composes from them:
76
+ `canon teach render` takes a JSON array of blocks and renders it through the same components a lesson body composes. Four types, and every structural body composes from them:
77
77
 
78
78
  - `{"type":"heading","level":1|2,"text":"<text>"}`: an `<h1>` or `<h2>`.
79
79
  - `{"type":"paragraph","text":"<text>","lede":true}`: a `<p>`, marked `lede` for the dek that opens the lesson.
@@ -5,11 +5,11 @@ description: Rendering HTML sources to PNG, what the command asserts about fonts
5
5
 
6
6
  # Capture
7
7
 
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. That default is a generic starting point rather than a convention every target shares, and it stays because a missing folder still refuses loud, naming the argument, rather than failing silently. This repository no longer takes it: its own sources moved to `assets/captures/` and its images stayed in `assets/`, so a run here names the source explicitly and sends the output where that run wants it, `--out assets` to rebuild the committed images and somewhere disposable to preview them.
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. That default is a generic starting point rather than a convention every target shares, and it stays because a missing folder still refuses loud, naming the argument, rather than failing silently. This repository no longer takes it: its own sources moved to `assets/captures/` and its images moved to `assets/evidence/`, so a run here names the source explicitly and sends the output where that run wants it, `--out assets/evidence` to rebuild the committed images and somewhere disposable to preview them.
9
9
 
10
10
  ```bash
11
11
  canon capture --selector .window
12
- canon capture assets/captures/install.html --selector .window --out assets
12
+ canon capture assets/captures/install.html --selector .window --out assets/evidence
13
13
  canon capture assets/captures --selector .window --out .canon/review/captures
14
14
  canon capture https://example.com --selector .window --out preview.png
15
15
  ```
@@ -18,13 +18,13 @@ canon capture https://example.com --selector .window --out preview.png
18
18
 
19
19
  ## What this repository captures
20
20
 
21
- A capture set here spans two folders. `assets/captures/` holds five sources and the template each is written from, and `assets/` holds the five images a document points at with the stamp answering for each. One run over the source folder rebuilds every image, and it takes `--out assets` to put each PNG where its document points rather than beside the markup it rendered. None of the five is edited by hand. `scripts/core/regen-hero.sh` writes each `.html` from the template beside it, filling one shared value map into all of them, and `bun run check` regenerates them and fails on the difference.
21
+ A capture set here spans two folders. `assets/captures/` holds five sources and the template each is written from, and `assets/evidence/` holds the five images a document points at with the stamp answering for each, under the segment `canon pr evidence` compares. One run over the source folder rebuilds every image, and it takes `--out assets/evidence` to put each PNG where its document points rather than beside the markup it rendered. None of the five is edited by hand. `scripts/core/regen-hero.sh` writes each `.html` from the template beside it, filling one shared value map into all of them, and `bun run check` regenerates them and fails on the difference.
22
22
 
23
23
  Three of the five take catalog data, so a stack gaining a rule moves the frame on the next run. `install.html` and `task-board.html` are the two exceptions: the first holds terminal text from a real run and the second holds a hand-frozen snapshot of a gitignored board, each in its template rather than derived from a live catalog at build time.
24
24
 
25
- `assets/captures/` is read flat and never descends, by the regeneration script, by this command, and by the drift stage alike. A source nested a further folder down is skipped by all three with nothing reported, so a new frame is a template dropped directly in rather than a folder of its own. The split is what retired the name prefix the flat layout used to need: a frame is named for itself now, since `assets/` no longer mixes markup in with the images.
25
+ `assets/captures/` is read flat and never descends, by the regeneration script, by this command, and by the drift stage alike. A source nested a further folder down is skipped by all three with nothing reported, so a new frame is a template dropped directly in rather than a folder of its own. The split is what retired the name prefix the flat layout used to need: a frame is named for itself now, since `assets/captures/` no longer mixes markup in with the images.
26
26
 
27
- 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/captures --selector .window --out assets` when the check reports the HTML changed.
27
+ 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/captures --selector .window --out assets/evidence` when the check reports the HTML changed.
28
28
 
29
29
  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. 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.
30
30
 
@@ -34,6 +34,7 @@ Neither digest is ever written by hand. A digest is what the gate compares, so a
34
34
  | ------------------ | ------------------------------------------------------------------------------------------------------ |
35
35
  | `--out <dir>` | Write every PNG here instead of beside its source. For a URL source, names the destination PNG itself. |
36
36
  | `--selector <sel>` | Element to capture, required and never defaulted |
37
+ | `--width <px>` | Render at this viewport width so a media query resolves there. One whole number of 1 or more. |
37
38
 
38
39
  ## What the command asserts
39
40
 
@@ -50,3 +51,9 @@ The command ships to targets, alongside `demo`, `inventory`, and `drive`. It was
50
51
  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.
51
52
 
52
53
  `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 or a named `http(s)://` URL, where the rest drive a running application.
54
+
55
+ ## Capturing at a width
56
+
57
+ `--width` sets the browser viewport and leaves the page alone. A media query answers to the viewport, so a wrapper narrowed to 390px renders a thin column of the desktop layout and proves nothing about the mobile one. The height stays at Playwright's default of 720, which keeps `vh` units where they are without the flag. A run that passes no `--width` passes no viewport option at all, so every committed image renders as before and no stamp digest moves.
58
+
59
+ The flag takes one integer and no list. A sweep across breakpoints is one run per width, and the caller picks `--out` for each, since two widths written into one directory overwrite each other under the source's name. A value that is not a whole number of 1 or more refuses ahead of the browser import, naming the flag.
@@ -103,8 +103,8 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
103
103
  | `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`) |
104
104
  | `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`) |
105
105
  | `canon drive <url> <run>` | Walk a page through named interactions and measure each state it reaches, reporting findings rather than gating (`--json`) |
106
- | `canon capture [source]` | Render an HTML source or an `http(s)://` URL to PNG and prove the font each one declares resolved (`--selector` required, `--out`) |
107
- | `canon serve [dir]` | Serve a directory on the loopback interface and print the link that opens it, running until interrupted (`--port`, `--entry`, `--json`) |
106
+ | `canon capture [source]` | Render an HTML source or an `http(s)://` URL to PNG and prove the font each one declares resolved (`--selector` required, `--out`, `--width`) |
107
+ | `canon serve [dir]` | Serve a directory on the loopback interface and print the link that opens it, running until interrupted (`--port`, `--entry`, `--index`, `--json`) |
108
108
  | `canon upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
109
109
 
110
110
  `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 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. A teach lesson's stylesheet is embedded rather than linked, so only its script still needs a server. 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.
@@ -113,7 +113,7 @@ A port already in use is the ordinary case rather than a refusal, so it walks fo
113
113
 
114
114
  A request naming a directory is redirected to its trailing-slash form rather than answered in place. A browser resolves a relative asset against the last slash of the URL it is on, so answering `/lesson` directly leaves the page asking for `/course.css` instead of `/lesson/course.css`, and it renders unstyled through the server that exists to prevent exactly that.
115
115
 
116
- Containment is tested after symlinks are followed rather than on the path as written, and the test sits immediately before the read rather than beside the request that produced it. Resolving a request lexically clears a link pointing outside the served root, and this repository is a live instance of that shape, since `claude/standards` and `claude/snippets` are links out of `claude/`. Position is what makes the property hold: a directory request appends its index after the request path has been checked, so a check placed earlier leaves that index untested. An `--entry` that escapes the root refuses with `no-entry` before a port is taken, because `url` is the field a caller hands to a reader.
116
+ Containment is tested after symlinks are followed rather than on the path as written, and the test sits immediately before the read rather than beside the request that produced it. Resolving a request lexically clears a link pointing outside the served root, and this repository is a live instance of that shape, since `claude/standards` and `claude/snippets` are links out of `claude/`. Position is what makes the property hold: a directory request appends its index after the request path has been checked, so a check placed earlier leaves that index untested. `--index` answers a directory holding no `index.html` with a listing read per request, where the default stays a 404. Every entry the listing names takes the same containment test a request for it would, so a link out of the root is absent rather than greyed, and dotfiles are hidden. With no `--entry` and no root `index.html`, the printed link is `/` and `entryExists` reads true. An `--entry` that escapes the root refuses with `no-entry` before a port is taken, because `url` is the field a caller hands to a reader.
117
117
 
118
118
  `canon demo` is the second browser command and the one that ships, since its purpose is running in a target rather than regenerating what this repository commits. It needs a browser binary the package does not carry, installed once with `bunx playwright install chromium`.
119
119
 
@@ -73,7 +73,7 @@ A rule under `governance/rules/` installs into a target, and its `paths:` entrie
73
73
 
74
74
  `internal/rules/` ships nowhere. The tree it governs is the tree present, which makes the question answerable, and all 14 of its globs across 7 rules match at the commit this shipped on.
75
75
 
76
- What that leaves unreached is a glob that matches real files and still reaches none of the work it was scoped at. `governance/rules/lib/305-e2e-reliability.md` scopes itself at `e2e/*.ts` and `e2e/**/*.ts`, and no probe in this repository is written under `e2e/`, so the rule asking a session to watch a new guard fail never fired for the session writing guards. Resolution is mechanical and reach is a judgment about where the work happens, so only the first is here.
76
+ What that leaves unreached is a glob that matches real files and still reaches none of the work it was scoped at. `governance/rules/lib/305-e2e-reliability.md` scoped itself at `e2e/*.ts` and `e2e/**/*.ts` and now reads `**/e2e/**/*.ts`. No probe in this repository is written under `e2e/`, so the rule asking a session to watch a new guard fail never fired for the session writing guards. Resolution is mechanical and reach is a judgment about where the work happens, so only the first is here.
77
77
 
78
78
  ## The exemption marker
79
79
 
@@ -141,7 +141,7 @@ The order is drawn here rather than instructed, and that is the point of the ver
141
141
 
142
142
  ## Render
143
143
 
144
- `canon teach render` renders a lesson body's structural blocks to HTML, through the components `src/teach/render-fixture.tsx` composes by hand. It takes no topic and no `--root`, since the verb is a stateless transform reading nothing off a workspace on disk.
144
+ `canon teach render` renders a lesson body's structural blocks to HTML, through the three lesson components. It takes no topic and no `--root`, since the verb is a stateless transform reading nothing off a workspace on disk.
145
145
 
146
146
  ```bash
147
147
  echo '[{"type":"heading","level":1,"text":"Compass bearings"}]' | canon teach render --json
@@ -1,8 +1,7 @@
1
1
  ---
2
2
  description: Enforce settled waits and falsifiable guards in end-to-end tests
3
3
  paths:
4
- - 'e2e/*.ts'
5
- - 'e2e/**/*.ts'
4
+ - '**/e2e/**/*.ts'
6
5
  ---
7
6
 
8
7
  # End-to-end reliability standards
@@ -14,6 +13,8 @@ paths:
14
13
  - Bound every settle with an explicit timeout.
15
14
  - Do not raise a timeout to clear a failure that reproduces under load. Replace the wait with a settle.
16
15
  - Do not read a value once after a pause. Poll it.
16
+ - Settle a smooth scroll on the `scrollend` event. Fall back to a stillness window only when the target was already in view.
17
+ - Run with motion reduced unless the test asserts motion, and opt back in per test.
17
18
 
18
19
  ## Falsifiable guards
19
20
 
@@ -1,8 +1,7 @@
1
1
  ---
2
2
  description: Enforce which specs and which engines an end-to-end run covers at each point in the loop
3
3
  paths:
4
- - 'e2e/*.ts'
5
- - 'e2e/**/*.ts'
4
+ - '**/e2e/**/*.ts'
6
5
  ---
7
6
 
8
7
  # Test scope standards
@@ -1,2 +1,2 @@
1
1
  extends = "node"
2
- rules = ["210-astro", "350-security-web", "400-ui", "410-a11y", "420-forms", "430-ux-completeness", "440-surface-capture", "450-link-behavior", "460-design-taste", "470-motion"]
2
+ rules = ["210-astro", "300-testing-ts", "305-e2e-reliability", "306-test-scope", "350-security-web", "400-ui", "410-a11y", "420-forms", "430-ux-completeness", "440-surface-capture", "450-link-behavior", "460-design-taste", "470-motion"]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.92.0",
4
+ "version": "4.93.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -19,7 +19,8 @@
19
19
  "tsconfig.json",
20
20
  "!scripts/sandbox",
21
21
  "!scripts/eval",
22
- "!**/*.test.ts"
22
+ "!**/*.test.ts",
23
+ "!**/*.test.tsx"
23
24
  ],
24
25
  "publishConfig": {
25
26
  "access": "public"
@@ -18,10 +18,11 @@
18
18
  # Only the HTML regenerates here. The PNG beside it is a chromium render whose
19
19
  # bytes move with the browser version, so asserting it in verify.sh would fail
20
20
  # on a machine whose chromium differs rather than on a stale count. Rebuild the
21
- # images with `canon capture assets/captures --selector .window --out assets`
21
+ # images with `canon capture assets/captures --selector .window --out assets/evidence`
22
22
  # after this script reports a change. The markup and the image sit in two
23
- # folders, so that run reads the sources here and sends every PNG and stamp back
24
- # up to assets/, where the documents point. The selector has no default, since
23
+ # folders, so that run reads the sources here and sends every PNG and stamp to
24
+ # assets/evidence/, where the documents point and where `canon pr evidence`
25
+ # compares a changed image. The selector has no default, since
25
26
  # the element a capture crops to belongs to the page rather than to the command,
26
27
  # and `.window` is the class this repository's own sources declare.
27
28
  # That capture also writes a .stamp beside each PNG, which records the digest of
@@ -25,6 +25,7 @@ import { formatStamp, hashSource, stampPath } from '@/capture/stamp'
25
25
  */
26
26
 
27
27
  const DEVICE_SCALE_FACTOR = 2
28
+ const DEFAULT_VIEWPORT_HEIGHT = 720
28
29
  const FONT_PROBE_SIZE = 72
29
30
  const FONT_PROBE_TEXT = 'canon capture 0123456789'
30
31
  const ABSENT_FAMILY = '__canon_absent_family__'
@@ -32,6 +33,7 @@ const ABSENT_FAMILY = '__canon_absent_family__'
32
33
  export interface CaptureOptions {
33
34
  selector: string
34
35
  outDir?: string
36
+ width?: number
35
37
  }
36
38
 
37
39
  export type CaptureResult =
@@ -54,7 +56,9 @@ export async function captureSources(
54
56
  const browser = await chromium.launch()
55
57
  try {
56
58
  return await Promise.all(
57
- sources.map((source) => captureOne(browser, source, options.selector)),
59
+ sources.map((source) =>
60
+ captureOne(browser, source, options.selector, options.width),
61
+ ),
58
62
  )
59
63
  } finally {
60
64
  await browser.close()
@@ -69,9 +73,19 @@ async function captureOne(
69
73
  browser: Browser,
70
74
  source: CaptureSource,
71
75
  selector: string,
76
+ width: number | undefined,
72
77
  ): Promise<CaptureResult> {
78
+ /**
79
+ * A viewport rather than a wrapper, since a media query answers to the
80
+ * viewport. Absent leaves the option out so the browser default stands and
81
+ * every committed capture renders as before. The height is Playwright's own
82
+ * default, which keeps `vh` units where they were.
83
+ */
73
84
  const page = await browser.newPage({
74
85
  deviceScaleFactor: DEVICE_SCALE_FACTOR,
86
+ ...(width === undefined
87
+ ? {}
88
+ : { viewport: { width, height: DEFAULT_VIEWPORT_HEIGHT } }),
75
89
  })
76
90
  try {
77
91
  await page.goto(
package/src/cli.ts CHANGED
@@ -121,7 +121,7 @@ function showHelp(): void {
121
121
  `${GREY}│${NC} canon design board`,
122
122
  `${GREY}│${NC} canon slides render`,
123
123
  `${GREY}│${NC} canon slides list --json`,
124
- `${GREY}│${NC} canon capture assets/captures/install.html --selector .window --out assets`,
124
+ `${GREY}│${NC} canon capture assets/captures/install.html --selector .window --out assets/evidence`,
125
125
  `${GREY}│${NC} canon serve .canon/teach`,
126
126
  `${GREY}│${NC} canon inventory focus --json`,
127
127
  `${GREY}│${NC} canon drive http://localhost:4173 run.json --json`,
@@ -54,10 +54,14 @@ export function register(program: Command): void {
54
54
  'Output directory for a file source, or the destination PNG for a URL source; defaults beside the source',
55
55
  )
56
56
  .option('-s, --selector <selector>', 'Element to capture')
57
+ .option(
58
+ '--width <px>',
59
+ 'Viewport width in pixels, so a media query resolves at that width',
60
+ )
57
61
  .action(
58
62
  async (
59
63
  source: string,
60
- opts: { out?: string; selector?: string },
64
+ opts: { out?: string; selector?: string; width?: string },
61
65
  ): Promise<void> => {
62
66
  /**
63
67
  * Refused rather than defaulted, and refused ahead of every other
@@ -75,6 +79,15 @@ export function register(program: Command): void {
75
79
  }
76
80
  const selector = opts.selector
77
81
 
82
+ const width = parseWidth(opts.width)
83
+ if (Number.isNaN(width)) {
84
+ frameError(
85
+ `--width takes a whole number of pixels of 1 or more, got ${opts.width}.`,
86
+ )
87
+ process.exitCode = 1
88
+ return
89
+ }
90
+
78
91
  const isUrl = isUrlSource(source)
79
92
  const sourcePath = isUrl ? source : resolve(process.cwd(), source)
80
93
  if (!isUrl && !existsSync(sourcePath)) {
@@ -89,6 +102,7 @@ export function register(program: Command): void {
89
102
  const renderer = await import('@/capture/render')
90
103
  results = await renderer.captureSources(sourcePath, {
91
104
  selector,
105
+ width,
92
106
  outDir: opts.out ? resolve(process.cwd(), opts.out) : undefined,
93
107
  })
94
108
  } catch (error) {
@@ -122,6 +136,16 @@ export function register(program: Command): void {
122
136
  )
123
137
  }
124
138
 
139
+ /**
140
+ * Undefined for an absent flag and `NaN` for a value that is not a positive
141
+ * integer, so the caller can tell no width from a bad one.
142
+ */
143
+ function parseWidth(raw: string | undefined): number | undefined {
144
+ if (raw === undefined) return undefined
145
+ const value = Number(raw)
146
+ return Number.isInteger(value) && value >= 1 ? value : Number.NaN
147
+ }
148
+
125
149
  /**
126
150
  * Closes an open frame around a failure that stopped the whole run rather than
127
151
  * one source. Two of them are setup states rather than defects and each names
@@ -7,6 +7,7 @@ const DEFAULT_DIR = '.'
7
7
 
8
8
  interface ServeCommandOptions {
9
9
  readonly entry?: string
10
+ readonly index?: boolean
10
11
  readonly json?: boolean
11
12
  readonly port?: string
12
13
  }
@@ -19,6 +20,10 @@ export function register(program: Command): void {
19
20
  .helpOption('-h, --help', 'Show this help message')
20
21
  .option('--port <number>', `Port to try first, default ${DEFAULT_PORT}`)
21
22
  .option('--entry <path>', 'Page the printed link opens, default index.html')
23
+ .option(
24
+ '--index',
25
+ 'List a directory that has no index.html instead of answering 404',
26
+ )
22
27
  .option('--json', 'Emit a machine-readable record on stdout')
23
28
  .addHelpText(
24
29
  'after',
@@ -39,6 +44,7 @@ export function register(program: Command): void {
39
44
  ' canon serve .canon/teach',
40
45
  ' canon serve .canon/teach --entry 03-fde-system-design/index.html',
41
46
  ' canon serve dist --port 4000 --json',
47
+ ' canon serve .canon/groundwork --index',
42
48
  '',
43
49
  ].join('\n'),
44
50
  )
@@ -61,7 +67,11 @@ async function runServe(
61
67
  )
62
68
  }
63
69
 
64
- const outcome = startServer(dir, { port, entry: opts.entry })
70
+ const outcome = startServer(dir, {
71
+ port,
72
+ entry: opts.entry,
73
+ index: opts.index,
74
+ })
65
75
  const code = report(outcome, emitJson)
66
76
  if (!outcome.ok) return code
67
77
 
@@ -142,7 +142,7 @@ export function auditsBaselineRel(root: string): string {
142
142
  }
143
143
 
144
144
  export const CAPTURE_STAMP_FAILURE =
145
- 'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets and commit each frame with its image and its stamp.'
145
+ 'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets/evidence and commit each frame with its image and its stamp.'
146
146
 
147
147
  function parseJson(payload: string): unknown {
148
148
  try {
@@ -1118,11 +1118,12 @@ async function collectPluginManifests(ctx: MeasureContext): Promise<string[]> {
1118
1118
  /**
1119
1119
  * A capture set spans two folders. The markup and the template beside it are
1120
1120
  * the authored half and sit under `assets/captures/`, while the image a
1121
- * document points at and the stamp answering for it stay in `assets/`, which is
1122
- * where `--out assets` sends them.
1121
+ * document points at and the stamp answering for it sit under
1122
+ * `assets/evidence/`, which is where `--out assets/evidence` sends them and where
1123
+ * `canon pr evidence` looks for a changed image.
1123
1124
  */
1124
1125
  const CAPTURE_MARKUP_DIR = 'assets/captures'
1125
- const CAPTURE_OUTPUT_DIR = 'assets'
1126
+ const CAPTURE_OUTPUT_DIR = 'assets/evidence'
1126
1127
 
1127
1128
  /**
1128
1129
  * Every capture under `assets/captures/`, named by the base its three files
@@ -201,7 +201,7 @@ export const STAGES: readonly Stage[] = [
201
201
  kind: 'drift',
202
202
  pathspec: 'assets/captures/*.html',
203
203
  failure:
204
- 'A generated frame drifted from the catalogs or the design source. Run bun run check, then canon capture assets/captures --selector .window --out assets, and commit each assets/captures/*.html with its .png and .stamp.',
204
+ 'A generated frame drifted from the catalogs or the design source. Run bun run check, then canon capture assets/captures --selector .window --out assets/evidence, and commit each assets/captures/*.html with its .png and .stamp.',
205
205
  },
206
206
  { kind: 'measure', measure: captureStamps },
207
207
  ],
@@ -1,4 +1,4 @@
1
- import { existsSync, realpathSync, statSync } from 'node:fs'
1
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
2
2
  import { join, resolve, sep } from 'node:path'
3
3
 
4
4
  /**
@@ -81,6 +81,66 @@ export type ServeOutcome = ServeStarted | ServeRefused
81
81
  export interface ServeOptions {
82
82
  readonly port?: number
83
83
  readonly entry?: string
84
+ /** Answers a directory request with no index page by listing it. Off by default. */
85
+ readonly index?: boolean
86
+ }
87
+
88
+ function escapeHtml(text: string): string {
89
+ return text
90
+ .replaceAll('&', '&amp;')
91
+ .replaceAll('<', '&lt;')
92
+ .replaceAll('>', '&gt;')
93
+ .replaceAll('"', '&quot;')
94
+ .replaceAll("'", '&#39;')
95
+ }
96
+
97
+ interface ListingEntry {
98
+ readonly name: string
99
+ readonly isDirectory: boolean
100
+ }
101
+
102
+ /**
103
+ * Reads the entries a listing may name. Each one takes the same containment
104
+ * test a request for it would, since a listed name is itself a fact about a
105
+ * place outside the root. Dotfiles are hidden because a listing is for
106
+ * navigating pages, and a hidden file is still reachable by its URL.
107
+ */
108
+ function listEntries(root: string, directory: string): ListingEntry[] {
109
+ return readdirSync(directory)
110
+ .filter((name) => !name.startsWith('.'))
111
+ .filter((name) => !escapesThroughLink(root, join(directory, name)))
112
+ .map((name) => ({
113
+ name,
114
+ isDirectory:
115
+ statSync(join(directory, name), {
116
+ throwIfNoEntry: false,
117
+ })?.isDirectory() ?? false,
118
+ }))
119
+ .sort(
120
+ (a, b) =>
121
+ Number(b.isDirectory) - Number(a.isDirectory) ||
122
+ a.name.localeCompare(b.name),
123
+ )
124
+ }
125
+
126
+ function renderListing(
127
+ pathname: string,
128
+ entries: readonly ListingEntry[],
129
+ hasParent: boolean,
130
+ ): string {
131
+ const rows = entries.map(({ name, isDirectory }) => {
132
+ const label = isDirectory ? `${name}/` : name
133
+ return `<li><a href="${escapeHtml(encodeURIComponent(name))}${isDirectory ? '/' : ''}">${escapeHtml(label)}</a></li>`
134
+ })
135
+ if (hasParent) rows.unshift('<li><a href="../">../</a></li>')
136
+ return `<!doctype html>
137
+ <meta charset="utf-8">
138
+ <title>Index of ${escapeHtml(pathname)}</title>
139
+ <h1>Index of ${escapeHtml(pathname)}</h1>
140
+ <ul>
141
+ ${rows.join('\n')}
142
+ </ul>
143
+ `
84
144
  }
85
145
 
86
146
  function refuse(reason: ServeRefusal, detail: string): ServeRefused {
@@ -165,13 +225,14 @@ export function shouldWalkPast(error: unknown): boolean {
165
225
  function listen(
166
226
  root: string,
167
227
  first: number,
228
+ index: boolean,
168
229
  ): { server: ReturnType<typeof Bun.serve>; port: number } | undefined {
169
230
  for (let port = first; port < first + PORT_ATTEMPTS; port++) {
170
231
  try {
171
232
  const server = Bun.serve({
172
233
  hostname: SERVE_HOST,
173
234
  port,
174
- fetch: (request) => respond(root, request),
235
+ fetch: (request) => respond(root, request, index),
175
236
  })
176
237
  /**
177
238
  * The bound port rather than the requested one. Port 0 asks the OS to
@@ -196,6 +257,7 @@ function listen(
196
257
  export async function respond(
197
258
  root: string,
198
259
  request: Request,
260
+ index = false,
199
261
  ): Promise<Response> {
200
262
  const { pathname, search } = new URL(request.url)
201
263
  const forbidden = () =>
@@ -229,7 +291,19 @@ export async function respond(
229
291
  headers: { location: `${pathname}/${search}` },
230
292
  })
231
293
  }
232
- path = join(path, DEFAULT_ENTRY)
294
+ const indexPage = join(path, DEFAULT_ENTRY)
295
+ if (index && !existsSync(indexPage)) {
296
+ return new Response(
297
+ renderListing(pathname, listEntries(root, path), path !== root),
298
+ {
299
+ headers: {
300
+ 'content-type': 'text/html; charset=utf-8',
301
+ 'cache-control': 'no-store',
302
+ },
303
+ },
304
+ )
305
+ }
306
+ path = indexPage
233
307
  }
234
308
 
235
309
  /**
@@ -291,7 +365,7 @@ export function startServer(
291
365
  */
292
366
  let bound: ReturnType<typeof listen>
293
367
  try {
294
- bound = listen(root, first)
368
+ bound = listen(root, first, options.index ?? false)
295
369
  } catch (error) {
296
370
  const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
297
371
  return refuse(
@@ -307,14 +381,24 @@ export function startServer(
307
381
  )
308
382
  }
309
383
 
384
+ /**
385
+ * A listing answers `/` where no index page exists, so under the flag the
386
+ * default entry is the root itself. An explicit `--entry` keeps its own link
387
+ * and its own warning.
388
+ */
389
+ const listsRoot =
390
+ (options.index ?? false) &&
391
+ options.entry === undefined &&
392
+ !existsSync(entryPath)
393
+
310
394
  return {
311
395
  ok: true,
312
396
  root,
313
397
  host: SERVE_HOST,
314
398
  port: bound.port,
315
399
  entry,
316
- url: `http://${SERVE_HOST}:${bound.port}/${entry}`,
317
- entryExists: existsSync(entryPath),
400
+ url: `http://${SERVE_HOST}:${bound.port}/${listsRoot ? '' : entry}`,
401
+ entryExists: listsRoot || existsSync(entryPath),
318
402
  stop: async () => {
319
403
  await bound.server.stop(true)
320
404
  },
@@ -109,8 +109,8 @@ function renderBlock(
109
109
 
110
110
  /**
111
111
  * Renders a lesson body's structural blocks through the same components
112
- * `render-fixture.tsx` composes by hand, so a real lesson and the committed
113
- * fixture share one rendering mechanism. Every block past the first refusal
112
+ * `lesson.test.tsx` composes as JSX, so a real lesson and the component test
113
+ * share one rendering mechanism. Every block past the first refusal
114
114
  * goes unread, matching how a malformed JSON parse refuses the whole call.
115
115
  */
116
116
  export function renderLessonBody(blocks: readonly unknown[]): RenderOutcome {
@@ -13,6 +13,8 @@ export default defineConfig({
13
13
  use: {
14
14
  trace: 'on-first-retry',
15
15
  baseURL,
16
+ // Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
17
+ reducedMotion: 'reduce',
16
18
  },
17
19
  projects: [
18
20
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
@@ -11,6 +11,8 @@ export default defineConfig({
11
11
  use: {
12
12
  trace: 'on-first-retry',
13
13
  baseURL,
14
+ // Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
15
+ reducedMotion: 'reduce',
14
16
  },
15
17
  projects: [
16
18
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
@@ -13,6 +13,8 @@ export default defineConfig({
13
13
  use: {
14
14
  trace: 'on-first-retry',
15
15
  baseURL,
16
+ // Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
17
+ reducedMotion: 'reduce',
16
18
  },
17
19
  projects: [
18
20
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
@@ -1,95 +0,0 @@
1
- /** @jsxImportSource ./html */
2
- import { mkdir, writeFile } from 'node:fs/promises'
3
- import { join } from 'node:path'
4
- import { TEACH_STYLESHEET_COMPONENTS } from '@/design/components'
5
- import { buildDesignCss } from '@/design/css'
6
- import { TEACH_FONT_FACES } from '@/teach/fonts'
7
- import { Heading } from '@/teach/components/heading'
8
- import { List } from '@/teach/components/list'
9
- import { Paragraph } from '@/teach/components/paragraph'
10
- import { render } from '@/teach/html/jsx-runtime'
11
-
12
- /**
13
- * Regenerates the committed fixture lesson under
14
- * `examples/teach/00-fixture/`, the same shape `examples/slides/showcase.md`
15
- * takes against its own hand-rendered snapshot. Run with
16
- * `bun src/teach/render-fixture.tsx` after a component changes shape.
17
- */
18
-
19
- const FIXTURE_ROOT = join(
20
- import.meta.dir,
21
- '..',
22
- '..',
23
- 'examples',
24
- 'teach',
25
- '00-fixture',
26
- )
27
- const LESSON_TITLE = 'Compass bearings'
28
- const LESSON_FILE = '0001-compass-bearings.html'
29
-
30
- const STEPS = [
31
- 'Point the direction-of-travel arrow at the landmark.',
32
- 'Rotate the bezel until the orienting arrow lines up with the needle.',
33
- 'Read the bearing where the direction-of-travel arrow meets the bezel.',
34
- ]
35
-
36
- const body = (
37
- <>
38
- <Heading level={1}>{LESSON_TITLE}</Heading>
39
- <Paragraph lede>
40
- A bearing is the compass direction from where you stand to whatever you
41
- are aiming at, measured clockwise from north.
42
- </Paragraph>
43
- <Paragraph>
44
- Hold the compass level and let the needle settle before reading anything
45
- off it. A bearing taken while walking or tilted reads confidently and
46
- wrong.
47
- </Paragraph>
48
- <Paragraph>
49
- Three steps turn a sighted landmark into a number you can act on:
50
- </Paragraph>
51
- <List ordered items={STEPS} />
52
- <Paragraph>
53
- The same three steps run in reverse turn a bearing on a map into a
54
- direction to walk, which is the only reason to learn them in this order.
55
- </Paragraph>
56
- </>
57
- )
58
-
59
- function page(title: string, main: string): string {
60
- return `<!doctype html>
61
- <html lang="en">
62
- <head>
63
- <meta charset="utf-8">
64
- <meta name="viewport" content="width=device-width, initial-scale=1">
65
- <title>${title}</title>
66
- <link rel="stylesheet" href="../assets/course.css">
67
- </head>
68
- <body>
69
- <main class="wide-body">
70
- ${main}
71
- </main>
72
- </body>
73
- </html>
74
- `
75
- }
76
-
77
- async function main(): Promise<void> {
78
- await mkdir(join(FIXTURE_ROOT, 'lessons'), { recursive: true })
79
- await mkdir(join(FIXTURE_ROOT, 'assets'), { recursive: true })
80
-
81
- await writeFile(
82
- join(FIXTURE_ROOT, 'assets', 'course.css'),
83
- buildDesignCss(undefined, {
84
- embedFonts: TEACH_FONT_FACES,
85
- components: TEACH_STYLESHEET_COMPONENTS,
86
- }),
87
- )
88
-
89
- await writeFile(
90
- join(FIXTURE_ROOT, 'lessons', LESSON_FILE),
91
- page(LESSON_TITLE, render(body)),
92
- )
93
- }
94
-
95
- await main()