@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.
Files changed (55) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/draft-figure/REQUIREMENT.md +8 -2
  3. package/claude/skills/draft-figure/SKILL.md +19 -4
  4. package/claude/skills/draft-wiki/SKILL.md +1 -1
  5. package/claude/skills/draft-wireframes/SKILL.md +1 -1
  6. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  7. package/claude/skills/search-craft/REQUIREMENT.md +57 -0
  8. package/claude/skills/search-craft/SKILL.md +83 -0
  9. package/claude/skills/teach-workspace/SKILL.md +1 -1
  10. package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
  11. package/docs/agents/canvas.md +36 -0
  12. package/docs/agents/commands.md +3 -3
  13. package/docs/agents/demo.md +3 -1
  14. package/docs/agents/index.md +1 -0
  15. package/docs/agents/teach.md +2 -2
  16. package/docs/workflow/skill-map.md +1 -0
  17. package/docs/workflow/visual-design-workflow.md +8 -15
  18. package/governance/rules/claude/566-output.md +3 -5
  19. package/governance/rules/claude/575-hooks.md +1 -1
  20. package/package.json +1 -1
  21. package/scripts/lib/sandbox-git.sh +2 -2
  22. package/src/canvas/address.ts +184 -0
  23. package/src/canvas/capture.ts +172 -0
  24. package/src/canvas/client/app.tsx +2 -0
  25. package/src/canvas/client/inspector.tsx +121 -0
  26. package/src/canvas/client/layers.tsx +118 -0
  27. package/src/canvas/client/pages-panel.tsx +47 -13
  28. package/src/canvas/client/shell.css +131 -1
  29. package/src/canvas/client/state.ts +186 -0
  30. package/src/canvas/client/surface.tsx +272 -20
  31. package/src/canvas/content.ts +307 -16
  32. package/src/canvas/server.ts +182 -13
  33. package/src/claude/cases/misc.ts +5 -0
  34. package/src/commands/canvas.ts +200 -3
  35. package/src/commands/demo.ts +2 -0
  36. package/src/commands/migrate.ts +1 -1
  37. package/src/commands/teach.ts +3 -1
  38. package/src/demo/drive.ts +36 -7
  39. package/src/demo/timeline.ts +64 -0
  40. package/src/migrate/rename.ts +5 -4
  41. package/src/teach/browser/compile.ts +44 -0
  42. package/src/teach/browser/dismiss.ts +76 -0
  43. package/src/teach/browser/focus-line.ts +19 -0
  44. package/src/teach/browser/glossary-filter.ts +44 -0
  45. package/src/teach/browser/head.ts +30 -0
  46. package/src/teach/browser/quiz.ts +19 -0
  47. package/src/teach/browser/sidebar.ts +233 -0
  48. package/src/teach/browser/theme.ts +18 -0
  49. package/src/teach/nav.ts +19 -374
  50. package/src/teach/workspace.ts +61 -6
  51. package/standards/figures.md +1 -0
  52. package/tooling/claude/reference.md +1 -1
  53. package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
  54. package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
  55. package/tooling/claude/seeds/.claude/settings.json +9 -0
@@ -1,13 +1,23 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import {
3
+ closeSync,
2
4
  existsSync,
3
5
  mkdirSync,
6
+ openSync,
4
7
  readdirSync,
5
8
  readFileSync,
6
9
  renameSync,
10
+ rmSync,
7
11
  statSync,
12
+ unlinkSync,
8
13
  writeFileSync,
9
14
  } from 'node:fs'
10
15
  import { join } from 'node:path'
16
+ import {
17
+ type ElementAddress,
18
+ resolveAddress,
19
+ type SourceElement,
20
+ } from '@/canvas/address'
11
21
  import { recordDir } from '@/record-root'
12
22
 
