@erclx/canon 5.65.0 → 5.67.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 (84) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +35 -0
  4. package/claude/skills/app-drive/SKILL.md +112 -0
  5. package/claude/skills/app-drive/references/config.md +38 -0
  6. package/claude/skills/app-drive/references/drive-record.md +70 -0
  7. package/claude/skills/app-drive/references/isolate.md +46 -0
  8. package/claude/skills/canon-cli/SKILL.md +2 -0
  9. package/claude/skills/create-skill/references/skill.md +1 -1
  10. package/claude/skills/git-pr/references/labels.md +6 -2
  11. package/claude/skills/git-pr-close/REQUIREMENT.md +29 -0
  12. package/claude/skills/git-pr-close/SKILL.md +55 -0
  13. package/claude/skills/git-pr-close/references/close.md +54 -0
  14. package/claude/skills/plan-feature/references/plan.md +1 -1
  15. package/claude/skills/plan-intake/references/intake.md +12 -2
  16. package/claude/skills/pr-evidence/SKILL.md +2 -2
  17. package/claude/skills/pr-evidence/references/evidence.md +3 -1
  18. package/claude/skills/review-ui/SKILL.md +5 -4
  19. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  20. package/claude/skills/review-ui/references/driving.md +1 -1
  21. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  22. package/claude/skills/role-planner/SKILL.md +2 -2
  23. package/claude/skills/role-worker/SKILL.md +6 -8
  24. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  25. package/claude/skills/session-map/SKILL.md +8 -1
  26. package/claude/skills/session-map/references/session.md +19 -5
  27. package/claude/skills/ui-record/REQUIREMENT.md +34 -0
  28. package/claude/skills/ui-record/SKILL.md +103 -0
  29. package/claude/skills/ui-record/references/config.md +40 -0
  30. package/claude/skills/ui-record/references/handoff.md +48 -0
  31. package/claude/skills/ui-record/references/take-craft.md +92 -0
  32. package/docs/agents/records.md +1 -0
  33. package/docs/workflow/parallel-features.md +1 -1
  34. package/docs/workflow/skill-map-on-request.md +1 -0
  35. package/docs/workflow/skill-map.md +2 -0
  36. package/docs/workflow/visual-design-workflow.md +2 -1
  37. package/package.json +1 -1
  38. package/src/audits/catalog.ts +5 -1
  39. package/src/browser/agent-cli.ts +68 -0
  40. package/src/claude/cases/git.ts +4 -0
  41. package/src/claude/cases/workflow.ts +20 -0
  42. package/src/cli/help.ts +3 -0
  43. package/src/cli.ts +4 -0
  44. package/src/commands/browser.ts +102 -0
  45. package/src/commands/intake.ts +181 -1
  46. package/src/commands/labels.ts +152 -4
  47. package/src/commands/pr.ts +278 -8
  48. package/src/commands/records.ts +18 -1
  49. package/src/commands/take.ts +266 -0
  50. package/src/context/requirements.ts +1 -11
  51. package/src/git/status.ts +73 -0
  52. package/src/intake/drive-check.ts +355 -0
  53. package/src/intake/folder.ts +1 -1
  54. package/src/labels/apply.ts +10 -5
  55. package/src/labels/sync.ts +17 -0
  56. package/src/markdown/scan.ts +10 -0
  57. package/src/pr/close.ts +152 -0
  58. package/src/pr/drive-copy.ts +97 -84
  59. package/src/pr/health.ts +56 -0
  60. package/src/pr/local.ts +49 -8
  61. package/src/pr/rest.ts +10 -2
  62. package/src/records/session-map.ts +75 -0
  63. package/src/records/validate.ts +64 -8
  64. package/src/sandbox/replay/run.ts +30 -5
  65. package/src/take/camera.ts +309 -0
  66. package/src/take/encode.ts +170 -0
  67. package/src/take/film.ts +206 -0
  68. package/src/take/fixtures/fade.html +49 -0
  69. package/src/take/fixtures/fetch.html +38 -0
  70. package/src/take/log.ts +109 -0
  71. package/src/take/perform.ts +200 -0
  72. package/src/take/plan.ts +193 -0
  73. package/src/take/screencast.ts +94 -0
  74. package/src/take/shell.ts +287 -0
  75. package/src/take/stepper.ts +252 -0
  76. package/src/take/zoom/NOTICE.md +45 -0
  77. package/src/take/zoom/math-utils.ts +69 -0
  78. package/src/take/zoom/region-utils.ts +55 -0
  79. package/src/take/zoom/spring.ts +133 -0
  80. package/src/tasks/archive.ts +1 -3
  81. package/src/tooling/verify-stack.ts +4 -5
  82. package/tooling/base/reference.md +1 -0
  83. package/tooling/base/seeds/canon/config/pr-labels.toml +41 -0
  84. package/claude/skills/review-ui/scripts/pw.sh +0 -60
