@erclx/aitk 3.6.0 → 3.8.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 (59) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/claude-autoship/SKILL.md +5 -5
  3. package/claude/skills/claude-docs/SKILL.md +15 -10
  4. package/claude/skills/claude-memory-review/SKILL.md +7 -7
  5. package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
  6. package/claude/skills/claude-orchestrate/SKILL.md +2 -2
  7. package/claude/skills/claude-pr-review/SKILL.md +25 -7
  8. package/claude/skills/claude-review/SKILL.md +5 -3
  9. package/claude/skills/claude-screencast/SKILL.md +9 -4
  10. package/claude/skills/claude-tasks/SKILL.md +2 -2
  11. package/claude/skills/create-rule/REQUIREMENT.md +2 -1
  12. package/claude/skills/create-rule/SKILL.md +8 -8
  13. package/claude/skills/create-snippet/REQUIREMENT.md +3 -0
  14. package/claude/skills/create-snippet/SKILL.md +2 -2
  15. package/claude/skills/git-pr/references/pr.md +3 -0
  16. package/claude/skills/git-ship/SKILL.md +1 -1
  17. package/claude/skills/git-split/references/pr.md +3 -0
  18. package/claude/skills/restate/REQUIREMENT.md +41 -0
  19. package/claude/skills/restate/SKILL.md +39 -0
  20. package/claude/skills/toolkit-feedback/SKILL.md +2 -2
  21. package/claude/skills/write-human/REQUIREMENT.md +1 -1
  22. package/claude/skills/write-human/SKILL.md +1 -1
  23. package/docs/agents/capture.md +3 -1
  24. package/docs/agents/commands.md +25 -21
  25. package/docs/agents/demo.md +82 -0
  26. package/docs/agents/index.md +2 -0
  27. package/docs/agents/install-and-sync.md +6 -2
  28. package/docs/agents/records.md +2 -2
  29. package/docs/agents/routing.md +61 -0
  30. package/docs/agents/tasks.md +1 -1
  31. package/docs/ai-workflow.md +8 -5
  32. package/docs/operating-model.md +13 -4
  33. package/governance/rules/claude/558-plan.md +1 -2
  34. package/governance/rules/lib/300-testing-ts.md +1 -0
  35. package/package.json +3 -2
  36. package/src/claude/routing.ts +283 -0
  37. package/src/cli.ts +4 -1
  38. package/src/commands/claude.ts +130 -1
  39. package/src/commands/demo.ts +373 -0
  40. package/src/commands/feedback.ts +10 -3
  41. package/src/commands/tasks.ts +1 -1
  42. package/src/demo/beats.ts +135 -0
  43. package/src/demo/compile.ts +295 -0
  44. package/src/demo/cursors.ts +55 -0
  45. package/src/demo/drive.ts +256 -0
  46. package/src/demo/pointer.ts +178 -0
  47. package/src/demo/theme.ts +112 -0
  48. package/src/gov/adapter.ts +1 -0
  49. package/src/records/backup.ts +34 -8
  50. package/src/snippets/adapter.ts +1 -0
  51. package/src/sync/engine.ts +25 -1
  52. package/src/tasks/archive.ts +11 -4
  53. package/standards/bundled/pr.md +3 -0
  54. package/standards/plan.md +1 -1
  55. package/standards/tasks.md +4 -4
  56. package/tooling/claude/manifest.toml +1 -1
  57. package/tooling/claude/reference.md +6 -5
  58. package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +4 -1
  59. package/tooling/claude/seeds/CLAUDE.md +1 -1
