@erclx/canon 5.68.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 (125) hide show
  1. package/CHANGELOG.md +36 -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/REQUIREMENT.md +5 -5
  6. package/claude/skills/app-drive/SKILL.md +17 -6
  7. package/claude/skills/app-drive/references/drive-record.md +61 -8
  8. package/claude/skills/app-drive/references/isolate.md +1 -1
  9. package/claude/skills/canon-cli/SKILL.md +2 -2
  10. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
  11. package/claude/skills/canon-feedback/SKILL.md +1 -1
  12. package/claude/skills/design-identity/REQUIREMENT.md +4 -4
  13. package/claude/skills/design-identity/SKILL.md +7 -7
  14. package/claude/skills/{pick-by-eye → design-pick}/REQUIREMENT.md +6 -6
  15. package/claude/skills/{pick-by-eye → design-pick}/SKILL.md +19 -10
  16. package/claude/skills/{pick-by-eye → design-pick}/references/board.md +17 -11
  17. package/claude/skills/design-pick/references/design-config.md +33 -0
  18. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  19. package/claude/skills/design-record/SKILL.md +1 -1
  20. package/claude/skills/design-taste/REQUIREMENT.md +2 -2
  21. package/claude/skills/design-taste/SKILL.md +3 -3
  22. package/claude/skills/design-taste/references/preflight.md +1 -1
  23. package/claude/skills/git-issue/REQUIREMENT.md +1 -1
  24. package/claude/skills/git-issue/references/issue.md +1 -1
  25. package/claude/skills/plan-feature/SKILL.md +1 -1
  26. package/claude/skills/plan-feature/references/plan.md +21 -6
  27. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  28. package/claude/skills/pr-evidence/references/evidence.md +2 -2
  29. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +3 -3
  30. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  31. package/claude/skills/role-orchestrator/references/orchestrator-planning-wait.md +24 -0
  32. package/claude/skills/role-planner/SKILL.md +4 -2
  33. package/claude/skills/role-worker/SKILL.md +5 -6
  34. package/claude/skills/task-board/references/tasks.md +1 -1
  35. package/claude/skills/task-complete/references/receipt-sweep.md +1 -1
  36. package/claude/skills/{ux-audit → ui-audit}/REQUIREMENT.md +3 -3
  37. package/claude/skills/{ux-audit → ui-audit}/SKILL.md +7 -7
  38. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  39. package/claude/skills/ui-craft/SKILL.md +1 -1
  40. package/claude/skills/ui-craft/references/copy.md +4 -1
  41. package/claude/skills/{ui-record → ui-film}/REQUIREMENT.md +6 -4
  42. package/claude/skills/{ui-record → ui-film}/SKILL.md +26 -13
  43. package/claude/skills/ui-film/references/compose-craft.md +81 -0
  44. package/claude/skills/{ui-record → ui-film}/references/config.md +9 -4
  45. package/claude/skills/{ui-record → ui-film}/references/handoff.md +17 -7
  46. package/claude/skills/{ui-record → ui-film}/references/pr-clip.md +5 -5
  47. package/claude/skills/{ui-record → ui-film}/references/take-craft.md +28 -2
  48. package/claude/skills/{ux-measure → ui-measure}/REQUIREMENT.md +3 -3
  49. package/claude/skills/{ux-measure → ui-measure}/SKILL.md +6 -6
  50. package/docs/agents/audits.md +1 -1
  51. package/docs/agents/migrate.md +13 -13
  52. package/docs/index.md +1 -0
  53. package/docs/migrations/by-hand.md +107 -0
  54. package/docs/migrations/by-verb.md +118 -0
  55. package/docs/migrations/index.md +14 -0
  56. package/docs/target/index.md +1 -2
  57. package/docs/target/projects.md +3 -3
  58. package/docs/target/sync.md +23 -3
  59. package/docs/workflow/ai-workflow.md +1 -1
  60. package/docs/workflow/skill-map-on-request.md +1 -2
  61. package/docs/workflow/skill-map.md +9 -10
  62. package/docs/workflow/visual-design-workflow.md +6 -1
  63. package/governance/ledger/claude/background.md +26 -0
  64. package/governance/ledger/skills/ui-craft/copy.md +8 -4
  65. package/governance/rules/claude/background.md +19 -0
  66. package/governance/stacks/base.toml +1 -0
  67. package/package.json +6 -4
  68. package/scripts/ci/install-playwright-deps.sh +52 -0
  69. package/scripts/core/design-sync.ts +204 -0
  70. package/scripts/core/regen-web-favicon.ts +27 -12
  71. package/scripts/core/regen-web-previews.ts +8 -1
  72. package/scripts/core/render-film.ts +179 -0
  73. package/src/capture/frame-values.ts +12 -12
  74. package/src/claude/cases/authoring.ts +2 -2
  75. package/src/claude/cases/misc.ts +1 -1
  76. package/src/claude/cases/setup.ts +0 -5
  77. package/src/claude/cases/workflow.ts +6 -6
  78. package/src/commands/fixtures/gh.sh +64 -0
  79. package/src/commands/gov.ts +33 -3
  80. package/src/commands/migrate.ts +12 -2
  81. package/src/commands/records.ts +3 -2
  82. package/src/commands/sandbox.ts +9 -2
  83. package/src/commands/take.ts +159 -11
  84. package/src/commands/tasks.ts +183 -0
  85. package/src/design/favicon.ts +85 -13
  86. package/src/design/reference.ts +98 -0
  87. package/src/design/render.ts +16 -14
  88. package/src/design/tokens.ts +4 -3
  89. package/src/gate/stages.ts +8 -2
  90. package/src/gov/audit.ts +48 -11
  91. package/src/gov/detect.ts +1 -1
  92. package/src/init/steps.ts +2 -2
  93. package/src/markdown/scope.ts +9 -14
  94. package/src/migrate/record-layout.ts +5 -3
  95. package/src/migrate/skill-names.ts +21 -4
  96. package/src/migrate/status.ts +3 -3
  97. package/src/records/validate.ts +15 -2
  98. package/src/sandbox/expect.ts +32 -0
  99. package/src/sandbox/headless/dispatch-shim.sh +15 -0
  100. package/src/sandbox/headless/dispatch.ts +12 -14
  101. package/src/sandbox/headless/run.ts +6 -3
  102. package/src/sandbox/stub-gh.sh +26 -0
  103. package/src/sandbox/stub-remote.ts +8 -29
  104. package/src/sync/engine.ts +1 -1
  105. package/src/take/camera.ts +100 -11
  106. package/src/take/film.ts +55 -4
  107. package/src/take/fixtures/input.html +138 -0
  108. package/src/take/keys.ts +193 -0
  109. package/src/take/log.ts +94 -1
  110. package/src/take/perform.ts +165 -10
  111. package/src/take/plan.ts +101 -6
  112. package/src/take/pointer.ts +2 -1
  113. package/src/take/screencast.ts +28 -3
  114. package/src/take/stepper.ts +141 -23
  115. package/src/take/zoom/NOTICE.md +1 -1
  116. package/src/tasks/launch-wait.ts +160 -0
  117. package/tooling/claude/reference.md +1 -1
  118. package/claude/skills/canon-feedback-triage/REQUIREMENT.md +0 -34
  119. package/claude/skills/canon-feedback-triage/SKILL.md +0 -98
  120. package/claude/skills/plan-ready/REQUIREMENT.md +0 -35
  121. package/claude/skills/plan-ready/SKILL.md +0 -136
  122. package/claude/skills/plan-ready/references/assembly.md +0 -75
  123. package/claude/skills/plan-ready/references/ready.md +0 -128
  124. package/docs/target/migrations.md +0 -203
  125. /package/claude/skills/{pick-by-eye → design-pick}/references/live-arms.md +0 -0
