@erclx/canon 5.52.0 → 5.54.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 (201) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +11 -13
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +5 -8
  5. package/claude/skills/context-fold/REQUIREMENT.md +0 -1
  6. package/claude/skills/design-identity/REQUIREMENT.md +2 -2
  7. package/claude/skills/design-identity/SKILL.md +6 -6
  8. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  9. package/claude/skills/design-record/SKILL.md +4 -4
  10. package/claude/skills/design-record/references/folder.md +1 -1
  11. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  12. package/claude/skills/design-taste/references/kinds.md +0 -1
  13. package/claude/skills/draw-figure/REQUIREMENT.md +3 -4
  14. package/claude/skills/draw-figure/SKILL.md +5 -6
  15. package/claude/skills/draw-figure/references/figures.md +0 -1
  16. package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
  17. package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -5
  18. package/claude/skills/pick-by-eye/SKILL.md +34 -41
  19. package/claude/skills/pick-by-eye/references/board.md +42 -0
  20. package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
  21. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  22. package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
  23. package/claude/skills/teach-craft/SKILL.md +70 -0
  24. package/claude/skills/teach-craft/references/adopted.md +113 -0
  25. package/claude/skills/teach-craft/references/lesson-craft.md +60 -0
  26. package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +41 -6
  27. package/docs/agents/audits.md +0 -1
  28. package/docs/agents/install-and-sync.md +0 -8
  29. package/docs/target/migrations.md +16 -2
  30. package/docs/target/projects.md +1 -1
  31. package/docs/workflow/parallel-features.md +1 -1
  32. package/docs/workflow/skill-map-on-request.md +10 -22
  33. package/docs/workflow/skill-map.md +11 -14
  34. package/docs/workflow/visual-design-workflow.md +1 -4
  35. package/governance/detect.toml +1 -6
  36. package/governance/renames.toml +0 -1
  37. package/package.json +2 -8
  38. package/scripts/core/regen-web-previews.ts +2 -67
  39. package/src/audits/catalog.ts +0 -1
  40. package/src/browser/engine.ts +3 -6
  41. package/src/capture/render.ts +1 -1
  42. package/src/claude/cases/authoring.ts +0 -12
  43. package/src/claude/cases/misc.ts +0 -22
  44. package/src/claude/cases/workflow.ts +2 -38
  45. package/src/claude/skills-audit.ts +1 -1
  46. package/src/cli/help.ts +0 -10
  47. package/src/cli.ts +0 -8
  48. package/src/commands/design.ts +16 -315
  49. package/src/commands/records.ts +0 -1
  50. package/src/commands/serve.ts +2 -2
  51. package/src/commands/sync.ts +4 -5
  52. package/src/design/components.ts +8 -1514
  53. package/src/design/css.ts +5 -6
  54. package/src/design/fonts.ts +3 -5
  55. package/src/design/regen.ts +5 -32
  56. package/src/design/render.ts +1 -1
  57. package/src/design/tokens.ts +3 -2
  58. package/src/driver/drive.ts +3 -4
  59. package/src/driver/steps.ts +2 -5
  60. package/src/gate/stages.ts +4 -10
  61. package/src/gov/audit.ts +1 -6
  62. package/src/inventory/walk.ts +1 -1
  63. package/src/markdown/scope.ts +6 -35
  64. package/src/records/validate.ts +4 -176
  65. package/src/sandbox/equivalence.ts +0 -5
  66. package/src/shipped/references.ts +1 -2
  67. package/src/sync/check.ts +4 -29
  68. package/src/sync/stamp.ts +6 -6
  69. package/src/sync/target.ts +1 -4
  70. package/tooling/claude/reference.md +1 -1
  71. package/tooling/claude/seeds/canon/DESIGN.md +1 -1
  72. package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
  73. package/claude/skills/canvas-board/SKILL.md +0 -143
  74. package/claude/skills/canvas-board/references/canvas.md +0 -170
  75. package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
  76. package/claude/skills/draft-and-pick/SKILL.md +0 -11
  77. package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
  78. package/claude/skills/draft-doc/SKILL.md +0 -11
  79. package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
  80. package/claude/skills/draft-figure/SKILL.md +0 -11
  81. package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
  82. package/claude/skills/draft-identity/SKILL.md +0 -11
  83. package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
  84. package/claude/skills/draft-ready/SKILL.md +0 -11
  85. package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
  86. package/claude/skills/draft-screencast/SKILL.md +0 -11
  87. package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
  88. package/claude/skills/draft-slides/SKILL.md +0 -11
  89. package/claude/skills/make-deck/REQUIREMENT.md +0 -36
  90. package/claude/skills/make-deck/SKILL.md +0 -80
  91. package/claude/skills/make-deck/references/deck.md +0 -183
  92. package/claude/skills/read-frames/REQUIREMENT.md +0 -30
  93. package/claude/skills/read-frames/SKILL.md +0 -55
  94. package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
  95. package/claude/skills/record-screencast/SKILL.md +0 -99
  96. package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
  97. package/claude/skills/script-screencast/SKILL.md +0 -70
  98. package/claude/skills/script-screencast/references/beats-template.md +0 -119
  99. package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
  100. package/claude/skills/teach-workspace/SKILL.md +0 -196
  101. package/claude/skills/teach-workspace/references/glossary.md +0 -90
  102. package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
  103. package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
  104. package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
  105. package/claude/skills/teach-workspace/references/promotion.md +0 -116
  106. package/claude/skills/teach-workspace/references/revisit.md +0 -28
  107. package/claude/skills/teach-workspace/references/teach.md +0 -211
  108. package/claude/skills/video-craft/REQUIREMENT.md +0 -35
  109. package/claude/skills/video-craft/SKILL.md +0 -96
  110. package/claude/skills/video-craft/references/adopted.md +0 -110
  111. package/governance/rules/ui/canvas.md +0 -13
  112. package/src/canvas/address.ts +0 -227
  113. package/src/canvas/arrange.ts +0 -66
  114. package/src/canvas/capture.ts +0 -391
  115. package/src/canvas/client/app.tsx +0 -188
  116. package/src/canvas/client/inspector/align-grid.tsx +0 -162
  117. package/src/canvas/client/inspector/color-field.tsx +0 -398
  118. package/src/canvas/client/inspector/color-inputs.tsx +0 -139
  119. package/src/canvas/client/inspector/color-picker.tsx +0 -668
  120. package/src/canvas/client/inspector/color-space.ts +0 -240
  121. package/src/canvas/client/inspector/field.tsx +0 -263
  122. package/src/canvas/client/inspector/fields.css +0 -763
  123. package/src/canvas/client/inspector/section.tsx +0 -27
  124. package/src/canvas/client/inspector/size-field.tsx +0 -69
  125. package/src/canvas/client/inspector/token-select.tsx +0 -109
  126. package/src/canvas/client/inspector/values.ts +0 -297
  127. package/src/canvas/client/inspector.tsx +0 -692
  128. package/src/canvas/client/keys.ts +0 -71
  129. package/src/canvas/client/layers.tsx +0 -118
  130. package/src/canvas/client/main.tsx +0 -71
  131. package/src/canvas/client/pages-panel.tsx +0 -212
  132. package/src/canvas/client/panel-handle.tsx +0 -90
  133. package/src/canvas/client/selection.tsx +0 -199
  134. package/src/canvas/client/shell.css +0 -762
  135. package/src/canvas/client/state.ts +0 -792
  136. package/src/canvas/client/surface.tsx +0 -803
  137. package/src/canvas/client/theme-panel.tsx +0 -55
  138. package/src/canvas/client/tools.tsx +0 -153
  139. package/src/canvas/content.ts +0 -781
  140. package/src/canvas/edit.ts +0 -575
  141. package/src/canvas/editing.ts +0 -203
  142. package/src/canvas/fit.ts +0 -238
  143. package/src/canvas/global.d.ts +0 -15
  144. package/src/canvas/history.ts +0 -116
  145. package/src/canvas/index.html +0 -20
  146. package/src/canvas/server.ts +0 -766
  147. package/src/canvas/tokens.ts +0 -221
  148. package/src/commands/canvas.ts +0 -1097
  149. package/src/commands/demo.ts +0 -542
  150. package/src/commands/slides.ts +0 -184
  151. package/src/commands/teach.ts +0 -1401
  152. package/src/demo/beats.ts +0 -135
  153. package/src/demo/compile.ts +0 -350
  154. package/src/demo/container.ts +0 -219
  155. package/src/demo/cursors.ts +0 -55
  156. package/src/demo/drive.ts +0 -442
  157. package/src/demo/pointer.ts +0 -178
  158. package/src/demo/theme.ts +0 -112
  159. package/src/demo/timeline.ts +0 -64
  160. package/src/design/adapter.ts +0 -59
  161. package/src/design/base.css +0 -203
  162. package/src/design/brand-favicon.ts +0 -18
  163. package/src/design/neutral.ts +0 -127
  164. package/src/design/project-fonts.ts +0 -392
  165. package/src/slides/convert/chart.ts +0 -165
  166. package/src/slides/convert/deck.ts +0 -244
  167. package/src/slides/convert/export.ts +0 -567
  168. package/src/slides/convert/master.ts +0 -198
  169. package/src/slides/convert/shapes.ts +0 -804
  170. package/src/slides/convert/svg.ts +0 -38
  171. package/src/slides/convert/walk.ts +0 -671
  172. package/src/slides/decks.ts +0 -110
  173. package/src/slides/open.ts +0 -18
  174. package/src/slides/package/fonts.ts +0 -301
  175. package/src/slides/package/motion.ts +0 -316
  176. package/src/slides/package/test-face.ts +0 -113
  177. package/src/teach/browser/compile.ts +0 -44
  178. package/src/teach/browser/dismiss.ts +0 -76
  179. package/src/teach/browser/focus-line.ts +0 -19
  180. package/src/teach/browser/glossary-filter.ts +0 -44
  181. package/src/teach/browser/head.ts +0 -30
  182. package/src/teach/browser/quiz.ts +0 -19
  183. package/src/teach/browser/reload.ts +0 -9
  184. package/src/teach/browser/sidebar.ts +0 -233
  185. package/src/teach/browser/theme.ts +0 -18
  186. package/src/teach/components/code.tsx +0 -24
  187. package/src/teach/components/heading.tsx +0 -11
  188. package/src/teach/components/list.tsx +0 -17
  189. package/src/teach/components/paragraph.tsx +0 -29
  190. package/src/teach/components/refs.tsx +0 -28
  191. package/src/teach/cross-references.ts +0 -78
  192. package/src/teach/fonts.ts +0 -16
  193. package/src/teach/highlight.ts +0 -71
  194. package/src/teach/html/jsx-dev-runtime.ts +0 -13
  195. package/src/teach/html/jsx-runtime.ts +0 -113
  196. package/src/teach/html/raw.ts +0 -28
  197. package/src/teach/lesson.ts +0 -178
  198. package/src/teach/nav.ts +0 -1337
  199. package/src/teach/render.ts +0 -240
  200. package/src/teach/workspace.ts +0 -1164
  201. package/src/teach/write-if-changed.ts +0 -17