@@ -0,0 +1,295 @@
1
+ import type { Beat, Draft } from '@/demo/beats'
2
+
3
+ /**
4
+ * Turns the human-facing draft into the machine-facing plan. The two are
5
+ * separate artifacts on purpose: a beat carries no target, no wait condition,
6
+ * and no timing, and putting those four fields on every beat would destroy the
7
+ * property the draft was designed around. See
8
+ * `.claude/groundwork/demo-recorder/06-decision.md`.
9
+ *
10
+ * A compiled plan is committed rather than scratch, because the timing below is
11
+ * a starting point the operator tunes and the draft cannot reproduce a tuned
12
+ * value.
13
+ */
14
+
15
+ /** Hand-tuned in spike 2 against one fixture. Nothing establishes them in general. */
16
+ const POINTER_STEPS = 45
17
+ const TYPE_DELAY_MS = 110
18
+ const HOLD_MS = 600
19
+ const FINAL_HOLD_MS = 1200
20
+
21
+ const VIEWPORT = { width: 1280, height: 720 } as const
22
+ const ANNOTATIONS = {
23
+ durationMs: 900,
24
+ position: 'bottom-right',
25
+ fontSize: 22,
26
+ } as const
27
+
28
+ export type StepKind =
29
+ | 'navigate'
30
+ | 'click'
31
+ | 'fill'
32
+ | 'hover'
33
+ | 'scroll'
34
+ | 'wait'
35
+ | 'hold'
36
+
37
+ /** Verbs that point at an element, so a plan without a target cannot run. */
38
+ const TARGETED: ReadonlySet<StepKind> = new Set<StepKind>([
39
+ 'click',
40
+ 'fill',
41
+ 'hover',
42
+ 'scroll',
43
+ ])
44
+
45
+ const VERBS: Record<string, StepKind> = {
46
+ navigate: 'navigate',
47
+ open: 'navigate',
48
+ visit: 'navigate',
49
+ load: 'navigate',
50
+ click: 'click',
51
+ press: 'click',
52
+ tap: 'click',
53
+ submit: 'click',
54
+ toggle: 'click',
55
+ type: 'fill',
56
+ fill: 'fill',
57
+ enter: 'fill',
58
+ input: 'fill',
59
+ hover: 'hover',
60
+ scroll: 'scroll',
61
+ wait: 'wait',
62
+ pause: 'wait',
63
+ settle: 'wait',
64
+ }
65
+
66
+ export interface DemoStep {
67
+ readonly beat: number
68
+ readonly name: string
69
+ readonly kind: StepKind
70
+ readonly target: string
71
+ readonly text: string
72
+ readonly waitFor: string
73
+ readonly holdMs: number
74
+ readonly caption: string
75
+ readonly still: boolean
76
+ readonly note?: string
77
+ }
78
+
79
+ export interface DemoPlan {
80
+ readonly slug: string
81
+ readonly title: string
82
+ readonly url: string
83
+ readonly viewport: { readonly width: number; readonly height: number }
84
+ readonly output: { readonly video: string; readonly still: string }
85
+ readonly pointer: { readonly steps: number; readonly typeDelayMs: number }
86
+ readonly annotations: typeof ANNOTATIONS
87
+ readonly steps: readonly DemoStep[]
88
+ }
89
+
90
+ export interface CompileOptions {
91
+ readonly slug: string
92
+ readonly outDir: string
93
+ }
94
+
95
+ export function compilePlan(draft: Draft, options: CompileOptions): DemoPlan {
96
+ const stillAt = heroIndex(draft.beats)
97
+ return {
98
+ slug: options.slug,
99
+ title: draft.title,
100
+ url: '',
101
+ viewport: VIEWPORT,
102
+ output: {
103
+ video: `${options.outDir}/${options.slug}.webm`,
104
+ still: `${options.outDir}/${options.slug}.png`,
105
+ },
106
+ pointer: { steps: POINTER_STEPS, typeDelayMs: TYPE_DELAY_MS },
107
+ annotations: ANNOTATIONS,
108
+ steps: draft.beats.map((beat, position) =>
109
+ compileStep(beat, {
110
+ still: position === stillAt,
111
+ last: position === draft.beats.length - 1,
112
+ }),
113
+ ),
114
+ }
115
+ }
116
+
117
+ function compileStep(
118
+ beat: Beat,
119
+ place: { still: boolean; last: boolean },
120
+ ): DemoStep {
121
+ const verb = firstVerb(beat.action)
122
+ const kind = verb ? VERBS[verb] : undefined
123
+ const step: DemoStep = {
124
+ beat: beat.index,
125
+ name: beat.name,
126
+ kind: kind ?? 'hold',
127
+ target: '',
128
+ text: '',
129
+ waitFor: '',
130
+ holdMs: place.last ? FINAL_HOLD_MS : HOLD_MS,
131
+ caption: beat.caption,
132
+ still: place.still,
133
+ }
134
+
135
+ if (kind) return step
136
+ return {
137
+ ...step,
138
+ note: `no step maps to the verb "${beat.action.trim()}", so this beat only waits`,
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The draft specifies one verb per beat, and a hand-edited beat reads as a
144
+ * phrase often enough that matching only the whole field would refuse work a
145
+ * reader can see is a click. The first recognized word wins.
146
+ */
147
+ function firstVerb(action: string): string | undefined {
148
+ return action
149
+ .toLowerCase()
150
+ .split(/[^a-z]+/)
151
+ .find((word) => word in VERBS)
152
+ }
153
+
154
+ /**
155
+ * The still is a landing frame, so it wants the beat the draft calls the hero
156
+ * rather than an arbitrary one. Falling back to the last beat rather than the
157
+ * first is deliberate: a demo's final state is the payoff, and a cold open is
158
+ * usually an empty screen.
159
+ */
160
+ function heroIndex(beats: readonly Beat[]): number {
161
+ const named = beats.findIndex((beat) => /hero/i.test(beat.name))
162
+ return named === -1 ? beats.length - 1 : named
163
+ }
164
+
165
+ export type PlanParse =
166
+ | { status: 'parsed'; plan: DemoPlan }
167
+ | { status: 'failed'; reason: string }
168
+
169
+ /**
170
+ * Validates a plan read off disk. The file is hand-edited between being
171
+ * compiled and being run, which is the whole reason it is committed rather than
172
+ * regenerated, so every field is checked here rather than trusted.
173
+ *
174
+ * An absent field takes the seeded default and a present one is kept, so a
175
+ * plan that dropped a timing block still runs while a tuned one is never
176
+ * overwritten.
177
+ */
178
+ export function parsePlan(text: string): PlanParse {
179
+ let raw: unknown
180
+ try {
181
+ raw = JSON.parse(text)
182
+ } catch (error) {
183
+ return {
184
+ status: 'failed',
185
+ reason: error instanceof Error ? error.message : 'unreadable json',
186
+ }
187
+ }
188
+
189
+ if (!isRecord(raw)) return { status: 'failed', reason: 'not a json object' }
190
+ if (!Array.isArray(raw.steps))
191
+ return { status: 'failed', reason: 'steps is not an array' }
192
+ if (!raw.steps.length) return { status: 'failed', reason: 'steps is empty' }
193
+
194
+ const steps: DemoStep[] = []
195
+ for (const [index, entry] of raw.steps.entries()) {
196
+ const step = parseStep(entry, index)
197
+ if ('reason' in step) return { status: 'failed', reason: step.reason }
198
+ steps.push(step.step)
199
+ }
200
+
201
+ const slug = asText(raw.slug)
202
+ const output = isRecord(raw.output) ? raw.output : {}
203
+ const pointer = isRecord(raw.pointer) ? raw.pointer : {}
204
+ const viewport = isRecord(raw.viewport) ? raw.viewport : {}
205
+
206
+ return {
207
+ status: 'parsed',
208
+ plan: {
209
+ slug,
210
+ title: asText(raw.title),
211
+ url: asText(raw.url),
212
+ viewport: {
213
+ width: asNumber(viewport.width, VIEWPORT.width),
214
+ height: asNumber(viewport.height, VIEWPORT.height),
215
+ },
216
+ output: {
217
+ video: asText(output.video) || `demos/${slug || 'demo'}.webm`,
218
+ still: asText(output.still) || `demos/${slug || 'demo'}.png`,
219
+ },
220
+ pointer: {
221
+ steps: asNumber(pointer.steps, POINTER_STEPS),
222
+ typeDelayMs: asNumber(pointer.typeDelayMs, TYPE_DELAY_MS),
223
+ },
224
+ annotations: ANNOTATIONS,
225
+ steps,
226
+ },
227
+ }
228
+ }
229
+
230
+ function parseStep(
231
+ entry: unknown,
232
+ index: number,
233
+ ): { step: DemoStep } | { reason: string } {
234
+ if (!isRecord(entry)) return { reason: `steps[${index}] is not an object` }
235
+
236
+ const kind = asText(entry.kind)
237
+ if (!isStepKind(kind)) {
238
+ return {
239
+ reason: `steps[${index}].kind is "${kind}", which is not a step this can drive`,
240
+ }
241
+ }
242
+
243
+ const step: DemoStep = {
244
+ beat: asNumber(entry.beat, index + 1),
245
+ name: asText(entry.name),
246
+ kind,
247
+ target: asText(entry.target),
248
+ text: asText(entry.text),
249
+ waitFor: asText(entry.waitFor),
250
+ holdMs: asNumber(entry.holdMs, HOLD_MS),
251
+ caption: asText(entry.caption),
252
+ still: entry.still === true,
253
+ }
254
+ const note = asText(entry.note)
255
+ return { step: note ? { ...step, note } : step }
256
+ }
257
+
258
+ function isStepKind(value: string): value is StepKind {
259
+ return (
260
+ value === 'navigate' ||
261
+ value === 'click' ||
262
+ value === 'fill' ||
263
+ value === 'hover' ||
264
+ value === 'scroll' ||
265
+ value === 'wait' ||
266
+ value === 'hold'
267
+ )
268
+ }
269
+
270
+ function isRecord(value: unknown): value is Record<string, unknown> {
271
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
272
+ }
273
+
274
+ function asText(value: unknown): string {
275
+ return typeof value === 'string' ? value : ''
276
+ }
277
+
278
+ function asNumber(value: unknown, fallback: number): number {
279
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
280
+ }
281
+
282
+ /**
283
+ * Names every field a person still has to fill before the plan can drive
284
+ * anything. Compile reports these and run refuses on them, so an unfilled plan
285
+ * fails at the point it is written rather than part-way through a recording.
286
+ */
287
+ export function unresolved(plan: DemoPlan): string[] {
288
+ const missing: string[] = plan.url.trim() ? [] : ['url']
289
+ plan.steps.forEach((step, index) => {
290
+ if (TARGETED.has(step.kind) && !step.target.trim()) {
291
+ missing.push(`steps[${index}].target`)
292
+ }
293
+ })
294
+ return missing
295
+ }
@@ -0,0 +1,55 @@
1
+ import type { CursorSet } from '@/demo/pointer'
2
+
3
+ /**
4
+ * The bundled pointer artwork, drawn as vector markup rather than read from a
5
+ * theme on disk. Spike 3 proved the browser decodes a Windows cursor resource
6
+ * directly, so `--cursor` points at a theme folder and gets that path instead.
7
+ * This set is what makes the command work in a target that has no theme to
8
+ * point at, which is every target on first run.
9
+ *
10
+ * Each drawing sits in a 48 by 48 box so one hotspot scale factor covers the
11
+ * set, and each carries a drop shadow so it stays visible over a light surface
12
+ * and a dark one.
13
+ */
14
+
15
+ const SHADOW =
16
+ '<filter id="s" x="-50%" y="-50%" width="200%" height="200%">' +
17
+ '<feDropShadow dx="0" dy="1" stdDeviation="1.2" flood-opacity="0.45"/></filter>'
18
+
19
+ function svg(body: string): string {
20
+ return `data:image/svg+xml;utf8,${encodeURIComponent(
21
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">${SHADOW}<g filter="url(#s)">${body}</g></svg>`,
22
+ )}`
23
+ }
24
+
25
+ const STROKE = 'fill="#ffffff" stroke="#1b1b1b" stroke-width="2.2"'
26
+
27
+ /**
28
+ * Hotspots are stated against the 48 by 48 box each drawing uses, so the
29
+ * pointer scales them the same way it scales a hotspot read off a real cursor
30
+ * resource. Skipping the scale offsets the artwork by roughly a third of a
31
+ * cursor, which is the defect spike 3 recorded.
32
+ */
33
+ export const DEFAULT_CURSORS: CursorSet = {
34
+ default: {
35
+ image: svg(
36
+ `<path ${STROKE} d="M5 3 L5 35 L13.5 27 L19 39.5 L25 37 L19.5 25 L31 24.5 Z"/>`,
37
+ ),
38
+ hotspot: { width: 48, height: 48, hotspotX: 5, hotspotY: 3 },
39
+ },
40
+ pointer: {
41
+ image: svg(
42
+ `<path ${STROKE} d="M18 4 a3.2 3.2 0 0 1 6.4 0 v14 a3 3 0 0 1 5.6 0 v2 a3 3 0 0 1 5.6 0 v2 a3 3 0 0 1 5.4 0 v9 a12 12 0 0 1 -12 12 h-6 a12 12 0 0 1 -12 -12 v-9 a3.2 3.2 0 0 1 6.4 0 z"/>`,
43
+ ),
44
+ hotspot: { width: 48, height: 48, hotspotX: 21, hotspotY: 4 },
45
+ },
46
+ // Drawn as one filled outline rather than three stroked segments. A stroked
47
+ // version needs its own fill, and a second fill attribute on a path carrying
48
+ // STROKE makes the markup invalid, which renders as a broken image.
49
+ text: {
50
+ image: svg(
51
+ `<path ${STROKE} d="M18 5 h12 v3.5 h-4 v31 h4 V43 h-12 v-3.5 h4 v-31 h-4 z"/>`,
52
+ ),
53
+ hotspot: { width: 48, height: 48, hotspotX: 24, hotspotY: 24 },
54
+ },
55
+ }
@@ -0,0 +1,256 @@
1
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import { chromium } from 'playwright-core'
5
+ import type { Browser, BrowserContext, Page } from 'playwright-core'
6
+ import type { DemoPlan, DemoStep } from '@/demo/compile'
7
+ import type { CursorSet } from '@/demo/pointer'
8
+ import { pointerSource } from '@/demo/pointer'
9
+
10
+ /**
11
+ * Drives a running application and records what it did. Every browser reference
12
+ * the demo feature adds lives here, and `src/commands/demo.ts` reaches it
13
+ * through a dynamic import so no other command resolves the engine at startup.
14
+ *
15
+ * Unlike `@/capture/render`, this module ships. The capture command is excluded
16
+ * from the published package because it regenerates images committed to this
17
+ * repository, and that reason does not transfer to a command whose whole
18
+ * purpose is running in someone else's project.
19
+ *
20
+ * It imports `playwright-core` rather than `@playwright/test`, which stays a
21
+ * development dependency for the capture module. Shipping puts the import in
22
+ * every target's dependency tree, and a target needs the driver rather than a
23
+ * test runner and an assertion library. Both are pinned to one version rather
24
+ * than a range, because `bunx playwright install chromium` fetches the browser
25
+ * revision the installed engine expects and a float would leave a target
26
+ * resolving a binary its engine cannot launch.
27
+ */
28
+
29
+ const POINTER_SIZE = 32
30
+ /**
31
+ * Where the pointer starts. Any position inside the viewport works, since the
32
+ * point is giving it one move to install and paint before it travels to the
33
+ * first target. A corner keeps that first move out of the way of the content.
34
+ */
35
+ const START = { x: 8, y: 8 }
36
+ const SETTLE_MS = 250
37
+
38
+ /**
39
+ * The two output paths arrive resolved rather than as a root this re-resolves
40
+ * against, because the plan already carries a directory and resolving it twice
41
+ * nests the whole path inside itself.
42
+ *
43
+ * An absent path means the caller asked for that artifact not to be produced.
44
+ */
45
+ export interface DriveOptions {
46
+ readonly plan: DemoPlan
47
+ readonly cursors: CursorSet
48
+ readonly videoPath?: string
49
+ readonly stillPath?: string
50
+ }
51
+
52
+ export type DriveResult =
53
+ | {
54
+ status: 'recorded'
55
+ videoPath?: string
56
+ stillPath?: string
57
+ steps: number
58
+ durationMs: number
59
+ }
60
+ | { status: 'failed'; reason: DriveRefusal; message: string }
61
+
62
+ export type DriveRefusal = 'browser-missing' | 'drive-failed'
63
+
64
+ interface DriveFailure {
65
+ readonly status: 'failed'
66
+ readonly reason: DriveRefusal
67
+ readonly message: string
68
+ }
69
+
70
+ type Launch = { status: 'launched'; value: Browser } | DriveFailure
71
+
72
+ export async function drive(options: DriveOptions): Promise<DriveResult> {
73
+ const { plan } = options
74
+
75
+ const browser = await launch()
76
+ if (browser.status === 'failed') return browser
77
+
78
+ let videoDir: string | undefined
79
+ let context: BrowserContext
80
+ const started = Date.now()
81
+ try {
82
+ // Created after the launch, so a target with no browser binary does not
83
+ // leave an empty directory behind for a run that never started, and inside
84
+ // the try so a failure here closes the browser rather than leaking it.
85
+ videoDir = options.videoPath
86
+ ? mkdtempSync(join(tmpdir(), 'aitk-demo-'))
87
+ : undefined
88
+
89
+ context = await browser.value.newContext({
90
+ viewport: plan.viewport,
91
+ // Pointed the opposite way from a test. A recording wants the motion the
92
+ // interface was designed with, where a test wants it suppressed.
93
+ reducedMotion: 'no-preference',
94
+ ...(videoDir
95
+ ? {
96
+ recordVideo: {
97
+ dir: videoDir,
98
+ size: plan.viewport,
99
+ showActions: {
100
+ duration: plan.annotations.durationMs,
101
+ position: plan.annotations.position,
102
+ fontSize: plan.annotations.fontSize,
103
+ },
104
+ },
105
+ }
106
+ : {}),
107
+ })
108
+ } catch (error) {
109
+ await browser.value.close()
110
+ if (videoDir) rmSync(videoDir, { recursive: true, force: true })
111
+ return failed('drive-failed', error)
112
+ }
113
+
114
+ let stillPath: string | undefined
115
+ let videoPath: string | undefined
116
+
117
+ try {
118
+ await context.addInitScript({
119
+ content: pointerSource(options.cursors, POINTER_SIZE),
120
+ })
121
+ const page = await context.newPage()
122
+ const video = page.video()
123
+
124
+ // The opening navigate is skipped when the plan already starts with one,
125
+ // because a draft written around an opening verb compiles to a `navigate`
126
+ // step for the same URL and the second load is a visible reload.
127
+ if (plan.steps[0]?.kind !== 'navigate') {
128
+ await page.goto(plan.url)
129
+ await page.mouse.move(START.x, START.y, { steps: 2 })
130
+ }
131
+
132
+ for (const step of plan.steps) {
133
+ await runStep(page, plan, step)
134
+ // The first marked step wins. One file holds one frame, so a plan a
135
+ // person edited to mark several would otherwise write each over the last
136
+ // and keep whichever ran last, with nothing saying so.
137
+ if (step.still && options.stillPath && !stillPath) {
138
+ stillPath = options.stillPath
139
+ mkdirSync(dirname(stillPath), { recursive: true })
140
+ await page.screenshot({ path: stillPath })
141
+ }
142
+ }
143
+
144
+ await context.close()
145
+
146
+ if (video && options.videoPath) {
147
+ videoPath = options.videoPath
148
+ mkdirSync(dirname(videoPath), { recursive: true })
149
+ await video.saveAs(videoPath)
150
+ // The engine keeps the auto-named recording beside the saved copy, so a
151
+ // run that skipped this would leave two files for every demo.
152
+ await video.delete()
153
+ }
154
+ } catch (error) {
155
+ return failed('drive-failed', error)
156
+ } finally {
157
+ await browser.value.close()
158
+ if (videoDir) rmSync(videoDir, { recursive: true, force: true })
159
+ }
160
+
161
+ return {
162
+ status: 'recorded',
163
+ ...(videoPath ? { videoPath } : {}),
164
+ ...(stillPath ? { stillPath } : {}),
165
+ steps: plan.steps.length,
166
+ durationMs: Date.now() - started,
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Separates a browser binary that was never installed from every other launch
172
+ * failure, because the first is a setup step the operator has to run and the
173
+ * second is a defect. A target inherits that setup step, which is the stated
174
+ * cost of shipping this command outside the toolkit.
175
+ */
176
+ async function launch(): Promise<Launch> {
177
+ try {
178
+ return { status: 'launched', value: await chromium.launch() }
179
+ } catch (error) {
180
+ const text = error instanceof Error ? error.message : String(error)
181
+ return failed(
182
+ /executable doesn't exist|playwright install/i.test(text)
183
+ ? 'browser-missing'
184
+ : 'drive-failed',
185
+ error,
186
+ )
187
+ }
188
+ }
189
+
190
+ async function runStep(
191
+ page: Page,
192
+ plan: DemoPlan,
193
+ step: DemoStep,
194
+ ): Promise<void> {
195
+ switch (step.kind) {
196
+ case 'navigate':
197
+ await page.goto(step.target || plan.url)
198
+ await page.mouse.move(START.x, START.y, { steps: 2 })
199
+ break
200
+ case 'click':
201
+ await moveTo(page, plan, step)
202
+ await page.mouse.down()
203
+ await page.mouse.up()
204
+ break
205
+ case 'fill':
206
+ await moveTo(page, plan, step)
207
+ await page.mouse.down()
208
+ await page.mouse.up()
209
+ await page.keyboard.type(step.text, { delay: plan.pointer.typeDelayMs })
210
+ break
211
+ case 'hover':
212
+ await moveTo(page, plan, step)
213
+ break
214
+ case 'scroll':
215
+ await page.locator(step.target).first().scrollIntoViewIfNeeded()
216
+ await page.waitForTimeout(SETTLE_MS)
217
+ await moveTo(page, plan, step)
218
+ break
219
+ case 'wait':
220
+ case 'hold':
221
+ break
222
+ }
223
+
224
+ if (step.waitFor) await page.locator(step.waitFor).first().waitFor()
225
+ await page.waitForTimeout(step.holdMs)
226
+ }
227
+
228
+ /**
229
+ * Travel is the whole point of driving the engine's pointer rather than calling
230
+ * the element-clicking helper, which resolves a target and jumps to it. The
231
+ * step count is what separates a cursor that glides from one that teleports.
232
+ *
233
+ * Resolving through a bounding box assumes the target is in the viewport, so a
234
+ * target below the fold needs a `scroll` step ahead of it.
235
+ */
236
+ async function moveTo(
237
+ page: Page,
238
+ plan: DemoPlan,
239
+ step: DemoStep,
240
+ ): Promise<void> {
241
+ const locator = page.locator(step.target).first()
242
+ await locator.waitFor()
243
+ const box = await locator.boundingBox()
244
+ if (!box) throw new Error(`${step.target} has no box to point at`)
245
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
246
+ steps: plan.pointer.steps,
247
+ })
248
+ }
249
+
250
+ function failed(reason: DriveRefusal, error: unknown): DriveFailure {
251
+ return {
252
+ status: 'failed',
253
+ reason,
254
+ message: error instanceof Error ? error.message : String(error),
255
+ }
256
+ }