package/src/take/plan.ts CHANGED
@@ -6,19 +6,44 @@
6
6
  * keyboard leaves nothing in the log to draw.
7
7
  */
8
8
 
9
+ import { MODIFIERS, type Modifier, isModifier, parseKeys } from '@/take/keys'
10
+
9
11
  /** How a beat is filmed: frame stepping, or Playwright's real-time screencast. */
10
12
  export const ROUTES = ['step', 'screencast'] as const
11
13
 
12
14
  export type Route = (typeof ROUTES)[number]
13
15
 
16
+ /** The keys held through a press, absent when none is. */
17
+ interface Held {
18
+ readonly modifiers?: readonly Modifier[]
19
+ }
20
+
14
21
  export type TakeStep =
15
22
  | { readonly kind: 'move'; readonly target: string }
16
23
  | { readonly kind: 'move'; readonly x: number; readonly y: number }
17
- | { readonly kind: 'click'; readonly target: string }
24
+ | ({ readonly kind: 'click'; readonly target: string } & Held)
25
+ | ({ readonly kind: 'dblclick'; readonly target: string } & Held)
26
+ | ({
27
+ readonly kind: 'drag'
28
+ readonly from: string
29
+ readonly to: string
30
+ } & Held)
31
+ | { readonly kind: 'key'; readonly keys: string }
18
32
  | { readonly kind: 'fill'; readonly target: string; readonly text: string }
