@erclx/canon 5.74.0 → 5.76.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 (61) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/create-rule/SKILL.md +5 -1
  4. package/claude/skills/create-rule/references/ledger.md +73 -0
  5. package/claude/skills/create-rule/references/rule.md +1 -1
  6. package/claude/skills/create-skill/references/skill-practice.md +1 -1
  7. package/claude/skills/design-identity/REQUIREMENT.md +1 -1
  8. package/claude/skills/design-identity/SKILL.md +13 -4
  9. package/claude/skills/design-identity/references/card-route.md +11 -4
  10. package/claude/skills/git-issue/REQUIREMENT.md +4 -2
  11. package/claude/skills/git-issue/SKILL.md +32 -3
  12. package/claude/skills/git-issue/references/issue.md +7 -0
  13. package/claude/skills/issue-triage/REQUIREMENT.md +35 -0
  14. package/claude/skills/issue-triage/SKILL.md +130 -0
  15. package/claude/skills/pr-evidence/references/reel.md +9 -9
  16. package/claude/skills/proposal-critique/references/adopted.md +44 -0
  17. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +15 -15
  20. package/claude/skills/role-orchestrator/scripts/poll.ts +211 -96
  21. package/claude/skills/role-orchestrator/scripts/repo.ts +53 -1
  22. package/claude/skills/ui-film/SKILL.md +8 -7
  23. package/docs/agents/audits.md +24 -24
  24. package/docs/agents/migrate.md +10 -10
  25. package/docs/migrations/by-verb.md +10 -4
  26. package/docs/workflow/ai-workflow.md +1 -1
  27. package/docs/workflow/skill-map.md +1 -0
  28. package/governance/detect.toml +4 -0
  29. package/governance/ledger/claude/rule.md +16 -0
  30. package/governance/rules/claude/rule.md +13 -0
  31. package/package.json +1 -1
  32. package/scripts/ci/refresh-frames-pr.sh +11 -12
  33. package/src/audits/catalog.ts +7 -2
  34. package/src/capture/render.ts +30 -5
  35. package/src/capture/stamp.ts +3 -5
  36. package/src/claude/cases/git.ts +5 -0
  37. package/src/claude/cases/workflow.ts +4 -0
  38. package/src/claude/skills-audit.ts +65 -0
  39. package/src/cli/help.ts +1 -1
  40. package/src/commands/capture.ts +38 -1
  41. package/src/commands/claude.ts +10 -2
  42. package/src/commands/gov.ts +12 -7
  43. package/src/commands/migrate.ts +6 -2
  44. package/src/commands/take.ts +36 -7
  45. package/src/counts/catalogs.ts +3 -5
  46. package/src/gate/measures.ts +1 -144
  47. package/src/gate/stages.ts +0 -23
  48. package/src/gov/audit.ts +59 -13
  49. package/src/markdown/scope.ts +4 -0
  50. package/src/migrate/session-defaults.ts +146 -8
  51. package/src/take/encode.ts +9 -1
  52. package/src/take/film.ts +17 -2
  53. package/src/take/fixtures/busy.html +107 -0
  54. package/src/take/perform.ts +99 -25
  55. package/src/take/screencast.ts +4 -3
  56. package/src/take/stepper.ts +33 -20
  57. package/tooling/claude/reference.md +4 -2
  58. package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
  59. package/tooling/claude/seeds/.claude/settings.json +12 -0
  60. package/scripts/core/regen-hero.ts +0 -113
  61. package/src/capture/frame-values.ts +0 -326
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Adds the session defaults the seeded `.claude/settings.json` carries to a
3
- * target that was seeded before them: the subagent deny, the memory default, and
4
- * the merge deny entries.
3
+ * target that was seeded before them: the subagent deny, the memory default,
4
+ * the merge deny entries, the lease push allow entries, and the plan guard's
5
+ * PreToolUse wiring where the project holds the guard script.
5
6
  *
6
7
  * `canon claude init` copies a seed only when the file is absent, so a target
7
8
  * holding its own settings never sees a later seed change. This verb adds each
@@ -28,6 +29,37 @@ export const MERGE_DENIED: readonly string[] = [
28
29
  'Bash(gh pr merge:*)',
29
30
  ]
30
31
 
