@erclx/canon 5.69.0 → 5.71.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 (67) hide show
  1. package/CHANGELOG.md +25 -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/git-pr/references/task-number.md +13 -0
  10. package/claude/skills/markdown-craft/references/markdown.md +1 -0
  11. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +1 -0
  12. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -20
  13. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +10 -0
  14. package/claude/skills/role-orchestrator/references/orchestrator-talk-launch.md +30 -0
  15. package/claude/skills/task-board/references/tasks-record-lines.md +40 -0
  16. package/claude/skills/task-board/references/tasks.md +4 -8
  17. package/claude/skills/ui-craft/references/copy.md +4 -1
  18. package/claude/skills/ui-film/REQUIREMENT.md +4 -2
  19. package/claude/skills/ui-film/SKILL.md +24 -11
  20. package/claude/skills/ui-film/references/compose-craft.md +81 -0
  21. package/claude/skills/ui-film/references/config.md +6 -1
  22. package/claude/skills/ui-film/references/handoff.md +13 -3
  23. package/claude/skills/ui-film/references/take-craft.md +27 -1
  24. package/docs/index.md +1 -0
  25. package/docs/migrations/by-hand.md +107 -0
  26. package/docs/migrations/by-verb.md +118 -0
  27. package/docs/migrations/index.md +14 -0
  28. package/docs/target/index.md +1 -2
  29. package/docs/target/projects.md +2 -2
  30. package/docs/target/sync.md +22 -2
  31. package/docs/workflow/ai-workflow.md +1 -1
  32. package/governance/ledger/skills/ui-craft/copy.md +8 -4
  33. package/package.json +3 -1
  34. package/scripts/core/design-sync.ts +204 -0
  35. package/scripts/core/regen-web-favicon.ts +27 -12
  36. package/scripts/core/regen-web-previews.ts +8 -1
  37. package/scripts/core/render-film.ts +179 -0
  38. package/src/capture/frame-values.ts +12 -12
  39. package/src/commands/markdown.ts +6 -1
  40. package/src/commands/records.ts +3 -2
  41. package/src/commands/sandbox.ts +9 -2
  42. package/src/commands/take.ts +159 -11
  43. package/src/commands/tasks.ts +133 -0
  44. package/src/design/favicon.ts +85 -13
  45. package/src/design/reference.ts +98 -0
  46. package/src/design/render.ts +16 -14
  47. package/src/design/tokens.ts +4 -3
  48. package/src/gate/stages.ts +6 -0
  49. package/src/init/steps.ts +2 -2
  50. package/src/markdown/colons.ts +102 -16
  51. package/src/migrate/status.ts +3 -3
  52. package/src/records/validate.ts +14 -2
  53. package/src/sandbox/expect.ts +32 -0
  54. package/src/sandbox/headless/run.ts +6 -3
  55. package/src/take/camera.ts +99 -10
  56. package/src/take/film.ts +35 -2
  57. package/src/take/fixtures/input.html +138 -0
  58. package/src/take/keys.ts +193 -0
  59. package/src/take/log.ts +94 -1
  60. package/src/take/perform.ts +165 -10
  61. package/src/take/plan.ts +101 -6
  62. package/src/take/screencast.ts +28 -3
  63. package/src/take/stepper.ts +122 -18
  64. package/src/tasks/archive.ts +54 -1
  65. package/src/tasks/record.ts +118 -0
  66. package/src/tasks/validate.ts +37 -0
  67. package/docs/target/migrations.md +0 -205
