@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
@@ -44,7 +44,15 @@ export interface AssertionResult {
44
44
  */
45
45
  export interface RunEnvelope {
46
46
  readonly isError: boolean
47
+ /** The runner cap ended the run, read from `subtype` rather than `turns`. */
48
+ readonly isTruncated: boolean
47
49
  readonly turns: number
50
+ /**
51
+ * The cap the run ran under. `num_turns` counts more than the cap does, so
52
+ * an untruncated run can report past it, and a ceiling at or above the cap
53
+ * is held by `isTruncated` instead. A non-finite value reads as absent.
54
+ */
55
+ readonly turnCap?: number
48
56
  readonly denials: number
49
57
  readonly reply?: string
50
58
  }
@@ -487,6 +495,23 @@ function checkReply(
487
495
  }
488
496
  }
489
497
 
498
+ function isFiniteCap(turnCap: number | undefined): turnCap is number {
499
+ return turnCap !== undefined && Number.isFinite(turnCap)
500
+ }
501
+
502
+ function capped(turnCap: number | undefined): string {
503
+ return isFiniteCap(turnCap) ? ` of ${turnCap} turns` : ''
504
+ }
505
+
506
+ /**
507
+ * A ceiling below the cap was declared in `num_turns` and is compared in it. One
508
+ * at or above the cap skips with no result, since emitting a pass would let the
509
+ * envelope pass a run.
510
+ */
511
+ function isBelowCap(maxTurns: number, turnCap: number | undefined): boolean {
512
+ return !isFiniteCap(turnCap) || maxTurns < turnCap
513
+ }
514
+
490
515
  /**
491
516
  * The envelope never determines a pass. It can only fail a run the expectations
492
517
  * would otherwise have passed. Under `bypassPermissions` the denial count is
@@ -516,8 +541,15 @@ function checkEnvelope(
516
541
  message: `envelope: ${envelope.denials} permission denials`,
517
542
  })
518
543
  }
544
+ if (envelope.isTruncated) {
545
+ results.push({
546
+ ok: false,
547
+ message: `envelope: truncated by the runner cap${capped(envelope.turnCap)}`,
548
+ })
549
+ }
519
550
  if (
520
551
  expectation.maxTurns !== undefined &&
552
+ isBelowCap(expectation.maxTurns, envelope.turnCap) &&
521
553
  envelope.turns > expectation.maxTurns
522
554
  ) {
523
555
  results.push({
@@ -45,9 +45,12 @@ import {
45
45
  *
46
46
  * `maxTurns` is the only budget. `max_turns` in an arm's `expect.toml` is a
47
47
  * ceiling asserted after the run, not a cap enforced during it, so a
48
- * declaration cannot raise what it runs under. An arm needing more than this
49
- * truncates, and a truncated run fails the same assertions a reasoning miss
50
- * does with nothing to separate them.
48
+ * declaration cannot raise what it runs under. A truncated run exits 1 with
49
+ * `subtype` `error_max_turns`, which `canon sandbox check` fails by name.
50
+ * `num_turns` counts more than this cap does, so a ceiling at or above it is
51
+ * held by the cap and compares nothing. The check reads the cap from its own
52
+ * environment, so a run under a raised cap, checked later by hand without the
53
+ * variable, reads the default.
51
54
  *
52
55
  * `bypassPermissions` is the only mode that lets a run write under `.claude/`,
53
56
  * which most arms need. The scoping the permission layer would have given
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type Box,
3
+ CLIP_SIZES,
3
4
  type LogEvent,
4
5
  type Point,
5
6
  type TakeLog,
@@ -21,7 +22,7 @@ import {
21
22
  /**
22
23
  * Turns a take's log into one camera per frame, preset O4 from groundwork
23
24
  * track `193-ui-record`, a folder name kept as written (canon-keep-retired).
24
- * The preset is OpenScreen's timing aimed at each click, a fixed depth, a focus that holds still until a click leaves the inner part of the
25
+ * The preset is OpenScreen's timing aimed at each click, a fixed depth the clip's resolution caps, a focus that holds still until a click leaves the inner part of the
25
26
  * view, and a dead zone that keeps the cursor in frame. HyperFrames renders the
26
27
  * track as it stands, so it carries no curve a renderer would have to agree on.
27
28
  */
@@ -31,6 +32,13 @@ export const CAMERA_VERSION = 1
31
32
  /** The depth every click zooms to, unless its target already fills the view. */
32
33
  const DEPTH = 1.8
33
34
 
35
+ /**
36
+ * How far a clip with one pixel per CSS pixel zooms before it goes soft, which
37
+ * caps a screencast's depth well under `DEPTH` and leaves the stepper's 4K
38
+ * clip at it.
39
+ */
40
+ const SHARP_DEPTH = 1.35
41
+
34
42
  /** How much of the zoomed view a target may fill, which caps the depth for a big one. */
35
43
  const FILL = 0.8
36
44
 
@@ -57,6 +65,13 @@ const EDGE = 0.9
57
65
  /** How long the click ring the edit draws takes to expand. */
58
66
  const RING_MS = 450
59
67
 
