@erclx/canon 4.53.0 → 4.55.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.
Files changed (33) hide show
  1. package/README.md +8 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canon-cli/SKILL.md +1 -1
  4. package/claude/skills/claude-feature/SKILL.md +1 -1
  5. package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +7 -3
  6. package/claude/skills/claude-planner/SKILL.md +1 -1
  7. package/claude/skills/context-draft/REQUIREMENT.md +37 -0
  8. package/claude/skills/context-draft/SKILL.md +59 -0
  9. package/claude/skills/create-skill/REQUIREMENT.md +1 -1
  10. package/claude/skills/create-standard/SKILL.md +1 -1
  11. package/claude/skills/index-lookup/REQUIREMENT.md +35 -0
  12. package/claude/skills/index-lookup/SKILL.md +42 -0
  13. package/claude/skills/wireframe-draft/REQUIREMENT.md +38 -0
  14. package/claude/skills/wireframe-draft/SKILL.md +64 -0
  15. package/claude/skills/youtube-transcripts/SKILL.md +1 -1
  16. package/docs/agents/commands.md +1 -0
  17. package/docs/agents/demo.md +11 -8
  18. package/docs/agents/index.md +1 -1
  19. package/docs/agents/indexes.md +17 -1
  20. package/docs/workflow/ai-workflow.md +15 -12
  21. package/governance/rules/core/085-worktrees.md +2 -0
  22. package/package.json +1 -1
  23. package/scripts/core/regen-agent-fixture.sh +235 -0
  24. package/src/claude/cases/authoring.ts +10 -0
  25. package/src/claude/cases/misc.ts +4 -0
  26. package/src/commands/demo.ts +25 -1
  27. package/src/commands/indexes.ts +67 -0
  28. package/src/demo/container.ts +61 -0
  29. package/src/demo/drive.ts +67 -9
  30. package/src/indexes/list.ts +64 -0
  31. package/src/indexes/render.ts +1 -1
  32. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +1 -1
  33. package/tooling/web/configs/scripts/screenshot.sh +1 -1
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env bash
2
+ # Writes web/src/fixtures/agent-view.json from a real `canon sessions list
3
+ # --json` read of this repository's own sessions.
4
+ #
5
+ # The page it feeds re-creates an orchestrator dispatching workers, which is the
6
+ # one thing this toolkit does that no other surface here shows. A re-creation
7
+ # earns nothing if the rows behind it are invented, so the session half is a
8
+ # live listing and never a literal.
9
+ #
10
+ # Every role in the roster appears, not the workers alone. The controlling
11
+ # session is pinned above the bands and planners sit in them beside workers,
12
+ # which is what the surface being re-created does. An earlier version of this
13
+ # script filtered the listing down to sessions holding a feature branch, and
14
+ # that dropped the orchestrator and every planner, since a controlling session
15
+ # and a planning session both sit on the default branch. A page about a dispatch
16
+ # that excludes the dispatcher is the defect that filter shipped.
17
+ #
18
+ # Two fields cannot come from the listing and are transcribed by hand into the
19
+ # table below. The activity text is written by Claude Code into its own status
20
+ # line and no verb here reports it. The pull request number sits on a task file
21
+ # with nothing joining a session record to it, which the plan behind this script
22
+ # took as a trade rather than building the join. Both are recorded as
23
+ # transcribed in the fixture's own header, so a reader of the committed file can
24
+ # tell which half was measured and which was typed.
25
+ #
26
+ # The honesty cost this leaves open is the one assets/captures/install.html.tmpl
27
+ # already documents for the terminal frames: a hand-edit after generation
28
+ # defeats the discipline silently, and nothing here detects one.
29
+ #
30
+ # Clone-only. It reads this repository's own sessions, and a registry install
31
+ # carries no such listing.
32
+ set -e
33
+ set -o pipefail
34
+
35
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
36
+ PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
37
+
38
+ OUT="$PROJECT_ROOT/web/src/fixtures/agent-view.json"
39
+
40
+ # Transcribed half, keyed by session name. The name is the only key that reaches
41
+ # every row: a controlling session and a planning session both sit on the
42
+ # default branch, so a branch cannot tell them apart or from each other. A
43
+ # dispatched session is named `<role>-<project>-<slug>` after the work it was
44
+ # given rather than after what it turned out to be doing, which is what makes
45
+ # the name stable enough to key on here.
46
+ #
47
+ # Each row is `name|activity|pullRequest|state|age`. The state is `pinned`,
48
+ # `working`, or `completed`, and it is where the row renders. An empty pull
49
+ # request field is a session that had not reached its ship step when the
50
+ # snapshot was taken.
51
+ #
52
+ # A pinned or working row has to appear in the live listing or this refuses, and
53
+ # its age comes from the listing rather than the table, so the trailing field is
54
+ # empty for those. A completed row names a session that has already exited, so
55
+ # no listing carries it, its age is transcribed with the rest of the row, and
56
+ # leaving that field blank would print an empty column on half the list.
57
+ # Re-transcribe this table against a fresh listing rather than editing the
58
+ # generated file.
59
+ #
60
+ # A planner belongs in this table as much as a worker does. `claude-planner`
61
+ # forbids entering a worktree, so a planner registers on the default branch at
62
+ # launch and stays there for its whole life, which is why the branch filter this
63
+ # script used to carry dropped every one of them permanently rather than
64
+ # occasionally.
65
+ ROWS=(
66
+ "orchestrator-canon-lead|dispatching the planners that need a plan||pinned|"
67
+ "worker-canon-agent-view-and-deploy|PR #1510 open (landing page)|1510|working|" # canon-allow-reference: transcribed status text
68
+ "planner-canon-context-wireframe-draft|drafting the context and wireframe plan||working|"
69
+ "planner-canon-skill-coverage-pointers|reading the skill coverage pointers||working|"
70
+ "planner-canon-screenshot-trap-guard|planning the screenshot trap guard||working|"
71
+ "planner-canon-write-route-hook-bypass|plan written; tested write probe||completed|32m"
72
+ "planner-canon-indexes-list-lookup|plan and task board consistent||completed|13m"
73
+ "worker-canon-labels-scan-body-file|address-review pass finished|1508|completed|21m"
74
+ "worker-canon-web-context-entry|PR #1507 addressed with commit|1507|completed|20m" # canon-allow-reference: transcribed status text
75
+ )
76
+
77
+ # The listing reports a session's repository as the main `.git` directory, which
78
+ # is what this resolves whether the script runs from the main checkout or from a
79
+ # linked worktree. Comparing against PROJECT_ROOT instead would match nothing
80
+ # from a worktree, where the two differ.
81
+ MAIN_GIT_DIR="$(cd "$PROJECT_ROOT" && git rev-parse --path-format=absolute --git-common-dir)"
82
+
83
+ # `bun src/cli.ts` rather than `canon`, since a globally linked binary resolves
84
+ # to the main checkout no matter which worktree is running, and this script is
85
+ # run from a worktree as often as not.
86
+ SESSIONS_JSON="$(cd "$PROJECT_ROOT" && CANON_NON_INTERACTIVE=1 bun src/cli.ts sessions list --json 2>/dev/null)"
87
+
88
+ if [ -z "$SESSIONS_JSON" ]; then
89
+ echo "regen-agent-fixture: the session listing returned nothing, refusing to write an empty fixture" >&2
90
+ exit 1
91
+ fi
92
+
93
+ # The listing crosses into the eval as a file rather than an environment entry,
94
+ # for the reason regen-hero.sh records: Linux caps a single env string at 128KB
95
+ # and a machine running many sessions can cross it, which fails the exec with
96
+ # E2BIG before any check can report a thing.
97
+ SESSIONS_FILE="$(mktemp)"
98
+ trap 'rm -f "$SESSIONS_FILE"' EXIT
99
+ printf '%s' "$SESSIONS_JSON" >"$SESSIONS_FILE"
100
+
101
+ TRANSCRIBED_ROWS="$(printf '%s\n' "${ROWS[@]}")"
102
+ READ_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
103
+
104
+ export SESSIONS_FILE OUT PROJECT_ROOT READ_AT TRANSCRIBED_ROWS MAIN_GIT_DIR
105
+
106
+ bun --eval '
107
+ const { readFileSync } = require("node:fs")
108
+
109
+ const { SESSIONS_FILE, OUT, PROJECT_ROOT, READ_AT, TRANSCRIBED_ROWS, MAIN_GIT_DIR } =
110
+ process.env
111
+
112
+ const listing = JSON.parse(readFileSync(SESSIONS_FILE, "utf8"))
113
+
114
+ const rows = TRANSCRIBED_ROWS.split("\n")
115
+ .map((line) => line.replace(/\s*#\s*canon-allow-reference:.*$/, "").trim())
116
+ .filter((line) => line !== "")
117
+ .map((line) => {
118
+ const [name, activity, pullRequest, state, age] = line.split("|")
119
+ return {
120
+ name,
121
+ activity,
122
+ pullRequest: pullRequest ? Number(pullRequest) : null,
123
+ state,
124
+ age: age || null,
125
+ }
126
+ })
127
+
128
+ // Narrowed to this repository, and the reason is disclosure rather than scope.
129
+ // `canon sessions list` is machine-wide, so an unfiltered read carries the
130
+ // session names and worktree paths of every other project on the machine, which
131
+ // here includes paths under a folder named `private` and several client names.
132
+ // This fixture renders on a public page, so the filter is what keeps them off
133
+ // it. The cost is a fidelity gap stated plainly: the real surface is
134
+ // machine-wide and the depiction is one repository, and that is a deliberate
135
+ // narrowing rather than an oversight.
136
+ //
137
+ // Keyed by name for the reason the shell comment gives: a session on the
138
+ // default branch has no other identity, and the orchestrator and every planner
139
+ // sit there.
140
+ const live = new Map(
141
+ listing.sessions
142
+ .filter((session) => session.repository === MAIN_GIT_DIR)
143
+ .map((session) => [session.name, session]),
144
+ )
145
+
146
+ // The real surface shows an age per row, so the fixture carries one. It is
147
+ // frozen at the read rather than live, which the rendered provenance line dates
148
+ // so a reader is never told a stale figure is current.
149
+ const ageLabel = (startedAt) => {
150
+ const minutes = Math.round(
151
+ (Date.parse(READ_AT) - Date.parse(startedAt)) / 60000,
152
+ )
153
+ if (!Number.isFinite(minutes) || minutes < 1) return "now"
154
+ if (minutes < 60) return `${minutes}m`
155
+ const hours = Math.round(minutes / 60)
156
+ return hours < 24 ? `${hours}h` : `${Math.round(hours / 24)}d`
157
+ }
158
+
159
+ // A row the page shows as running has to be running. Transcribing one that is
160
+ // not is the failure the live half exists to prevent, so it refuses rather than
161
+ // rendering.
162
+ const absent = rows.filter(
163
+ (row) => row.state !== "completed" && !live.has(row.name),
164
+ )
165
+ if (absent.length > 0) {
166
+ console.error(
167
+ "regen-agent-fixture: no live session for " +
168
+ absent.map((row) => row.name).join(", ") +
169
+ ". Re-transcribe the table in this script against a fresh listing rather than editing the generated fixture.",
170
+ )
171
+ process.exit(1)
172
+ }
173
+
174
+ const sessions = rows.map((row) => {
175
+ const session = live.get(row.name)
176
+ return {
177
+ name: row.name,
178
+ branch: session ? session.branch : null,
179
+ state: row.state,
180
+ activity: row.activity,
181
+ pullRequest: row.pullRequest,
182
+ age: session ? ageLabel(session.startedAt) : row.age,
183
+ }
184
+ })
185
+
186
+ const ageless = sessions.filter((entry) => !entry.age)
187
+ if (ageless.length > 0) {
188
+ console.error(
189
+ "regen-agent-fixture: no age for " +
190
+ ageless.map((entry) => entry.name).join(", ") +
191
+ ". A completed row carries its age in the table, since the session has exited and no listing reports one.",
192
+ )
193
+ process.exit(1)
194
+ }
195
+
196
+ // What the real surface prints above its own list. The remainder is every
197
+ // session on the machine this page has no room to show, which is a real count.
198
+ const summary = {
199
+ working: sessions.filter((entry) => entry.state === "working").length,
200
+ completed: sessions.filter((entry) => entry.state === "completed").length,
201
+ more: Math.max(0, listing.sessions.length - live.size),
202
+ }
203
+
204
+ const fixture = {
205
+ // Read by web/src/components/AgentView.astro. Every field below is either a
206
+ // live read or a hand transcription, and this header is what says which.
207
+ generatedBy: "scripts/core/regen-agent-fixture.sh",
208
+ readAt: READ_AT,
209
+ source: {
210
+ command: "canon sessions list --json",
211
+ live: [
212
+ "name",
213
+ "branch",
214
+ "age",
215
+ "that every pinned and working row is running",
216
+ "the remainder count",
217
+ ],
218
+ transcribed: {
219
+ activity:
220
+ "written by Claude Code into its own status line, which no verb here reports",
221
+ pullRequest:
222
+ "read off a task file by hand, since nothing joins a session record to one",
223
+ state:
224
+ "a completed session has already exited, so no listing carries the row at all",
225
+ },
226
+ },
227
+ summary,
228
+ sessions,
229
+ }
230
+
231
+ await Bun.write(OUT, JSON.stringify(fixture, null, 2) + "\n")
232
+ console.error(
233
+ `regen-agent-fixture: wrote ${sessions.length} session rows to ${OUT.replace(PROJECT_ROOT + "/", "")}`,
234
+ )
235
+ '
@@ -42,6 +42,16 @@ export const AUTHORING_CASES: readonly SkillCase[] = [
42
42
  'Write a brand-new docs page for the capture command, nothing under docs/ covers it yet.',
43
43
  expect: 'docs-draft',
44
44
  },
45
+ {
46
+ prompt:
47
+ 'Write a context entry for the payments domain, there is no .claude/context page for it yet.',
48
+ expect: 'context-draft',
49
+ },
50
+ {
51
+ prompt:
52
+ 'Draft a wireframe for the settings panel, nothing under .claude/wireframes covers that surface yet.',
53
+ expect: 'wireframe-draft',
54
+ },
45
55
  {
46
56
  prompt: 'Say what that dense answer actually means in plain terms.',
47
57
  expect: 'restate-plainly',
@@ -33,4 +33,8 @@ export const MISC_CASES: readonly SkillCase[] = [
33
33
  prompt: 'Does our github about text still match what the readme says?',
34
34
  expect: 'repo-metadata',
35
35
  },
36
+ {
37
+ prompt: 'Is there a page anywhere in this repo that covers retries?',
38
+ expect: 'index-lookup',
39
+ },
36
40
  ]
@@ -4,7 +4,7 @@ import type { Command } from 'commander'
4
4
  import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
5
5
  import { parseDraft } from '@/demo/beats'
6
6
  import { compilePlan, parsePlan, unresolved } from '@/demo/compile'
7
- import { convertToMp4, INSTALL_CONVERTER } from '@/demo/container'
7
+ import { convertToGif, convertToMp4, INSTALL_CONVERTER } from '@/demo/container'
8
8
  import { DEFAULT_CURSORS } from '@/demo/cursors'
9
9
  import { loadCursorTheme } from '@/demo/theme'
10
10
  import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
@@ -30,6 +30,7 @@ interface RunOptions {
30
30
  readonly cursor?: string
31
31
  readonly video: boolean
32
32
  readonly still: boolean
33
+ readonly gif?: boolean
33
34
  readonly json?: boolean
34
35
  }
35
36
 
@@ -82,6 +83,7 @@ export function register(program: Command): void {
82
83
  )
83
84
  .option('--no-video', 'Skip the recording and write only the still')
84
85
  .option('--no-still', 'Skip the still and write only the recording')
86
+ .option('--gif', 'Also write a gif, for a host that strips video')
85
87
  .option('--json', 'Add a machine-readable record on stdout')
86
88
  .addHelpText(
87
89
  'after',
@@ -93,6 +95,9 @@ export function register(program: Command): void {
93
95
  'Writes mp4 beside the webm when ffmpeg is on PATH, and skips it',
94
96
  `otherwise without failing the run. Install it with: ${INSTALL_CONVERTER}`,
95
97
  '',
98
+ 'A gif is opt-in behind --gif, since it is far larger than the webm',
99
+ 'and only a host that strips video needs one. GitHub is that host.',
100
+ '',
96
101
  'Exit codes:',
97
102
  ' 0 the recording and the still were written',
98
103
  ' 1 refused, with the reason on stderr',
@@ -306,16 +311,35 @@ async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
306
311
  }
307
312
  }
308
313
 
314
+ let gifPath: string | undefined
315
+ let gifReason: string | undefined
316
+ if (result.videoPath && opts.gif) {
317
+ const converted = await convertToGif(result.videoPath)
318
+ if (converted.status === 'converted') {
319
+ gifPath = converted.gifPath
320
+ logInfo(display(gifPath))
321
+ } else if (converted.status === 'skipped') {
322
+ gifReason = converted.reason
323
+ logWarn('ffmpeg is not installed, so no gif was written.')
324
+ logWarn(`Install it with: ${INSTALL_CONVERTER}`)
325
+ } else {
326
+ gifReason = converted.reason
327
+ logWarn(`gif conversion failed: ${converted.reason}`)
328
+ }
329
+ }
330
+
309
331
  outro()
310
332
 
311
333
  emit(opts.json, {
312
334
  plan: source,
313
335
  video: result.videoPath ?? null,
314
336
  mp4: mp4Path ?? null,
337
+ gif: gifPath ?? null,
315
338
  still: result.stillPath ?? null,
316
339
  steps: result.steps,
317
340
  durationMs: result.durationMs,
318
341
  ...(mp4Reason ? { mp4Reason } : {}),
342
+ ...(gifReason ? { gifReason } : {}),
319
343
  })
320
344
  return 0
321
345
  }
@@ -2,6 +2,7 @@ import { stat } from 'node:fs/promises'
2
2
  import { dirname, resolve } from 'node:path'
3
3
  import { $ } from 'bun'
4
4
  import type { Command } from 'commander'
5
+ import { buildIndexCatalog } from '@/indexes/list'
5
6
  import { exitCodeFor, type RegenResult, regenOne } from '@/indexes/regen'
6
7
  import { gitEnv } from '@/git-env'
7
8
  import { findIndexedAncestor, isIgnored, listIndexes } from '@/indexes/walk'
@@ -14,6 +15,10 @@ interface RegenCommandOptions {
14
15
  readonly stage?: boolean
15
16
  }
16
17
 
18
+ interface ListCommandOptions {
19
+ readonly json?: boolean
20
+ }
21
+
17
22
  export function register(program: Command): void {
18
23
  const indexes = program
19
24
  .command('indexes')
@@ -51,6 +56,30 @@ export function register(program: Command): void {
51
56
  .action(async (paths: string[], opts: RegenCommandOptions) => {
52
57
  process.exitCode = await runRegen(paths, opts)
53
58
  })
59
+
60
+ indexes
61
+ .command('list')
62
+ .description('Flatten every folder index under a path into one catalog')
63
+ .argument('[path]', 'Folder to walk (default: cwd)')
64
+ .helpOption('-h, --help', 'Show this help message')
65
+ .option('--json', 'Emit a machine-readable catalog on stdout')
66
+ .addHelpText(
67
+ 'after',
68
+ [
69
+ '',
70
+ 'Exit codes:',
71
+ ' 0 no frontmatter errors',
72
+ ' 1 root not a directory, or a folder failed frontmatter validation',
73
+ '',
74
+ 'Examples:',
75
+ ' canon indexes list',
76
+ ' canon indexes list --json docs/',
77
+ '',
78
+ ].join('\n'),
79
+ )
80
+ .action(async (path: string | undefined, opts: ListCommandOptions) => {
81
+ process.exitCode = await runList(path, opts)
82
+ })
54
83
  }
55
84
 
56
85
  async function runRegen(
@@ -115,6 +144,44 @@ async function runRegen(
115
144
  return exitCodeFor(results, { dryRun })
116
145
  }
117
146
 
147
+ async function runList(
148
+ path: string | undefined,
149
+ opts: ListCommandOptions,
150
+ ): Promise<number> {
151
+ const root = resolve(path ?? process.cwd())
152
+ const emitJson = opts.json ?? false
153
+
154
+ if (!(await isDirectory(root))) {
155
+ if (emitJson) {
156
+ process.stdout.write(
157
+ `${JSON.stringify({ error: `root not a directory: ${root}` })}\n`,
158
+ )
159
+ return 1
160
+ }
161
+ process.stderr.write(`Root is not a directory: ${root}\n`)
162
+ return 1
163
+ }
164
+
165
+ const catalog = await buildIndexCatalog(root)
166
+
167
+ for (const message of catalog.errors) {
168
+ process.stderr.write(`ERROR: ${message}\n`)
169
+ }
170
+
171
+ if (emitJson) {
172
+ process.stdout.write(`${JSON.stringify({ root, ...catalog })}\n`)
173
+ } else {
174
+ intro('canon indexes list')
175
+ logStep('Catalog')
176
+ for (const entry of catalog.entries) {
177
+ logInfo(`${entry.path}: ${entry.title} — ${entry.description}`)
178
+ }
179
+ outro()
180
+ }
181
+
182
+ return catalog.errors.length > 0 ? 1 : 0
183
+ }
184
+
118
185
  function collectFromPaths(paths: string[], root: string): string[] {
119
186
  const dirs: string[] = []
120
187
 
@@ -15,6 +15,11 @@ export type ContainerResult =
15
15
  | { status: 'skipped'; reason: 'converter-missing' }
16
16
  | { status: 'failed'; reason: string }
17
17
 
18
+ export type GifResult =
19
+ | { status: 'converted'; gifPath: string }
20
+ | { status: 'skipped'; reason: 'converter-missing' }
21
+ | { status: 'failed'; reason: string }
22
+
18
23
  /**
19
24
  * Writes mp4 beside the webm rather than instead of it, since both stated use
20
25
  * cases are a `<video>` tag on a page the operator controls, where webm
@@ -61,3 +66,59 @@ export async function convertToMp4(
61
66
  }
62
67
  return { status: 'converted', mp4Path }
63
68
  }
69
+
70
+ /**
71
+ * Opt-in rather than written beside every recording, because a gif is an order
72
+ * of magnitude larger than the webm it derives from and only one destination
73
+ * needs one: a README on a host that strips `<video>`, which GitHub does. The
74
+ * mp4 above is unconditional for the opposite reason, being cheap and wanted
75
+ * wherever the webm already plays.
76
+ *
77
+ * A palette is generated from the source and then applied, rather than letting
78
+ * ffmpeg quantize per frame. A per-frame palette is what makes a screen
79
+ * recording of flat UI colors band and shimmer, which is precisely the artifact
80
+ * that would make a page look worse in the README than it does in a browser.
81
+ *
82
+ * Dithering is off because this command records applications rather than
83
+ * photographs. A global palette already covers flat interface colors, so the
84
+ * dither only writes noise the encoder then has to store: measured at 3.19MB
85
+ * against 2.70MB on one recording, with the same text region cropped from both
86
+ * and read as visually identical. Width is the larger lever at 2.38MB for 800
87
+ * pixels, and it is not taken, since the terminal rows are what the recording
88
+ * exists to have read. Frame rate is almost no lever at all.
89
+ *
90
+ * The missing-binary and failure handling matches `convertToMp4` exactly: the
91
+ * recording already succeeded by the time this runs, so an optional step never
92
+ * fails the run.
93
+ */
94
+ export async function convertToGif(
95
+ webmPath: string,
96
+ bin: string = CONVERTER_BIN,
97
+ ): Promise<GifResult> {
98
+ const { dir, name } = parse(webmPath)
99
+ const gifPath = join(dir, `${name}.gif`)
100
+
101
+ const result = await execa(
102
+ bin,
103
+ [
104
+ '-y',
105
+ '-i',
106
+ webmPath,
107
+ '-filter_complex',
108
+ '[0:v] fps=12,scale=960:-1:flags=lanczos,split [a][b];[a] palettegen [p];[b][p] paletteuse=dither=none',
109
+ gifPath,
110
+ ],
111
+ { reject: false },
112
+ )
113
+
114
+ if (result.failed && result.code === 'ENOENT') {
115
+ return { status: 'skipped', reason: 'converter-missing' }
116
+ }
117
+ if (result.exitCode !== 0) {
118
+ return {
119
+ status: 'failed',
120
+ reason: result.stderr?.trim() || `ffmpeg exited ${result.exitCode}`,
121
+ }
122
+ }
123
+ return { status: 'converted', gifPath }
124
+ }
package/src/demo/drive.ts CHANGED
@@ -38,6 +38,27 @@ const POINTER_SIZE = 32
38
38
  * first target. A corner keeps that first move out of the way of the content.
39
39
  */
40
40
  const START = { x: 8, y: 8 }
41
+
42
+ /**
43
+ * Whether any step will move the pointer. The injected element installs on
44
+ * `DOMContentLoaded` and paints itself off-screen, so it costs nothing until
45
+ * something moves it, and seeding `START` is what first makes it visible.
46
+ *
47
+ * A plan that only navigates, scrolls and holds has nothing to point at, and
48
+ * seeding it anyway parked a cursor in the corner for the whole recording. That
49
+ * reads as a stuck artifact rather than a pointer, which is the same defect as
50
+ * resting it on a row of text, moved to a corner rather than fixed.
51
+ *
52
+ * `scroll` is absent deliberately. It is a targeted verb the compiler groups
53
+ * with the rest, and it stopped moving the pointer when a scroll step began
54
+ * leaving it where it was.
55
+ */
56
+ function movesPointer(plan: DemoPlan): boolean {
57
+ return plan.steps.some(
58
+ (step) =>
59
+ step.kind === 'click' || step.kind === 'fill' || step.kind === 'hover',
60
+ )
61
+ }
41
62
  const SETTLE_MS = 250
42
63
  /** Round trips sampled to price one, on the page a step is actually about to move across. */
43
64
  const CALIBRATION_STEPS = 8
@@ -100,16 +121,22 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
100
121
  // Pointed the opposite way from a test. A recording wants the motion the
101
122
  // interface was designed with, where a test wants it suppressed.
102
123
  reducedMotion: 'no-preference',
124
+ // `showActions` is deliberately not passed. Playwright's annotation draws
125
+ // a dot on the interacted element and a title naming the API call it
126
+ // made, and this recorder already supersedes both: the injected pointer
127
+ // is a real cursor where the dot is a marker, and the caption bar carries
128
+ // the beat's narration where the title carries `Mouse move`. Leaving it
129
+ // on ran four overlays where two were wanted, and the two redundant ones
130
+ // were the two a viewer reads as noise. `plan.annotations` survives as a
131
+ // shape the type still carries and nothing now reads. It is not a record
132
+ // of what a committed plan configured, because `parsePlan` has always
133
+ // replaced it with the `ANNOTATIONS` constant rather than reading the
134
+ // file's own values, so that path never existed to preserve.
103
135
  ...(videoDir
104
136
  ? {
105
137
  recordVideo: {
106
138
  dir: videoDir,
107
139
  size: plan.viewport,
108
- showActions: {
109
- duration: plan.annotations.durationMs,
110
- position: plan.annotations.position,
111
- fontSize: plan.annotations.fontSize,
112
- },
113
140
  },
114
141
  }
115
142
  : {}),
@@ -137,7 +164,9 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
137
164
  // step for the same URL and the second load is a visible reload.
138
165
  if (plan.steps[0]?.kind !== 'navigate') {
139
166
  await page.goto(plan.url)
140
- await page.mouse.move(START.x, START.y, { steps: 2 })
167
+ if (movesPointer(plan)) {
168
+ await page.mouse.move(START.x, START.y, { steps: 2 })
169
+ }
141
170
  }
142
171
 
143
172
  for (const step of plan.steps) {
@@ -217,7 +246,9 @@ export async function runStep(
217
246
  switch (step.kind) {
218
247
  case 'navigate':
219
248
  await page.goto(step.target || plan.url)
220
- await page.mouse.move(START.x, START.y, { steps: 2 })
249
+ if (movesPointer(plan)) {
250
+ await page.mouse.move(START.x, START.y, { steps: 2 })
251
+ }
221
252
  break
222
253
  case 'click':
223
254
  await moveTo(page, plan, step, pace)
@@ -234,9 +265,36 @@ export async function runStep(
234
265
  await moveTo(page, plan, step, pace)
235
266
  break
236
267
  case 'scroll':
237
- await page.locator(step.target).first().scrollIntoViewIfNeeded()
268
+ // Centred rather than `scrollIntoViewIfNeeded`, which scrolls the least
269
+ // it can and leaves a target taller than the remaining space flush
270
+ // against the bottom edge. Measured on this repository's own recording at
271
+ // 225 pixels of dead space above the content and 2 below, where centring
272
+ // splits it 114 and 113. A recording frames its subject, so the least
273
+ // scroll that technically reveals it is the wrong amount.
274
+ await page
275
+ .locator(step.target)
276
+ .first()
277
+ .evaluate((node) =>
278
+ node.scrollIntoView({
279
+ block: 'center',
280
+ inline: 'nearest',
281
+ // Explicit, because this runs in the page where
282
+ // `scrollIntoViewIfNeeded` ran through the debugging protocol and
283
+ // was always instant. An in-page scroll honours the document's own
284
+ // `scroll-behavior`, so a project setting it to `smooth` gets a
285
+ // call that returns before the scroll finishes, with only
286
+ // `SETTLE_MS` behind it. Nothing in this repository sets it, which
287
+ // is exactly why the recorder cannot catch this on its own pages.
288
+ behavior: 'instant',
289
+ }),
290
+ )
238
291
  await page.waitForTimeout(SETTLE_MS)
239
- await moveTo(page, plan, step, pace)
292
+ // The pointer stays where it was. A scroll is not a pointing action, so
293
+ // gliding the cursor to the target's centre parks it on top of whatever
294
+ // the scroll just revealed and covers a row of it. A real session scrolls
295
+ // with a wheel and leaves the cursor alone. On a plan that points at
296
+ // nothing, `movesPointer` then keeps it off-screen for the whole run
297
+ // rather than parked in a corner.
240
298
  break
241
299
  case 'wait':
242
300
  case 'hold':
@@ -0,0 +1,64 @@
1
+ import { dirname, relative, resolve } from 'node:path'
2
+ import { readField, readFrontmatter } from '@/indexes/frontmatter'
3
+ import { collectEntries } from '@/indexes/render'
4
+ import { listIndexes } from '@/indexes/walk'
5
+
6
+ export interface CatalogEntry {
7
+ readonly path: string
8
+ readonly title: string
9
+ readonly description: string
10
+ }
11
+
12
+ export interface IndexCatalog {
13
+ readonly entries: CatalogEntry[]
14
+ readonly errors: string[]
15
+ }
16
+
17
+ /**
18
+ * Flattens every folder index under `root` into one queryable catalog.
19
+ *
20
+ * A folder's own frontmatter error lands in `errors` without dropping the
21
+ * rest of the walk, the same isolation `regenOne` gives one folder.
22
+ */
23
+ export async function buildIndexCatalog(root: string): Promise<IndexCatalog> {
24
+ const indexPaths = await listIndexes(root)
25
+ const entries: CatalogEntry[] = []
26
+ const errors: string[] = []
27
+
28
+ for (const indexPath of indexPaths) {
29
+ const dir = dirname(indexPath)
30
+ const frontmatter = await readFrontmatter(indexPath)
31
+ const title = readField(frontmatter, 'title')
32
+ const subtitle = readField(frontmatter, 'subtitle')
33
+
34
+ if (!title || !subtitle) {
35
+ errors.push(
36
+ `missing frontmatter field "title" or "subtitle" in ${indexPath}`,
37
+ )
38
+ } else {
39
+ entries.push({
40
+ path: relative(root, indexPath),
41
+ title,
42
+ description: subtitle,
43
+ })
44
+ }
45
+
46
+ const collected = await collectEntries(dir)
47
+ if (!collected.ok) {
48
+ errors.push(...collected.errors)
49
+ continue
50
+ }
51
+
52
+ for (const entry of collected.entries) {
53
+ entries.push({
54
+ path: relative(root, resolve(dir, entry.name)),
55
+ title: entry.title,
56
+ description: entry.description,
57
+ })
58
+ }
59
+ }
60
+
61
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
62
+
63
+ return { entries, errors }
64
+ }