@@ -0,0 +1,138 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Input</title>
6
+ <!--
7
+ One target per input a take performs, each posting what the page saw to
8
+ /report with a sequence number, so a test reads the inputs in the order
9
+ the page received them after the take has closed the browser.
10
+
11
+ - #handle follows a pointer-event drag and reports where it dropped and
12
+ how many of its moves carried the held button
13
+ - #frame reports each dblclick
14
+ - #layer reports pointerdown, pointerup, and click with the Shift flag
15
+ - #field takes focus for key steps, and every keyup reports its value
16
+ -->
17
+ <style>
18
+ body {
19
+ margin: 0;
20
+ background: #f4f4f4;
21
+ font: 24px sans-serif;
22
+ }
23
+
24
+ #handle {
25
+ position: absolute;
26
+ left: 200px;
27
+ top: 200px;
28
+ width: 80px;
29
+ height: 80px;
30
+ /* Neutral, so the reel's blue ring reads over it at any alpha. */
31
+ background: #444;
32
+ touch-action: none;
33
+ }
34
+
35
+ #lane {
36
+ position: absolute;
37
+ left: 700px;
38
+ top: 160px;
39
+ width: 300px;
40
+ height: 160px;
41
+ border: 2px dashed #555;
42
+ box-sizing: border-box;
43
+ }
44
+
45
+ #frame,
46
+ #layer {
47
+ position: absolute;
48
+ top: 500px;
49
+ width: 200px;
50
+ height: 100px;
51
+ background: #ddd;
52
+ user-select: none;
53
+ }
54
+
55
+ #frame {
56
+ left: 200px;
57
+ }
58
+
59
+ #layer {
60
+ left: 700px;
61
+ }
62
+
63
+ #field {
64
+ position: absolute;
65
+ left: 200px;
66
+ top: 750px;
67
+ width: 400px;
68
+ height: 48px;
69
+ font: inherit;
70
+ }
71
+ </style>
72
+ </head>
73
+ <body>
74
+ <div id="lane"></div>
75
+ <div id="handle"></div>
76
+ <div id="frame">Frame</div>
77
+ <div id="layer">Layer</div>
78
+ <input id="field" />
79
+ <script>
80
+ let seq = 0
81
+ const report = (entry) =>
82
+ fetch('/report', {
83
+ method: 'POST',
84
+ body: JSON.stringify({ seq: (seq += 1), ...entry }),
85
+ keepalive: true,
86
+ })
87
+
88
+ const handle = document.getElementById('handle')
89
+ const lane = document.getElementById('lane')
90
+ let drag = null
91
+ handle.addEventListener('pointerdown', (event) => {
92
+ handle.setPointerCapture(event.pointerId)
93
+ drag = { held: 0, hover: 0 }
94
+ })
95
+ handle.addEventListener('pointermove', (event) => {
96
+ if (!drag) return
97
+ if (event.buttons === 1) drag.held += 1
98
+ else drag.hover += 1
99
+ handle.style.left = `${event.clientX - 40}px`
100
+ handle.style.top = `${event.clientY - 40}px`
101
+ })
102
+ handle.addEventListener('pointerup', (event) => {
103
+ if (!drag) return
104
+ const box = lane.getBoundingClientRect()
105
+ const onTarget =
106
+ event.clientX >= box.left &&
107
+ event.clientX <= box.right &&
108
+ event.clientY >= box.top &&
109
+ event.clientY <= box.bottom
110
+ report({ type: 'drop', ...drag, onTarget, altKey: event.altKey })
111
+ drag = null
112
+ })
113
+
114
+ document
115
+ .getElementById('frame')
116
+ .addEventListener('dblclick', () => report({ type: 'dblclick' }))
117
+
118
+ const layer = document.getElementById('layer')
119
+ for (const type of ['pointerdown', 'pointerup', 'click']) {
120
+ layer.addEventListener(type, (event) =>
121
+ report({ type, shiftKey: event.shiftKey }),
122
+ )
123
+ }
124
+
125
+ const field = document.getElementById('field')
126
+ document.addEventListener('keydown', (event) =>
127
+ report({
128
+ type: 'keydown',
129
+ key: event.key,
130
+ ctrlKey: event.ctrlKey,
131
+ }),
132
+ )
133
+ document.addEventListener('keyup', (event) =>
134
+ report({ type: 'keyup', key: event.key, value: field.value }),
135
+ )
136
+ </script>
137
+ </body>
138
+ </html>
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The keys a take plan may press, named as Playwright names them so the
3
+ * screencast passes a combination straight to `page.keyboard`, and each one's
4
+ * CDP descriptor so the stepper can send the same press as raw key events.
5
+ * A key outside this vocabulary refuses at plan read, since the stepper has no
6
+ * descriptor to send for it.
7
+ */
8
+
9
+ export const MODIFIERS = ['Alt', 'Control', 'Meta', 'Shift'] as const
10
+
11
+ export type Modifier = (typeof MODIFIERS)[number]
12
+
13
+ /** A combination: the modifiers held, in order, around one key. */
14
+ export interface Chord {
15
+ readonly modifiers: readonly Modifier[]
16
+ readonly key: string
17
+ }
18
+
19
+ /** The fields `Input.dispatchKeyEvent` needs to press one key. */
20
+ export interface KeyDescriptor {
21
+ readonly key: string
22
+ readonly code: string
23
+ readonly keyCode: number
24
+ /** What the key types, absent for a key that types nothing. */
25
+ readonly text?: string
26
+ /** 1 for a modifier, which sits on the left of the keyboard. */
27
+ readonly location: number
28
+ }
29
+
30
+ /** CDP's modifier bit field: "Alt=1, Ctrl=2, Meta/Command=4, Shift=8". */
31
+ const MODIFIER_BITS: Record<Modifier, number> = {
32
+ Alt: 1,
33
+ Control: 2,
34
+ Meta: 4,
35
+ Shift: 8,
36
+ }
37
+
38
+ const MODIFIER_KEY_CODES: Record<Modifier, number> = {
39
+ Alt: 18,
40
+ Control: 17,
41
+ Meta: 91,
42
+ Shift: 16,
43
+ }
44
+
45
+ const NAMED: Record<string, { code?: string; keyCode: number; text?: string }> =
46
+ {
47
+ Enter: { keyCode: 13, text: '\r' },
48
+ Tab: { keyCode: 9 },
49
+ Escape: { keyCode: 27 },
50
+ Backspace: { keyCode: 8 },
51
+ Delete: { keyCode: 46 },
52
+ Insert: { keyCode: 45 },
53
+ Space: { keyCode: 32, text: ' ' },
54
+ Home: { keyCode: 36 },
55
+ End: { keyCode: 35 },
56
+ PageUp: { keyCode: 33 },
57
+ PageDown: { keyCode: 34 },
58
+ ArrowLeft: { keyCode: 37 },
59
+ ArrowUp: { keyCode: 38 },
60
+ ArrowRight: { keyCode: 39 },
61
+ ArrowDown: { keyCode: 40 },
62
+ ...Object.fromEntries(
63
+ Array.from({ length: 12 }, (_, i) => [`F${i + 1}`, { keyCode: 112 + i }]),
64
+ ),
65
+ }
66
+
67
+ /** The US layout's punctuation, each with its shifted twin on the same key. */
68
+ const PUNCTUATION: readonly (readonly [string, string, string, number])[] = [
69
+ ['`', '~', 'Backquote', 192],
70
+ ['-', '_', 'Minus', 189],
71
+ ['=', '+', 'Equal', 187],
72
+ ['[', '{', 'BracketLeft', 219],
73
+ [']', '}', 'BracketRight', 221],
74
+ ['\\', '|', 'Backslash', 220],
75
+ [';', ':', 'Semicolon', 186],
76
+ ["'", '"', 'Quote', 222],
77
+ [',', '<', 'Comma', 188],
78
+ ['.', '>', 'Period', 190],
79
+ ['/', '?', 'Slash', 191],
80
+ ]
81
+
82
+ const SHIFTED_DIGITS = ')!@#$%^&*('
83
+
84
+ /**
85
+ * With `shift` set, a printable key types its shifted twin, as Playwright's
86
+ * keyboard swaps to it while Shift is held, so `Shift+a` types `A` on both
87
+ * routes.
88
+ */
89
+ export function describeKey(
90
+ key: string,
91
+ options: { readonly shift?: boolean } = {},
92
+ ): KeyDescriptor | undefined {
93
+ return describeTyped(options.shift ? shiftedTwin(key) : key)
94
+ }
95
+
96
+ function shiftedTwin(key: string): string {
97
+ if (/^[a-z]$/.test(key)) return key.toUpperCase()
98
+ if (/^[0-9]$/.test(key)) return SHIFTED_DIGITS[Number(key)] ?? key
99
+ const row = PUNCTUATION.find(([plain]) => key === plain)
100
+ return row ? row[1] : key
101
+ }
102
+
103
+ function describeTyped(key: string): KeyDescriptor | undefined {
104
+ if (isModifier(key)) {
105
+ return {
106
+ key,
107
+ code: `${key}Left`,
108
+ keyCode: MODIFIER_KEY_CODES[key],
109
+ location: 1,
110
+ }
111
+ }
112
+ const named = NAMED[key]
113
+ if (named) {
114
+ return {
115
+ key: key === 'Space' ? ' ' : key,
116
+ code: named.code ?? key,
117
+ keyCode: named.keyCode,
118
+ ...(named.text ? { text: named.text } : {}),
119
+ location: 0,
120
+ }
121
+ }
122
+ if (key.length !== 1) return undefined
123
+
124
+ if (/^[a-z]$/i.test(key)) {
125
+ const upper = key.toUpperCase()
126
+ return printable(key, `Key${upper}`, upper.charCodeAt(0))
127
+ }
128
+ if (/^[0-9]$/.test(key)) {
129
+ return printable(key, `Digit${key}`, key.charCodeAt(0))
130
+ }
131
+ const shiftedDigit = SHIFTED_DIGITS.indexOf(key)
132
+ if (shiftedDigit >= 0) {
133
+ return printable(key, `Digit${shiftedDigit}`, 48 + shiftedDigit)
134
+ }
135
+ if (key === ' ') return printable(key, 'Space', 32)
136
+ const row = PUNCTUATION.find(
137
+ ([plain, shifted]) => key === plain || key === shifted,
138
+ )
139
+ return row ? printable(key, row[2], row[3]) : undefined
140
+ }
141
+
142
+ export type KeysRead =
143
+ | { readonly kind: 'parsed'; readonly chord: Chord }
144
+ | { readonly kind: 'refused'; readonly detail: string }
145
+
146
+ /**
147
+ * Reads a combination such as `Control+K`, the modifiers first and the key
148
+ * last. A trailing `+` after a separator is the plus key itself.
149
+ */
150
+ export function parseKeys(keys: string): KeysRead {
151
+ const parts =
152
+ keys === '+'
153
+ ? ['+']
154
+ : keys.endsWith('++')
155
+ ? [...keys.slice(0, -2).split('+'), '+']
156
+ : keys.split('+')
157
+ const key = parts.at(-1) ?? ''
158
+ const held = parts.slice(0, -1)
159
+
160
+ if (!key) return { kind: 'refused', detail: `${keys || 'it'} names no key` }
161
+ const modifiers: Modifier[] = []
162
+ for (const name of held) {
163
+ if (!isModifier(name)) {
164
+ return {
165
+ kind: 'refused',
166
+ detail: `${keys} holds ${name || 'nothing'}, which is not ${MODIFIERS.join(', ')}`,
167
+ }
168
+ }
169
+ if (modifiers.includes(name)) {
170
+ return { kind: 'refused', detail: `${keys} holds ${name} twice` }
171
+ }
172
+ modifiers.push(name)
173
+ }
174
+ if (!describeKey(key)) {
175
+ return {
176
+ kind: 'refused',
177
+ detail: `${keys} presses ${key}, which is not a modifier, one character, or a named key`,
178
+ }
179
+ }
180
+ return { kind: 'parsed', chord: { modifiers, key } }
181
+ }
182
+
183
+ export function modifierBits(modifiers: readonly Modifier[]): number {
184
+ return modifiers.reduce((bits, name) => bits | MODIFIER_BITS[name], 0)
185
+ }
186
+
187
+ export function isModifier(name: unknown): name is Modifier {
188
+ return MODIFIERS.includes(name as Modifier)
189
+ }
190
+
191
+ function printable(key: string, code: string, keyCode: number): KeyDescriptor {
192
+ return { key, code, keyCode, text: key, location: 0 }
193
+ }
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
  }