@erclx/canon 5.14.1 → 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 (77) hide show
  1. package/README.md +3 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
  4. package/claude/skills/draft-screencast/SKILL.md +14 -9
  5. package/claude/skills/draft-wiki/SKILL.md +1 -1
  6. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  7. package/claude/skills/read-frames/SKILL.md +3 -2
  8. package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
  9. package/claude/skills/record-screencast/SKILL.md +28 -5
  10. package/claude/skills/search-craft/REQUIREMENT.md +57 -0
  11. package/claude/skills/search-craft/SKILL.md +83 -0
  12. package/docs/agents/canvas.md +36 -0
  13. package/docs/agents/commands.md +3 -0
  14. package/docs/agents/demo.md +34 -7
  15. package/docs/agents/index.md +1 -0
  16. package/docs/agents/migrate.md +1 -1
  17. package/docs/agents/sandbox.md +25 -0
  18. package/docs/target/migrations.md +1 -1
  19. package/docs/workflow/ai-workflow.md +2 -3
  20. package/docs/workflow/skill-map.md +2 -1
  21. package/governance/renames.toml +7 -5
  22. package/governance/rules/canon/630-requirements.md +1 -1
  23. package/package.json +5 -2
  24. package/scripts/lib/sandbox-git.sh +2 -2
  25. package/src/canvas/address.ts +184 -0
  26. package/src/canvas/capture.ts +172 -0
  27. package/src/canvas/client/app.tsx +87 -0
  28. package/src/canvas/client/inspector.tsx +121 -0
  29. package/src/canvas/client/layers.tsx +118 -0
  30. package/src/canvas/client/main.tsx +62 -0
  31. package/src/canvas/client/pages-panel.tsx +157 -0
  32. package/src/canvas/client/shell.css +449 -0
  33. package/src/canvas/client/state.ts +372 -0
  34. package/src/canvas/client/surface.tsx +465 -0
  35. package/src/canvas/content.ts +617 -0
  36. package/src/canvas/index.html +14 -0
  37. package/src/canvas/server.ts +471 -0
  38. package/src/canvas/tokens.ts +71 -0
  39. package/src/claude/cases/misc.ts +5 -0
  40. package/src/claude/seeds.ts +0 -1
  41. package/src/cli.ts +2 -0
  42. package/src/commands/canvas.ts +522 -0
  43. package/src/commands/claude.ts +9 -14
  44. package/src/commands/demo.ts +6 -6
  45. package/src/commands/migrate.ts +1 -1
  46. package/src/commands/sandbox.ts +108 -0
  47. package/src/context/classify/prompts.ts +1 -1
  48. package/src/context/folders.ts +0 -1
  49. package/src/demo/compile.ts +8 -4
  50. package/src/demo/container.ts +1 -1
  51. package/src/gov/renames.ts +3 -2
  52. package/src/help.ts +1 -0
  53. package/src/migrate/rename.ts +5 -4
  54. package/src/record-root.ts +1 -0
  55. package/src/sandbox/equivalence.ts +847 -0
  56. package/src/serve/static.ts +25 -11
  57. package/src/surface-root.ts +0 -1
  58. package/src/teach/browser/compile.ts +44 -0
  59. package/src/teach/browser/dismiss.ts +76 -0
  60. package/src/teach/browser/focus-line.ts +19 -0
  61. package/src/teach/browser/glossary-filter.ts +44 -0
  62. package/src/teach/browser/head.ts +30 -0
  63. package/src/teach/browser/quiz.ts +19 -0
  64. package/src/teach/browser/sidebar.ts +233 -0
  65. package/src/teach/browser/theme.ts +18 -0
  66. package/src/teach/nav.ts +19 -374
  67. package/standards/architecture.md +1 -1
  68. package/standards/context.md +1 -1
  69. package/standards/design.md +1 -1
  70. package/standards/index.md +0 -1
  71. package/standards/requirements.md +4 -6
  72. package/standards/wireframes.md +2 -2
  73. package/tooling/claude/reference.md +0 -1
  74. package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
  75. package/governance/rules/canon/645-decisions.md +0 -12
  76. package/standards/decisions.md +0 -105
  77. package/tooling/claude/seeds/canon/decisions/index.md +0 -8
