@erclx/canon 5.15.0 → 5.16.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 (35) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/draft-wiki/SKILL.md +1 -1
  3. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  4. package/claude/skills/search-craft/REQUIREMENT.md +57 -0
  5. package/claude/skills/search-craft/SKILL.md +83 -0
  6. package/docs/agents/canvas.md +36 -0
  7. package/docs/agents/commands.md +3 -3
  8. package/docs/agents/index.md +1 -0
  9. package/docs/workflow/skill-map.md +1 -0
  10. package/package.json +1 -1
  11. package/scripts/lib/sandbox-git.sh +2 -2
  12. package/src/canvas/address.ts +184 -0
  13. package/src/canvas/capture.ts +172 -0
  14. package/src/canvas/client/app.tsx +2 -0
  15. package/src/canvas/client/inspector.tsx +121 -0
  16. package/src/canvas/client/layers.tsx +118 -0
  17. package/src/canvas/client/pages-panel.tsx +47 -13
  18. package/src/canvas/client/shell.css +131 -1
  19. package/src/canvas/client/state.ts +186 -0
  20. package/src/canvas/client/surface.tsx +272 -20
  21. package/src/canvas/content.ts +307 -16
  22. package/src/canvas/server.ts +182 -13
  23. package/src/claude/cases/misc.ts +5 -0
  24. package/src/commands/canvas.ts +200 -3
  25. package/src/commands/migrate.ts +1 -1
  26. package/src/migrate/rename.ts +5 -4
  27. package/src/teach/browser/compile.ts +44 -0
  28. package/src/teach/browser/dismiss.ts +76 -0
  29. package/src/teach/browser/focus-line.ts +19 -0
  30. package/src/teach/browser/glossary-filter.ts +44 -0
  31. package/src/teach/browser/head.ts +30 -0
  32. package/src/teach/browser/quiz.ts +19 -0
  33. package/src/teach/browser/sidebar.ts +233 -0
  34. package/src/teach/browser/theme.ts +18 -0
  35. package/src/teach/nav.ts +19 -374
@@ -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: ContentRefused['reason'] | 'no-root'
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(
@@ -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 is a separate repository and is left alone.',
1225
+ 'aitk-sandbox, the old name of erclx/canon-sandbox, is left alone.',
1226
1226
  '',
1227
1227
  'Examples:',
1228
1228
  ' canon migrate rename',
@@ -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 separate repository that is not being renamed. It has to
172
- * win against the bare token, and it also has to win against the owner-scoped
173
- * spelling, since `erclx/aitk-sandbox` would otherwise rewrite to
174
- * `erclx/canon-sandbox` and name a repository that does not exist.
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
+ }
@@ -0,0 +1,44 @@
1
+ export function glossaryFilter(): void {
2
+ var input = document.getElementById('gfilter') as HTMLInputElement | null
3
+ var list = document.getElementById('gloss')
4
+ var count = document.getElementById('gloss-count')
5
+ if (!input || !list) return
6
+ var field = input
7
+ var terms = list
8
+ function updateGroups() {
9
+ terms.querySelectorAll('.gloss-group').forEach(function (heading) {
10
+ var el = heading.nextElementSibling as HTMLElement | null
11
+ var any = false
12
+ while (el && !el.classList.contains('gloss-group')) {
13
+ if (el.style.display !== 'none') any = true
14
+ el = el.nextElementSibling as HTMLElement | null
15
+ }
16
+ ;(heading as HTMLElement).style.display = any ? '' : 'none'
17
+ })
18
+ }
19
+ field.addEventListener('input', function () {
20
+ var q = field.value.toLowerCase()
21
+ var n = 0
22
+ terms.querySelectorAll<HTMLElement>('.gterm').forEach(function (entry) {
23
+ var match = (entry.textContent as string).toLowerCase().includes(q)
24
+ entry.style.display = match ? '' : 'none'
25
+ if (match) n++
26
+ })
27
+ terms.classList.toggle('none', n === 0)
28
+ updateGroups()
29
+ if (count) {
30
+ var total = terms.querySelectorAll('.gterm').length
31
+ count.textContent =
32
+ (n === total ? total : n + ' of ' + total) +
33
+ (total === 1 ? ' term' : ' terms')
34
+ }
35
+ })
36
+ var clear = terms.querySelector('.clear')
37
+ if (clear) {
38
+ clear.addEventListener('click', function () {
39
+ field.value = ''
40
+ field.dispatchEvent(new Event('input'))
41
+ field.focus()
42
+ })
43
+ }
44
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The panel's state, settled before the first paint rather than after it. An
3
+ * arm that set the narrow default afterwards slid the panel in and back out on
4
+ * every load, and a restored custom width animated in from the default.
5
+ *
6
+ * A listing page starts shut because the body already lists what the panel
7
+ * would, and a lesson starts open because there the panel is the only
8
+ * cross-lesson navigation on the page. A stored preference beats both. A count
9
+ * threshold on its own was built and reverted, since it hides the panel on a
10
+ * lesson too.
11
+ */
12
+ export function head(page: 'index' | 'lesson', breakpoint: number): void {
13
+ var r = document.documentElement
14
+ var s: string | null = null
15
+ r.dataset.page = page
16
+ if (matchMedia('(max-width: ' + breakpoint + 'px)').matches) {
17
+ r.classList.add('sb-shut')
18
+ return
19
+ }
20
+ try {
21
+ s = localStorage.getItem('teach-sb')
22
+ } catch (e) {}
23
+ var idx = r.dataset.page === 'index'
24
+ if (s === 'shut' || (s === null && idx)) r.classList.add('sb-shut')
25
+ var w: string | null = null
26
+ try {
27
+ w = localStorage.getItem('teach-sb-w')
28
+ } catch (e) {}
29
+ if (w) r.style.setProperty('--sb-w', w + 'px')
30
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * What reveals feedback in a lesson written with button options. The radio
3
+ * shape needs none of it.
4
+ */
5
+ export function quiz(): void {
6
+ document.querySelectorAll<HTMLElement>('.q').forEach(function (q) {
7
+ var f = q.querySelector('.fb') as HTMLElement
8
+ q.querySelectorAll<HTMLElement>('.opt').forEach(function (b) {
9
+ b.addEventListener('click', function () {
10
+ if (f.classList.contains('show')) return
11
+ q.querySelectorAll<HTMLElement>('.opt').forEach(function (o) {
12
+ o.dataset.state =
13
+ o.dataset.a === '1' ? 'right' : o === b ? 'chosen' : 'wrong'
14
+ })
15
+ f.classList.add('show')
16
+ })
17
+ })
18
+ })
19
+ }