13
23
  /**
@@ -21,6 +31,23 @@ export const CANVAS_FOLDER = 'canvas'
21
31
 
22
32
  export const LAYOUT_FILE = 'layout.json'
23
33
 
34
+ /** The frame or element the operator pointed at, beside the pages it names. */
35
+ export const SELECTION_FILE = 'selection.json'
36
+
37
+ /** Suffix of the file a write goes through before it replaces its target. */
38
+ export const TEMP_SUFFIX = '.tmp'
39
+
40
+ /** Suffix of the file a writer holds while it reads, merges, and replaces. */
41
+ export const LOCK_SUFFIX = '.lock'
42
+
43
+ /** A lock older than this was left by a writer that died holding it. */
44
+ const LOCK_STALE_MS = 10_000
45
+
46
+ /** How long a writer waits on another's lock before it refuses. */
47
+ const LOCK_WAIT_MS = 2_000
48
+
49
+ const LOCK_POLL_MS = 5
50
+
24
51
  const FRAME_EXTENSION = '.html'
25
52
 
26
53
  /** The size a frame takes when nothing states one. */
@@ -62,7 +89,13 @@ export type ContentRefusal =
62
89
  | 'invalid-size'
63
90
  | 'exists'
64
91
  | 'no-page'
92
+ | 'no-frame'
93
+ | 'invalid-position'
65
94
  | 'malformed-layout'
95
+ | 'busy'
96
+ | 'invalid-address'
97
+ | 'address-mismatch'
98
+ | 'stale-address'
66
99
 