32
+ /**
33
+ * The two lease pushes a worker runs after rebasing onto a merged pull request,
34
+ * each pushing the current branch to its own name. An allow rule resolves
35
+ * before auto mode's classifier, so each entry is exact: a wildcard would also
36
+ * approve a refspec, a remote, or a flag that reaches the default branch.
37
+ */
38
+ export const PUSH_ALLOWED: readonly string[] = [
39
+ 'Bash(git push --force-with-lease)',
40
+ 'Bash(git push --force-with-lease origin HEAD)',
41
+ ]
42
+
43
+ /** The command the seed wires, as the seeded settings spell it. */
44
+ export const PLAN_GUARD_COMMAND =
45
+ '"$CLAUDE_PROJECT_DIR"/.claude/hooks/plan-guard.sh'
46
+
47
+ const PLAN_GUARD_SCRIPT = 'plan-guard.sh'
48
+
49
+ /**
50
+ * Each tool the guard acts on, beside the seeded matcher that carries it. A
51
+ * guard wired by hand under any matcher covering the tool counts as wired.
52
+ */
53
+ const PLAN_GUARD_WIRING: readonly { tool: string; matcher: string }[] = [
54
+ { tool: 'Bash', matcher: 'Bash' },
55
+ { tool: 'Write', matcher: 'Write|Edit' },
56
+ ]
57
+
58
+ export interface SessionDefaultsOptions {
59
+ /** True when the project holds `.claude/hooks/plan-guard.sh` to wire. */
60
+ readonly planGuard?: boolean
61
+ }
62
+
31
63
  export interface SessionDefaultsPlan {
32
64
  readonly keys: readonly SettingsKey[]
33
65
  readonly next: SettingsRecord
@@ -40,6 +72,8 @@ export interface SessionDefaultsPlan {
40
72
  readonly predates: boolean
41
73
  /** True when the deny list lacks any merge entry, whatever else it holds. */
42
74
  readonly lacksMergeDeny: boolean
75
+ /** True when the allow list lacks any lease push entry. */
76
+ readonly lacksPushAllow: boolean
43
77
  }
44
78
 
45
79
  export type SessionDefaultsRead =
@@ -66,24 +100,41 @@ function isRecord(value: unknown): value is SettingsRecord {
66
100
  */
67
101
  export function planSessionDefaults(
68
102
  current: SettingsRecord,
103
+ { planGuard = false }: SessionDefaultsOptions = {},
69
104
  ): SessionDefaultsPlan {
70
105
  const next: SettingsRecord = { ...current }
71
106
  const permissions = isRecord(current.permissions) ? current.permissions : {}
72
107
  const deny = Array.isArray(permissions.deny) ? permissions.deny : []
108
+ const allow = Array.isArray(permissions.allow) ? permissions.allow : []
73
109
 
74
110
  const subagentsMissing = DENIED_TOOLS.filter((tool) => !deny.includes(tool))
75
111
  const mergeMissing = MERGE_DENIED.filter((entry) => !deny.includes(entry))
76
112
  const missing = [...subagentsMissing, ...mergeMissing]
77
- if (missing.length > 0) {
78
- next.permissions = { ...permissions, deny: [...deny, ...missing] }
113
+ const allowMissing = PUSH_ALLOWED.filter((entry) => !allow.includes(entry))
114
+
115
+ const nextPermissions: SettingsRecord = { ...permissions }
116
+ if (missing.length > 0) nextPermissions.deny = [...deny, ...missing]
117
+ if (allowMissing.length > 0) {
118
+ nextPermissions.allow = [...allow, ...allowMissing]
119
+ }
120
+ if (missing.length + allowMissing.length > 0) {
121
+ next.permissions = nextPermissions
79
122
  }
80
123
 
81
124
  const memoryAbsent = !('autoMemoryEnabled' in current)
82
125
  if (memoryAbsent) next.autoMemoryEnabled = false
83
126
 
127
+ const preToolUse = planGuard ? wirePlanGuard(current.hooks) : undefined
128
+ if (preToolUse !== undefined) {
129
+ const hooks = isRecord(current.hooks) ? current.hooks : {}
130
+ next.hooks = { ...hooks, PreToolUse: preToolUse }
131
+ }
132
+
84
133
  const keys: SettingsKey[] = [
85
134
  { label: 'permissions.deny', changed: missing.length > 0 },
135
+ { label: 'permissions.allow', changed: allowMissing.length > 0 },
86
136
  { label: 'autoMemoryEnabled', changed: memoryAbsent },
137
+ { label: 'hooks.PreToolUse', changed: preToolUse !== undefined },
87
138
  ]
88
139
  return {
89
140
  keys,
@@ -94,13 +145,76 @@ export function planSessionDefaults(
94
145
  subagentsMissing.length === DENIED_TOOLS.length &&
95
146
  mergeMissing.length === MERGE_DENIED.length,
96
147
  lacksMergeDeny: mergeMissing.length > 0,
148
+ lacksPushAllow: allowMissing.length > 0,
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Claude Code reads an absent, empty, or `*` matcher as every tool, one of
154
+ * only name characters as exact names split on `|` or `,`, and any other as
155
+ * an unanchored regular expression, per its hooks reference.
156
+ */
157
+ function matcherCovers(matcher: unknown, tool: string): boolean {
158
+ if (matcher === undefined || matcher === '' || matcher === '*') return true
159
+ if (typeof matcher !== 'string') return false
160
+ if (/^[\w\s,|-]+$/.test(matcher)) {
161
+ return matcher.split(/[|,]/).some((name) => name.trim() === tool)
162
+ }
163
+ try {
164
+ return new RegExp(matcher).test(tool)
165
+ } catch {
166
+ return false
167
+ }
168
+ }
169
+
170
+ function runsPlanGuard(group: unknown, tool: string): boolean {
171
+ if (!isRecord(group) || !Array.isArray(group.hooks)) return false
172
+ if (!matcherCovers(group.matcher, tool)) return false
173
+ return group.hooks.some(
174
+ (hook) =>
175
+ isRecord(hook) &&
176
+ typeof hook.command === 'string' &&
177
+ hook.command.includes(PLAN_GUARD_SCRIPT),
178
+ )
179
+ }
180
+
181
+ /**
182
+ * Returns the PreToolUse list with the guard appended to each seeded matcher
183
+ * lacking it, or undefined when every tool already runs it. An existing group
184
+ * under the seeded matcher takes the entry, keeping its own hooks first.
185
+ */
186
+ function wirePlanGuard(hooks: unknown): unknown[] | undefined {
187
+ const current =
188
+ isRecord(hooks) && Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []
189
+ const lacking = PLAN_GUARD_WIRING.filter(
190
+ ({ tool }) => !current.some((group) => runsPlanGuard(group, tool)),
191
+ )
192
+ if (lacking.length === 0) return undefined
193
+
194
+ const entry = { type: 'command', command: PLAN_GUARD_COMMAND }
195
+ const next = [...current]
196
+ for (const { matcher } of lacking) {
197
+ const at = next.findIndex(
198
+ (group) =>
199
+ isRecord(group) &&
200
+ group.matcher === matcher &&
201
+ Array.isArray(group.hooks),
202
+ )
203
+ const group = next[at]
204
+ if (isRecord(group) && Array.isArray(group.hooks)) {
205
+ next[at] = { ...group, hooks: [...group.hooks, entry] }
206
+ } else {
207
+ next.push({ matcher, hooks: [entry] })
208
+ }
97
209
  }
210
+ return next
98
211
  }
99
212
 
100
213
  /**
101
214
  * Refuses rather than plans whenever a write could replace something the
102
215
  * person owns: the toolkit's own settings, a file `JSON.parse` cannot read,
103
- * or a `permissions` or `deny` value of an unexpected shape.
216
+ * or a `permissions`, `deny`, `hooks`, or `PreToolUse` value of an
217
+ * unexpected shape.
104
218
  */
105
219
  export async function readSessionDefaults(
106
220
  root: string,
@@ -131,10 +245,34 @@ export async function readSessionDefaults(
131
245
  if (permissions !== undefined && !isRecord(permissions)) {
132
246
  return { kind: 'refused', reason: `${path}: permissions is not an object` }
133
247
  }
134
- if (permissions?.deny !== undefined && !Array.isArray(permissions.deny)) {
248
+ for (const list of ['deny', 'allow'] as const) {
249
+ if (
250
+ permissions?.[list] !== undefined &&
251
+ !Array.isArray(permissions[list])
252
+ ) {
253
+ return {
254
+ kind: 'refused',
255
+ reason: `${path}: permissions.${list} is not a list`,
256
+ }
257
+ }
258
+ }
259
+
260
+ const planGuard = existsSync(
261
+ join(root, '.claude', 'hooks', PLAN_GUARD_SCRIPT),
262
+ )
263
+ const { hooks } = read.value
264
+ if (planGuard && hooks !== undefined && !isRecord(hooks)) {
265
+ return { kind: 'refused', reason: `${path}: hooks is not an object` }
266
+ }
267
+ if (
268
+ planGuard &&
269
+ isRecord(hooks) &&
270
+ hooks.PreToolUse !== undefined &&
271
+ !Array.isArray(hooks.PreToolUse)
272
+ ) {
135
273
  return {
136
274
  kind: 'refused',
137
- reason: `${path}: permissions.deny is not a list`,
275
+ reason: `${path}: hooks.PreToolUse is not a list`,
138
276
  }
139
277
  }
140
278
 
@@ -142,7 +280,7 @@ export async function readSessionDefaults(
142
280
  kind: 'planned',
143
281
  path,
144
282
  indent: read.indent,
145
- plan: planSessionDefaults(read.value),
283
+ plan: planSessionDefaults(read.value, { planGuard }),
146
284
  }
147
285
  }
148
286
 
@@ -97,11 +97,17 @@ export function openFrameEncoder(out: string, fps: number): FrameEncoder {
97
97
  }
98
98
  }
99
99
 
100
- /** Re-encodes a recorded clip to the same H.264 output at a constant `fps`. */
100
+ /**
101
+ * Re-encodes a recorded clip to the same H.264 output at a constant `fps`, cut
102
+ * at `ms`. Playwright's recorder pads its webm past the last frame by at least
103
+ * a second, so the cut is what ends the clip where the take's clock stopped.
104
+ * A shorter input stays as it is, since `-t` never extends one.
105
+ */
101
106
  export async function transcode(
102
107
  input: string,
103
108
  out: string,
104
109
  fps: number,
110
+ ms: number,
105
111
  ): Promise<void> {
106
112
  const proc = spawn(
107
113
  'ffmpeg',
@@ -111,6 +117,8 @@ export async function transcode(
111
117
  'error',
112
118
  '-i',
113
119
  input,
120
+ '-t',
121
+ (ms / 1000).toFixed(3),
114
122
  '-r',
115
123
  String(fps),
116
124
  ...OUTPUT,
package/src/take/film.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  type TakeLog,
12
12
  VIEWPORT,
13
13
  } from '@/take/log'
14
- import { TargetMissing } from '@/take/perform'
14
+ import { TargetMissing, TargetOffscreen, TargetRefused } from '@/take/perform'
15
15
  import type { Beat, Route, TakePlan } from '@/take/plan'
16
16
  import { filmScreencast } from '@/take/screencast'
17
17
  import { ShellMissing, findShell } from '@/take/shell'
@@ -30,6 +30,8 @@ export type TakeRefusal =
30
30
  | 'browser-missing'
31
31
  | 'server-unreachable'
32
32
  | 'target-missing'
33
+ | 'target-offscreen'
34
+ | 'over-cap'
33
35
  | 'take-failed'
34
36
 
35
37
  export interface FilmedBeat {
@@ -74,6 +76,8 @@ export interface TakeRequest {
74
76
  readonly env?: NodeJS.ProcessEnv
75
77
  /** How long one stepped frame may take before its beat falls back to a screencast. */
76
78
  readonly stallMs?: number
79
+ /** The filmed seconds the beats may sum to, past which the take refuses. */
80
+ readonly maxSeconds?: number
77
81
  }
78
82
 
79
83
  const REACH_TIMEOUT_MS = 5000
@@ -127,7 +131,7 @@ export async function take(request: TakeRequest): Promise<TakeResult> {
127
131
  )
128
132
  } catch (error) {
129
133
  const cause = error instanceof FellBack ? error.error : error
130
- const shot = cause instanceof TargetMissing ? cause.still : undefined
134
+ const shot = cause instanceof TargetRefused ? cause.still : undefined
131
135
  const still = shot ? join(dir, 'still.jpg') : undefined
132
136
  if (shot && still) writeFileSync(join(out, still), shot)
133
137
  return {
@@ -136,6 +140,16 @@ export async function take(request: TakeRequest): Promise<TakeResult> {
136
140
  ...(still ? { still } : {}),
137
141
  }
138
142
  }
143
+ // Checked after each beat rather than once at the end, so a take refused
144
+ // on its first beat films nothing past it.
145
+ const frames = beats.reduce((sum, filmed) => sum + filmed.frames, 0)
146
+ if (request.maxSeconds !== undefined && frames > request.maxSeconds * FPS) {
147
+ return failed(
148
+ 'over-cap',
149
+ `the beats through ${beat.name} run ${(frames / FPS).toFixed(2)} s, past the ${request.maxSeconds} s cap`,
150
+ beat.name,
151
+ )
152
+ }
139
153
  }
140
154
 
141
155
  writeFileSync(
@@ -231,6 +245,7 @@ class FellBack extends Error {
231
245
 
232
246
  function classify(error: unknown): TakeRefusal {
233
247
  if (error instanceof TargetMissing) return 'target-missing'
248
+ if (error instanceof TargetOffscreen) return 'target-offscreen'
234
249
  if (error instanceof ShellMissing || isBrowserMissing(error)) {
235
250
  return 'browser-missing'
236
251
  }
@@ -0,0 +1,107 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Busy</title>
6
+ <!--
7
+ A hero canvas that redraws every frame and spends busy=<ms> of main
8
+ thread work on each, so a frame takes about that long to render. A card
9
+ sits about 2900px down, below the window, and the rail link scrolls to it.
10
+ With timed=1 each frame spins on performance.now() instead, as the
11
+ groundwork page did, which never ends once the stepper pauses virtual
12
+ time.
13
+ -->
14
+ <style>
15
+ html {
16
+ scroll-behavior: smooth;
17
+ }
18
+
19
+ body {
20
+ margin: 0;
21
+ font-family: sans-serif;
22
+ }
23
+
24
+ nav {
25
+ position: fixed;
26
+ top: 10px;
27
+ right: 10px;
28
+ z-index: 2;
29
+ }
30
+
31
+ nav a {
32
+ display: block;
33
+ padding: 12px 20px;
34
+ background: #fff;
35
+ }
36
+
37
+ canvas {
38
+ display: block;
39
+ width: 100vw;
40
+ height: 100vh;
41
+ }
42
+
43
+ section {
44
+ height: 900px;
45
+ border-top: 1px solid #ccc;
46
+ padding: 40px;
47
+ }
48
+
49
+ .card {
50
+ width: 400px;
51
+ height: 240px;
52
+ background: #ddd;
53
+ }
54
+
55
+ .card:hover {
56
+ background: #f60;
57
+ }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <nav><a id="rail" href="#cards">Cards</a></nav>
62
+ <canvas id="hero"></canvas>
63
+ <section>one</section>
64
+ <section>two</section>
65
+ <section id="cards"><div class="card" id="card">card</div></section>
66
+ <section>four</section>
67
+ <script>
68
+ const params = new URLSearchParams(location.search)
69
+ const busy = Number(params.get('busy') || 0)
70
+ const timed = params.get('timed') === '1'
71
+ const hero = document.getElementById('hero')
72
+ hero.width = innerWidth
73
+ hero.height = innerHeight
74
+ const context = hero.getContext('2d')
75
+ // The work is a count calibrated in real time on load, since a loop
76
+ // timed by performance.now() never ends under a paused virtual clock.
77
+ let sink = 0
78
+ const spin = (count) => {
79
+ for (let i = 0; i < count; i += 1) sink += Math.sqrt(i)
80
+ }
81
+ const measure = (ms) => {
82
+ let count = 0
83
+ const start = performance.now()
84
+ while (performance.now() - start < ms) {
85
+ spin(10000)
86
+ count += 10000
87
+ }
88
+ return count / ms
89
+ }
90
+ // Warmed first, so the count matches the optimized loop a frame runs.
91
+ measure(100)
92
+ const work = Math.round(busy * measure(200))
93
+ const draw = (time) => {
94
+ if (timed) {
95
+ const start = performance.now()
96
+ while (performance.now() - start < busy) {}
97
+ } else {
98
+ spin(work)
99
+ }
100
+ context.fillStyle = `hsl(${(time / 10) % 360}, 60%, 50%)`
101
+ context.fillRect(0, 0, hero.width, hero.height)
102
+ requestAnimationFrame(draw)
103
+ }
104
+ requestAnimationFrame(draw)
105
+ </script>
106
+ </body>
107
+ </html>
@@ -3,6 +3,7 @@ import {
3
3
  FPS,
4
4
  type LogEvent,
5
5
  type Point,
6
+ VIEWPORT,
6
7
  along,
7
8
  travelMs,
8
9
  } from '@/take/log'
@@ -74,13 +75,40 @@ export interface Stage {
74
75
  evaluate<T>(expression: string): Promise<T>
75
76
  }
76
77
 
77
- /** A target that resolved to nothing, which refuses rather than films a guess. */
78
- export class TargetMissing extends Error {
78
+ /** A target the pointer cannot reach, which refuses rather than films a guess. */
79
+ export class TargetRefused extends Error {
79
80
  /** The page as it stood when the lookup failed, as a JPEG, when the route caught one. */
80
81
  still?: Buffer
81
82
 
82
- constructor(readonly target: string) {
83
- super(`nothing on the page matches ${target}`)
83
+ constructor(
84
+ readonly target: string,
85
+ message: string,
86
+ ) {
87
+ super(message)
88
+ }
89
+ }
90
+
91
+ /** A target that resolved to nothing. */
92
+ export class TargetMissing extends TargetRefused {
93
+ constructor(target: string) {
94
+ super(target, `nothing on the page matches ${target}`)
95
+ }
96
+ }
97
+
98
+ /**
99
+ * A target whose center lies outside the window, so the pointer would aim at
100
+ * a point the clip never shows, or for a scroll, a target with no part inside
101
+ * it. A setup step brings it into view first.
102
+ */
103
+ export class TargetOffscreen extends TargetRefused {
104
+ constructor(
105
+ target: string,
106
+ readonly at: Point,
107
+ ) {
108
+ super(
109
+ target,
110
+ `${target} is centered at (${Math.round(at.x)}, ${Math.round(at.y)}), outside the ${VIEWPORT.width}x${VIEWPORT.height} window, so a setup step must bring it into view first`,
111
+ )
84
112
  }
85
113
  }
86
114
 
@@ -155,19 +183,12 @@ export async function performSteps(
155
183
  break
156
184
  }
157
185
  case 'scroll': {
158
- const at = center(await boxOf(stage, step.target))
186
+ const at = await scrollPoint(stage, step.target)
159
187
  await moveTo(stage, state, at)
160
188
  const frames = Math.round(SCROLL_MS / FRAME_MS)
161
- state.log?.push({
162
- t: stage.now(),
163
- kind: 'scroll',
164
- at,
165
- dx: 0,
166
- dy: step.dy,
167
- ms: frames * FRAME_MS,
168
- })
189
+ const start = stage.now()
169
190
  let sent = 0
170
- for (let frame = 1; frame <= frames; frame += 1) {
191
+ await paced(stage, frames, async (frame) => {
171
192
  const want = along(
172
193
  { x: 0, y: 0 },
173
194
  { x: 0, y: step.dy },
@@ -175,7 +196,15 @@ export async function performSteps(
175
196
  ).y
176
197
  await stage.pointer({ type: 'wheel', ...at, dy: want - sent })
177
198
  sent = want
178
- }
199
+ })
200
+ state.log?.push({
201
+ t: start,
202
+ kind: 'scroll',
203
+ at,
204
+ dx: 0,
205
+ dy: step.dy,
206
+ ms: stage.now() - start,
207
+ })
179
208
  break
180
209
  }
181
210
  case 'wait':
@@ -194,21 +223,35 @@ async function moveTo(
194
223
  const from = state.pointer
195
224
  if (Math.hypot(to.x - from.x, to.y - from.y) < 1) return
196
225
  const frames = Math.max(1, Math.round(travelMs(from, to) / FRAME_MS))
197
- state.log?.push({
198
- t: stage.now(),
199
- kind: 'move',
200
- from,
201
- to,
202
- ms: frames * FRAME_MS,
203
- })
204
226
  const pressed = held
205
227
  ? { held: true as const, ...withModifiers(held.modifiers) }
206
228
  : {}
207
- for (let frame = 1; frame <= frames; frame += 1) {
229
+ const start = stage.now()
230
+ await paced(stage, frames, async (frame) => {
208
231
  state.pointer = along(from, to, frame / frames)
209
232
  await stage.pointer({ type: 'move', ...state.pointer, ...pressed })
210
- }
233
+ })
211
234
  state.pointer = to
235
+ state.log?.push({ t: start, kind: 'move', from, to, ms: stage.now() - start })
236
+ }
237
+
238
+ /**
239
+ * Runs a motion planned as `frames` inputs, sending each at the frame the
240
+ * stage's clock has reached, so a page slower than a frame per input skips the
241
+ * points it is behind on and the motion still ends near its planned length. A
242
+ * clock advancing one frame per input, as the stepper's does, gets every frame.
243
+ */
244
+ async function paced(
245
+ stage: Stage,
246
+ frames: number,
247
+ input: (frame: number) => Promise<void>,
248
+ ): Promise<void> {
249
+ const start = stage.now()
250
+ for (let frame = 0; frame < frames;) {
251
+ const reached = Math.round((stage.now() - start) / FRAME_MS)
252
+ frame = Math.min(frames, Math.max(frame + 1, reached + 1))
253
+ await input(frame)
254
+ }
212
255
  }
213
256
 
214
257
  async function click(
@@ -315,12 +358,43 @@ function center(box: Box): Point {
315
358
  return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
316
359
  }
317
360
 
318
- async function boxOf(stage: Stage, target: string): Promise<Box> {
361
+ function inWindow(at: Point): boolean {
362
+ return (
363
+ at.x >= 0 && at.x <= VIEWPORT.width && at.y >= 0 && at.y <= VIEWPORT.height
364
+ )
365
+ }
366
+
367
+ async function foundBox(stage: Stage, target: string): Promise<Box> {
319
368
  const box = await stage.evaluate<Box | null>(boxExpression(target))
320
369
  if (!box) throw new TargetMissing(target)
321
370
  return box
322
371
  }
323
372
 
373
+ async function boxOf(stage: Stage, target: string): Promise<Box> {
374
+ const box = await foundBox(stage, target)
375
+ const at = center(box)
376
+ if (!inWindow(at)) throw new TargetOffscreen(target, at)
377
+ return box
378
+ }
379
+
380
+ /**
381
+ * Aims a wheel at the target's center, or, when that lies outside the window,
382
+ * at the center of the part the window shows. A wheel scrolls whatever sits
383
+ * under it, so any visible point of the target serves, and a page-tall element
384
+ * such as `body` has a center no setup step can bring into view.
385
+ */
386
+ async function scrollPoint(stage: Stage, target: string): Promise<Point> {
387
+ const box = await foundBox(stage, target)
388
+ const at = center(box)
389
+ if (inWindow(at)) return at
390
+ const left = Math.max(box.x, 0)
391
+ const right = Math.min(box.x + box.width, VIEWPORT.width)
392
+ const top = Math.max(box.y, 0)
393
+ const bottom = Math.min(box.y + box.height, VIEWPORT.height)
394
+ if (right <= left || bottom <= top) throw new TargetOffscreen(target, at)
395
+ return { x: (left + right) / 2, y: (top + bottom) / 2 }
396
+ }
397
+
324
398
  /**
325
399
  * Finds a target's box in CSS pixels, reading `text=<words>` as the first
326
400
  * innermost element with a size whose whole text is those words, so a hidden
@@ -7,7 +7,7 @@ import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
7
7
  import {
8
8
  type PointerInput,
9
9
  type Stage,
10
- TargetMissing,
10
+ TargetRefused,
11
11
  performSteps,
12
12
  } from '@/take/perform'
13
13
  import type { Beat } from '@/take/plan'
@@ -92,13 +92,14 @@ export async function filmScreencast(request: {
92
92
  log: events,
93
93
  })
94
94
  await recording.hold(HOLD_MS)
95
+ const filmedMs = recording.now()
95
96
  await page.screencast.stop()
96
97
 
97
- await transcode(webm, request.clip, FPS)
98
+ await transcode(webm, request.clip, FPS, filmedMs)
98
99
  const frames = await probeFrames(request.clip)
99
100
  return { frames, events, still: await lastFrame(request.clip, frames) }
100
101
  } catch (error) {
101
- if (error instanceof TargetMissing && still) error.still = await still()
102
+ if (error instanceof TargetRefused && still) error.still = await still()
102
103
  throw error
103
104
  } finally {
104
105
  await browser.close()