@erclx/canon 5.69.0 → 5.70.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 (53) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +33 -159
  3. package/assets/brand/mark.svg +17 -9
  4. package/claude/.claude-plugin/plugin.json +1 -1
  5. package/claude/skills/app-drive/SKILL.md +3 -2
  6. package/claude/skills/app-drive/references/drive-record.md +52 -10
  7. package/claude/skills/app-drive/references/isolate.md +1 -1
  8. package/claude/skills/design-pick/SKILL.md +1 -0
  9. package/claude/skills/ui-craft/references/copy.md +4 -1
  10. package/claude/skills/ui-film/REQUIREMENT.md +4 -2
  11. package/claude/skills/ui-film/SKILL.md +24 -11
  12. package/claude/skills/ui-film/references/compose-craft.md +81 -0
  13. package/claude/skills/ui-film/references/config.md +6 -1
  14. package/claude/skills/ui-film/references/handoff.md +13 -3
  15. package/claude/skills/ui-film/references/take-craft.md +27 -1
  16. package/docs/index.md +1 -0
  17. package/docs/migrations/by-hand.md +107 -0
  18. package/docs/migrations/by-verb.md +118 -0
  19. package/docs/migrations/index.md +14 -0
  20. package/docs/target/index.md +1 -2
  21. package/docs/target/projects.md +2 -2
  22. package/docs/target/sync.md +22 -2
  23. package/docs/workflow/ai-workflow.md +1 -1
  24. package/governance/ledger/skills/ui-craft/copy.md +8 -4
  25. package/package.json +3 -1
  26. package/scripts/core/design-sync.ts +204 -0
  27. package/scripts/core/regen-web-favicon.ts +27 -12
  28. package/scripts/core/regen-web-previews.ts +8 -1
  29. package/scripts/core/render-film.ts +179 -0
  30. package/src/capture/frame-values.ts +12 -12
  31. package/src/commands/records.ts +3 -2
  32. package/src/commands/sandbox.ts +9 -2
  33. package/src/commands/take.ts +159 -11
  34. package/src/design/favicon.ts +85 -13
  35. package/src/design/reference.ts +98 -0
  36. package/src/design/render.ts +16 -14
  37. package/src/design/tokens.ts +4 -3
  38. package/src/gate/stages.ts +6 -0
  39. package/src/init/steps.ts +2 -2
  40. package/src/migrate/status.ts +3 -3
  41. package/src/records/validate.ts +14 -2
  42. package/src/sandbox/expect.ts +32 -0
  43. package/src/sandbox/headless/run.ts +6 -3
  44. package/src/take/camera.ts +99 -10
  45. package/src/take/film.ts +35 -2
  46. package/src/take/fixtures/input.html +138 -0
  47. package/src/take/keys.ts +193 -0
  48. package/src/take/log.ts +94 -1
  49. package/src/take/perform.ts +165 -10
  50. package/src/take/plan.ts +101 -6
  51. package/src/take/screencast.ts +28 -3
  52. package/src/take/stepper.ts +122 -18
  53. package/docs/target/migrations.md +0 -205
package/src/take/log.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Route } from '@/take/plan'
1
+ import type { Modifier } from '@/take/keys'
2
+ import { ROUTES, type Route } from '@/take/plan'
2
3
 
3
4
  /**
4
5
  * The click log a take writes beside its clip, and the pointer path both
@@ -43,6 +44,33 @@ export type LogEvent =
43
44
  readonly kind: 'click'
44
45
  readonly at: Point
45
46
  readonly box: Box
47
+ /** The keys held through the press, absent when none is. */
48
+ readonly modifiers?: readonly Modifier[]
49
+ /** 2 for a double click, absent for a single one. */
50
+ readonly count?: number
51
+ }
52
+ | {
53
+ readonly t: number
54
+ readonly kind: 'drag'
55
+ /** Where the button went down and where it came up. */
56
+ readonly from: Point
57
+ readonly to: Point
58
+ /** The box pressed and the box it was dropped on. */
59
+ readonly box: Box
60
+ readonly drop: Box
61
+ readonly modifiers?: readonly Modifier[]
62
+ /**
63
+ * From press to release. The travel between is logged as `move` events,
64
+ * which the cursor is read off.
65
+ */
66
+ readonly ms: number
67
+ }
68
+ | {
69
+ readonly t: number
70
+ readonly kind: 'key'
71
+ /** The combination as the plan wrote it, such as `Control+K`. */
72
+ readonly keys: string
73
+ readonly ms: number
46
74
  }
