@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.
- package/CHANGELOG.md +17 -0
- package/README.md +33 -159
- package/assets/brand/mark.svg +17 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/SKILL.md +3 -2
- package/claude/skills/app-drive/references/drive-record.md +52 -10
- package/claude/skills/app-drive/references/isolate.md +1 -1
- package/claude/skills/design-pick/SKILL.md +1 -0
- package/claude/skills/ui-craft/references/copy.md +4 -1
- package/claude/skills/ui-film/REQUIREMENT.md +4 -2
- package/claude/skills/ui-film/SKILL.md +24 -11
- package/claude/skills/ui-film/references/compose-craft.md +81 -0
- package/claude/skills/ui-film/references/config.md +6 -1
- package/claude/skills/ui-film/references/handoff.md +13 -3
- package/claude/skills/ui-film/references/take-craft.md +27 -1
- package/docs/index.md +1 -0
- package/docs/migrations/by-hand.md +107 -0
- package/docs/migrations/by-verb.md +118 -0
- package/docs/migrations/index.md +14 -0
- package/docs/target/index.md +1 -2
- package/docs/target/projects.md +2 -2
- package/docs/target/sync.md +22 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/governance/ledger/skills/ui-craft/copy.md +8 -4
- package/package.json +3 -1
- package/scripts/core/design-sync.ts +204 -0
- package/scripts/core/regen-web-favicon.ts +27 -12
- package/scripts/core/regen-web-previews.ts +8 -1
- package/scripts/core/render-film.ts +179 -0
- package/src/capture/frame-values.ts +12 -12
- package/src/commands/records.ts +3 -2
- package/src/commands/sandbox.ts +9 -2
- package/src/commands/take.ts +159 -11
- package/src/design/favicon.ts +85 -13
- package/src/design/reference.ts +98 -0
- package/src/design/render.ts +16 -14
- package/src/design/tokens.ts +4 -3
- package/src/gate/stages.ts +6 -0
- package/src/init/steps.ts +2 -2
- package/src/migrate/status.ts +3 -3
- package/src/records/validate.ts +14 -2
- package/src/sandbox/expect.ts +32 -0
- package/src/sandbox/headless/run.ts +6 -3
- package/src/take/camera.ts +99 -10
- package/src/take/film.ts +35 -2
- package/src/take/fixtures/input.html +138 -0
- package/src/take/keys.ts +193 -0
- package/src/take/log.ts +94 -1
- package/src/take/perform.ts +165 -10
- package/src/take/plan.ts +101 -6
- package/src/take/screencast.ts +28 -3
- package/src/take/stepper.ts +122 -18
- package/docs/target/migrations.md +0 -205
package/src/sandbox/expect.ts
CHANGED
|
@@ -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.
|
|
49
|
-
*
|
|
50
|
-
* does
|
|
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
|
package/src/take/camera.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
package/src/take/keys.ts
ADDED
|
@@ -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
|
+
}
|