19
33
  | { readonly kind: 'scroll'; readonly target: string; readonly dy: number }
20
34
  | { readonly kind: 'wait'; readonly ms: number }
21
35
 
36
+ const KINDS = [
37
+ 'move',
38
+ 'click',
39
+ 'dblclick',
40
+ 'drag',
41
+ 'key',
42
+ 'fill',
43
+ 'scroll',
44
+ 'wait',
45
+ ] as const
46
+
22
47
  export interface Beat {
23
48
  readonly name: string
24
49
  /**
@@ -26,6 +51,11 @@ export interface Beat {
26
51
  * back to `screencast` on its own, and the take's record says which ran.
27
52
  */
28
53
  readonly route: Route
54
+ /**
55
+ * False turns the camera's zoom off for this beat, while its track still
56
+ * carries the cursor and the click ring. Absent means the zoom runs.
57
+ */
58
+ readonly push?: false
29
59
  /** Runs after the page loads and before the first frame is kept. */
30
60
  readonly setup: readonly TakeStep[]
31
61
  readonly steps: readonly TakeStep[]
@@ -89,16 +119,25 @@ function readPlan(document: unknown): TakePlan {
89
119
  throw new Refusal('unreadable-plan', 'the document is not an object')
90
120
  }
91
121
  const { beats } = document
122
+ const route = document.route ?? 'step'
123
+ if (!ROUTES.includes(route as Route)) {
124
+ throw new Refusal(
125
+ 'unknown-route',
126
+ `the plan names route ${String(route)}. This build records on: ${ROUTES.join(', ')}.`,
127
+ )
128
+ }
92
129
  if (!Array.isArray(beats) || beats.length === 0) {
93
130
  throw new Refusal(
94
131
  'no-beats',
95
132
  'beats carries no entry, so nothing is filmed',
96
133
  )
97
134
  }
98
- return { beats: beats.map((entry, index) => readBeat(entry, index)) }
135
+ return {
136
+ beats: beats.map((entry, index) => readBeat(entry, index, route as Route)),
137
+ }
99
138
  }
100
139
 