68
+ /**
69
+ * How far a drag's ring grows before it holds for the drag, then runs on to 1
70
+ * from the release. A held value keeps the ring's one field, so every reader
71
+ * drawing a growing ring draws a held press with no change.
72
+ */
73
+ const GRAB_RING = 0.3
74
+
60
75
  export interface CameraFrame {
61
76
  readonly ms: number
62
77
  /** How far the view is zoomed, 1 being the whole page. */
@@ -91,16 +106,30 @@ interface Window {
91
106
  readonly aims: Aim[]
92
107
  }
93
108
 
94
- export function cameraTrack(log: TakeLog): CameraTrack {
109
+ export interface CameraOptions {
110
+ /**
111
+ * How much faster the edit plays the clip, so the track is built on the
112
+ * playback clock and a zoom settles at the speed a viewer sees. 1 by default.
113
+ */
114
+ readonly rate?: number
115
+ }
116
+
117
+ export function cameraTrack(
118
+ source: TakeLog,
119
+ options: CameraOptions = {},
120
+ ): CameraTrack {
121
+ const log = atRate(source, options.rate ?? 1)
95
122
  const { width, height } = log.viewport
123
+ const clip = log.clip ?? CLIP_SIZES[log.route]
124
+ const deepest = Math.min(DEPTH, (SHARP_DEPTH * clip.width) / width)
96
125
  const depthFor = (box: Box): number =>
97
126
  Math.min(
98
- DEPTH,
127
+ deepest,
99
128
  (width * FILL) / Math.max(box.width, 1),
100
129
  (height * FILL) / Math.max(box.height, 1),
101
130
  )
102
131
 
103
- const aims: Aim[] = log.events
132
+ const aims: Aim[] = (log.push === false ? [] : log.events)
104
133
  .flatMap((event): Aim[] => {
105
134
  if (event.kind === 'click') {
106
135
  return [
@@ -112,6 +141,19 @@ export function cameraTrack(log: TakeLog): CameraTrack {
112
141
  },
113
142
  ]
114
143
  }
144
+ if (event.kind === 'drag') {
145
+ // One aim over both ends, held to the release, so a short drag stays
146
+ // framed whole and a long one asks for a depth under MIN_ZOOM.
147
+ const box = union(event.box, event.drop)
148
+ return [
149
+ {
150
+ t: event.t,
151
+ until: event.t + event.ms,
152
+ at: { x: box.x + box.width / 2, y: box.y + box.height / 2 },
153
+ depth: depthFor(box),
154
+ },
155
+ ]
156
+ }
115
157
  if (event.kind === 'type') {
116
158
  const box = event.box
117
159
  return [
@@ -129,9 +171,26 @@ export function cameraTrack(log: TakeLog): CameraTrack {
129
171
  .sort((a, b) => a.t - b.t)
130
172
 
131
173
  const scrolls = log.events.filter((event) => event.kind === 'scroll')
132
- // Read off every click rather than off the aims, which drop a click too large
174
+ // Read off every press rather than off the aims, which drop a click too large
133
175
  // to zoom and add a typing burst that is no press.
134
- const clicks = log.events.filter((event) => event.kind === 'click')
176
+ const presses = log.events.flatMap((event) =>
177
+ event.kind === 'click'
178
+ ? [{ t: event.t, release: event.t }]
179
+ : event.kind === 'drag'
180
+ ? [{ t: event.t, release: event.t + event.ms }]
181
+ : [],
182
+ )
183
+ const ringAt = (ms: number): number | null => {
184
+ for (const press of presses) {
185
+ if (ms < press.t) continue
186
+ const grown = (ms - press.t) / RING_MS
187
+ if (ms <= press.release) return Math.min(GRAB_RING, grown)
188
+ const held = Math.min(GRAB_RING, (press.release - press.t) / RING_MS)
189
+ const ring = held + (ms - press.release) / RING_MS
190
+ if (ring <= 1) return ring
191
+ }
192
+ return null
193
+ }
135
194
  const windows = zoomWindows(aims, scrolls)
136
195
 
137
196
  const targetAt = (ms: number) => {
@@ -196,16 +255,14 @@ export function cameraTrack(log: TakeLog): CameraTrack {
196
255
  cursor.y + half.y * EDGE,
197
256
  )
198
257
 
199
- const ring = clicks.find(
200
- (click) => ms >= click.t && ms - click.t <= RING_MS,
201
- )
258
+ const ring = ringAt(ms)
202
259
  frames.push({
203
260
  ms: round(ms, 2),
204
261
  scale: round(scale, 4),
205
262
  x: round(clamp(springs.x.value, half.x, width - half.x) - half.x, 2),
206
263
  y: round(clamp(springs.y.value, half.y, height - half.y) - half.y, 2),
207
264
  cursor: roundPoint(cursor),
208
- ring: ring ? round((ms - ring.t) / RING_MS, 3) : null,
265
+ ring: ring === null ? null : round(ring, 3),
209
266
  })
210
267
  }
211
268
 
@@ -217,6 +274,27 @@ export function cameraTrack(log: TakeLog): CameraTrack {
217
274
  }
218
275
  }
219
276
 
277
+ /** Reads a playback rate off a flag, which has to be a positive number. */
278
+ export function readRate(value: string): number | undefined {
279
+ const rate = Number(value)
280
+ return Number.isFinite(rate) && rate > 0 ? rate : undefined
281
+ }
282
+
283
+ /** The log as a clip played `rate` times faster shows it, every time divided. */
284
+ function atRate(log: TakeLog, rate: number): TakeLog {
285
+ if (rate === 1) return log
286
+ return {
287
+ ...log,
288
+ frames: Math.round(log.frames / rate),
289
+ events: log.events.map((event): LogEvent => {
290
+ const t = event.t / rate
291
+ return 'ms' in event
292
+ ? { ...event, t, ms: event.ms / rate }
293
+ : { ...event, t }
294
+ }),
295
+ }
296
+ }
297
+
220
298
  /**
221
299
  * Groups aims into zooms. One holds from its first aim to the last plus the
222
300
  * hold, a scroll ends it since the content moves under the camera, and the next
@@ -295,6 +373,17 @@ function focusAt(
295
373
  return focus
296
374
  }
297
375
 
376
+ function union(a: Box, b: Box): Box {
377
+ const x = Math.min(a.x, b.x)
378
+ const y = Math.min(a.y, b.y)
379
+ return {
380
+ x,
381
+ y,
382
+ width: Math.max(a.x + a.width, b.x + b.width) - x,
383
+ height: Math.max(a.y + a.height, b.y + b.height) - y,
384
+ }
385
+ }
386
+
298
387
  function clamp(value: number, low: number, high: number): number {
299
388
  return Math.min(Math.max(value, low), high)
300
389
  }
package/src/take/film.ts CHANGED
@@ -4,6 +4,7 @@ import { isBrowserMissing, isServerUnreachable } from '@/browser/engine'
4
4
  import { cameraTrack } from '@/take/camera'
5
5
  import { FfmpegMissing, requireFfmpeg } from '@/take/encode'
6
6
  import {
7
+ CLIP_SIZES,
7
8
  FPS,
8
9
  LOG_VERSION,
9
10
  type LogEvent,
@@ -60,6 +61,10 @@ export type TakeResult =
60
61
  readonly reason: TakeRefusal
61
62
  readonly message: string
62
63
  readonly beat?: string
64
+ /** Why the refused beat had fallen back to a screencast, when it had. */
65
+ readonly fallback?: string
66
+ /** The page as the failing step met it, relative to the take's folder. */
67
+ readonly still?: string
63
68
  }
64
69
 
65
70
  export interface TakeRequest {
@@ -121,7 +126,15 @@ export async function take(request: TakeRequest): Promise<TakeResult> {
121
126
  ),
122
127
  )
123
128
  } catch (error) {
124
- return failed(classify(error), error, beat.name)
129
+ const cause = error instanceof FellBack ? error.error : error
130
+ const shot = cause instanceof TargetMissing ? cause.still : undefined
131
+ const still = shot ? join(dir, 'still.jpg') : undefined
132
+ if (shot && still) writeFileSync(join(out, still), shot)
133
+ return {
134
+ ...failed(classify(cause), cause, beat.name),
135
+ ...(error instanceof FellBack ? { fallback: error.fallback } : {}),
136
+ ...(still ? { still } : {}),
137
+ }
125
138
  }
126
139
  }
127
140
 
@@ -159,7 +172,11 @@ async function filmBeat(
159
172
  if (!(error instanceof StepperStalled)) throw error
160
173
  route = 'screencast'
161
174
  fallback = error.message
162
- filmed = await filmScreencast({ url, beat, clip: target })
175
+ try {
176
+ filmed = await filmScreencast({ url, beat, clip: target })
177
+ } catch (cause) {
178
+ throw new FellBack(cause, fallback)
179
+ }
163
180
  }
164
181
  } else {
165
182
  filmed = await filmScreencast({ url, beat, clip: target })
@@ -170,6 +187,8 @@ async function filmBeat(
170
187
  viewport: VIEWPORT,
171
188
  fps: FPS,
172
189
  route,
190
+ clip: CLIP_SIZES[route],
191
+ ...(beat.push === false ? { push: false as const } : {}),
173
192
  frames: filmed.frames,
174
193
  events: filmed.events,
175
194
  }
@@ -196,6 +215,20 @@ async function filmBeat(
196
215
  }
197
216
  }
198
217
 
218
+ /**
219
+ * A beat that failed in the screencast it fell back to, carrying why it fell
220
+ * back, since the fallback loads the page a second time and a single-use link
221
+ * is spent by then.
222
+ */
223
+ class FellBack extends Error {
224
+ constructor(
225
+ readonly error: unknown,
226
+ readonly fallback: string,
227
+ ) {
228
+ super(error instanceof Error ? error.message : String(error))
229
+ }
230
+ }
231
+
199
232
  function classify(error: unknown): TakeRefusal {
200
233
  if (error instanceof TargetMissing) return 'target-missing'
201
234
  if (error instanceof ShellMissing || isBrowserMissing(error)) {
@@ -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
+ }