@erclx/canon 5.15.0 → 5.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-figure/REQUIREMENT.md +8 -2
- package/claude/skills/draft-figure/SKILL.md +19 -4
- package/claude/skills/draft-wiki/SKILL.md +1 -1
- package/claude/skills/draft-wireframes/SKILL.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/search-craft/REQUIREMENT.md +57 -0
- package/claude/skills/search-craft/SKILL.md +83 -0
- package/claude/skills/teach-workspace/SKILL.md +1 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
- package/docs/agents/canvas.md +36 -0
- package/docs/agents/commands.md +3 -3
- package/docs/agents/demo.md +3 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/teach.md +2 -2
- package/docs/workflow/skill-map.md +1 -0
- package/docs/workflow/visual-design-workflow.md +8 -15
- package/governance/rules/claude/566-output.md +3 -5
- package/governance/rules/claude/575-hooks.md +1 -1
- package/package.json +1 -1
- package/scripts/lib/sandbox-git.sh +2 -2
- package/src/canvas/address.ts +184 -0
- package/src/canvas/capture.ts +172 -0
- package/src/canvas/client/app.tsx +2 -0
- package/src/canvas/client/inspector.tsx +121 -0
- package/src/canvas/client/layers.tsx +118 -0
- package/src/canvas/client/pages-panel.tsx +47 -13
- package/src/canvas/client/shell.css +131 -1
- package/src/canvas/client/state.ts +186 -0
- package/src/canvas/client/surface.tsx +272 -20
- package/src/canvas/content.ts +307 -16
- package/src/canvas/server.ts +182 -13
- package/src/claude/cases/misc.ts +5 -0
- package/src/commands/canvas.ts +200 -3
- package/src/commands/demo.ts +2 -0
- package/src/commands/migrate.ts +1 -1
- package/src/commands/teach.ts +3 -1
- package/src/demo/drive.ts +36 -7
- package/src/demo/timeline.ts +64 -0
- package/src/migrate/rename.ts +5 -4
- package/src/teach/browser/compile.ts +44 -0
- package/src/teach/browser/dismiss.ts +76 -0
- package/src/teach/browser/focus-line.ts +19 -0
- package/src/teach/browser/glossary-filter.ts +44 -0
- package/src/teach/browser/head.ts +30 -0
- package/src/teach/browser/quiz.ts +19 -0
- package/src/teach/browser/sidebar.ts +233 -0
- package/src/teach/browser/theme.ts +18 -0
- package/src/teach/nav.ts +19 -374
- package/src/teach/workspace.ts +61 -6
- package/standards/figures.md +1 -0
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
- package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
- package/tooling/claude/seeds/.claude/settings.json +9 -0
package/src/commands/canvas.ts
CHANGED
|
@@ -8,8 +8,12 @@ import {
|
|
|
8
8
|
canvasDir,
|
|
9
9
|
DEFAULT_FRAME,
|
|
10
10
|
listPages,
|
|
11
|
+
moveFrame,
|
|
12
|
+
readPage,
|
|
13
|
+
readSelection,
|
|
11
14
|
renamePage,
|
|
12
15
|
} from '@/canvas/content'
|
|
16
|
+
import { captureCanvas } from '@/canvas/capture'
|
|
13
17
|
import { CANVAS_PORT, startCanvas } from '@/canvas/server'
|
|
14
18
|
import { resolveFrameTokens } from '@/canvas/tokens'
|
|
15
19
|
import { displayPath, parsePort, waitForInterrupt } from '@/serve/report'
|
|
@@ -34,7 +38,11 @@ interface RootOptions {
|
|
|
34
38
|
|
|
35
39
|
interface Refused {
|
|
36
40
|
readonly ok: false
|
|
37
|
-
readonly reason:
|
|
41
|
+
readonly reason:
|
|
42
|
+
| ContentRefused['reason']
|
|
43
|
+
| 'no-root'
|
|
44
|
+
| 'no-server'
|
|
45
|
+
| 'capture-failed'
|
|
38
46
|
readonly detail: string
|
|
39
47
|
}
|
|
40
48
|
|
|
@@ -94,7 +102,7 @@ export function register(program: Command): void {
|
|
|
94
102
|
.command('canvas')
|
|
95
103
|
.helpOption('-h, --help', 'Show this help message')
|
|
96
104
|
.description(
|
|
97
|
-
'Local canvas of pages and HTML frames (serve, list, page, frame)',
|
|
105
|
+
'Local canvas of pages and HTML frames (serve, list, page, frame, selection, capture)',
|
|
98
106
|
)
|
|
99
107
|
.addHelpText(
|
|
100
108
|
'after',
|
|
@@ -108,6 +116,9 @@ export function register(program: Command): void {
|
|
|
108
116
|
' canon canvas serve',
|
|
109
117
|
' canon canvas page add drafts',
|
|
110
118
|
' canon canvas frame add drafts hero --width 1440 --height 900',
|
|
119
|
+
' canon canvas frame move drafts hero --x 200 --y 120',
|
|
120
|
+
' canon canvas selection --json',
|
|
121
|
+
' canon canvas capture drafts/hero',
|
|
111
122
|
' canon canvas list --json',
|
|
112
123
|
'',
|
|
113
124
|
].join('\n'),
|
|
@@ -212,7 +223,7 @@ export function register(program: Command): void {
|
|
|
212
223
|
const frame = canvas
|
|
213
224
|
.command('frame')
|
|
214
225
|
.helpOption('-h, --help', 'Show this help message')
|
|
215
|
-
.description('Add a frame to a page')
|
|
226
|
+
.description('Add a frame to a page, or move one')
|
|
216
227
|
|
|
217
228
|
withRoot(
|
|
218
229
|
frame
|
|
@@ -256,6 +267,192 @@ export function register(program: Command): void {
|
|
|
256
267
|
outro()
|
|
257
268
|
},
|
|
258
269
|
)
|
|
270
|
+
|
|
271
|
+
withRoot(
|
|
272
|
+
frame
|
|
273
|
+
.command('move')
|
|
274
|
+
.description('Move a frame, keeping its size')
|
|
275
|
+
.argument('<page>', 'Page the frame is on')
|
|
276
|
+
.argument('<name>', 'Frame name')
|
|
277
|
+
.option('--x <px>', 'New x, defaulting to where the frame is')
|
|
278
|
+
.option('--y <px>', 'New y, defaulting to where the frame is'),
|
|
279
|
+
).action(
|
|
280
|
+
async (
|
|
281
|
+
pageName: string,
|
|
282
|
+
name: string,
|
|
283
|
+
opts: RootOptions & { x?: string; y?: string },
|
|
284
|
+
) => {
|
|
285
|
+
const resolved = await resolveRoot(opts)
|
|
286
|
+
if (!resolved.ok) {
|
|
287
|
+
process.exitCode = refuse(resolved, opts.json ?? false)
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
const { root } = resolved
|
|
291
|
+
const current = readPage(root, pageName)?.frames.find(
|
|
292
|
+
(candidate) => candidate.name === name,
|
|
293
|
+
)
|
|
294
|
+
const outcome = moveFrame(root, pageName, name, {
|
|
295
|
+
x: parsePosition(opts.x, current?.x ?? 0),
|
|
296
|
+
y: parsePosition(opts.y, current?.y ?? 0),
|
|
297
|
+
})
|
|
298
|
+
if (!outcome.ok) {
|
|
299
|
+
process.exitCode = refuse(outcome, opts.json ?? false)
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
if (opts.json) writeJson(outcome)
|
|
303
|
+
intro(BANNER)
|
|
304
|
+
logAdd(`${outcome.page}/${outcome.file}`)
|
|
305
|
+
logInfo(`at ${outcome.box.x}, ${outcome.box.y}`)
|
|
306
|
+
outro()
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
withRoot(
|
|
311
|
+
canvas
|
|
312
|
+
.command('selection')
|
|
313
|
+
.description(
|
|
314
|
+
'Report the frame, or the element in it, the operator selected',
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
.addHelpText(
|
|
318
|
+
'after',
|
|
319
|
+
[
|
|
320
|
+
'',
|
|
321
|
+
'Reports none when nothing is selected or when the selected frame has',
|
|
322
|
+
'since been removed. Read this when the operator says "this one".',
|
|
323
|
+
'',
|
|
324
|
+
'An element is named by its index in document order, with the tag,',
|
|
325
|
+
'classes, and text it had when picked. stale is true once the frame',
|
|
326
|
+
'file changed since, since the index may then name another element.',
|
|
327
|
+
'',
|
|
328
|
+
].join('\n'),
|
|
329
|
+
)
|
|
330
|
+
.action(async (opts: RootOptions) => {
|
|
331
|
+
const resolved = await resolveRoot(opts)
|
|
332
|
+
if (!resolved.ok) {
|
|
333
|
+
process.exitCode = refuse(resolved, opts.json ?? false)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
const { root } = resolved
|
|
337
|
+
const selected = readSelection(root)
|
|
338
|
+
if (opts.json) {
|
|
339
|
+
writeJson({
|
|
340
|
+
ok: true,
|
|
341
|
+
selection: selected
|
|
342
|
+
? {
|
|
343
|
+
...selected,
|
|
344
|
+
path: resolve(canvasDir(root), selected.page, selected.file),
|
|
345
|
+
}
|
|
346
|
+
: null,
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
intro(BANNER)
|
|
350
|
+
if (!selected) {
|
|
351
|
+
logStep('Nothing selected')
|
|
352
|
+
} else {
|
|
353
|
+
logStep(`${selected.page}/${selected.frame}`)
|
|
354
|
+
const { box, element } = selected
|
|
355
|
+
logInfo(`${box.width} × ${box.height} at ${box.x}, ${box.y}`)
|
|
356
|
+
if (element) {
|
|
357
|
+
const classes = element.classes.map((name) => `.${name}`).join('')
|
|
358
|
+
logInfo(
|
|
359
|
+
`<${element.tag}${classes}> at index ${element.index}: ${element.text}`,
|
|
360
|
+
)
|
|
361
|
+
if (element.stale) {
|
|
362
|
+
logWarn('The frame changed since, so the index may have moved')
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
outro()
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
withRoot(
|
|
370
|
+
canvas
|
|
371
|
+
.command('capture')
|
|
372
|
+
.description('Capture a frame, or every frame of a page, as a PNG')
|
|
373
|
+
.argument('<target>', '<page>/<frame> for one frame, <page> for all')
|
|
374
|
+
.option(
|
|
375
|
+
'-o, --out <path>',
|
|
376
|
+
'The PNG for a frame, or the folder for a page; defaults to session scratch',
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
.addHelpText(
|
|
380
|
+
'after',
|
|
381
|
+
[
|
|
382
|
+
'',
|
|
383
|
+
'Serves the canvas for the call and captures through that address, so',
|
|
384
|
+
'the injected tokens are in the PNG and each frame renders at its own',
|
|
385
|
+
'width. The raw file on disk is never captured.',
|
|
386
|
+
'',
|
|
387
|
+
'Exit codes:',
|
|
388
|
+
' 0 every frame rendered',
|
|
389
|
+
' 1 refused or a frame failed, with the reason on stderr or in the JSON record',
|
|
390
|
+
'',
|
|
391
|
+
].join('\n'),
|
|
392
|
+
)
|
|
393
|
+
.action(async (target: string, opts: RootOptions & { out?: string }) => {
|
|
394
|
+
const resolved = await resolveRoot(opts)
|
|
395
|
+
if (!resolved.ok) {
|
|
396
|
+
process.exitCode = refuse(resolved, opts.json ?? false)
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
let outcome: Awaited<ReturnType<typeof captureCanvas>>
|
|
400
|
+
try {
|
|
401
|
+
outcome = await captureCanvas(
|
|
402
|
+
resolved.root,
|
|
403
|
+
target,
|
|
404
|
+
opts.out ? resolve(process.cwd(), opts.out) : undefined,
|
|
405
|
+
)
|
|
406
|
+
} catch (error) {
|
|
407
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
408
|
+
process.exitCode = refuse(
|
|
409
|
+
{ ok: false, reason: 'capture-failed', detail },
|
|
410
|
+
opts.json ?? false,
|
|
411
|
+
)
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
if (!outcome.ok) {
|
|
415
|
+
process.exitCode = refuse(outcome, opts.json ?? false)
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const rows = outcome.captures.map(({ target: item, result }) =>
|
|
420
|
+
result.status === 'rendered'
|
|
421
|
+
? {
|
|
422
|
+
page: item.page,
|
|
423
|
+
frame: item.frame,
|
|
424
|
+
status: result.status,
|
|
425
|
+
path: result.pngPath,
|
|
426
|
+
width: result.width,
|
|
427
|
+
height: result.height,
|
|
428
|
+
}
|
|
429
|
+
: {
|
|
430
|
+
page: item.page,
|
|
431
|
+
frame: item.frame,
|
|
432
|
+
status: result.status,
|
|
433
|
+
reason: result.reason,
|
|
434
|
+
},
|
|
435
|
+
)
|
|
436
|
+
const failed = rows.some((row) => row.status === 'failed')
|
|
437
|
+
if (opts.json) writeJson({ ok: !failed, captures: rows })
|
|
438
|
+
intro(BANNER)
|
|
439
|
+
for (const row of rows) {
|
|
440
|
+
if (row.status === 'rendered') {
|
|
441
|
+
logAdd(`${row.page}/${row.frame}`)
|
|
442
|
+
logInfo(`${displayPath(row.path)} ${row.width}x${row.height}`)
|
|
443
|
+
} else {
|
|
444
|
+
logError(`${row.page}/${row.frame}: ${row.reason}`)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
outro()
|
|
448
|
+
if (failed) process.exitCode = 1
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** NaN for a value that is not a number, which the content module refuses. */
|
|
453
|
+
function parsePosition(raw: string | undefined, fallback: number): number {
|
|
454
|
+
if (raw === undefined) return fallback
|
|
455
|
+
return raw.trim() === '' ? Number.NaN : Number(raw)
|
|
259
456
|
}
|
|
260
457
|
|
|
261
458
|
async function runServe(
|
package/src/commands/demo.ts
CHANGED
|
@@ -333,6 +333,7 @@ async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
|
|
|
333
333
|
|
|
334
334
|
if (result.videoPath) logInfo(display(result.videoPath))
|
|
335
335
|
if (result.stillPath) logInfo(`${display(result.stillPath)} still`)
|
|
336
|
+
if (result.timelinePath) logInfo(`${display(result.timelinePath)} timeline`)
|
|
336
337
|
logInfo(
|
|
337
338
|
`${result.steps} steps in ${Math.round(result.durationMs / 100) / 10}s`,
|
|
338
339
|
)
|
|
@@ -380,6 +381,7 @@ async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
|
|
|
380
381
|
mp4: mp4Path ?? null,
|
|
381
382
|
gif: gifPath ?? null,
|
|
382
383
|
still: result.stillPath ?? null,
|
|
384
|
+
timeline: result.timelinePath ?? null,
|
|
383
385
|
steps: result.steps,
|
|
384
386
|
durationMs: result.durationMs,
|
|
385
387
|
...(mp4Reason ? { mp4Reason } : {}),
|
package/src/commands/migrate.ts
CHANGED
|
@@ -1222,7 +1222,7 @@ export function register(program: Command): void {
|
|
|
1222
1222
|
'',
|
|
1223
1223
|
'The changelog is never rewritten. Its entries record what shipped',
|
|
1224
1224
|
'under the old name, and GitHub redirects the links they carry.',
|
|
1225
|
-
'aitk-sandbox
|
|
1225
|
+
'aitk-sandbox, the old name of erclx/canon-sandbox, is left alone.',
|
|
1226
1226
|
'',
|
|
1227
1227
|
'Examples:',
|
|
1228
1228
|
' canon migrate rename',
|
package/src/commands/teach.ts
CHANGED
|
@@ -237,7 +237,9 @@ export function register(program: Command): void {
|
|
|
237
237
|
'only place a later session reads that from.',
|
|
238
238
|
'',
|
|
239
239
|
'A URL already listed under either heading is refused rather than',
|
|
240
|
-
'repeated, because two entries split what rests on one source.',
|
|
240
|
+
'repeated, because two entries split what rests on one source. The one',
|
|
241
|
+
'exception is a --read URL listed under Leads, which moves to Read under',
|
|
242
|
+
'its new title.',
|
|
241
243
|
'',
|
|
242
244
|
'Examples:',
|
|
243
245
|
' canon teach resource regular-expressions --read "MDN regular expressions=https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions"',
|
package/src/demo/drive.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { deriveSteps } from '@/demo/compile'
|
|
|
8
8
|
import type { DemoPlan, DemoStep } from '@/demo/compile'
|
|
9
9
|
import type { CursorSet } from '@/demo/pointer'
|
|
10
10
|
import { pointerSource } from '@/demo/pointer'
|
|
11
|
+
import type { StepTiming, TargetBox } from '@/demo/timeline'
|
|
12
|
+
import { timelineEntry, timelinePath, writeTimeline } from '@/demo/timeline'
|
|
11
13
|
|
|
12
14
|
declare global {
|
|
13
15
|
interface Window {
|
|
@@ -84,6 +86,7 @@ export type DriveResult =
|
|
|
84
86
|
status: 'recorded'
|
|
85
87
|
videoPath?: string
|
|
86
88
|
stillPath?: string
|
|
89
|
+
timelinePath?: string
|
|
87
90
|
steps: number
|
|
88
91
|
durationMs: number
|
|
89
92
|
}
|
|
@@ -150,6 +153,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
150
153
|
|
|
151
154
|
let stillPath: string | undefined
|
|
152
155
|
let videoPath: string | undefined
|
|
156
|
+
let timeline: string | undefined
|
|
153
157
|
|
|
154
158
|
try {
|
|
155
159
|
await context.addInitScript({
|
|
@@ -157,7 +161,12 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
157
161
|
})
|
|
158
162
|
await context.addInitScript({ content: captionInitScript() })
|
|
159
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()
|
|
160
168
|
const video = page.video()
|
|
169
|
+
const timings: StepTiming[] = []
|
|
161
170
|
const pace: PointerPace = {}
|
|
162
171
|
|
|
163
172
|
// The opening navigate is skipped when the plan already starts with one,
|
|
@@ -170,8 +179,16 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
170
179
|
}
|
|
171
180
|
}
|
|
172
181
|
|
|
173
|
-
for (const step of plan.steps) {
|
|
174
|
-
|
|
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
|
+
})
|
|
175
192
|
// The first marked step wins. One file holds one frame, so a plan a
|
|
176
193
|
// person edited to mark several would otherwise write each over the last
|
|
177
194
|
// and keep whichever ran last, with nothing saying so.
|
|
@@ -191,6 +208,11 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
191
208
|
// The engine keeps the auto-named recording beside the saved copy, so a
|
|
192
209
|
// run that skipped this would leave two files for every demo.
|
|
193
210
|
await video.delete()
|
|
211
|
+
timeline = timelinePath(videoPath)
|
|
212
|
+
writeTimeline(
|
|
213
|
+
timeline,
|
|
214
|
+
timings.map((timing) => timelineEntry(timing, recordingStart)),
|
|
215
|
+
)
|
|
194
216
|
}
|
|
195
217
|
} catch (error) {
|
|
196
218
|
return failed('drive-failed', error)
|
|
@@ -203,6 +225,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
203
225
|
status: 'recorded',
|
|
204
226
|
...(videoPath ? { videoPath } : {}),
|
|
205
227
|
...(stillPath ? { stillPath } : {}),
|
|
228
|
+
...(timeline ? { timelinePath: timeline } : {}),
|
|
206
229
|
steps: plan.steps.length,
|
|
207
230
|
durationMs: Date.now() - started,
|
|
208
231
|
}
|
|
@@ -237,13 +260,17 @@ export interface PointerPace {
|
|
|
237
260
|
* Exported so `drive.e2e.test.ts` can drive one real step against a real
|
|
238
261
|
* caption and read it back, which is the integration a full `drive()` call
|
|
239
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.
|
|
240
266
|
*/
|
|
241
267
|
export async function runStep(
|
|
242
268
|
page: Page,
|
|
243
269
|
plan: DemoPlan,
|
|
244
270
|
step: DemoStep,
|
|
245
271
|
pace: PointerPace,
|
|
246
|
-
): Promise<
|
|
272
|
+
): Promise<TargetBox | undefined> {
|
|
273
|
+
let box: TargetBox | undefined
|
|
247
274
|
switch (step.kind) {
|
|
248
275
|
case 'navigate':
|
|
249
276
|
await page.goto(step.target || plan.url)
|
|
@@ -252,18 +279,18 @@ export async function runStep(
|
|
|
252
279
|
}
|
|
253
280
|
break
|
|
254
281
|
case 'click':
|
|
255
|
-
await moveTo(page, plan, step, pace)
|
|
282
|
+
box = await moveTo(page, plan, step, pace)
|
|
256
283
|
await page.mouse.down()
|
|
257
284
|
await page.mouse.up()
|
|
258
285
|
break
|
|
259
286
|
case 'fill':
|
|
260
|
-
await moveTo(page, plan, step, pace)
|
|
287
|
+
box = await moveTo(page, plan, step, pace)
|
|
261
288
|
await page.mouse.down()
|
|
262
289
|
await page.mouse.up()
|
|
263
290
|
await page.keyboard.type(step.text, { delay: plan.pointer.typeDelayMs })
|
|
264
291
|
break
|
|
265
292
|
case 'hover':
|
|
266
|
-
await moveTo(page, plan, step, pace)
|
|
293
|
+
box = await moveTo(page, plan, step, pace)
|
|
267
294
|
break
|
|
268
295
|
case 'scroll':
|
|
269
296
|
// Centred rather than `scrollIntoViewIfNeeded`, which scrolls the least
|
|
@@ -307,6 +334,7 @@ export async function runStep(
|
|
|
307
334
|
await setCaption(page, step.caption)
|
|
308
335
|
if (step.waitFor) await page.locator(step.waitFor).first().waitFor()
|
|
309
336
|
await page.waitForTimeout(step.holdMs)
|
|
337
|
+
return box
|
|
310
338
|
}
|
|
311
339
|
|
|
312
340
|
/**
|
|
@@ -333,7 +361,7 @@ async function moveTo(
|
|
|
333
361
|
plan: DemoPlan,
|
|
334
362
|
step: DemoStep,
|
|
335
363
|
pace: PointerPace,
|
|
336
|
-
): Promise<
|
|
364
|
+
): Promise<TargetBox> {
|
|
337
365
|
pace.roundTripMs ??= await calibrateRoundTrip(page)
|
|
338
366
|
const locator = page.locator(step.target).first()
|
|
339
367
|
await locator.waitFor()
|
|
@@ -342,6 +370,7 @@ async function moveTo(
|
|
|
342
370
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
|
|
343
371
|
steps: deriveSteps(plan.pointer.travelMs, pace.roundTripMs),
|
|
344
372
|
})
|
|
373
|
+
return box
|
|
345
374
|
}
|
|
346
375
|
|
|
347
376
|
/**
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, extname, join } from 'node:path'
|
|
3
|
+
import type { DemoStep } from '@/demo/compile'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A step's target in viewport pixels, which are the video frame's own
|
|
7
|
+
* coordinates because the recording is sized to the viewport.
|
|
8
|
+
*/
|
|
9
|
+
export interface TargetBox {
|
|
10
|
+
readonly x: number
|
|
11
|
+
readonly y: number
|
|
12
|
+
readonly width: number
|
|
13
|
+
readonly height: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One step as the run clocked it, in wall-clock milliseconds. */
|
|
17
|
+
export interface StepTiming {
|
|
18
|
+
readonly index: number
|
|
19
|
+
readonly kind: DemoStep['kind']
|
|
20
|
+
readonly startedAt: number
|
|
21
|
+
readonly endedAt: number
|
|
22
|
+
readonly box?: TargetBox
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Times count from the recording's start rather than the first step, since
|
|
27
|
+
* that is the clock a composition places the take on. A step ends once its
|
|
28
|
+
* hold has played, so the span covers what the viewer sees of it.
|
|
29
|
+
*/
|
|
30
|
+
export interface TimelineEntry {
|
|
31
|
+
readonly index: number
|
|
32
|
+
readonly kind: DemoStep['kind']
|
|
33
|
+
readonly startMs: number
|
|
34
|
+
readonly endMs: number
|
|
35
|
+
readonly box?: TargetBox
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function timelineEntry(
|
|
39
|
+
timing: StepTiming,
|
|
40
|
+
recordingStart: number,
|
|
41
|
+
): TimelineEntry {
|
|
42
|
+
return {
|
|
43
|
+
index: timing.index,
|
|
44
|
+
kind: timing.kind,
|
|
45
|
+
startMs: Math.round(timing.startedAt - recordingStart),
|
|
46
|
+
endMs: Math.round(timing.endedAt - recordingStart),
|
|
47
|
+
...(timing.box ? { box: timing.box } : {}),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function timelinePath(videoPath: string): string {
|
|
52
|
+
return join(
|
|
53
|
+
dirname(videoPath),
|
|
54
|
+
`${basename(videoPath, extname(videoPath))}.timeline.json`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function writeTimeline(
|
|
59
|
+
path: string,
|
|
60
|
+
entries: readonly TimelineEntry[],
|
|
61
|
+
): void {
|
|
62
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
63
|
+
writeFileSync(path, `${JSON.stringify({ entries }, null, 2)}\n`)
|
|
64
|
+
}
|
package/src/migrate/rename.ts
CHANGED
|
@@ -168,10 +168,11 @@ const AITK_ARTICLE = /\b([Aa])n(\s+`?)(canon|CANON|Canon)(?![A-Za-z])/g
|
|
|
168
168
|
/**
|
|
169
169
|
* The `aitk` to `canon` rename.
|
|
170
170
|
*
|
|
171
|
-
* `aitk-sandbox` is a
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
171
|
+
* `aitk-sandbox` is the retired spelling of a repository that was later renamed
|
|
172
|
+
* `canon-sandbox`, and GitHub still resolves the old name through a redirect.
|
|
173
|
+
* It has to win against the bare token, and it also has to win against the
|
|
174
|
+
* owner-scoped spelling, so a consumer's text naming `erclx/aitk-sandbox` keeps
|
|
175
|
+
* the form that resolves rather than being rewritten.
|
|
175
176
|
*
|
|
176
177
|
* Case is carried in the map rather than derived, because the uppercase form
|
|
177
178
|
* is an environment variable prefix and the title-case form is a heading word,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
type BrowserFunction = (...args: never[]) => unknown
|
|
2
|
+
|
|
3
|
+
const transpiler = new Bun.Transpiler({ loader: 'ts' })
|
|
4
|
+
|
|
5
|
+
const MODULE_SYNTAX = /^\s*(import|export)\b/m
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `</script>` inside a serialized argument would end the block early, and
|
|
9
|
+
* `<` is the same character to the JavaScript parser.
|
|
10
|
+
*/
|
|
11
|
+
function serialize(args: readonly unknown[]): string {
|
|
12
|
+
return JSON.stringify(args).slice(1, -1).replace(/</g, '\\u003c')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Turns a browser module's function into the `<script>` a page carries. The
|
|
17
|
+
* transpiler does not bundle, so a module is self-contained: whatever it
|
|
18
|
+
* calls but does not define arrives as a named function in `helpers`, and a
|
|
19
|
+
* value it needs arrives in `args`. Output holding module syntax throws, since
|
|
20
|
+
* a script block with an `import` fails in the browser with nothing in the
|
|
21
|
+
* type checker to say so.
|
|
22
|
+
*/
|
|
23
|
+
export function compileScript(
|
|
24
|
+
run: BrowserFunction,
|
|
25
|
+
args: readonly unknown[] = [],
|
|
26
|
+
helpers: readonly BrowserFunction[] = [],
|
|
27
|
+
): string {
|
|
28
|
+
const source = helpers.length
|
|
29
|
+
? `(function () {\n${helpers.map(String).join('\n')}\nreturn ${run};\n})()`
|
|
30
|
+
: String(run)
|
|
31
|
+
// A bare expression statement is dead code to the transpiler and comes back
|
|
32
|
+
// empty, so the source rides an export that it must keep.
|
|
33
|
+
const code = transpiler
|
|
34
|
+
.transformSync(`export default ${source}`)
|
|
35
|
+
.trim()
|
|
36
|
+
.replace(/^export default /, '')
|
|
37
|
+
.replace(/;$/, '')
|
|
38
|
+
|
|
39
|
+
if (MODULE_SYNTAX.test(code)) {
|
|
40
|
+
throw new Error(`Browser module ${run.name} left import or export syntax`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return `<script>(${code})(${serialize(args)})</script>`
|
|
44
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export function dismiss(): void {
|
|
2
|
+
var MENUS = 'details.jump[open], details.sb-ws[open]'
|
|
3
|
+
|
|
4
|
+
function close(except: Element | null) {
|
|
5
|
+
document.querySelectorAll<HTMLDetailsElement>(MENUS).forEach(function (d) {
|
|
6
|
+
if (d !== except) d.open = false
|
|
7
|
+
})
|
|
8
|
+
}
|
|
9
|
+
document.addEventListener('click', function (e) {
|
|
10
|
+
var inside = (e.target as Element).closest('details.jump, details.sb-ws')
|
|
11
|
+
close(inside)
|
|
12
|
+
})
|
|
13
|
+
document.addEventListener('keydown', function (e) {
|
|
14
|
+
if (e.key !== 'Escape') return
|
|
15
|
+
var open = document.querySelector<HTMLDetailsElement>(MENUS)
|
|
16
|
+
if (!open) return
|
|
17
|
+
open.open = false
|
|
18
|
+
var s = open.querySelector('summary')
|
|
19
|
+
if (s) s.focus()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/* Hover intent on the breadcrumb only. The sidebar's workspace switcher stays
|
|
23
|
+
click-only on purpose: its panel opens directly over the lesson list, which
|
|
24
|
+
is where the pointer is headed, so hovering it would cover the thing
|
|
25
|
+
being reached for. The breadcrumb's panel drops over body text instead. */
|
|
26
|
+
var OPEN = 120,
|
|
27
|
+
SHUT = 260
|
|
28
|
+
|
|
29
|
+
document
|
|
30
|
+
.querySelectorAll<HTMLDetailsElement>('details.jump')
|
|
31
|
+
.forEach(function (d) {
|
|
32
|
+
var host = d.closest('.crumb-item') || d
|
|
33
|
+
var timer: ReturnType<typeof setTimeout> | undefined
|
|
34
|
+
var openedByHover = false
|
|
35
|
+
|
|
36
|
+
function arm(want: boolean) {
|
|
37
|
+
clearTimeout(timer)
|
|
38
|
+
timer = setTimeout(
|
|
39
|
+
function () {
|
|
40
|
+
if (want) close(d)
|
|
41
|
+
d.open = want
|
|
42
|
+
},
|
|
43
|
+
want ? OPEN : SHUT,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
host.addEventListener('mouseenter', function () {
|
|
48
|
+
if (!d.open) openedByHover = true
|
|
49
|
+
arm(true)
|
|
50
|
+
})
|
|
51
|
+
host.addEventListener('mouseleave', function () {
|
|
52
|
+
openedByHover = false
|
|
53
|
+
arm(false)
|
|
54
|
+
})
|
|
55
|
+
/* Keeps it open while the pointer is inside the panel, without cancelling a
|
|
56
|
+
pending open, which is what silently disabled this on one of two menus. */
|
|
57
|
+
d.addEventListener('mouseenter', function () {
|
|
58
|
+
if (d.open) clearTimeout(timer)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/* Hover and click were wired to one disclosure and fought: hover opened the
|
|
62
|
+
panel, then the summary's native click toggled what hover had already
|
|
63
|
+
opened, so reaching for an item shut it. A click on a panel hover opened
|
|
64
|
+
keeps it open, and a second click closes. The click path stays live for
|
|
65
|
+
touch, where no hover exists, and for the keyboard, where Enter is the
|
|
66
|
+
only way in. */
|
|
67
|
+
var summary = d.querySelector('summary')
|
|
68
|
+
if (summary)
|
|
69
|
+
summary.addEventListener('click', function (e) {
|
|
70
|
+
if (d.open && openedByHover) {
|
|
71
|
+
e.preventDefault()
|
|
72
|
+
openedByHover = false
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outline rail's focus-line ramp. It runs from near the top at scroll 0 to
|
|
3
|
+
* the viewport's bottom edge at max scroll, so the last heading is reachable
|
|
4
|
+
* however little content trails it. A ramp ending 120px short of the edge left
|
|
5
|
+
* a heading with under 120px after it permanently unmarked, since its top never
|
|
6
|
+
* fell below the line even at max scroll.
|
|
7
|
+
*
|
|
8
|
+
* The sidebar script embeds this function's own source, so a test exercises
|
|
9
|
+
* what the browser runs.
|
|
10
|
+
*/
|
|
11
|
+
export function focusLine(
|
|
12
|
+
scrollY: number,
|
|
13
|
+
max: number,
|
|
14
|
+
innerHeight: number,
|
|
15
|
+
): number {
|
|
16
|
+
if (max <= 0) return innerHeight
|
|
17
|
+
var progress = Math.min(1, Math.max(0, scrollY / max))
|
|
18
|
+
return 120 + progress * Math.max(0, innerHeight - 120)
|
|
19
|
+
}
|