67
100
  export interface ContentRefused {
68
101
  readonly ok: false
@@ -155,12 +188,55 @@ function readLayout(dir: string): LayoutRead {
155
188
  return { boxes, malformed: false }
156
189
  }
157
190
 
191
+ function sleep(ms: number): void {
192
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
193
+ }
194
+
195
+ /**
196
+ * Holds an exclusive lock file for the length of a read, merge, and write. The
197
+ * server and a CLI verb are two processes, so a synchronous step inside either
198
+ * does not keep them from reading the same layout and each replacing it with
199
+ * its own edit, which loses the other's. A refused call leaves the file as it
200
+ * found it.
201
+ */
202
+ function withLayoutLock(dir: string, run: () => FrameOutcome): FrameOutcome {
203
+ const lock = join(dir, `${LAYOUT_FILE}${LOCK_SUFFIX}`)
204
+ const deadline = Date.now() + LOCK_WAIT_MS
205
+ for (;;) {
206
+ try {
207
+ closeSync(openSync(lock, 'wx'))
208
+ break
209
+ } catch (error) {
210
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
211
+ const modified = statSync(lock, { throwIfNoEntry: false })?.mtimeMs
212
+ if (modified !== undefined && Date.now() - modified > LOCK_STALE_MS) {
213
+ rmSync(lock, { force: true })
214
+ continue
215
+ }
216
+ if (Date.now() >= deadline) {
217
+ return refuse('busy', `${LAYOUT_FILE} is being written, try again`)
218
+ }
219
+ sleep(LOCK_POLL_MS)
220
+ }
221
+ }
222
+ try {
223
+ return run()
224
+ } finally {
225
+ rmSync(lock, { force: true })
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Replaces the file in one rename, so the shell reading while a CLI verb writes
231
+ * never sees half a layout and reports it as malformed. The caller holds
232
+ * `withLayoutLock`, which is what keeps two writers from losing each other.
233
+ */
158
234
  function writeLayout(dir: string, boxes: ReadonlyMap<string, Box>): void {
159
235
  const frames = Object.fromEntries(boxes)
160
- writeFileSync(
161
- join(dir, LAYOUT_FILE),
162
- `${JSON.stringify({ frames }, null, 2)}\n`,
163
- )
236
+ const target = join(dir, LAYOUT_FILE)
237
+ const temp = `${target}.${process.pid}${TEMP_SUFFIX}`
238
+ writeFileSync(temp, `${JSON.stringify({ frames }, null, 2)}\n`)
239
+ renameSync(temp, target)
164
240
  }
165
241
 
166
242
  function frameNames(dir: string): string[] {
@@ -308,19 +384,234 @@ export function addFrame(
308
384
  return refuse('exists', `frame ${frame} already exists on ${page}`)
309
385
  }
310
386
 
311
- /*
312
- * Placed against the boxes a reader would draw, unplaced frames included, so
313
- * a new frame never lands under one the layout has yet to name.
387
+ return withLayoutLock(dir, () => {
388
+ /*
389
+ * Placed against the boxes a reader would draw, unplaced frames included,
390
+ * so a new frame never lands under one the layout has yet to name.
391
+ */
392
+ const current = readPage(root, page)?.frames ?? []
393
+ const { boxes, malformed } = readLayout(dir)
394
+ /* Writing over a layout that does not parse would erase every box in it. */
395
+ if (malformed) {
396
+ return refuse('malformed-layout', `${page}/${LAYOUT_FILE} does not parse`)
397
+ }
398
+ const box: Box = { x: nextX(current), y: 0, ...size }
399
+
400
+ writeFileSync(join(dir, file), frameScaffold(frame))
401
+ writeLayout(dir, new Map([...boxes, [frame, box]]))
402
+ return { ok: true, page, frame, file, box }
403
+ })
404
+ }
405
+
406
+ export interface Position {
407
+ readonly x: number
408
+ readonly y: number
409
+ }
410
+
411
+ /**
412
+ * Moves one frame and keeps its size. A frame the layout never named takes the
413
+ * default size it was already drawn at, so the move does not resize it.
414
+ */
415
+ export function moveFrame(
416
+ root: string,
417
+ page: string,
418
+ frame: string,
419
+ to: Position,
420
+ ): FrameOutcome {
421
+ if (!isValidName(page)) {
422
+ return refuse('invalid-name', `${page} is not a valid page name`)
423
+ }
424
+ if (!isValidName(frame)) {
425
+ return refuse('invalid-name', `${frame} is not a valid frame name`)
426
+ }
427
+ if (!Number.isFinite(to.x) || !Number.isFinite(to.y)) {
428
+ return refuse('invalid-position', 'x and y must be numbers')
429
+ }
430
+
431
+ const dir = pagePath(root, page)
432
+ if (!isDirectory(dir)) return refuse('no-page', `page ${page} does not exist`)
433
+ const file = `${frame}${FRAME_EXTENSION}`
434
+ if (!existsSync(join(dir, file))) {
435
+ return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
436
+ }
437
+
438
+ return withLayoutLock(dir, () => {
439
+ const { boxes, malformed } = readLayout(dir)
440
+ if (malformed) {
441
+ return refuse('malformed-layout', `${page}/${LAYOUT_FILE} does not parse`)
442
+ }
443
+ const size = boxes.get(frame) ?? DEFAULT_FRAME
444
+ const box: Box = {
445
+ x: to.x,
446
+ y: to.y,
447
+ width: size.width,
448
+ height: size.height,
449
+ }
450
+
451
+ writeLayout(dir, new Map([...boxes, [frame, box]]))
452
+ return { ok: true, page, frame, file, box }
453
+ })
454
+ }
455
+
456
+ export interface Selection {
457
+ readonly page: string
458
+ readonly frame: string
459
+ readonly element?: ElementAddress
460
+ }
461
+
462
+ export interface SelectedElement extends SourceElement {
463
+ readonly index: number
464
+ /**
465
+ * Whether the frame file changed since the element was picked. The address
466
+ * is an index, so an edit can shift it onto another element, and the fields
467
+ * here are what was picked rather than what sits at the index now.
314
468
  */
315
- const current = readPage(root, page)?.frames ?? []
316
- const { boxes, malformed } = readLayout(dir)
317
- /* Writing over a layout that does not parse would erase every box in it. */
318
- if (malformed) {
319
- return refuse('malformed-layout', `${page}/${LAYOUT_FILE} does not parse`)
469
+ readonly stale: boolean
470
+ }
471
+
472
+ export interface SelectedFrame {
473
+ readonly page: string
474
+ readonly frame: string
475
+ readonly file: string
476
+ readonly box: Box
477
+ readonly element?: SelectedElement
478
+ }
479
+
480
+ export type SelectionOutcome = { readonly ok: true } | ContentRefused
481
+
482
+ interface StoredElement extends SourceElement {
483
+ readonly index: number
484
+ readonly hash: string
485
+ }
486
+
487
+ function selectionPath(root: string): string {
488
+ return join(canvasDir(root), SELECTION_FILE)
489
+ }
490
+
491
+ export function contentHash(html: string | Uint8Array): string {
492
+ return createHash('sha256').update(html).digest('hex')
493
+ }
494
+
495
+ function isAddress(value: ElementAddress): boolean {
496
+ return (
497
+ Number.isInteger(value.index) &&
498
+ Number.isInteger(value.count) &&
499
+ value.index >= 0 &&
500
+ value.index < value.count &&
501
+ typeof value.tag === 'string' &&
502
+ value.tag !== ''
503
+ )
504
+ }
505
+
506
+ /**
507
+ * Records which frame, or which element in it, "this one" means, or clears it.
508
+ * Only a frame on disk can be recorded, and only an element the file holds at
509
+ * the address given, so the file never names something a reader would have to
510
+ * guess about.
511
+ */
512
+ export function writeSelection(
513
+ root: string,
514
+ selection: Selection | undefined,
515
+ ): SelectionOutcome {
516
+ if (selection === undefined) {
517
+ if (existsSync(selectionPath(root))) unlinkSync(selectionPath(root))
518
+ return { ok: true }
320
519
  }
321
- const box: Box = { x: nextX(current), y: 0, ...size }
322
520
 
323
- writeFileSync(join(dir, file), frameScaffold(frame))
324
- writeLayout(dir, new Map([...boxes, [frame, box]]))
325
- return { ok: true, page, frame, file, box }
521
+ const { page, frame, element } = selection
522
+ if (!isValidName(page) || !isValidName(frame)) {
523
+ return refuse('invalid-name', `${page}/${frame} is not a valid frame`)
524
+ }
525
+ const onPage = readPage(root, page)
526
+ if (!onPage) return refuse('no-page', `page ${page} does not exist`)
527
+ const found = onPage.frames.find((candidate) => candidate.name === frame)
528
+ if (!found) {
529
+ return refuse('no-frame', `frame ${frame} does not exist on ${page}`)
530
+ }
531
+
532
+ let stored: StoredElement | undefined
533
+ if (element !== undefined) {
534
+ if (!isAddress(element)) {
535
+ return refuse(
536
+ 'invalid-address',
537
+ 'an element address needs a whole index below its count and a tag',
538
+ )
539
+ }
540
+ const bytes = readFileSync(join(pagePath(root, page), found.file))
541
+ const hash = contentHash(bytes)
542
+ const html = bytes.toString('utf8')
543
+ if (element.hash !== undefined && element.hash !== hash) {
544
+ return refuse(
545
+ 'stale-address',
546
+ `${page}/${frame} changed after the pick was made, so pick it again`,
547
+ )
548
+ }
549
+ const check = resolveAddress(html, element)
550
+ if (!check.ok) return refuse(check.reason, check.detail)
551
+ stored = { index: element.index, ...check.element, hash }
552
+ }
553
+
554
+ const target = selectionPath(root)
555
+ const temp = `${target}.${process.pid}${TEMP_SUFFIX}`
556
+ const record = stored ? { page, frame, element: stored } : { page, frame }
557
+ writeFileSync(temp, `${JSON.stringify(record)}\n`)
558
+ renameSync(temp, target)
559
+ return { ok: true }
560
+ }
561
+
562
+ function readStoredElement(value: unknown): StoredElement | undefined {
563
+ if (typeof value !== 'object' || value === null) return undefined
564
+ const { index, tag, classes, text, hash } = value as Record<string, unknown>
565
+ if (
566
+ typeof index !== 'number' ||
567
+ typeof tag !== 'string' ||
568
+ !Array.isArray(classes) ||
569
+ !classes.every((name) => typeof name === 'string') ||
570
+ typeof text !== 'string' ||
571
+ typeof hash !== 'string'
572
+ ) {
573
+ return undefined
574
+ }
575
+ return { index, tag, classes, text, hash }
576
+ }
577
+
578
+ /**
579
+ * The selected frame with its box, or undefined when nothing is selected, the
580
+ * file does not parse, or the frame it names has since been removed, so a
581
+ * reader never gets a stale name back. An element is reported as picked, with
582
+ * `stale` set once the frame file no longer matches what was picked from.
583
+ */
584
+ export function readSelection(root: string): SelectedFrame | undefined {
585
+ const path = selectionPath(root)
586
+ if (!existsSync(path)) return undefined
587
+
588
+ let parsed: unknown
589
+ try {
590
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
591
+ } catch {
592
+ return undefined
593
+ }
594
+ if (typeof parsed !== 'object' || parsed === null) return undefined
595
+ const { page, frame, element } = parsed as Record<string, unknown>
596
+ if (typeof page !== 'string' || typeof frame !== 'string') return undefined
597
+
598
+ const found = readPage(root, page)?.frames.find(
599
+ (candidate) => candidate.name === frame,
600
+ )
601
+ if (!found) return undefined
602
+ const { x, y, width, height } = found
603
+ const selected = {
604
+ page,
605
+ frame,
606
+ file: found.file,
607
+ box: { x, y, width, height },
608
+ }
609
+
610
+ const stored = readStoredElement(element)
611
+ if (!stored) return selected
612
+ const { hash, ...picked } = stored
613
+ const current = contentHash(
614
+ readFileSync(join(pagePath(root, page), found.file)),
615
+ )
616
+ return { ...selected, element: { ...picked, stale: current !== hash } }
326
617
  }
@@ -1,5 +1,20 @@
1
1
  import { type FSWatcher, mkdirSync, watch } from 'node:fs'
2
- import { canvasDir, listPages } from '@/canvas/content'
2
+ import {
3
+ type ElementAddress,
4
+ HASH_ATTRIBUTE,
5
+ TOKENS_ATTRIBUTE,
6
+ } from '@/canvas/address'
7
+ import {
8
+ type ContentRefused,
9
+ canvasDir,
10
+ contentHash,
11
+ listPages,
12
+ LOCK_SUFFIX,
13
+ moveFrame,
14
+ readSelection,
15
+ TEMP_SUFFIX,
16
+ writeSelection,
17
+ } from '@/canvas/content'
3
18
  import { resolveFrameTokens, type TokenOptions } from '@/canvas/tokens'
4
19
  import { buildDesignCss } from '@/design/css'
5
20
  import {
@@ -61,21 +76,31 @@ export interface ChangeEvent {
61
76
  readonly file: string
62
77
  }
63
78
 
64
- /** Escapes the one sequence that would end the injected element early. */
65
- function styleElement(css: string): string {
66
- return `<style data-canvas-tokens>${css.replaceAll('</style', '<\\/style')}</style>`
79
+ /**
80
+ * Escapes the one sequence that would end the injected element early. The
81
+ * hash is hex, so it needs no escaping inside the attribute.
82
+ */
83
+ function styleElement(css: string, hash: string | undefined): string {
84
+ const stamp = hash === undefined ? '' : ` ${HASH_ATTRIBUTE}="${hash}"`
85
+ return `<style ${TOKENS_ATTRIBUTE}${stamp}>${css.replaceAll('</style', '<\\/style')}</style>`
67
86
  }
68
87
 
69
88
  /**
70
89
  * Puts the tokens first in the head, so a stylesheet the frame links itself
71
- * still wins the cascade. A frame with no head gets the element ahead of
72
- * everything, which a browser hoists into the head it builds.
90
+ * still wins the cascade. A frame with no head gets the element right after
91
+ * its doctype, which a browser hoists into the head it builds. A hash stamps
92
+ * the element even when no tokens resolve, since the shell sends it back with
93
+ * an element pick.
73
94
  */
74
- export function injectTokens(html: string, css: string): string {
75
- if (css === '') return html
76
- const element = styleElement(css)
95
+ export function injectTokens(html: string, css: string, hash?: string): string {
96
+ if (css === '' && hash === undefined) return html
97
+ const element = styleElement(css, hash)
77
98
  const head = html.match(/<head(?:\s[^>]*)?>/i)
78
- if (head?.index === undefined) return `${element}${html}`
99
+ if (head?.index === undefined) {
100
+ /* Ahead of a doctype the element would drop the frame into quirks mode. */
101
+ const doctype = html.match(/^\s*<!doctype[^>]*>/i)?.[0] ?? ''
102
+ return `${doctype}${element}${html.slice(doctype.length)}`
103
+ }
79
104
  const at = head.index + head[0].length
80
105
  return `${html.slice(0, at)}${element}${html.slice(at)}`
81
106
  }
@@ -105,8 +130,9 @@ function guarded(handler: Handler): Handler {
105
130
  })
106
131
  }
107
132
 
108
- function json(body: unknown): Response {
133
+ function json(body: unknown, status = 200): Response {
109
134
  return new Response(JSON.stringify(body), {
135
+ status,
110
136
  headers: {
111
137
  'content-type': 'application/json; charset=utf-8',
112
138
  'cache-control': 'no-store',
@@ -114,6 +140,79 @@ function json(body: unknown): Response {
114
140
  })
115
141
  }
116
142
 
143
+ const REFUSAL_STATUS: Record<ContentRefused['reason'], number> = {
144
+ 'invalid-name': 400,
145
+ 'invalid-size': 400,
146
+ 'invalid-position': 400,
147
+ 'no-page': 404,
148
+ 'no-frame': 404,
149
+ exists: 409,
150
+ 'malformed-layout': 409,
151
+ busy: 409,
152
+ 'invalid-address': 400,
153
+ 'address-mismatch': 409,
154
+ 'stale-address': 409,
155
+ }
156
+
157
+ function refusal(refused: ContentRefused): Response {
158
+ return json(refused, REFUSAL_STATUS[refused.reason])
159
+ }
160
+
161
+ function badBody(detail: string): Response {
162
+ return json({ ok: false, reason: 'invalid-body', detail }, 400)
163
+ }
164
+
165
+ /**
166
+ * The Host guard cannot tell the shell from a page on another origin that
167
+ * reaches this socket, since a browser sends the loopback Host either way. A
168
+ * write also carries an Origin when a page sent it, so one naming any other
169
+ * origin is refused, and a JSON content type forces that page through a
170
+ * preflight this server never answers.
171
+ */
172
+ function mutation(handler: (body: unknown) => Response): Handler {
173
+ return async (request, server) => {
174
+ const origin = request.headers.get('origin')
175
+ if (origin !== null && !isLoopbackOrigin(origin, server.port ?? 0)) {
176
+ return new Response('Forbidden\n', { status: 403 })
177
+ }
178
+ const type = request.headers.get('content-type') ?? ''
179
+ if (!type.startsWith('application/json')) {
180
+ return json(
181
+ { ok: false, reason: 'invalid-body', detail: 'send application/json' },
182
+ 415,
183
+ )
184
+ }
185
+ let body: unknown
186
+ try {
187
+ body = await request.json()
188
+ } catch {
189
+ return badBody('the body is not JSON')
190
+ }
191
+ return handler(body)
192
+ }
193
+ }
194
+
195
+ function isLoopbackOrigin(origin: string, port: number): boolean {
196
+ return (
197
+ origin === `http://${SERVE_HOST}:${port}` ||
198
+ origin === `http://localhost:${port}`
199
+ )
200
+ }
201
+
202
+ function isRecord(value: unknown): value is Record<string, unknown> {
203
+ return typeof value === 'object' && value !== null
204
+ }
205
+
206
+ function isAddressShape(value: unknown): value is ElementAddress {
207
+ return (
208
+ isRecord(value) &&
209
+ typeof value.index === 'number' &&
210
+ typeof value.tag === 'string' &&
211
+ typeof value.count === 'number' &&
212
+ (value.hash === undefined || typeof value.hash === 'string')
213
+ )
214
+ }
215
+
117
216
  async function serveFrame(
118
217
  root: string,
119
218
  content: string,
@@ -128,7 +227,13 @@ async function serveFrame(
128
227
  if (file.status !== 200 || !type.startsWith('text/html')) return file
129
228
 
130
229
  const { css } = resolveFrameTokens(root, tokens)
131
- return new Response(injectTokens(await file.text(), css), {
230
+ /*
231
+ * Hashed over the bytes on disk, the same input `writeSelection` reads,
232
+ * since decoding to text drops a byte order mark the file still holds.
233
+ */
234
+ const bytes = new Uint8Array(await file.arrayBuffer())
235
+ const html = new TextDecoder().decode(bytes)
236
+ return new Response(injectTokens(html, css, contentHash(bytes)), {
132
237
  headers: { 'content-type': type, 'cache-control': 'no-store' },
133
238
  })
134
239
  }
@@ -158,6 +263,8 @@ class ChangeStream {
158
263
  private queue(path: string): void {
159
264
  const [page, ...rest] = path.split(/[\\/]/)
160
265
  if (page === undefined || page === '') return
266
+ /* A write's staging or lock file, gone a moment later. */
267
+ if (path.endsWith(TEMP_SUFFIX) || path.endsWith(LOCK_SUFFIX)) return
161
268
  clearTimeout(this.pending.get(path))
162
269
  this.pending.set(
163
270
  path,
@@ -245,8 +352,70 @@ export function startCanvas(
245
352
  '/api/pages': guarded(() => {
246
353
  const resolved = resolveFrameTokens(root, tokens)
247
354
  const { css: _css, ...source } = resolved
248
- return json({ pages: listPages(root), tokens: source })
355
+ const selected = readSelection(root)
356
+ return json({
357
+ pages: listPages(root),
358
+ tokens: source,
359
+ selection: selected && {
360
+ page: selected.page,
361
+ frame: selected.frame,
362
+ ...(selected.element && {
363
+ element: {
364
+ index: selected.element.index,
365
+ tag: selected.element.tag,
366
+ stale: selected.element.stale,
367
+ },
368
+ }),
369
+ },
370
+ })
249
371
  }),
372
+ '/api/frames/move': {
373
+ POST: guarded(
374
+ mutation((body) => {
375
+ if (
376
+ !isRecord(body) ||
377
+ typeof body.page !== 'string' ||
378
+ typeof body.frame !== 'string' ||
379
+ typeof body.x !== 'number' ||
380
+ typeof body.y !== 'number'
381
+ ) {
382
+ return badBody('send page, frame, x, and y')
383
+ }
384
+ const outcome = moveFrame(root, body.page, body.frame, {
385
+ x: body.x,
386
+ y: body.y,
387
+ })
388
+ return outcome.ok ? json(outcome) : refusal(outcome)
389
+ }),
390
+ ),
391
+ },
392
+ '/api/selection': {
393
+ POST: guarded(
394
+ mutation((body) => {
395
+ if (!isRecord(body)) return badBody('send a frame or {}')
396
+ const { page, frame } = body
397
+ const target =
398
+ typeof page === 'string' && typeof frame === 'string'
399
+ ? { page, frame }
400
+ : undefined
401
+ if (target === undefined && (page != null || frame != null)) {
402
+ return badBody('send both page and frame, or neither')
403
+ }
404
+ const { element } = body
405
+ if (element != null && target === undefined) {
406
+ return badBody('send an element with its page and frame')
407
+ }
408
+ if (element != null && !isAddressShape(element)) {
409
+ return badBody('send an element as index, tag, and count')
410
+ }
411
+ const outcome = writeSelection(
412
+ root,
413
+ target && element != null ? { ...target, element } : target,
414
+ )
415
+ return outcome.ok ? json(outcome) : refusal(outcome)
416
+ }),
417
+ ),
418
+ },
250
419
  '/api/chrome.css': guarded(
251
420
  () =>
252
421
  new Response(chromeCss, {
@@ -79,6 +79,11 @@ export const MISC_CASES: readonly SkillCase[] = [
79
79
  'The same discount rule sits in two files and I need a third variant. Should I extract it, or add a strategy class for the two cases?',
80
80
  expect: 'code-craft',
81
81
  },
82
+ {
83
+ prompt:
84
+ 'Find me the strongest evidence on whether rent control reduces housing supply. Where should I look?',
85
+ expect: 'search-craft',
86
+ },
82
87
  {
83
88
  prompt:
84
89
  "I've got the plan approved and it touches the parser, the command, and the docs. How should I build it so I don't end up with one giant diff?",