@@ -0,0 +1,465 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX, RefObject } from 'preact'
3
+ import { useEffect, useRef, useState } from 'preact/hooks'
4
+ import { addressOf, documentElements, elementAt } from '@/canvas/address'
5
+ import type { Frame } from '@/canvas/content'
6
+ import {
7
+ currentPage,
8
+ draggingFrame,
9
+ fitView,
10
+ type FrameRef,
11
+ frameDocuments,
12
+ frameKey,
13
+ frameTheme,
14
+ frameVersions,
15
+ hoveredElement,
16
+ moveFrameTo,
17
+ panBy,
18
+ previewMove,
19
+ registerFrameDocument,
20
+ selectElement,
21
+ selectFrame,
22
+ selection,
23
+ type Theme,
24
+ toggleFrameTheme,
25
+ view,
26
+ zoomAt,
27
+ } from '@/canvas/client/state'
28
+
29
+ /** One wheel notch or one button press. */
30
+ const ZOOM_STEP = 1.2
31
+
32
+ /** Screen pixels a press travels before it reads as a drag and not a click. */
33
+ const DRAG_THRESHOLD = 3
34
+
35
+ /** Surface units one arrow press moves a frame, and one with Shift held. */
36
+ const NUDGE = 10
37
+ const NUDGE_LARGE = 50
38
+
39
+ const ARROWS: Readonly<Record<string, { x: number; y: number } | undefined>> = {
40
+ ArrowLeft: { x: -1, y: 0 },
41
+ ArrowRight: { x: 1, y: 0 },
42
+ ArrowUp: { x: 0, y: -1 },
43
+ ArrowDown: { x: 0, y: 1 },
44
+ }
45
+
46
+ interface Gesture {
47
+ readonly startX: number
48
+ readonly startY: number
49
+ readonly originX: number
50
+ readonly originY: number
51
+ x: number
52
+ y: number
53
+ moved: boolean
54
+ }
55
+
56
+ export interface SurfaceProps {
57
+ readonly viewportRef: RefObject<HTMLDivElement | null>
58
+ }
59
+
60
+ /** A wheel turn in shell coordinates, wherever the pointer sat. */
61
+ interface WheelTurn {
62
+ readonly deltaX: number
63
+ readonly deltaY: number
64
+ readonly isZoom: boolean
65
+ readonly clientX: number
66
+ readonly clientY: number
67
+ }
68
+
69
+ /**
70
+ * The frame's own document is a separate window, so an element in it fails an
71
+ * `instanceof Element` against the shell's window. The node type is the test
72
+ * that holds across both.
73
+ */
74
+ function asElement(target: EventTarget | null): Element | undefined {
75
+ const node = target as Node | null
76
+ return node?.nodeType === 1 ? (node as Element) : undefined
77
+ }
78
+
79
+ function ElementOutline({
80
+ element,
81
+ kind,
82
+ }: {
83
+ readonly element: Element
84
+ readonly kind: 'hover' | 'selected'
85
+ }): JSX.Element {
86
+ const rect = element.getBoundingClientRect()
87
+ return (
88
+ <div
89
+ class="element-outline"
90
+ data-outline={kind}
91
+ aria-hidden="true"
92
+ style={{
93
+ left: `${rect.left}px`,
94
+ top: `${rect.top}px`,
95
+ width: `${rect.width}px`,
96
+ height: `${rect.height}px`,
97
+ }}
98
+ />
99
+ )
100
+ }
101
+
102
+ export function frameSrc(page: string, frame: Frame, version: number): string {
103
+ const path = `/frames/${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
104
+ return version === 0 ? path : `${path}?v=${version}`
105
+ }
106
+
107
+ /**
108
+ * Sets the frame document's theme the way the token stylesheet reads it. The
109
+ * frame shares the shell's origin, so its document is reachable once loaded.
110
+ */
111
+ function applyFrameTheme(iframe: HTMLIFrameElement | null, value: Theme): void {
112
+ const root = iframe?.contentDocument?.documentElement
113
+ if (!root) return
114
+ root.dataset.theme = value
115
+ root.style.colorScheme = value
116
+ }
117
+
118
+ function FrameView({
119
+ page,
120
+ frame,
121
+ onWheelTurn,
122
+ }: {
123
+ readonly page: string
124
+ readonly frame: Frame
125
+ readonly onWheelTurn: (turn: WheelTurn) => void
126
+ }): JSX.Element {
127
+ const key = frameKey(page, frame)
128
+ const version = frameVersions.value.get(key) ?? 0
129
+ const value = frameTheme(key)
130
+ const iframe = useRef<HTMLIFrameElement>(null)
131
+ const gesture = useRef<Gesture | undefined>(undefined)
132
+ const ref: FrameRef = { page, frame: frame.name }
133
+ const isSelected =
134
+ selection.value?.page === page && selection.value.frame === frame.name
135
+ /* Bumped when the frame document scrolls, so the outlines follow it. */
136
+ const [, setScrolled] = useState(0)
137
+ const doc = frameDocuments.value.get(key)
138
+
139
+ useEffect(() => {
140
+ applyFrameTheme(iframe.current, value)
141
+ }, [value])
142
+
143
+ /*
144
+ * The frame shares the shell's origin, so its document takes listeners
145
+ * directly. A click selects the element under it and goes no further, so a
146
+ * link or a submit button in a draft never navigates the frame away.
147
+ */
148
+ const handleLoad = () => {
149
+ const element = iframe.current
150
+ const loaded = element?.contentDocument
151
+ applyFrameTheme(element, frameTheme(key))
152
+ if (!element || !loaded) return
153
+ registerFrameDocument(key, loaded)
154
+
155
+ loaded.addEventListener('click', (event) => {
156
+ event.preventDefault()
157
+ event.stopPropagation()
158
+ const target = asElement(event.target)
159
+ const address = target && addressOf(loaded, target)
160
+ if (address) void selectElement(ref, key, address)
161
+ })
162
+ loaded.addEventListener('submit', (event) => event.preventDefault())
163
+ loaded.addEventListener('mouseover', (event) => {
164
+ const target = asElement(event.target)
165
+ const index = target ? documentElements(loaded).indexOf(target) : -1
166
+ hoveredElement.value = index === -1 ? undefined : { key, index }
167
+ })
168
+ loaded.addEventListener('mouseleave', () => {
169
+ if (hoveredElement.value?.key === key) hoveredElement.value = undefined
170
+ })
171
+ loaded.addEventListener('scroll', () => setScrolled((n) => n + 1))
172
+ loaded.addEventListener(
173
+ 'wheel',
174
+ (event) => {
175
+ event.preventDefault()
176
+ const rect = element.getBoundingClientRect()
177
+ const { zoom } = view.value
178
+ onWheelTurn({
179
+ deltaX: event.deltaX,
180
+ deltaY: event.deltaY,
181
+ isZoom: event.ctrlKey || event.metaKey,
182
+ clientX: rect.left + event.clientX * zoom,
183
+ clientY: rect.top + event.clientY * zoom,
184
+ })
185
+ },
186
+ { passive: false },
187
+ )
188
+ }
189
+
190
+ const picked =
191
+ isSelected && doc && selection.value?.element
192
+ ? selection.value.element
193
+ : undefined
194
+ const selectedNode =
195
+ doc && picked && !picked.stale ? elementAt(doc, picked) : undefined
196
+ const hovered = hoveredElement.value
197
+ const hoveredNode =
198
+ doc && hovered?.key === key
199
+ ? documentElements(doc)[hovered.index]
200
+ : undefined
201
+
202
+ /* The label means the frame itself, so it also drops a picked element. */
203
+ const select = () => {
204
+ if (!isSelected || selection.value?.element) void selectFrame(ref)
205
+ }
206
+
207
+ const handlePointerDown = (event: PointerEvent) => {
208
+ if (event.button !== 0) return
209
+ if ((event.target as HTMLElement | null)?.closest('.frame-theme')) return
210
+ gesture.current = {
211
+ startX: event.clientX,
212
+ startY: event.clientY,
213
+ originX: frame.x,
214
+ originY: frame.y,
215
+ x: frame.x,
216
+ y: frame.y,
217
+ moved: false,
218
+ }
219
+ ;(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId)
220
+ select()
221
+ }
222
+
223
+ const handlePointerMove = (event: PointerEvent) => {
224
+ const current = gesture.current
225
+ if (!current) return
226
+ const dx = event.clientX - current.startX
227
+ const dy = event.clientY - current.startY
228
+ if (!current.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
229
+ const { zoom } = view.value
230
+ current.moved = true
231
+ current.x = Math.round(current.originX + dx / zoom)
232
+ current.y = Math.round(current.originY + dy / zoom)
233
+ draggingFrame.value = frame.name
234
+ previewMove(ref, current.x, current.y)
235
+ }
236
+
237
+ const handlePointerUp = () => {
238
+ const current = gesture.current
239
+ gesture.current = undefined
240
+ draggingFrame.value = undefined
241
+ if (current?.moved) void moveFrameTo(ref, current.x, current.y)
242
+ }
243
+
244
+ const handlePointerCancel = () => {
245
+ const current = gesture.current
246
+ gesture.current = undefined
247
+ draggingFrame.value = undefined
248
+ if (current?.moved) previewMove(ref, current.originX, current.originY)
249
+ }
250
+
251
+ /*
252
+ * The keyboard path for a drag, since a pointer gesture alone leaves a
253
+ * frame unmovable without a mouse.
254
+ */
255
+ const handleKeyDown = (event: KeyboardEvent) => {
256
+ if (event.target !== event.currentTarget) return
257
+ if (event.key === 'Enter' || event.key === ' ') {
258
+ event.preventDefault()
259
+ select()
260
+ return
261
+ }
262
+ const step = event.shiftKey ? NUDGE_LARGE : NUDGE
263
+ const delta = ARROWS[event.key]
264
+ if (!delta || !isSelected) return
265
+ event.preventDefault()
266
+ void moveFrameTo(ref, frame.x + delta.x * step, frame.y + delta.y * step)
267
+ }
268
+
269
+ const next = value === 'dark' ? 'light' : 'dark'
270
+ return (
271
+ <figure
272
+ class="frame"
273
+ data-frame={frame.name}
274
+ data-selected={isSelected ? 'true' : undefined}
275
+ data-dragging={draggingFrame.value === frame.name ? 'true' : undefined}
276
+ tabIndex={0}
277
+ aria-label={`${frame.name}, ${frame.width} by ${frame.height}`}
278
+ aria-current={isSelected ? 'true' : undefined}
279
+ onKeyDown={handleKeyDown}
280
+ style={{
281
+ left: `${frame.x}px`,
282
+ top: `${frame.y}px`,
283
+ width: `${frame.width}px`,
284
+ }}
285
+ >
286
+ {/* The frame's handle, since a press inside the frame picks an element. */}
287
+ <figcaption
288
+ class="frame-label"
289
+ onPointerDown={handlePointerDown}
290
+ onPointerMove={handlePointerMove}
291
+ onPointerUp={handlePointerUp}
292
+ onPointerCancel={handlePointerCancel}
293
+ >
294
+ <span class="frame-name" title={frame.name}>
295
+ {frame.name}
296
+ </span>
297
+ <span class="frame-size">
298
+ {frame.width} × {frame.height}
299
+ </span>
300
+ <button
301
+ type="button"
302
+ class="frame-theme"
303
+ aria-label={`Show ${frame.name} in ${next} theme`}
304
+ title={`Show in ${next} theme`}
305
+ onClick={() => toggleFrameTheme(key)}
306
+ >
307
+ {value === 'dark' ? 'Dark' : 'Light'}
308
+ </button>
309
+ </figcaption>
310
+ <div class="frame-body">
311
+ <iframe
312
+ ref={iframe}
313
+ title={frame.name}
314
+ src={frameSrc(page, frame, version)}
315
+ width={frame.width}
316
+ height={frame.height}
317
+ style={{ height: `${frame.height}px` }}
318
+ onLoad={handleLoad}
319
+ />
320
+ {hoveredNode && hoveredNode !== selectedNode ? (
321
+ <ElementOutline element={hoveredNode} kind="hover" />
322
+ ) : null}
323
+ {selectedNode ? (
324
+ <ElementOutline element={selectedNode} kind="selected" />
325
+ ) : null}
326
+ </div>
327
+ </figure>
328
+ )
329
+ }
330
+
331
+ function viewportSize(viewport: RefObject<HTMLDivElement | null>): {
332
+ width: number
333
+ height: number
334
+ } {
335
+ const rect = viewport.current?.getBoundingClientRect()
336
+ return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
337
+ }
338
+
339
+ function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
340
+ const zoomCentered = (factor: number) => {
341
+ const { width, height } = viewportSize(viewportRef)
342
+ zoomAt(factor, width / 2, height / 2)
343
+ }
344
+ return (
345
+ <div class="toolbar" role="toolbar" aria-label="Zoom">
346
+ <button
347
+ type="button"
348
+ class="icon-button"
349
+ aria-label="Zoom out"
350
+ onClick={() => zoomCentered(1 / ZOOM_STEP)}
351
+ >
352
+ −
353
+ </button>
354
+ <output class="zoom" aria-live="polite">
355
+ {Math.round(view.value.zoom * 100)}%
356
+ </output>
357
+ <button
358
+ type="button"
359
+ class="icon-button"
360
+ aria-label="Zoom in"
361
+ onClick={() => zoomCentered(ZOOM_STEP)}
362
+ >
363
+ +
364
+ </button>
365
+ <button
366
+ type="button"
367
+ class="text-button"
368
+ onClick={() => {
369
+ const { width, height } = viewportSize(viewportRef)
370
+ fitView(width, height)
371
+ }}
372
+ >
373
+ Fit
374
+ </button>
375
+ </div>
376
+ )
377
+ }
378
+
379
+ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
380
+ const page = currentPage.value
381
+ const drag = useRef<{ x: number; y: number } | undefined>(undefined)
382
+
383
+ /* Fits a page once when it opens, never on a reload of the same page. */
384
+ useEffect(() => {
385
+ const { width, height } = viewportSize(viewportRef)
386
+ fitView(width, height)
387
+ }, [page?.name])
388
+
389
+ const handleWheelTurn = (turn: WheelTurn) => {
390
+ if (turn.isZoom) {
391
+ const rect = viewportRef.current?.getBoundingClientRect()
392
+ const factor = turn.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
393
+ zoomAt(
394
+ factor,
395
+ turn.clientX - (rect?.left ?? 0),
396
+ turn.clientY - (rect?.top ?? 0),
397
+ )
398
+ return
399
+ }
400
+ panBy(-turn.deltaX, -turn.deltaY)
401
+ }
402
+
403
+ const handleWheel = (event: WheelEvent) => {
404
+ event.preventDefault()
405
+ handleWheelTurn({
406
+ deltaX: event.deltaX,
407
+ deltaY: event.deltaY,
408
+ isZoom: event.ctrlKey || event.metaKey,
409
+ clientX: event.clientX,
410
+ clientY: event.clientY,
411
+ })
412
+ }
413
+
414
+ const handlePointerDown = (event: PointerEvent) => {
415
+ const target = event.target as HTMLElement | null
416
+ if (target?.closest('.frame, .toolbar')) return
417
+ drag.current = { x: event.clientX, y: event.clientY }
418
+ viewportRef.current?.setPointerCapture?.(event.pointerId)
419
+ }
420
+
421
+ const handlePointerMove = (event: PointerEvent) => {
422
+ const start = drag.current
423
+ if (!start) return
424
+ panBy(event.clientX - start.x, event.clientY - start.y)
425
+ drag.current = { x: event.clientX, y: event.clientY }
426
+ }
427
+
428
+ const handlePointerUp = () => {
429
+ drag.current = undefined
430
+ }
431
+
432
+ const { x, y, zoom } = view.value
433
+ return (
434
+ <main class="surface" aria-label="Canvas">
435
+ <div
436
+ ref={viewportRef}
437
+ class="viewport"
438
+ onWheel={handleWheel}
439
+ onPointerDown={handlePointerDown}
440
+ onPointerMove={handlePointerMove}
441
+ onPointerUp={handlePointerUp}
442
+ onPointerCancel={handlePointerUp}
443
+ >
444
+ <div
445
+ class="plane"
446
+ style={{
447
+ transform: `translate(${x}px, ${y}px) scale(${zoom})`,
448
+ /* Lets an outline inside the plane hold its screen weight at any zoom. */
449
+ '--outline-scale': String(1 / zoom),
450
+ }}
451
+ >
452
+ {page?.frames.map((frame) => (
453
+ <FrameView
454
+ key={frame.name}
455
+ page={page.name}
456
+ frame={frame}
457
+ onWheelTurn={handleWheelTurn}
458
+ />
459
+ ))}
460
+ </div>
461
+ </div>
462
+ <Toolbar viewportRef={viewportRef} />
463
+ </main>
464
+ )
465
+ }