101
- function readBeat(entry: unknown, index: number): Beat {
140
+ function readBeat(entry: unknown, index: number, fallback: Route): Beat {
102
141
  const at = `beat ${index + 1}`
103
142
  if (!isRecord(entry)) throw new Refusal('bad-beat', `${at} is not an object`)
104
143
 
@@ -110,7 +149,7 @@ function readBeat(entry: unknown, index: number): Beat {
110
149
  )
111
150
  }
112
151
 
113
- const route = entry.route ?? 'step'
152
+ const route = entry.route ?? fallback
114
153
  if (!ROUTES.includes(route as Route)) {
115
154
  throw new Refusal(
116
155
  'unknown-route',
@@ -127,10 +166,17 @@ function readBeat(entry: unknown, index: number): Beat {
127
166
  if (entry.setup !== undefined && !Array.isArray(entry.setup)) {
128
167
  throw new Refusal('bad-beat', `${name} carries a setup that is not a list`)
129
168
  }
169
+ if (entry.push !== undefined && typeof entry.push !== 'boolean') {
170
+ throw new Refusal(
171
+ 'bad-beat',
172
+ `${name} carries a push that is not true or false`,
173
+ )
174
+ }
130
175
 
131
176
  return {
132
177
  name,
133
178
  route: route as Route,
179
+ ...(entry.push === false ? { push: false as const } : {}),
134
180
  setup: (entry.setup ?? []).map((step: unknown, i: number) =>
135
181
  readStep(step, `${name}, setup step ${i + 1}`),
136
182
  ),
@@ -160,7 +206,35 @@ function readStep(entry: unknown, at: string): TakeStep {
160
206
  `${at} is a move with neither a target nor an x and y`,
161
207
  )
162
208
  case 'click':
163
- return { kind: 'click', target: needTarget() }
209
+ case 'dblclick':
210
+ return {
211
+ kind: entry.kind,
212
+ target: needTarget(),
213
+ ...readModifiers(entry.modifiers, at),
214
+ }
215
+ case 'drag':
216
+ if (typeof entry.from !== 'string' || !entry.from) {
217
+ throw new Refusal('bad-step', `${at} is a drag with no from target`)
218
+ }
219
+ if (typeof entry.to !== 'string' || !entry.to) {
220
+ throw new Refusal('bad-step', `${at} is a drag with no to target`)
221
+ }
222
+ return {
223
+ kind: 'drag',
224
+ from: entry.from,
225
+ to: entry.to,
226
+ ...readModifiers(entry.modifiers, at),
227
+ }
228
+ case 'key': {
229
+ if (typeof entry.keys !== 'string') {
230
+ throw new Refusal('bad-step', `${at} is a key with no keys to press`)
231
+ }
232
+ const read = parseKeys(entry.keys)
233
+ if (read.kind === 'refused') {
234
+ throw new Refusal('bad-step', `${at} is a key where ${read.detail}`)
235
+ }
236
+ return { kind: 'key', keys: entry.keys }
237
+ }
164
238
  case 'fill':
165
239
  if (typeof entry.text !== 'string') {
166
240
  throw new Refusal('bad-step', `${at} is a fill with no text`)
@@ -179,9 +253,30 @@ function readStep(entry: unknown, at: string): TakeStep {
179
253
  default:
180
254
  throw new Refusal(
181
255
  'bad-step',
182
- `${at} names kind ${String(entry.kind)}, which is not move, click, fill, scroll, or wait`,
256
+ `${at} names kind ${String(entry.kind)}, which is not ${KINDS.join(', ')}`,
257
+ )
258
+ }
259
+ }
260
+
261
+ function readModifiers(value: unknown, at: string): Held {
262
+ if (value === undefined) return {}
263
+ if (!Array.isArray(value)) {
264
+ throw new Refusal('bad-step', `${at} carries modifiers that are not a list`)
265
+ }
266
+ const modifiers: Modifier[] = []
267
+ for (const name of value) {
268
+ if (!isModifier(name)) {
269
+ throw new Refusal(
270
+ 'bad-step',
271
+ `${at} holds ${String(name)}, which is not ${MODIFIERS.join(', ')}`,
183
272
  )
273
+ }
274
+ if (modifiers.includes(name)) {
275
+ throw new Refusal('bad-step', `${at} holds ${name} twice`)
276
+ }
277
+ modifiers.push(name)
184
278
  }
279
+ return modifiers.length > 0 ? { modifiers } : {}
185
280
  }
186
281
 
187
282
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -2,7 +2,8 @@ import type { Point } from '@/take/log'
2
2
 
3
3
  /**
4
4
  * Draws the cursor arrow and the click ring onto one decoded frame, in the
5
- * shape groundwork track `193-ui-record` rendered them: a dark arrow outlined
5
+ * shape groundwork track `193-ui-record` rendered them (canon-keep-retired):
6
+ * a dark arrow outlined
6
7
  * in white, and a blue ring that grows from its tip and fades out. Both stay
7
8
  * one size whatever the frame shows, so the pointer reads the same in every
8
9
  * beat of a reel. Drawn in TypeScript rather than a browser, so the reel needs
@@ -4,7 +4,12 @@ import { join } from 'node:path'
4
4
  import { chromium } from 'playwright-core'
5
5
  import { lastFrame, probeFrames, transcode } from '@/take/encode'
6
6
  import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
7
- import { type PointerInput, type Stage, performSteps } from '@/take/perform'
7
+ import {
8
+ type PointerInput,
9
+ type Stage,
10
+ TargetMissing,
11
+ performSteps,
12
+ } from '@/take/perform'
8
13
  import type { Beat } from '@/take/plan'
9
14
  import { HOLD_MS } from '@/take/stepper'
10
15
 
@@ -19,6 +24,9 @@ const FRAME_MS = 1000 / FPS
19
24
 
20
25
  const SETTLE_MS = 2500
21
26
 
27
+ /** How long a refusal waits on the screenshot it shows. */
28
+ const STILL_CAP_MS = 2000
29
+
22
30
  export interface ScreencastTake {
23
31
  readonly frames: number
24
32
  readonly events: readonly LogEvent[]
@@ -33,8 +41,15 @@ export async function filmScreencast(request: {
33
41
  }): Promise<ScreencastTake> {
34
42
  const scratch = mkdtempSync(join(tmpdir(), 'canon-take-cast-'))
35
43
  const browser = await chromium.launch()
44
+ let still: (() => Promise<Buffer | undefined>) | undefined
36
45
  try {
37
46
  const page = await browser.newPage({ viewport: VIEWPORT })
47
+ // Best effort, so a closed or crashed page leaves the refusal standing
48
+ // without a still rather than replacing it.
49
+ still = () =>
50
+ page
51
+ .screenshot({ type: 'jpeg', quality: 95, timeout: STILL_CAP_MS })
52
+ .catch(() => undefined)
38
53
  await page.goto(request.url, { waitUntil: 'load' })
39
54
  await page.waitForTimeout(SETTLE_MS)
40
55
 
@@ -51,6 +66,13 @@ export async function filmScreencast(request: {
51
66
  await page.keyboard.insertText(text)
52
67
  await page.waitForTimeout(FRAME_MS)
53
68
  },
69
+ // Playwright's mouse reads the modifiers its keyboard holds, so a press
70
+ // here is all a modified click or drag needs on this route.
71
+ press: async (input) => {
72
+ if (input.type === 'down') await page.keyboard.down(input.key)
73
+ else await page.keyboard.up(input.key)
74
+ await page.waitForTimeout(FRAME_MS)
75
+ },
54
76
  evaluate: (expression) => page.evaluate(expression),
55
77
  })
56
78
 
@@ -75,6 +97,9 @@ export async function filmScreencast(request: {
75
97
  await transcode(webm, request.clip, FPS)
76
98
  const frames = await probeFrames(request.clip)
77
99
  return { frames, events, still: await lastFrame(request.clip, frames) }
100
+ } catch (error) {
101
+ if (error instanceof TargetMissing && still) error.still = await still()
102
+ throw error
78
103
  } finally {
79
104
  await browser.close()
80
105
  rmSync(scratch, { recursive: true, force: true })
@@ -88,9 +113,9 @@ async function send(mouse: Mouse, input: PointerInput): Promise<void> {
88
113
  case 'move':
89
114
  return mouse.move(input.x, input.y)
90
115
  case 'down':
91
- return mouse.down()
116
+ return mouse.down({ clickCount: input.clickCount })
92
117
  case 'up':
93
- return mouse.up()
118
+ return mouse.up({ clickCount: input.clickCount })
94
119
  case 'wheel':
95
120
  return mouse.wheel(0, input.dy)
96
121
  }
@@ -1,6 +1,13 @@
1
1
  import { openFrameEncoder } from '@/take/encode'
2
+ import { describeKey, modifierBits } from '@/take/keys'
2
3
  import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
3
- import { type PointerInput, type Stage, performSteps } from '@/take/perform'
4
+ import {
5
+ type KeyInput,
6
+ type PointerInput,
7
+ type Stage,
8
+ TargetMissing,
9
+ performSteps,
10
+ } from '@/take/perform'
4
11
  import type { Beat } from '@/take/plan'
5
12
  import { type ShellPage, openShell } from '@/take/shell'
6
13
 
@@ -36,10 +43,16 @@ const LOAD_CAP_MS = 15_000
36
43
  const SETTLE_MS = 2500
37
44
  const LOADING_FRAME_CAP_MS = 250
38
45
 
46
+ /** How long a refusal waits on the frame it shows, since the page may be wedged. */
47
+ const STILL_CAP_MS = 2000
48
+
39
49
  /** A frame that took longer than `STALL_MS`, so the beat is filmed another way. */
40
50
  export class StepperStalled extends Error {
41
- constructor(readonly frame: number) {
42
- super(`frame ${frame} took over ${STALL_MS / 1000} s under frame stepping`)
51
+ constructor(
52
+ readonly frame: number,
53
+ stallMs: number,
54
+ ) {
55
+ super(`frame ${frame} took over ${stallMs / 1000} s under frame stepping`)
43
56
  }
44
57
  }
45
58
 
@@ -55,6 +68,8 @@ export interface SteppedRequest {
55
68
  readonly url: string
56
69
  readonly beat: Beat
57
70
  readonly clip: string
71
+ /** How long one frame may take before the beat falls back, `STALL_MS` by default. */
72
+ readonly stallMs?: number
58
73
  }
59
74
 
60
75
  export async function filmStepped(
@@ -62,6 +77,8 @@ export async function filmStepped(
62
77
  ): Promise<SteppedTake> {
63
78
  const page = await openShell(request.executable, VIEWPORT)
64
79
  const encoder = openFrameEncoder(request.clip, FPS)
80
+ const stallMs = request.stallMs ?? STALL_MS
81
+ let still: (() => Promise<Buffer | undefined>) | undefined
65
82
  try {
66
83
  let ticks = await load(page, request.url)
67
84
  const beginFrame = (screenshot: boolean) =>
@@ -75,9 +92,10 @@ export async function filmStepped(
75
92
  : {}),
76
93
  },
77
94
  )
95
+ still = () => frameStill(beginFrame)
78
96
  await pump(beginFrame, SETTLE_MS)
79
97
 
80
- const live = liveStage(page, beginFrame)
98
+ const live = liveStage(page, beginFrame, stallMs)
81
99
  await performSteps(live, request.beat.setup, { pointer: START })
82
100
  if (request.beat.setup.length > 0) await live.hold(1000)
83
101
  await live.pointer({ type: 'move', ...START })
@@ -106,6 +124,7 @@ export async function filmStepped(
106
124
  const reply = await withinStall(
107
125
  advanced.then(() => beginFrame(true)),
108
126
  frames,
127
+ stallMs,
109
128
  )
110
129
  const data = reply.screenshotData
111
130
  ? Buffer.from(reply.screenshotData, 'base64')
@@ -121,16 +140,14 @@ export async function filmStepped(
121
140
  hold: async (ms) => {
122
141
  for (let i = 0; i < Math.round(ms / FRAME_MS); i += 1) await frame()
123
142
  },
124
- pointer: async (input) => {
125
- const sent = page.send('Input.dispatchMouseEvent', mouseEvent(input))
126
- await frame()
127
- await sent
128
- },
129
- key: async (text) => {
130
- const sent = page.send('Input.insertText', { text })
131
- await frame()
132
- await sent
133
- },
143
+ pointer: (input) =>
144
+ stepThen(
145
+ page.send('Input.dispatchMouseEvent', mouseEvent(input)),
146
+ frame,
147
+ ),
148
+ key: (text) => stepThen(page.send('Input.insertText', { text }), frame),
149
+ press: (input) =>
150
+ stepThen(page.send('Input.dispatchKeyEvent', keyEvent(input)), frame),
134
151
  evaluate: (expression) => page.evaluate(expression),
135
152
  }
136
153
 
@@ -146,12 +163,31 @@ export async function filmStepped(
146
163
  return { frames, events, still: last }
147
164
  } catch (error) {
148
165
  encoder.abort()
166
+ if (error instanceof TargetMissing && still) error.still = await still()
149
167
  throw error
150
168
  } finally {
151
169
  page.close()
152
170
  }
153
171
  }
154
172
 
173
+ /**
174
+ * Begins one frame with a screenshot for a refusal to show, through the same
175
+ * begin-frame call every kept frame uses, since a separate capture call can
176
+ * wait on a frame that never begins. Best effort, so a page that answers with
177
+ * nothing leaves the refusal standing without a still.
178
+ */
179
+ async function frameStill(
180
+ beginFrame: (screenshot: boolean) => Promise<{ screenshotData?: string }>,
181
+ ): Promise<Buffer | undefined> {
182
+ const reply = await Promise.race([
183
+ beginFrame(true).catch(() => undefined),
184
+ sleep(STILL_CAP_MS),
185
+ ])
186
+ return reply?.screenshotData
187
+ ? Buffer.from(reply.screenshotData, 'base64')
188
+ : undefined
189
+ }
190
+
155
191
  /**
156
192
  * Navigates and resolves with Chromium's monotonic clock in milliseconds, read
157
193
  * off the load event's own timestamp. The runtime's clock cannot stand in for
@@ -201,6 +237,7 @@ async function pump(
201
237
  function liveStage(
202
238
  page: ShellPage,
203
239
  beginFrame: (screenshot: boolean) => Promise<unknown>,
240
+ stallMs: number,
204
241
  ): Stage {
205
242
  const started = Date.now()
206
243
  return {
@@ -209,40 +246,121 @@ function liveStage(
209
246
  pointer: (input) => {
210
247
  const sent = page.send('Input.dispatchMouseEvent', mouseEvent(input))
211
248
  return withinStall(
212
- beginFrame(false).then(() => sent),
249
+ Promise.all([beginFrame(false), sent]),
213
250
  0,
251
+ stallMs,
214
252
  ).then(() => {})
215
253
  },
216
254
  key: (text) => {
217
255
  const sent = page.send('Input.insertText', { text })
218
256
  return withinStall(
219
- beginFrame(false).then(() => sent),
257
+ Promise.all([beginFrame(false), sent]),
220
258
  0,
259
+ stallMs,
260
+ ).then(() => {})
261
+ },
262
+ press: (input) => {
263
+ const sent = page.send('Input.dispatchKeyEvent', keyEvent(input))
264
+ return withinStall(
265
+ Promise.all([beginFrame(false), sent]),
266
+ 0,
267
+ stallMs,
221
268
  ).then(() => {})
222
269
  },
223
270
  evaluate: (expression) => page.evaluate(expression),
224
271
  }
225
272
  }
226
273
 
274
+ /**
275
+ * A pointer input as CDP takes it. `buttons` carries the held button through a
276
+ * drag's moves, or the page reads them as a hover, and `modifiers` carries the
277
+ * held keys as a bit field beside the key events that pressed them.
278
+ */
227
279
  function mouseEvent(input: PointerInput): Record<string, unknown> {
228
280
  const at = { x: input.x, y: input.y }
281
+ if (input.type === 'wheel') {
282
+ return { type: 'mouseWheel', ...at, deltaX: 0, deltaY: input.dy }
283
+ }
284
+ const modifiers = modifierBits(input.modifiers ?? [])
229
285
  switch (input.type) {
230
286
  case 'move':
231
- return { type: 'mouseMoved', ...at, button: 'none' }
287
+ return input.held
288
+ ? { type: 'mouseMoved', ...at, button: 'left', buttons: 1, modifiers }
289
+ : { type: 'mouseMoved', ...at, button: 'none', buttons: 0, modifiers }
232
290
  case 'down':
233
- return { type: 'mousePressed', ...at, button: 'left', clickCount: 1 }
234
291
  case 'up':
235
- return { type: 'mouseReleased', ...at, button: 'left', clickCount: 1 }
236
- case 'wheel':
237
- return { type: 'mouseWheel', ...at, deltaX: 0, deltaY: input.dy }
292
+ return {
293
+ type: input.type === 'down' ? 'mousePressed' : 'mouseReleased',
294
+ ...at,
295
+ button: 'left',
296
+ buttons: input.type === 'down' ? 1 : 0,
297
+ clickCount: input.clickCount,
298
+ modifiers,
299
+ }
300
+ }
301
+ }
302
+
303
+ /**
304
+ * A key input as CDP takes it. A key that types sends `keyDown` with its text
305
+ * only when no modifier but Shift is held, as Chromium's own keyboard does, so
306
+ * `Control+K` in a focused field inserts no K.
307
+ */
308
+ function keyEvent(input: KeyInput): Record<string, unknown> {
309
+ const descriptor = describeKey(input.key, {
310
+ shift: input.modifiers.includes('Shift'),
311
+ })
312
+ if (!descriptor) throw new Error(`no key descriptor for ${input.key}`)
313
+ const event = {
314
+ key: descriptor.key,
315
+ code: descriptor.code,
316
+ windowsVirtualKeyCode: descriptor.keyCode,
317
+ nativeVirtualKeyCode: descriptor.keyCode,
318
+ location: descriptor.location,
319
+ modifiers: modifierBits(input.modifiers),
320
+ }
321
+ if (input.type === 'up') return { type: 'keyUp', ...event }
322
+ const types =
323
+ descriptor.text !== undefined &&
324
+ input.modifiers.every((modifier) => modifier === 'Shift')
325
+ return types
326
+ ? {
327
+ type: 'keyDown',
328
+ ...event,
329
+ text: descriptor.text,
330
+ unmodifiedText: descriptor.text,
331
+ }
332
+ : { type: 'rawKeyDown', ...event }
333
+ }
334
+
335
+ /**
336
+ * Steps a frame, then awaits the input sent ahead of it. When the step fails,
337
+ * the input is left settling against a shell about to close, so its rejection
338
+ * is handled here rather than surfacing as an unhandled one.
339
+ */
340
+ async function stepThen(
341
+ sent: Promise<unknown>,
342
+ step: () => Promise<void>,
343
+ ): Promise<void> {
344
+ try {
345
+ await step()
346
+ } catch (error) {
347
+ sent.catch(() => {})
348
+ throw error
238
349
  }
350
+ await sent
239
351
  }
240
352
 
241
- async function withinStall<T>(work: Promise<T>, frame: number): Promise<T> {
353
+ async function withinStall<T>(
354
+ work: Promise<T>,
355
+ frame: number,
356
+ stallMs: number,
357
+ ): Promise<T> {
242
358
  let timer: ReturnType<typeof setTimeout> | undefined
243
359
  const stalled = new Promise<never>((_, fail) => {
244
- timer = setTimeout(() => fail(new StepperStalled(frame)), STALL_MS)
360
+ timer = setTimeout(() => fail(new StepperStalled(frame, stallMs)), stallMs)
245
361
  })
362
+ // A stall leaves the work pending until the shell closes and rejects it.
363
+ work.catch(() => {})
246
364
  try {
247
365
  return await Promise.race([work, stalled])
248
366
  } finally {
@@ -15,7 +15,7 @@ The files in this folder carry the zoom timing of [OpenScreen](https://github.co
15
15
  - The file names are kebab-case, as this repository names every TypeScript file, and the code is reformatted to its style
16
16
  - `math-utils.ts` keeps `clamp01`, `cubicBezier`, and `easeOutScreenStudio`, and drops the easings nothing here calls
17
17
  - `region-utils.ts` keeps `computeRegionStrength`, the connected-pan easing, and the window and gap constants. It drops the dominant-region search, its cache, the 3D rotation, and the cursor-follow focus, since the camera here aims at clicks and holds its focus by its own rule.
18
- - The zoom-out window is 700 ms rather than 1015.05 ms, and the connected pan runs 600 ms rather than 1000 ms, both on the operator's eye in groundwork track `193-ui-record`. The zoom-in window keeps OpenScreen's 1522.575 ms.
18
+ - The zoom-out window is 700 ms rather than 1015.05 ms, and the connected pan runs 600 ms rather than 1000 ms, both on the operator's eye in groundwork track `193-ui-record`. <!-- canon-keep-retired --> The zoom-in window keeps OpenScreen's 1522.575 ms.
19
19
  - `spring.ts` keeps the zoom spring's stiffness 320, damping 40, mass 0.92, its rest thresholds, the delta clamp, and the overshoot clamp from `stepAxis`. OpenScreen steps that spring through the `motion` package, and this solves it in closed form instead, so no dependency comes with it.
20
20
 
21
21
  ## License