@@ -1,55 +0,0 @@
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
- }
package/src/demo/drive.ts DELETED
@@ -1,442 +0,0 @@
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 { isBrowserMissing } from '@/browser/engine'
7
- import { deriveSteps } from '@/demo/compile'
8
- import type { DemoPlan, DemoStep } from '@/demo/compile'
9
- import type { CursorSet } from '@/demo/pointer'
10
- import { pointerSource } from '@/demo/pointer'
11
- import type { StepTiming, TargetBox } from '@/demo/timeline'
12
- import { timelineEntry, timelinePath, writeTimeline } from '@/demo/timeline'
13
-
14
- declare global {
15
- interface Window {
16
- __canon_demo_caption__?: (text: string) => void
17
- }
18
- }
19
-
20
- /**
21
- * Drives a running application and records what it did. Every browser reference
22
- * the demo feature adds lives here, and `src/commands/demo.ts` reaches it
23
- * through a dynamic import so no other command resolves the engine at startup.
24
- *
25
- * It imports `playwright-core` rather than `@playwright/test`, which stays a
26
- * development dependency the published tarball never carries. Shipping puts the
27
- * import in every target's dependency tree, and a target needs the driver
28
- * rather than a test runner and an assertion library. Every browser module here
29
- * takes the same import for that reason, `@/capture/render` included since it
30
- * started shipping too. Both are pinned to one version rather
31
- * than a range, because `bunx playwright install chromium` fetches the browser
32
- * revision the installed engine expects and a float would leave a target
33
- * resolving a binary its engine cannot launch.
34
- */
35
-
36
- const POINTER_SIZE = 32
37
- /**
38
- * Where the pointer starts. Any position inside the viewport works, since the
39
- * point is giving it one move to install and paint before it travels to the
40
- * first target. A corner keeps that first move out of the way of the content.
41
- */
42
- const START = { x: 8, y: 8 }
43
-
44
- /**
45
- * Whether any step will move the pointer. The injected element installs on
46
- * `DOMContentLoaded` and paints itself off-screen, so it costs nothing until
47
- * something moves it, and seeding `START` is what first makes it visible.
48
- *
49
- * A plan that only navigates, scrolls and holds has nothing to point at, and
50
- * seeding it anyway parked a cursor in the corner for the whole recording. That
51
- * reads as a stuck artifact rather than a pointer, which is the same defect as
52
- * resting it on a row of text, moved to a corner rather than fixed.
53
- *
54
- * `scroll` is absent deliberately. It is a targeted verb the compiler groups
55
- * with the rest, and it stopped moving the pointer when a scroll step began
56
- * leaving it where it was.
57
- */
58
- function movesPointer(plan: DemoPlan): boolean {
59
- return plan.steps.some(
60
- (step) =>
61
- step.kind === 'click' || step.kind === 'fill' || step.kind === 'hover',
62
- )
63
- }
64
- const SETTLE_MS = 250
65
- /** Round trips sampled to price one, on the page a step is actually about to move across. */
66
- const CALIBRATION_STEPS = 8
67
- /** DOM id the caption bar installs under, read back by `drive.e2e.test.ts`. */
68
- export const CAPTION_ID = '__canon_demo_caption_bar__'
69
-
70
- /**
71
- * The two output paths arrive resolved rather than as a root this re-resolves
72
- * against, because the plan already carries a directory and resolving it twice
73
- * nests the whole path inside itself.
74
- *
75
- * An absent path means the caller asked for that artifact not to be produced.
76
- */
77
- export interface DriveOptions {
78
- readonly plan: DemoPlan
79
- readonly cursors: CursorSet
80
- readonly videoPath?: string
81
- readonly stillPath?: string
82
- }
83
-
84
- export type DriveResult =
85
- | {
86
- status: 'recorded'
87
- videoPath?: string
88
- stillPath?: string
89
- timelinePath?: string
90
- steps: number
91
- durationMs: number
92
- }
93
- | { status: 'failed'; reason: DriveRefusal; message: string }
94
-
95
- export type DriveRefusal = 'browser-missing' | 'drive-failed'
96
-
97
- interface DriveFailure {
98
- readonly status: 'failed'
99
- readonly reason: DriveRefusal
100
- readonly message: string
101
- }
102
-
103
- type Launch = { status: 'launched'; value: Browser } | DriveFailure
104
-
105
- export async function drive(options: DriveOptions): Promise<DriveResult> {
106
- const { plan } = options
107
-
108
- const browser = await launch()
109
- if (browser.status === 'failed') return browser
110
-
111
- let videoDir: string | undefined
112
- let context: BrowserContext
113
- const started = Date.now()
114
- try {
115
- // Created after the launch, so a target with no browser binary does not
116
- // leave an empty directory behind for a run that never started, and inside
117
- // the try so a failure here closes the browser rather than leaking it.
118
- videoDir = options.videoPath
119
- ? mkdtempSync(join(tmpdir(), 'canon-demo-'))
120
- : undefined
121
-
122
- context = await browser.value.newContext({
123
- viewport: plan.viewport,
124
- ...(plan.colorScheme && { colorScheme: plan.colorScheme }),
125
- // Pointed the opposite way from a test. A recording wants the motion the
126
- // interface was designed with, where a test wants it suppressed.
127
- reducedMotion: 'no-preference',
128
- // `showActions` is deliberately not passed. Playwright's annotation draws
129
- // a dot on the interacted element and a title naming the API call it
130
- // made, and this recorder already supersedes both: the injected pointer
131
- // is a real cursor where the dot is a marker, and the caption bar carries
132
- // the beat's narration where the title carries `Mouse move`. Leaving it
133
- // on ran four overlays where two were wanted, and the two redundant ones
134
- // were the two a viewer reads as noise. `plan.annotations` survives as a
135
- // shape the type still carries and nothing now reads. It is not a record
136
- // of what a committed plan configured, because `parsePlan` has always
137
- // replaced it with the `ANNOTATIONS` constant rather than reading the
138
- // file's own values, so that path never existed to preserve.
139
- ...(videoDir
140
- ? {
141
- recordVideo: {
142
- dir: videoDir,
143
- size: plan.viewport,
144
- },
145
- }
146
- : {}),
147
- })
148
- } catch (error) {
149
- await browser.value.close()
150
- if (videoDir) rmSync(videoDir, { recursive: true, force: true })
151
- return failed('drive-failed', error)
152
- }
153
-
154
- let stillPath: string | undefined
155
- let videoPath: string | undefined
156
- let timeline: string | undefined
157
-
158
- try {
159
- await context.addInitScript({
160
- content: pointerSource(options.cursors, POINTER_SIZE),
161
- })
162
- await context.addInitScript({ content: captionInitScript() })
163
- const page = await context.newPage()
164
- // The recording begins with the page, so this is the instant a frame of
165
- // the take is zero. Taking it at the first step instead would drift every
166
- // entry by the opening page load.
167
- const recordingStart = performance.now()
168
- const video = page.video()
169
- const timings: StepTiming[] = []
170
- const pace: PointerPace = {}
171
-
172
- // The opening navigate is skipped when the plan already starts with one,
173
- // because a draft written around an opening verb compiles to a `navigate`
174
- // step for the same URL and the second load is a visible reload.
175
- if (plan.steps[0]?.kind !== 'navigate') {
176
- await page.goto(plan.url)
177
- if (movesPointer(plan)) {
178
- await page.mouse.move(START.x, START.y, { steps: 2 })
179
- }
180
- }
181
-
182
- for (const [index, step] of plan.steps.entries()) {
183
- const startedAt = performance.now()
184
- const box = await runStep(page, plan, step, pace)
185
- timings.push({
186
- index,
187
- kind: step.kind,
188
- startedAt,
189
- endedAt: performance.now(),
190
- ...(box ? { box } : {}),
191
- })
192
- // The first marked step wins. One file holds one frame, so a plan a
193
- // person edited to mark several would otherwise write each over the last
194
- // and keep whichever ran last, with nothing saying so.
195
- if (step.still && options.stillPath && !stillPath) {
196
- stillPath = options.stillPath
197
- mkdirSync(dirname(stillPath), { recursive: true })
198
- await page.screenshot({ path: stillPath })
199
- }
200
- }
201
-
202
- await context.close()
203
-
204
- if (video && options.videoPath) {
205
- videoPath = options.videoPath
206
- mkdirSync(dirname(videoPath), { recursive: true })
207
- await video.saveAs(videoPath)
208
- // The engine keeps the auto-named recording beside the saved copy, so a
209
- // run that skipped this would leave two files for every demo.
210
- await video.delete()
211
- timeline = timelinePath(videoPath)
212
- writeTimeline(
213
- timeline,
214
- timings.map((timing) => timelineEntry(timing, recordingStart)),
215
- )
216
- }
217
- } catch (error) {
218
- return failed('drive-failed', error)
219
- } finally {
220
- await browser.value.close()
221
- if (videoDir) rmSync(videoDir, { recursive: true, force: true })
222
- }
223
-
224
- return {
225
- status: 'recorded',
226
- ...(videoPath ? { videoPath } : {}),
227
- ...(stillPath ? { stillPath } : {}),
228
- ...(timeline ? { timelinePath: timeline } : {}),
229
- steps: plan.steps.length,
230
- durationMs: Date.now() - started,
231
- }
232
- }
233
-
234
- /**
235
- * Reads the launch through `@/browser/engine`, which is where the separation
236
- * between a binary that was never installed and every other launch failure now
237
- * lives. It moved out of this file when `canon inventory` became the second
238
- * command needing it, rather than being copied.
239
- */
240
- async function launch(): Promise<Launch> {
241
- try {
242
- return { status: 'launched', value: await chromium.launch() }
243
- } catch (error) {
244
- return failed(
245
- isBrowserMissing(error) ? 'browser-missing' : 'drive-failed',
246
- error,
247
- )
248
- }
249
- }
250
-
251
- /**
252
- * Holds the round trip once a step has measured it, so every `moveTo` after
253
- * the first reuses the same reading rather than re-timing on every move.
254
- */
255
- export interface PointerPace {
256
- roundTripMs?: number
257
- }
258
-
259
- /**
260
- * Exported so `drive.e2e.test.ts` can drive one real step against a real
261
- * caption and read it back, which is the integration a full `drive()` call
262
- * cannot assert without decoding the video it writes.
263
- *
264
- * Returns the box the pointer moved to, for the timeline, and nothing for
265
- * a step that points at no target.
266
- */
267
- export async function runStep(
268
- page: Page,
269
- plan: DemoPlan,
270
- step: DemoStep,
271
- pace: PointerPace,
272
- ): Promise<TargetBox | undefined> {
273
- let box: TargetBox | undefined
274
- switch (step.kind) {
275
- case 'navigate':
276
- await page.goto(step.target || plan.url)
277
- if (movesPointer(plan)) {
278
- await page.mouse.move(START.x, START.y, { steps: 2 })
279
- }
280
- break
281
- case 'click':
282
- box = await moveTo(page, plan, step, pace)
283
- await page.mouse.down()
284
- await page.mouse.up()
285
- break
286
- case 'fill':
287
- box = await moveTo(page, plan, step, pace)
288
- await page.mouse.down()
289
- await page.mouse.up()
290
- await page.keyboard.type(step.text, { delay: plan.pointer.typeDelayMs })
291
- break
292
- case 'hover':
293
- box = await moveTo(page, plan, step, pace)
294
- break
295
- case 'scroll':
296
- // Centred rather than `scrollIntoViewIfNeeded`, which scrolls the least
297
- // it can and leaves a target taller than the remaining space flush
298
- // against the bottom edge. Measured on this repository's own recording at
299
- // 225 pixels of dead space above the content and 2 below, where centring
300
- // splits it 114 and 113. A recording frames its subject, so the least
301
- // scroll that technically reveals it is the wrong amount.
302
- await page
303
- .locator(step.target)
304
- .first()
305
- .evaluate((node) =>
306
- node.scrollIntoView({
307
- block: 'center',
308
- inline: 'nearest',
309
- // Explicit, because this runs in the page where
310
- // `scrollIntoViewIfNeeded` ran through the debugging protocol and
311
- // was always instant. An in-page scroll honours the document's own
312
- // `scroll-behavior`, so a project setting it to `smooth` gets a
313
- // call that returns before the scroll finishes, with only
314
- // `SETTLE_MS` behind it. Nothing in this repository sets it, which
315
- // is exactly why the recorder cannot catch this on its own pages.
316
- behavior: 'instant',
317
- }),
318
- )
319
- await page.waitForTimeout(SETTLE_MS)
320
- // The pointer stays where it was. A scroll is not a pointing action, so
321
- // gliding the cursor to the target's centre parks it on top of whatever
322
- // the scroll just revealed and covers a row of it. A real session scrolls
323
- // with a wheel and leaves the cursor alone. On a plan that points at
324
- // nothing, `movesPointer` then keeps it off-screen for the whole run
325
- // rather than parked in a corner.
326
- break
327
- case 'wait':
328
- case 'hold':
329
- break
330
- }
331
-
332
- // Set after the action rather than before it, so the caption shows for the
333
- // hold that follows rather than for the page the action is about to leave.
334
- await setCaption(page, step.caption)
335
- if (step.waitFor) await page.locator(step.waitFor).first().waitFor()
336
- await page.waitForTimeout(step.holdMs)
337
- return box
338
- }
339
-
340
- /**
341
- * Timed on the page a `moveTo` is actually about to move across, never on the
342
- * blank page before it, since layout, paint, and page script are what a step
343
- * pays the round trip against and a blank page has none of the three.
344
- */
345
- async function calibrateRoundTrip(page: Page): Promise<number> {
346
- const startedAt = Date.now()
347
- await page.mouse.move(START.x, START.y, { steps: CALIBRATION_STEPS })
348
- return (Date.now() - startedAt) / CALIBRATION_STEPS
349
- }
350
-
351
- /**
352
- * Travel is the whole point of driving the engine's pointer rather than calling
353
- * the element-clicking helper, which resolves a target and jumps to it. The
354
- * step count is what separates a cursor that glides from one that teleports.
355
- *
356
- * Resolving through a bounding box assumes the target is in the viewport, so a
357
- * target below the fold needs a `scroll` step ahead of it.
358
- */
359
- async function moveTo(
360
- page: Page,
361
- plan: DemoPlan,
362
- step: DemoStep,
363
- pace: PointerPace,
364
- ): Promise<TargetBox> {
365
- pace.roundTripMs ??= await calibrateRoundTrip(page)
366
- const locator = page.locator(step.target).first()
367
- await locator.waitFor()
368
- const box = await locator.boundingBox()
369
- if (!box) throw new Error(`${step.target} has no box to point at`)
370
- await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
371
- steps: deriveSteps(plan.pointer.travelMs, pace.roundTripMs),
372
- })
373
- return box
374
- }
375
-
376
- /**
377
- * Playwright's own `showActions` overlay names the API call it made, not the
378
- * beat's narration, so a caption needs an element of its own rather than
379
- * reusing that annotation. Runs alongside `pointerSource`, guarded the same
380
- * way against a page that already carries one.
381
- */
382
- export function captionInitScript(): string {
383
- return `(() => {
384
- if (window.__canon_demo_caption__) return;
385
-
386
- let label;
387
-
388
- const install = () => {
389
- if (label || !document.body) return;
390
- const bar = document.createElement('div');
391
- bar.id = '${CAPTION_ID}';
392
- bar.setAttribute('aria-hidden', 'true');
393
- bar.style.cssText = [
394
- 'position:fixed',
395
- 'left:0',
396
- 'right:0',
397
- 'bottom:32px',
398
- 'display:flex',
399
- 'justify-content:center',
400
- 'pointer-events:none',
401
- 'z-index:2147483647',
402
- ].join(';');
403
- label = document.createElement('span');
404
- label.style.cssText = [
405
- 'background:rgba(16,16,20,0.85)',
406
- 'color:#f4f4f5',
407
- 'font:600 20px/1.4 system-ui,sans-serif',
408
- 'padding:10px 22px',
409
- 'border-radius:8px',
410
- 'max-width:80vw',
411
- 'text-align:center',
412
- 'display:none',
413
- ].join(';');
414
- bar.appendChild(label);
415
- document.body.appendChild(bar);
416
- window.__canon_demo_caption__ = (text) => {
417
- label.textContent = text || '';
418
- label.style.display = text ? 'inline-block' : 'none';
419
- };
420
- };
421
-
422
- if (document.readyState === 'loading') {
423
- addEventListener('DOMContentLoaded', install, { once: true });
424
- } else {
425
- install();
426
- }
427
- })();`
428
- }
429
-
430
- async function setCaption(page: Page, caption: string): Promise<void> {
431
- await page.evaluate((text) => {
432
- window.__canon_demo_caption__?.(text)
433
- }, caption)
434
- }
435
-
436
- function failed(reason: DriveRefusal, error: unknown): DriveFailure {
437
- return {
438
- status: 'failed',
439
- reason,
440
- message: error instanceof Error ? error.message : String(error),
441
- }
442
- }
@@ -1,178 +0,0 @@
1
- /**
2
- * The pointer the recording shows. The browser engine's own annotation draws a
3
- * dot at the moment of a click and paints no cursor, so a run without this
4
- * looks like the pointer teleports between targets.
5
- *
6
- * Nothing here touches a browser. `pointerSource` returns the script text that
7
- * `@/demo/drive` installs before navigation, which keeps the hotspot arithmetic
8
- * and the state table testable without launching anything.
9
- */
10
-
11
- export interface CursorHotspot {
12
- readonly width: number
13
- readonly height: number
14
- readonly hotspotX: number
15
- readonly hotspotY: number
16
- }
17
-
18
- export interface Cursor {
19
- readonly image: string
20
- readonly hotspot: CursorHotspot
21
- }
22
-
23
- export type CursorSet = Readonly<Record<string, Cursor>>
24
-
25
- const HEADER_BYTES = 6
26
- const ENTRY_BYTES = 16
27
- const CURSOR_TYPE = 2
28
- /** The format stores 256 as a zero, since the field is one byte wide. */
29
- const SIZE_256 = 256
30
-
31
- /**
32
- * Reads the directory of a Windows cursor resource and returns the hotspot of
33
- * its largest entry. A resource carries several sizes with a hotspot each, and
34
- * taking the largest is what matches the artwork the recorder draws at.
35
- *
36
- * Only the header and directory are read. The pixel payload is left to the
37
- * browser, which spike 3 measured as decoding the format directly with no
38
- * conversion step and no image tooling on the machine.
39
- */
40
- export function cursorHotspot(bytes: Uint8Array): CursorHotspot | undefined {
41
- if (bytes.byteLength < HEADER_BYTES + ENTRY_BYTES) return undefined
42
-
43
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
44
- if (view.getUint16(2, true) !== CURSOR_TYPE) return undefined
45
-
46
- const count = view.getUint16(4, true)
47
- if (count === 0) return undefined
48
-
49
- let largest: CursorHotspot | undefined
50
- for (let index = 0; index < count; index += 1) {
51
- const at = HEADER_BYTES + index * ENTRY_BYTES
52
- if (at + ENTRY_BYTES > bytes.byteLength) break
53
-
54
- const entry: CursorHotspot = {
55
- width: bytes[at] === 0 ? SIZE_256 : (bytes[at] ?? 0),
56
- height: bytes[at + 1] === 0 ? SIZE_256 : (bytes[at + 1] ?? 0),
57
- hotspotX: view.getUint16(at + 4, true),
58
- hotspotY: view.getUint16(at + 6, true),
59
- }
60
- if (!largest || entry.width * entry.height > largest.width * largest.height)
61
- largest = entry
62
- }
63
- return largest
64
- }
65
-
66
- /**
67
- * Converts a hotspot stated against the source artwork into an offset in the
68
- * size the pointer is drawn at. Skipping this puts the artwork's top left where
69
- * the click lands instead of its tip, which offsets every action by roughly a
70
- * third of a cursor.
71
- */
72
- export function scaleHotspot(
73
- hotspot: CursorHotspot,
74
- drawnSize: number,
75
- ): { x: number; y: number } {
76
- const scale = drawnSize / hotspot.width
77
- return {
78
- x: hotspot.hotspotX * scale,
79
- y: hotspot.hotspotY * (drawnSize / hotspot.height),
80
- }
81
- }
82
-
83
- interface PointerState {
84
- readonly image: string
85
- readonly x: number
86
- readonly y: number
87
- }
88
-
89
- /**
90
- * Returns the script installed before navigation. It is a string rather than a
91
- * function reference because the cursor payload is data resolved on this side,
92
- * and passing artwork through an argument would still need serializing.
93
- *
94
- * The element inherits the page's world, so a site with its own element at this
95
- * id, a stacking context that outranks it, or a style rule reaching it will
96
- * interfere. That is the cost of drawing the pointer inside the page rather
97
- * than reaching for a desktop recorder.
98
- */
99
- export function pointerSource(cursors: CursorSet, size: number): string {
100
- const states: Record<string, PointerState> = {}
101
- for (const [name, cursor] of Object.entries(cursors)) {
102
- const offset = scaleHotspot(cursor.hotspot, size)
103
- states[name] = { image: cursor.image, x: offset.x, y: offset.y }
104
- }
105
-
106
- const config = JSON.stringify({ size, states })
107
-
108
- return `(() => {
109
- const CONFIG = ${config};
110
- const ID = '__canon_demo_pointer__';
111
- if (window[ID]) return;
112
- window[ID] = true;
113
-
114
- let node;
115
- let state = 'default';
116
- let pressed = false;
117
- let x = -9999;
118
- let y = -9999;
119
-
120
- const install = () => {
121
- if (node || !document.body) return;
122
- node = document.createElement('img');
123
- node.id = ID;
124
- node.setAttribute('aria-hidden', 'true');
125
- node.style.cssText = [
126
- 'position:fixed',
127
- 'left:0',
128
- 'top:0',
129
- 'width:' + CONFIG.size + 'px',
130
- 'height:' + CONFIG.size + 'px',
131
- 'z-index:2147483647',
132
- 'pointer-events:none',
133
- 'user-select:none',
134
- 'will-change:transform',
135
- 'transition:transform 90ms ease-out',
136
- ].join(';');
137
- document.body.appendChild(node);
138
- paint();
139
- };
140
-
141
- const paint = () => {
142
- if (!node) return;
143
- const cursor = CONFIG.states[state] || CONFIG.states.default;
144
- if (!cursor) return;
145
- if (node.getAttribute('src') !== cursor.image) node.setAttribute('src', cursor.image);
146
- const scale = pressed ? 0.88 : 1;
147
- node.style.transform =
148
- 'translate(' + (x - cursor.x) + 'px,' + (y - cursor.y) + 'px) scale(' + scale + ')';
149
- };
150
-
151
- const stateAt = (target) => {
152
- if (!(target instanceof Element)) return 'default';
153
- const tag = target.tagName;
154
- if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return 'text';
155
- const style = getComputedStyle(target).cursor;
156
- if (style === 'pointer' && CONFIG.states.pointer) return 'pointer';
157
- if (style === 'text' && CONFIG.states.text) return 'text';
158
- return 'default';
159
- };
160
-
161
- addEventListener('mousemove', (event) => {
162
- x = event.clientX;
163
- y = event.clientY;
164
- state = stateAt(event.target);
165
- install();
166
- paint();
167
- }, true);
168
-
169
- addEventListener('mousedown', () => { pressed = true; paint(); }, true);
170
- addEventListener('mouseup', () => { pressed = false; paint(); }, true);
171
-
172
- if (document.readyState === 'loading') {
173
- addEventListener('DOMContentLoaded', install, { once: true });
174
- } else {
175
- install();
176
- }
177
- })();`
178
- }