47
75
  | {
48
76
  readonly t: number
@@ -68,10 +96,75 @@ export interface TakeLog {
68
96
  readonly fps: number
69
97
  /** The route that filmed the clip, which can differ from the one asked for. */
70
98
  readonly route: Route
99
+ /**
100
+ * The clip's size in pixels, which sets how deep the camera may zoom before
101
+ * the clip goes soft. A log written before this field read it off `route`.
102
+ */
103
+ readonly clip?: { readonly width: number; readonly height: number }
104
+ /** False when the beat turned the camera's zoom off. */
105
+ readonly push?: false
71
106
  readonly frames: number
72
107
  readonly events: readonly LogEvent[]
73
108
  }
74
109
 
110
+ /** The size each route films at, the stepper at twice the viewport. */
111
+ export const CLIP_SIZES: Record<
112
+ Route,
113
+ { readonly width: number; readonly height: number }
114
+ > = {
115
+ step: { width: VIEWPORT.width * 2, height: VIEWPORT.height * 2 },
116
+ screencast: VIEWPORT,
117
+ }
118
+
119
+ export type TakeLogRead =
120
+ | { readonly kind: 'read'; readonly log: TakeLog }
121
+ | { readonly kind: 'refused'; readonly detail: string }
122
+
123
+ /**
124
+ * Reads a log back off disk, checking the fields the camera builds on rather
125
+ * than every event, since a take wrote the events and a reader only ever
126
+ * points at the wrong file.
127
+ */
128
+ export function readTakeLog(source: string): TakeLogRead {
129
+ let document: unknown
130
+ try {
131
+ document = JSON.parse(source)
132
+ } catch (error) {
133
+ return {
134
+ kind: 'refused',
135
+ detail: error instanceof Error ? error.message : String(error),
136
+ }
137
+ }
138
+ if (typeof document !== 'object' || document === null) {
139
+ return { kind: 'refused', detail: 'the document is not an object' }
140
+ }
141
+ const log = document as Partial<Record<keyof TakeLog, unknown>>
142
+ const missing = [
143
+ isSize(log.viewport) ? '' : 'viewport',
144
+ isCount(log.fps) && log.fps > 0 ? '' : 'fps',
145
+ ROUTES.includes(log.route as Route) ? '' : 'route',
146
+ isCount(log.frames) ? '' : 'frames',
147
+ Array.isArray(log.events) ? '' : 'events',
148
+ ].filter(Boolean)
149
+ if (missing.length > 0) {
150
+ return {
151
+ kind: 'refused',
152
+ detail: `the document carries no valid ${missing.join(', ')}, so it is not a take's log.json`,
153
+ }
154
+ }
155
+ return { kind: 'read', log: document as TakeLog }
156
+ }
157
+
158
+ function isCount(value: unknown): value is number {
159
+ return Number.isInteger(value) && (value as number) >= 0
160
+ }
161
+
162
+ function isSize(value: unknown): boolean {
163
+ if (typeof value !== 'object' || value === null) return false
164
+ const { width, height } = value as Record<string, unknown>
165
+ return isCount(width) && isCount(height)
166
+ }
167
+
75
168
  /** Where the pointer rests before a take's first move. */
76
169
  export const START: Point = { x: VIEWPORT.width / 2, y: VIEWPORT.height / 2 }
77
170
 
@@ -6,6 +6,7 @@ import {
6
6
  along,
7
7
  travelMs,
8
8
  } from '@/take/log'
9
+ import { type Chord, type Modifier, isModifier, parseKeys } from '@/take/keys'
9
10
  import type { TakeStep } from '@/take/plan'
10
11
 
11
12
  /**
@@ -23,11 +24,25 @@ const KEY_MS = 90
23
24
  /** How long a scroll's wheel travel runs. */
24
25
  const SCROLL_MS = 900
25
26
 
27
+ /** How long a drag holds still after its press, so the viewer sees the grab land. */
28
+ const GRAB_MS = 150
29
+
26
30
  export type PointerInput =
27
31
  | {
28
- readonly type: 'move' | 'down' | 'up'
32
+ readonly type: 'move'
33
+ readonly x: number
34
+ readonly y: number
35
+ /** True while a drag holds the left button down. */
36
+ readonly held?: true
37
+ readonly modifiers?: readonly Modifier[]
38
+ }
39
+ | {
40
+ readonly type: 'down' | 'up'
29
41
  readonly x: number
30
42
  readonly y: number
43
+ /** Which press of a run this is, 2 being a double click's second. */
44
+ readonly clickCount: number
45
+ readonly modifiers?: readonly Modifier[]
31
46
  }
32
47
  | {
33
48
  readonly type: 'wheel'
@@ -36,6 +51,14 @@ export type PointerInput =
36
51
  readonly dy: number
37
52
  }
38
53
 
54
+ /** One key going down or coming up. */
55
+ export interface KeyInput {
56
+ readonly type: 'down' | 'up'
57
+ readonly key: string
58
+ /** The modifiers held once this input lands, so a modifier's own down counts it. */
59
+ readonly modifiers: readonly Modifier[]
60
+ }
61
+
39
62
  export interface Stage {
40
63
  /** Milliseconds on the clip's own clock. */
41
64
  now(): number
@@ -45,12 +68,17 @@ export interface Stage {
45
68
  pointer(input: PointerInput): Promise<void>
46
69
  /** Types one character into the focused field and lets one frame pass. */
47
70
  key(text: string): Promise<void>
71
+ /** Sends one key down or up and lets one frame pass. */
72
+ press(input: KeyInput): Promise<void>
48
73
  /** Evaluates an expression in the page. */
49
74
  evaluate<T>(expression: string): Promise<T>
50
75
  }
51
76
 
52
77
  /** A target that resolved to nothing, which refuses rather than films a guess. */
53
78
  export class TargetMissing extends Error {
79
+ /** The page as it stood when the lookup failed, as a JPEG, when the route caught one. */
80
+ still?: Buffer
81
+
54
82
  constructor(readonly target: string) {
55
83
  super(`nothing on the page matches ${target}`)
56
84
  }
@@ -77,11 +105,40 @@ export async function performSteps(
77
105
  )
78
106
  break
79
107
  case 'click':
80
- await click(stage, state, await boxOf(stage, step.target))
108
+ case 'dblclick':
109
+ await click(
110
+ stage,
111
+ state,
112
+ await boxOf(stage, step.target),
113
+ step.modifiers ?? [],
114
+ step.kind === 'dblclick' ? 2 : 1,
115
+ )
81
116
  break
117
+ case 'drag':
118
+ await drag(
119
+ stage,
120
+ state,
121
+ await boxOf(stage, step.from),
122
+ await boxOf(stage, step.to),
123
+ step.modifiers ?? [],
124
+ )
125
+ break
126
+ case 'key': {
127
+ const read = parseKeys(step.keys)
128
+ if (read.kind === 'refused') throw new Error(read.detail)
129
+ const start = stage.now()
130
+ await pressChord(stage, read.chord)
131
+ state.log?.push({
132
+ t: start,
133
+ kind: 'key',
134
+ keys: step.keys,
135
+ ms: stage.now() - start,
136
+ })
137
+ break
138
+ }
82
139
  case 'fill': {
83
140
  const box = await boxOf(stage, step.target)
84
- await click(stage, state, box)
141
+ await click(stage, state, box, [], 1)
85
142
  const start = stage.now()
86
143
  for (const character of step.text) {
87
144
  await stage.key(character)
@@ -132,6 +189,7 @@ async function moveTo(
132
189
  stage: Stage,
133
190
  state: Performance,
134
191
  to: Point,
192
+ held?: { readonly modifiers: readonly Modifier[] },
135
193
  ): Promise<void> {
136
194
  const from = state.pointer
137
195
  if (Math.hypot(to.x - from.x, to.y - from.y) < 1) return
@@ -143,9 +201,12 @@ async function moveTo(
143
201
  to,
144
202
  ms: frames * FRAME_MS,
145
203
  })
204
+ const pressed = held
205
+ ? { held: true as const, ...withModifiers(held.modifiers) }
206
+ : {}
146
207
  for (let frame = 1; frame <= frames; frame += 1) {
147
208
  state.pointer = along(from, to, frame / frames)
148
- await stage.pointer({ type: 'move', ...state.pointer })
209
+ await stage.pointer({ type: 'move', ...state.pointer, ...pressed })
149
210
  }
150
211
  state.pointer = to
151
212
  }
@@ -154,12 +215,100 @@ async function click(
154
215
  stage: Stage,
155
216
  state: Performance,
156
217
  box: Box,
218
+ modifiers: readonly Modifier[],
219
+ count: number,
157
220
  ): Promise<void> {
158
221
  const at = center(box)
159
222
  await moveTo(stage, state, at)
160
- state.log?.push({ t: stage.now(), kind: 'click', at, box })
161
- await stage.pointer({ type: 'down', ...at })
162
- await stage.pointer({ type: 'up', ...at })
223
+ await holding(stage, modifiers, async () => {
224
+ state.log?.push({
225
+ t: stage.now(),
226
+ kind: 'click',
227
+ at,
228
+ box,
229
+ ...withModifiers(modifiers),
230
+ ...(count > 1 ? { count } : {}),
231
+ })
232
+ for (let clickCount = 1; clickCount <= count; clickCount += 1) {
233
+ const press = { ...at, clickCount, ...withModifiers(modifiers) }
234
+ await stage.pointer({ type: 'down', ...press })
235
+ await stage.pointer({ type: 'up', ...press })
236
+ }
237
+ })
238
+ }
239
+
240
+ /**
241
+ * Presses on the source, holds a moment so the page reads a grab rather than
242
+ * a click, glides held to the target, and releases there. The drop target is
243
+ * read before the press, so a page that moves it once a drag starts is
244
+ * dropped on where it stood.
245
+ */
246
+ async function drag(
247
+ stage: Stage,
248
+ state: Performance,
249
+ box: Box,
250
+ drop: Box,
251
+ modifiers: readonly Modifier[],
252
+ ): Promise<void> {
253
+ const from = center(box)
254
+ const to = center(drop)
255
+ await moveTo(stage, state, from)
256
+ await holding(stage, modifiers, async () => {
257
+ const start = stage.now()
258
+ const press = { clickCount: 1, ...withModifiers(modifiers) }
259
+ await stage.pointer({ type: 'down', ...from, ...press })
260
+ await stage.hold(GRAB_MS)
261
+ await moveTo(stage, state, to, { modifiers })
262
+ await stage.pointer({ type: 'up', ...to, ...press })
263
+ state.log?.push({
264
+ t: start,
265
+ kind: 'drag',
266
+ from,
267
+ to,
268
+ box,
269
+ drop,
270
+ ...withModifiers(modifiers),
271
+ ms: stage.now() - start,
272
+ })
273
+ })
274
+ }
275
+
276
+ /**
277
+ * Presses each modifier in order, runs the inputs they hold, and releases them
278
+ * in reverse, so a page reads every input between as modified on both routes.
279
+ */
280
+ async function holding(
281
+ stage: Stage,
282
+ modifiers: readonly Modifier[],
283
+ inputs: () => Promise<void>,
284
+ ): Promise<void> {
285
+ for (const [i, key] of modifiers.entries()) {
286
+ await stage.press({
287
+ type: 'down',
288
+ key,
289
+ modifiers: modifiers.slice(0, i + 1),
290
+ })
291
+ }
292
+ await inputs()
293
+ for (const [i, key] of [...modifiers.entries()].reverse()) {
294
+ await stage.press({ type: 'up', key, modifiers: modifiers.slice(0, i) })
295
+ }
296
+ }
297
+
298
+ /** Presses a chord's key inside its modifiers, counting a modifier key as held while down. */
299
+ async function pressChord(stage: Stage, chord: Chord): Promise<void> {
300
+ const { key, modifiers } = chord
301
+ await holding(stage, modifiers, async () => {
302
+ const down = isModifier(key) ? [...modifiers, key] : modifiers
303
+ await stage.press({ type: 'down', key, modifiers: down })
304
+ await stage.press({ type: 'up', key, modifiers })
305
+ })
306
+ }
307
+
308
+ function withModifiers(modifiers: readonly Modifier[]): {
309
+ modifiers?: readonly Modifier[]
310
+ } {
311
+ return modifiers.length > 0 ? { modifiers } : {}
163
312
  }
164
313
 
165
314
  function center(box: Box): Point {
@@ -173,8 +322,10 @@ async function boxOf(stage: Stage, target: string): Promise<Box> {
173
322
  }
174
323
 
175
324
  /**
176
- * Finds a target's box in CSS pixels, reading `text=<words>` as the innermost
177
- * element whose whole text is those words and anything else as a CSS selector.
325
+ * Finds a target's box in CSS pixels, reading `text=<words>` as the first
326
+ * innermost element with a size whose whole text is those words, so a hidden
327
+ * copy earlier in the page cannot shadow a visible one, and anything else as a
328
+ * CSS selector.
178
329
  * It runs inside the page so both routes resolve a target the same way, and a
179
330
  * match with no size counts as missing, since a click on it would land on
180
331
  * whatever sits behind.
@@ -188,7 +339,11 @@ export function boxExpression(target: string): string {
188
339
  const matches = [...document.querySelectorAll('body *')].filter(
189
340
  (node) => node.textContent.trim() === want,
190
341
  )
191
- element = matches.find((node) => !matches.some((other) => other !== node && node.contains(other))) ?? null
342
+ const innermost = matches.filter((node) => !matches.some((other) => other !== node && node.contains(other)))
343
+ element = innermost.find((node) => {
344
+ const rect = node.getBoundingClientRect()
345
+ return rect.width > 0 && rect.height > 0
346
+ }) ?? null
192
347
  } else {
193
348
  element = document.querySelector(target)
194
349
  }
package/src/take/plan.ts CHANGED
@@ -6,19 +6,44 @@
6
6
  * keyboard leaves nothing in the log to draw.
7
7
  */
8
8
 
9
+ import { MODIFIERS, type Modifier, isModifier, parseKeys } from '@/take/keys'
10
+
9
11
  /** How a beat is filmed: frame stepping, or Playwright's real-time screencast. */
10
12
  export const ROUTES = ['step', 'screencast'] as const
11
13
 
12
14
  export type Route = (typeof ROUTES)[number]
13
15
 
16
+ /** The keys held through a press, absent when none is. */
17
+ interface Held {
18
+ readonly modifiers?: readonly Modifier[]
19
+ }
20
+
14
21
  export type TakeStep =
15
22
  | { readonly kind: 'move'; readonly target: string }
16
23
  | { readonly kind: 'move'; readonly x: number; readonly y: number }
17
- | { readonly kind: 'click'; readonly target: string }
24
+ | ({ readonly kind: 'click'; readonly target: string } & Held)
25
+ | ({ readonly kind: 'dblclick'; readonly target: string } & Held)
26
+ | ({
27
+ readonly kind: 'drag'
28
+ readonly from: string
29
+ readonly to: string
30
+ } & Held)
31
+ | { readonly kind: 'key'; readonly keys: string }
18
32
  | { readonly kind: 'fill'; readonly target: string; readonly text: string }
19
33
  | { readonly kind: 'scroll'; readonly target: string; readonly dy: number }
20
34
  | { readonly kind: 'wait'; readonly ms: number }
21
35
 
36
+ const KINDS = [
37
+ 'move',
38
+ 'click',
39
+ 'dblclick',
40
+ 'drag',
41
+ 'key',
42
+ 'fill',
43
+ 'scroll',
44
+ 'wait',
45
+ ] as const
46
+
22
47
  export interface Beat {
23
48
  readonly name: string
24
49
  /**
@@ -26,6 +51,11 @@ export interface Beat {
26
51
  * back to `screencast` on its own, and the take's record says which ran.
27
52
  */
28
53
  readonly route: Route
54
+ /**
55
+ * False turns the camera's zoom off for this beat, while its track still
56
+ * carries the cursor and the click ring. Absent means the zoom runs.
57
+ */
58
+ readonly push?: false
29
59
  /** Runs after the page loads and before the first frame is kept. */
30
60
  readonly setup: readonly TakeStep[]
31
61
  readonly steps: readonly TakeStep[]
@@ -89,16 +119,25 @@ function readPlan(document: unknown): TakePlan {
89
119
  throw new Refusal('unreadable-plan', 'the document is not an object')
90
120
  }
91
121
  const { beats } = document
122
+ const route = document.route ?? 'step'
123
+ if (!ROUTES.includes(route as Route)) {
124
+ throw new Refusal(
125
+ 'unknown-route',
126
+ `the plan names route ${String(route)}. This build records on: ${ROUTES.join(', ')}.`,
127
+ )
128
+ }
92
129
  if (!Array.isArray(beats) || beats.length === 0) {
93
130
  throw new Refusal(
94
131
  'no-beats',
95
132
  'beats carries no entry, so nothing is filmed',
96
133
  )
97
134
  }
98
- return { beats: beats.map((entry, index) => readBeat(entry, index)) }
135
+ return {
136
+ beats: beats.map((entry, index) => readBeat(entry, index, route as Route)),
137
+ }
99
138
  }
100
139
 
101
- function readBeat(entry: unknown, index: number): Beat {
140
+ function readBeat(entry: unknown, index: number, fallback: Route): Beat {
102
141
  const at = `beat ${index + 1}`
103
142
  if (!isRecord(entry)) throw new Refusal('bad-beat', `${at} is not an object`)
104
143
 
@@ -110,7 +149,7 @@ function readBeat(entry: unknown, index: number): Beat {
110
149
  )
111
150
  }
112
151
 
113
- const route = entry.route ?? 'step'
152
+ const route = entry.route ?? fallback
114
153
  if (!ROUTES.includes(route as Route)) {
115
154
  throw new Refusal(
116
155
  'unknown-route',
@@ -127,10 +166,17 @@ function readBeat(entry: unknown, index: number): Beat {
127
166
  if (entry.setup !== undefined && !Array.isArray(entry.setup)) {
128
167
  throw new Refusal('bad-beat', `${name} carries a setup that is not a list`)
129
168
  }
169
+ if (entry.push !== undefined && typeof entry.push !== 'boolean') {
170
+ throw new Refusal(
171
+ 'bad-beat',
172
+ `${name} carries a push that is not true or false`,
173
+ )
174
+ }
130
175
 
131
176
  return {
132
177
  name,
133
178
  route: route as Route,
179
+ ...(entry.push === false ? { push: false as const } : {}),
134
180
  setup: (entry.setup ?? []).map((step: unknown, i: number) =>
135
181
  readStep(step, `${name}, setup step ${i + 1}`),
136
182
  ),
@@ -160,7 +206,35 @@ function readStep(entry: unknown, at: string): TakeStep {
160
206
  `${at} is a move with neither a target nor an x and y`,
161
207
  )
162
208
  case 'click':
163
- return { kind: 'click', target: needTarget() }
209
+ case 'dblclick':
210
+ return {
211
+ kind: entry.kind,
212
+ target: needTarget(),
213
+ ...readModifiers(entry.modifiers, at),
214
+ }
215
+ case 'drag':
216
+ if (typeof entry.from !== 'string' || !entry.from) {
217
+ throw new Refusal('bad-step', `${at} is a drag with no from target`)
218
+ }
219
+ if (typeof entry.to !== 'string' || !entry.to) {
220
+ throw new Refusal('bad-step', `${at} is a drag with no to target`)
221
+ }
222
+ return {
223
+ kind: 'drag',
224
+ from: entry.from,
225
+ to: entry.to,
226
+ ...readModifiers(entry.modifiers, at),
227
+ }
228
+ case 'key': {
229
+ if (typeof entry.keys !== 'string') {
230
+ throw new Refusal('bad-step', `${at} is a key with no keys to press`)
231
+ }
232
+ const read = parseKeys(entry.keys)
233
+ if (read.kind === 'refused') {
234
+ throw new Refusal('bad-step', `${at} is a key where ${read.detail}`)
235
+ }
236
+ return { kind: 'key', keys: entry.keys }
237
+ }
164
238
  case 'fill':
165
239
  if (typeof entry.text !== 'string') {
166
240
  throw new Refusal('bad-step', `${at} is a fill with no text`)
@@ -179,9 +253,30 @@ function readStep(entry: unknown, at: string): TakeStep {
179
253
  default:
180
254
  throw new Refusal(
181
255
  'bad-step',
182
- `${at} names kind ${String(entry.kind)}, which is not move, click, fill, scroll, or wait`,
256
+ `${at} names kind ${String(entry.kind)}, which is not ${KINDS.join(', ')}`,
257
+ )
258
+ }
259
+ }
260
+
261
+ function readModifiers(value: unknown, at: string): Held {
262
+ if (value === undefined) return {}
263
+ if (!Array.isArray(value)) {
264
+ throw new Refusal('bad-step', `${at} carries modifiers that are not a list`)
265
+ }
266
+ const modifiers: Modifier[] = []
267
+ for (const name of value) {
268
+ if (!isModifier(name)) {
269
+ throw new Refusal(
270
+ 'bad-step',
271
+ `${at} holds ${String(name)}, which is not ${MODIFIERS.join(', ')}`,
183
272
  )
273
+ }
274
+ if (modifiers.includes(name)) {
275
+ throw new Refusal('bad-step', `${at} holds ${name} twice`)
276
+ }
277
+ modifiers.push(name)
184
278
  }
279
+ return modifiers.length > 0 ? { modifiers } : {}
185
280
  }
186
281
 
187
282
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -4,7 +4,12 @@ import { join } from 'node:path'
4
4
  import { chromium } from 'playwright-core'
5
5
  import { lastFrame, probeFrames, transcode } from '@/take/encode'
6
6
  import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
7
- import { type PointerInput, type Stage, performSteps } from '@/take/perform'
7
+ import {
8
+ type PointerInput,
9
+ type Stage,
10
+ TargetMissing,
11
+ performSteps,
12
+ } from '@/take/perform'
8
13
  import type { Beat } from '@/take/plan'
9
14
  import { HOLD_MS } from '@/take/stepper'
10
15
 
@@ -19,6 +24,9 @@ const FRAME_MS = 1000 / FPS
19
24
 
20
25
  const SETTLE_MS = 2500
21
26
 
27
+ /** How long a refusal waits on the screenshot it shows. */
28
+ const STILL_CAP_MS = 2000
29
+
22
30
  export interface ScreencastTake {
23
31
  readonly frames: number
24
32
  readonly events: readonly LogEvent[]
@@ -33,8 +41,15 @@ export async function filmScreencast(request: {
33
41
  }): Promise<ScreencastTake> {
34
42
  const scratch = mkdtempSync(join(tmpdir(), 'canon-take-cast-'))
35
43
  const browser = await chromium.launch()
44
+ let still: (() => Promise<Buffer | undefined>) | undefined
36
45
  try {
37
46
  const page = await browser.newPage({ viewport: VIEWPORT })
47
+ // Best effort, so a closed or crashed page leaves the refusal standing
48
+ // without a still rather than replacing it.
49
+ still = () =>
50
+ page
51
+ .screenshot({ type: 'jpeg', quality: 95, timeout: STILL_CAP_MS })
52
+ .catch(() => undefined)
38
53
  await page.goto(request.url, { waitUntil: 'load' })
39
54
  await page.waitForTimeout(SETTLE_MS)
40
55
 
@@ -51,6 +66,13 @@ export async function filmScreencast(request: {
51
66
  await page.keyboard.insertText(text)
52
67
  await page.waitForTimeout(FRAME_MS)
53
68
  },
69
+ // Playwright's mouse reads the modifiers its keyboard holds, so a press
70
+ // here is all a modified click or drag needs on this route.
71
+ press: async (input) => {
72
+ if (input.type === 'down') await page.keyboard.down(input.key)
73
+ else await page.keyboard.up(input.key)
74
+ await page.waitForTimeout(FRAME_MS)
75
+ },
54
76
  evaluate: (expression) => page.evaluate(expression),
55
77
  })
56
78
 
@@ -75,6 +97,9 @@ export async function filmScreencast(request: {
75
97
  await transcode(webm, request.clip, FPS)
76
98
  const frames = await probeFrames(request.clip)
77
99
  return { frames, events, still: await lastFrame(request.clip, frames) }
100
+ } catch (error) {
101
+ if (error instanceof TargetMissing && still) error.still = await still()
102
+ throw error
78
103
  } finally {
79
104
  await browser.close()
80
105
  rmSync(scratch, { recursive: true, force: true })
@@ -88,9 +113,9 @@ async function send(mouse: Mouse, input: PointerInput): Promise<void> {
88
113
  case 'move':
89
114
  return mouse.move(input.x, input.y)
90
115
  case 'down':
91
- return mouse.down()
116
+ return mouse.down({ clickCount: input.clickCount })
92
117
  case 'up':
93
- return mouse.up()
118
+ return mouse.up({ clickCount: input.clickCount })
94
119
  case 'wheel':
95
120
  return mouse.wheel(0, input.dy)
96
121
  }