@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/canvas/content.ts
CHANGED
|
@@ -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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
}
|
package/src/canvas/server.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { type FSWatcher, mkdirSync, watch } from 'node:fs'
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
72
|
-
*
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
package/src/claude/cases/misc.ts
CHANGED
|
@@ -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?",
|