@@ -0,0 +1,49 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Fade</title>
6
+ <!--
7
+ A band that fades from black to white over exactly one second of page
8
+ time once the button is pressed, so its brightness frame by frame reads
9
+ how much page time each frame of a take advanced.
10
+ -->
11
+ <style>
12
+ body {
13
+ margin: 0;
14
+ background: #808080;
15
+ }
16
+
17
+ #go {
18
+ position: absolute;
19
+ left: 100px;
20
+ top: 100px;
21
+ width: 200px;
22
+ height: 60px;
23
+ }
24
+
25
+ #band {
26
+ position: absolute;
27
+ left: 0;
28
+ top: 300px;
29
+ width: 1920px;
30
+ height: 780px;
31
+ background-color: #000;
32
+ transition: background-color 1000ms linear;
33
+ }
34
+
35
+ #band.lit {
36
+ background-color: #fff;
37
+ }
38
+ </style>
39
+ </head>
40
+ <body>
41
+ <button id="go">Go</button>
42
+ <div id="band"></div>
43
+ <script>
44
+ document.getElementById('go').addEventListener('click', () => {
45
+ document.getElementById('band').classList.add('lit')
46
+ })
47
+ </script>
48
+ </body>
49
+ </html>
@@ -0,0 +1,38 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Fetch</title>
6
+ <!--
7
+ A button whose press starts a fetch the server answers slowly, the way
8
+ a page switch in an app loads what the next view shows. A take must keep
9
+ stepping frames while the fetch is in flight.
10
+ -->
11
+ <style>
12
+ body {
13
+ margin: 0;
14
+ background: #808080;
15
+ }
16
+
17
+ #go {
18
+ position: absolute;
19
+ left: 100px;
20
+ top: 100px;
21
+ width: 200px;
22
+ height: 60px;
23
+ }
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <button id="go">Load</button>
28
+ <p id="status">idle</p>
29
+ <script>
30
+ document.getElementById('go').addEventListener('click', () => {
31
+ document.getElementById('status').textContent = 'loading'
32
+ fetch('/slow').then(() => {
33
+ document.getElementById('status').textContent = 'loaded'
34
+ })
35
+ })
36
+ </script>
37
+ </body>
38
+ </html>
@@ -0,0 +1,109 @@
1
+ import type { Route } from '@/take/plan'
2
+
3
+ /**
4
+ * The click log a take writes beside its clip, and the pointer path both
5
+ * capture routes move along and the camera reads back. Every coordinate is in
6
+ * CSS pixels of the page, and every time is in milliseconds on the clip's own
7
+ * clock, so frame `n` sits at `n * 1000 / FPS` whatever route filmed it.
8
+ *
9
+ * The path lives here rather than in a capture route because the edit draws the
10
+ * cursor from this log, so the route moving the real pointer and the reader
11
+ * redrawing it have to agree on one curve.
12
+ */
13
+
14
+ export const LOG_VERSION = 1
15
+
16
+ export const FPS = 60
17
+
18
+ /** The page a take films, in CSS pixels. The stepper renders it at twice this. */
19
+ export const VIEWPORT = { width: 1920, height: 1080 } as const
20
+
21
+ export interface Point {
22
+ readonly x: number
23
+ readonly y: number
24
+ }
25
+
26
+ export interface Box {
27
+ readonly x: number
28
+ readonly y: number
29
+ readonly width: number
30
+ readonly height: number
31
+ }
32
+
33
+ export type LogEvent =
34
+ | {
35
+ readonly t: number
36
+ readonly kind: 'move'
37
+ readonly from: Point
38
+ readonly to: Point
39
+ readonly ms: number
40
+ }
41
+ | {
42
+ readonly t: number
43
+ readonly kind: 'click'
44
+ readonly at: Point
45
+ readonly box: Box
46
+ }
47
+ | {
48
+ readonly t: number
49
+ readonly kind: 'type'
50
+ readonly at: Point
51
+ readonly box: Box
52
+ readonly chars: number
53
+ /** From the first key to the last, so the camera holds for the burst. */
54
+ readonly ms: number
55
+ }
56
+ | {
57
+ readonly t: number
58
+ readonly kind: 'scroll'
59
+ readonly at: Point
60
+ readonly dx: number
61
+ readonly dy: number
62
+ readonly ms: number
63
+ }
64
+
65
+ export interface TakeLog {
66
+ readonly version: typeof LOG_VERSION
67
+ readonly viewport: { readonly width: number; readonly height: number }
68
+ readonly fps: number
69
+ /** The route that filmed the clip, which can differ from the one asked for. */
70
+ readonly route: Route
71
+ readonly frames: number
72
+ readonly events: readonly LogEvent[]
73
+ }
74
+
75
+ /** Where the pointer rests before a take's first move. */
76
+ export const START: Point = { x: VIEWPORT.width / 2, y: VIEWPORT.height / 2 }
77
+
78
+ /**
79
+ * How long the pointer takes to travel, slower for longer reaches and never so
80
+ * quick that a viewer loses it. Carried over from the groundwork takes the
81
+ * camera presets were judged on.
82
+ */
83
+ export function travelMs(from: Point, to: Point): number {
84
+ return Math.min(
85
+ 1200,
86
+ Math.max(300, Math.hypot(to.x - from.x, to.y - from.y) * 0.9),
87
+ )
88
+ }
89
+
90
+ export function easeInOutCubic(t: number): number {
91
+ const x = Math.max(0, Math.min(1, t))
92
+ return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2
93
+ }
94
+
95
+ export function along(from: Point, to: Point, progress: number): Point {
96
+ const k = easeInOutCubic(progress)
97
+ return { x: from.x + (to.x - from.x) * k, y: from.y + (to.y - from.y) * k }
98
+ }
99
+
100
+ /** The pointer's position at a time on the clip's clock, read off the moves. */
101
+ export function cursorAt(events: readonly LogEvent[], ms: number): Point {
102
+ let position = START
103
+ for (const event of events) {
104
+ if (event.kind !== 'move') continue
105
+ if (ms < event.t) break
106
+ position = along(event.from, event.to, (ms - event.t) / event.ms)
107
+ }
108
+ return position
109
+ }
@@ -0,0 +1,200 @@
1
+ import {
2
+ type Box,
3
+ FPS,
4
+ type LogEvent,
5
+ type Point,
6
+ along,
7
+ travelMs,
8
+ } from '@/take/log'
9
+ import type { TakeStep } from '@/take/plan'
10
+
11
+ /**
12
+ * Performs a beat's steps through a stage and logs what the edit needs. The
13
+ * choreography sits here once, and each capture route supplies the stage, being
14
+ * how an input reaches the page and how clip time passes, so a beat moves the
15
+ * same way whichever route filmed it.
16
+ */
17
+
18
+ const FRAME_MS = 1000 / FPS
19
+
20
+ /** The gap between keys, slow enough that a viewer reads each one land. */
21
+ const KEY_MS = 90
22
+
23
+ /** How long a scroll's wheel travel runs. */
24
+ const SCROLL_MS = 900
25
+
26
+ export type PointerInput =
27
+ | {
28
+ readonly type: 'move' | 'down' | 'up'
29
+ readonly x: number
30
+ readonly y: number
31
+ }
32
+ | {
33
+ readonly type: 'wheel'
34
+ readonly x: number
35
+ readonly y: number
36
+ readonly dy: number
37
+ }
38
+
39
+ export interface Stage {
40
+ /** Milliseconds on the clip's own clock. */
41
+ now(): number
42
+ /** Lets `ms` of clip time pass. */
43
+ hold(ms: number): Promise<void>
44
+ /** Sends one pointer input and lets one frame pass. */
45
+ pointer(input: PointerInput): Promise<void>
46
+ /** Types one character into the focused field and lets one frame pass. */
47
+ key(text: string): Promise<void>
48
+ /** Evaluates an expression in the page. */
49
+ evaluate<T>(expression: string): Promise<T>
50
+ }
51
+
52
+ /** A target that resolved to nothing, which refuses rather than films a guess. */
53
+ export class TargetMissing extends Error {
54
+ constructor(readonly target: string) {
55
+ super(`nothing on the page matches ${target}`)
56
+ }
57
+ }
58
+
59
+ export interface Performance {
60
+ pointer: Point
61
+ /** Omitted for setup steps, which run before the clip starts. */
62
+ readonly log?: LogEvent[]
63
+ }
64
+
65
+ export async function performSteps(
66
+ stage: Stage,
67
+ steps: readonly TakeStep[],
68
+ state: Performance,
69
+ ): Promise<void> {
70
+ for (const step of steps) {
71
+ switch (step.kind) {
72
+ case 'move':
73
+ await moveTo(
74
+ stage,
75
+ state,
76
+ 'target' in step ? center(await boxOf(stage, step.target)) : step,
77
+ )
78
+ break
79
+ case 'click':
80
+ await click(stage, state, await boxOf(stage, step.target))
81
+ break
82
+ case 'fill': {
83
+ const box = await boxOf(stage, step.target)
84
+ await click(stage, state, box)
85
+ const start = stage.now()
86
+ for (const character of step.text) {
87
+ await stage.key(character)
88
+ await stage.hold(KEY_MS - FRAME_MS)
89
+ }
90
+ state.log?.push({
91
+ t: start,
92
+ kind: 'type',
93
+ at: state.pointer,
94
+ box,
95
+ chars: step.text.length,
96
+ ms: stage.now() - start,
97
+ })
98
+ break
99
+ }
100
+ case 'scroll': {
101
+ const at = center(await boxOf(stage, step.target))
102
+ await moveTo(stage, state, at)
103
+ const frames = Math.round(SCROLL_MS / FRAME_MS)
104
+ state.log?.push({
105
+ t: stage.now(),
106
+ kind: 'scroll',
107
+ at,
108
+ dx: 0,
109
+ dy: step.dy,
110
+ ms: frames * FRAME_MS,
111
+ })
112
+ let sent = 0
113
+ for (let frame = 1; frame <= frames; frame += 1) {
114
+ const want = along(
115
+ { x: 0, y: 0 },
116
+ { x: 0, y: step.dy },
117
+ frame / frames,
118
+ ).y
119
+ await stage.pointer({ type: 'wheel', ...at, dy: want - sent })
120
+ sent = want
121
+ }
122
+ break
123
+ }
124
+ case 'wait':
125
+ await stage.hold(step.ms)
126
+ break
127
+ }
128
+ }
129
+ }
130
+
131
+ async function moveTo(
132
+ stage: Stage,
133
+ state: Performance,
134
+ to: Point,
135
+ ): Promise<void> {
136
+ const from = state.pointer
137
+ if (Math.hypot(to.x - from.x, to.y - from.y) < 1) return
138
+ const frames = Math.max(1, Math.round(travelMs(from, to) / FRAME_MS))
139
+ state.log?.push({
140
+ t: stage.now(),
141
+ kind: 'move',
142
+ from,
143
+ to,
144
+ ms: frames * FRAME_MS,
145
+ })
146
+ for (let frame = 1; frame <= frames; frame += 1) {
147
+ state.pointer = along(from, to, frame / frames)
148
+ await stage.pointer({ type: 'move', ...state.pointer })
149
+ }
150
+ state.pointer = to
151
+ }
152
+
153
+ async function click(
154
+ stage: Stage,
155
+ state: Performance,
156
+ box: Box,
157
+ ): Promise<void> {
158
+ const at = center(box)
159
+ 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 })
163
+ }
164
+
165
+ function center(box: Box): Point {
166
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
167
+ }
168
+
169
+ async function boxOf(stage: Stage, target: string): Promise<Box> {
170
+ const box = await stage.evaluate<Box | null>(boxExpression(target))
171
+ if (!box) throw new TargetMissing(target)
172
+ return box
173
+ }
174
+
175
+ /**
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.
178
+ * It runs inside the page so both routes resolve a target the same way, and a
179
+ * match with no size counts as missing, since a click on it would land on
180
+ * whatever sits behind.
181
+ */
182
+ export function boxExpression(target: string): string {
183
+ return `(() => {
184
+ const target = ${JSON.stringify(target)}
185
+ let element = null
186
+ if (target.startsWith('text=')) {
187
+ const want = target.slice(5).trim()
188
+ const matches = [...document.querySelectorAll('body *')].filter(
189
+ (node) => node.textContent.trim() === want,
190
+ )
191
+ element = matches.find((node) => !matches.some((other) => other !== node && node.contains(other))) ?? null
192
+ } else {
193
+ element = document.querySelector(target)
194
+ }
195
+ if (!element) return null
196
+ const rect = element.getBoundingClientRect()
197
+ if (rect.width === 0 || rect.height === 0) return null
198
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
199
+ })()`
200
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The take plan: the beats a take films, each a list of steps. It reuses
3
+ * `canon drive`'s kind names for the steps the two share and adds `move`,
4
+ * since a take films the pointer's travel and a drive skips it. `tab` is left
5
+ * out because a take draws its cursor from the log, and focus moved by the
6
+ * keyboard leaves nothing in the log to draw.
7
+ */
8
+
9
+ /** How a beat is filmed: frame stepping, or Playwright's real-time screencast. */
10
+ export const ROUTES = ['step', 'screencast'] as const
11
+
12
+ export type Route = (typeof ROUTES)[number]
13
+
14
+ export type TakeStep =
15
+ | { readonly kind: 'move'; readonly target: string }
16
+ | { readonly kind: 'move'; readonly x: number; readonly y: number }
17
+ | { readonly kind: 'click'; readonly target: string }
18
+ | { readonly kind: 'fill'; readonly target: string; readonly text: string }
19
+ | { readonly kind: 'scroll'; readonly target: string; readonly dy: number }
20
+ | { readonly kind: 'wait'; readonly ms: number }
21
+
22
+ export interface Beat {
23
+ readonly name: string
24
+ /**
25
+ * The route asked for. A `step` beat that frame stepping cannot drive falls
26
+ * back to `screencast` on its own, and the take's record says which ran.
27
+ */
28
+ readonly route: Route
29
+ /** Runs after the page loads and before the first frame is kept. */
30
+ readonly setup: readonly TakeStep[]
31
+ readonly steps: readonly TakeStep[]
32
+ }
33
+
34
+ export interface TakePlan {
35
+ readonly beats: readonly Beat[]
36
+ }
37
+
38
+ export type TakePlanRefusal =
39
+ | 'unreadable-plan'
40
+ | 'no-beats'
41
+ | 'bad-beat'
42
+ | 'no-steps'
43
+ | 'unknown-route'
44
+ | 'bad-step'
45
+
46
+ export type TakePlanRead =
47
+ | { readonly kind: 'read'; readonly plan: TakePlan }
48
+ | {
49
+ readonly kind: 'refused'
50
+ readonly reason: TakePlanRefusal
51
+ readonly detail: string
52
+ }
53
+
54
+ class Refusal extends Error {
55
+ constructor(
56
+ readonly reason: TakePlanRefusal,
57
+ detail: string,
58
+ ) {
59
+ super(detail)
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Reads a take plan out of a JSON document, the same form `canon drive` reads
65
+ * its run in, refusing the first thing a take could not perform.
66
+ */
67
+ export function readTakePlan(source: string): TakePlanRead {
68
+ let document: unknown
69
+ try {
70
+ document = JSON.parse(source)
71
+ } catch (error) {
72
+ return {
73
+ kind: 'refused',
74
+ reason: 'unreadable-plan',
75
+ detail: error instanceof Error ? error.message : String(error),
76
+ }
77
+ }
78
+
79
+ try {
80
+ return { kind: 'read', plan: readPlan(document) }
81
+ } catch (error) {
82
+ if (!(error instanceof Refusal)) throw error
83
+ return { kind: 'refused', reason: error.reason, detail: error.message }
84
+ }
85
+ }
86
+
87
+ function readPlan(document: unknown): TakePlan {
88
+ if (!isRecord(document)) {
89
+ throw new Refusal('unreadable-plan', 'the document is not an object')
90
+ }
91
+ const { beats } = document
92
+ if (!Array.isArray(beats) || beats.length === 0) {
93
+ throw new Refusal(
94
+ 'no-beats',
95
+ 'beats carries no entry, so nothing is filmed',
96
+ )
97
+ }
98
+ return { beats: beats.map((entry, index) => readBeat(entry, index)) }
99
+ }
100
+
101
+ function readBeat(entry: unknown, index: number): Beat {
102
+ const at = `beat ${index + 1}`
103
+ if (!isRecord(entry)) throw new Refusal('bad-beat', `${at} is not an object`)
104
+
105
+ const name = typeof entry.name === 'string' ? entry.name : ''
106
+ if (!name) {
107
+ throw new Refusal(
108
+ 'bad-beat',
109
+ `${at} carries no name, and its clip is named by it`,
110
+ )
111
+ }
112
+
113
+ const route = entry.route ?? 'step'
114
+ if (!ROUTES.includes(route as Route)) {
115
+ throw new Refusal(
116
+ 'unknown-route',
117
+ `${name} names route ${String(route)}. This build records on: ${ROUTES.join(', ')}.`,
118
+ )
119
+ }
120
+
121
+ if (!Array.isArray(entry.steps) || entry.steps.length === 0) {
122
+ throw new Refusal(
123
+ 'no-steps',
124
+ `${name} names no step, so its take would film nothing`,
125
+ )
126
+ }
127
+ if (entry.setup !== undefined && !Array.isArray(entry.setup)) {
128
+ throw new Refusal('bad-beat', `${name} carries a setup that is not a list`)
129
+ }
130
+
131
+ return {
132
+ name,
133
+ route: route as Route,
134
+ setup: (entry.setup ?? []).map((step: unknown, i: number) =>
135
+ readStep(step, `${name}, setup step ${i + 1}`),
136
+ ),
137
+ steps: entry.steps.map((step: unknown, i: number) =>
138
+ readStep(step, `${name}, step ${i + 1}`),
139
+ ),
140
+ }
141
+ }
142
+
143
+ function readStep(entry: unknown, at: string): TakeStep {
144
+ if (!isRecord(entry)) throw new Refusal('bad-step', `${at} is not an object`)
145
+
146
+ const target = typeof entry.target === 'string' ? entry.target : undefined
147
+ const needTarget = (): string => {
148
+ if (target) return target
149
+ throw new Refusal('bad-step', `${at} is a ${entry.kind} with no target`)
150
+ }
151
+
152
+ switch (entry.kind) {
153
+ case 'move':
154
+ if (target) return { kind: 'move', target }
155
+ if (isNumber(entry.x) && isNumber(entry.y)) {
156
+ return { kind: 'move', x: entry.x, y: entry.y }
157
+ }
158
+ throw new Refusal(
159
+ 'bad-step',
160
+ `${at} is a move with neither a target nor an x and y`,
161
+ )
162
+ case 'click':
163
+ return { kind: 'click', target: needTarget() }
164
+ case 'fill':
165
+ if (typeof entry.text !== 'string') {
166
+ throw new Refusal('bad-step', `${at} is a fill with no text`)
167
+ }
168
+ return { kind: 'fill', target: needTarget(), text: entry.text }
169
+ case 'scroll':
170
+ if (!isNumber(entry.dy)) {
171
+ throw new Refusal('bad-step', `${at} is a scroll with no dy in pixels`)
172
+ }
173
+ return { kind: 'scroll', target: needTarget(), dy: entry.dy }
174
+ case 'wait':
175
+ if (!isNumber(entry.ms) || entry.ms < 0) {
176
+ throw new Refusal('bad-step', `${at} is a wait with no ms`)
177
+ }
178
+ return { kind: 'wait', ms: entry.ms }
179
+ default:
180
+ throw new Refusal(
181
+ 'bad-step',
182
+ `${at} names kind ${String(entry.kind)}, which is not move, click, fill, scroll, or wait`,
183
+ )
184
+ }
185
+ }
186
+
187
+ function isRecord(value: unknown): value is Record<string, unknown> {
188
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
189
+ }
190
+
191
+ function isNumber(value: unknown): value is number {
192
+ return typeof value === 'number' && Number.isFinite(value)
193
+ }
@@ -0,0 +1,94 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { chromium } from 'playwright-core'
5
+ import { probeFrames, transcode } from '@/take/encode'
6
+ import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
7
+ import { type PointerInput, type Stage, performSteps } from '@/take/perform'
8
+ import type { Beat } from '@/take/plan'
9
+ import { HOLD_MS } from '@/take/stepper'
10
+
11
+ /**
12
+ * Films a beat in real time through Playwright's screencast, for a page frame
13
+ * stepping cannot drive. It runs at the viewport's own 1920x1080, so it goes
14
+ * soft under a zoom where a stepped take stays sharp, and its frames land when
15
+ * the page paints rather than one per step.
16
+ */
17
+
18
+ const FRAME_MS = 1000 / FPS
19
+
20
+ const SETTLE_MS = 2500
21
+
22
+ export interface ScreencastTake {
23
+ readonly frames: number
24
+ readonly events: readonly LogEvent[]
25
+ }
26
+
27
+ export async function filmScreencast(request: {
28
+ readonly url: string
29
+ readonly beat: Beat
30
+ readonly clip: string
31
+ }): Promise<ScreencastTake> {
32
+ const scratch = mkdtempSync(join(tmpdir(), 'canon-take-cast-'))
33
+ const browser = await chromium.launch()
34
+ try {
35
+ const page = await browser.newPage({ viewport: VIEWPORT })
36
+ await page.goto(request.url, { waitUntil: 'load' })
37
+ await page.waitForTimeout(SETTLE_MS)
38
+
39
+ const stageOn = (clock: () => number): Stage => ({
40
+ now: clock,
41
+ hold: (ms) => page.waitForTimeout(ms),
42
+ pointer: async (input) => {
43
+ const sent = clock()
44
+ await send(page.mouse, input)
45
+ const spent = clock() - sent
46
+ if (spent < FRAME_MS) await page.waitForTimeout(FRAME_MS - spent)
47
+ },
48
+ key: async (text) => {
49
+ await page.keyboard.insertText(text)
50
+ await page.waitForTimeout(FRAME_MS)
51
+ },
52
+ evaluate: (expression) => page.evaluate(expression),
53
+ })
54
+
55
+ const live = stageOn(() => performance.now())
56
+ await performSteps(live, request.beat.setup, { pointer: START })
57
+ if (request.beat.setup.length > 0) await live.hold(1000)
58
+ await page.mouse.move(START.x, START.y)
59
+
60
+ const webm = join(scratch, 'take.webm')
61
+ const events: LogEvent[] = []
62
+ await page.screencast.start({ path: webm, size: VIEWPORT, fps: FPS })
63
+ const started = performance.now()
64
+ const recording = stageOn(() => performance.now() - started)
65
+ await recording.hold(HOLD_MS)
66
+ await performSteps(recording, request.beat.steps, {
67
+ pointer: START,
68
+ log: events,
69
+ })
70
+ await recording.hold(HOLD_MS)
71
+ await page.screencast.stop()
72
+
73
+ await transcode(webm, request.clip, FPS)
74
+ return { frames: await probeFrames(request.clip), events }
75
+ } finally {
76
+ await browser.close()
77
+ rmSync(scratch, { recursive: true, force: true })
78
+ }
79
+ }
80
+
81
+ type Mouse = import('playwright-core').Mouse
82
+
83
+ async function send(mouse: Mouse, input: PointerInput): Promise<void> {
84
+ switch (input.type) {
85
+ case 'move':
86
+ return mouse.move(input.x, input.y)
87
+ case 'down':
88
+ return mouse.down()
89
+ case 'up':
90
+ return mouse.up()
91
+ case 'wheel':
92
+ return mouse.wheel(0, input.dy)
93
+ }
94
+ }