@erclx/canon 5.14.1 → 5.15.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 (54) 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/read-frames/SKILL.md +3 -2
  6. package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
  7. package/claude/skills/record-screencast/SKILL.md +28 -5
  8. package/docs/agents/commands.md +3 -0
  9. package/docs/agents/demo.md +34 -7
  10. package/docs/agents/migrate.md +1 -1
  11. package/docs/agents/sandbox.md +25 -0
  12. package/docs/target/migrations.md +1 -1
  13. package/docs/workflow/ai-workflow.md +2 -3
  14. package/docs/workflow/skill-map.md +1 -1
  15. package/governance/renames.toml +7 -5
  16. package/governance/rules/canon/630-requirements.md +1 -1
  17. package/package.json +5 -2
  18. package/src/canvas/client/app.tsx +85 -0
  19. package/src/canvas/client/main.tsx +62 -0
  20. package/src/canvas/client/pages-panel.tsx +123 -0
  21. package/src/canvas/client/shell.css +319 -0
  22. package/src/canvas/client/state.ts +186 -0
  23. package/src/canvas/client/surface.tsx +213 -0
  24. package/src/canvas/content.ts +326 -0
  25. package/src/canvas/index.html +14 -0
  26. package/src/canvas/server.ts +302 -0
  27. package/src/canvas/tokens.ts +71 -0
  28. package/src/claude/seeds.ts +0 -1
  29. package/src/cli.ts +2 -0
  30. package/src/commands/canvas.ts +325 -0
  31. package/src/commands/claude.ts +9 -14
  32. package/src/commands/demo.ts +6 -6
  33. package/src/commands/sandbox.ts +108 -0
  34. package/src/context/classify/prompts.ts +1 -1
  35. package/src/context/folders.ts +0 -1
  36. package/src/demo/compile.ts +8 -4
  37. package/src/demo/container.ts +1 -1
  38. package/src/gov/renames.ts +3 -2
  39. package/src/help.ts +1 -0
  40. package/src/record-root.ts +1 -0
  41. package/src/sandbox/equivalence.ts +847 -0
  42. package/src/serve/static.ts +25 -11
  43. package/src/surface-root.ts +0 -1
  44. package/standards/architecture.md +1 -1
  45. package/standards/context.md +1 -1
  46. package/standards/design.md +1 -1
  47. package/standards/index.md +0 -1
  48. package/standards/requirements.md +4 -6
  49. package/standards/wireframes.md +2 -2
  50. package/tooling/claude/reference.md +0 -1
  51. package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
  52. package/governance/rules/canon/645-decisions.md +0 -12
  53. package/standards/decisions.md +0 -105
  54. package/tooling/claude/seeds/canon/decisions/index.md +0 -8
@@ -0,0 +1,213 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX, RefObject } from 'preact'
3
+ import { useEffect, useRef } from 'preact/hooks'
4
+ import type { Frame } from '@/canvas/content'
5
+ import {
6
+ currentPage,
7
+ fitView,
8
+ frameKey,
9
+ frameTheme,
10
+ frameVersions,
11
+ panBy,
12
+ type Theme,
13
+ toggleFrameTheme,
14
+ view,
15
+ zoomAt,
16
+ } from '@/canvas/client/state'
17
+
18
+ /** One wheel notch or one button press. */
19
+ const ZOOM_STEP = 1.2
20
+
21
+ export interface SurfaceProps {
22
+ readonly viewportRef: RefObject<HTMLDivElement | null>
23
+ }
24
+
25
+ export function frameSrc(page: string, frame: Frame, version: number): string {
26
+ const path = `/frames/${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
27
+ return version === 0 ? path : `${path}?v=${version}`
28
+ }
29
+
30
+ /**
31
+ * Sets the frame document's theme the way the token stylesheet reads it. The
32
+ * frame shares the shell's origin, so its document is reachable once loaded.
33
+ */
34
+ function applyFrameTheme(iframe: HTMLIFrameElement | null, value: Theme): void {
35
+ const root = iframe?.contentDocument?.documentElement
36
+ if (!root) return
37
+ root.dataset.theme = value
38
+ root.style.colorScheme = value
39
+ }
40
+
41
+ function FrameView({
42
+ page,
43
+ frame,
44
+ }: {
45
+ readonly page: string
46
+ readonly frame: Frame
47
+ }): JSX.Element {
48
+ const key = frameKey(page, frame)
49
+ const version = frameVersions.value.get(key) ?? 0
50
+ const value = frameTheme(key)
51
+ const iframe = useRef<HTMLIFrameElement>(null)
52
+
53
+ useEffect(() => {
54
+ applyFrameTheme(iframe.current, value)
55
+ }, [value])
56
+
57
+ const next = value === 'dark' ? 'light' : 'dark'
58
+ return (
59
+ <figure
60
+ class="frame"
61
+ data-frame={frame.name}
62
+ style={{
63
+ left: `${frame.x}px`,
64
+ top: `${frame.y}px`,
65
+ width: `${frame.width}px`,
66
+ }}
67
+ >
68
+ <figcaption class="frame-label">
69
+ <span class="frame-name" title={frame.name}>
70
+ {frame.name}
71
+ </span>
72
+ <span class="frame-size">
73
+ {frame.width} × {frame.height}
74
+ </span>
75
+ <button
76
+ type="button"
77
+ class="frame-theme"
78
+ aria-label={`Show ${frame.name} in ${next} theme`}
79
+ title={`Show in ${next} theme`}
80
+ onClick={() => toggleFrameTheme(key)}
81
+ >
82
+ {value === 'dark' ? 'Dark' : 'Light'}
83
+ </button>
84
+ </figcaption>
85
+ <iframe
86
+ ref={iframe}
87
+ title={frame.name}
88
+ src={frameSrc(page, frame, version)}
89
+ width={frame.width}
90
+ height={frame.height}
91
+ style={{ height: `${frame.height}px` }}
92
+ onLoad={() => applyFrameTheme(iframe.current, frameTheme(key))}
93
+ />
94
+ </figure>
95
+ )
96
+ }
97
+
98
+ function viewportSize(viewport: RefObject<HTMLDivElement | null>): {
99
+ width: number
100
+ height: number
101
+ } {
102
+ const rect = viewport.current?.getBoundingClientRect()
103
+ return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
104
+ }
105
+
106
+ function Toolbar({ viewportRef }: SurfaceProps): JSX.Element {
107
+ const zoomCentered = (factor: number) => {
108
+ const { width, height } = viewportSize(viewportRef)
109
+ zoomAt(factor, width / 2, height / 2)
110
+ }
111
+ return (
112
+ <div class="toolbar" role="toolbar" aria-label="Zoom">
113
+ <button
114
+ type="button"
115
+ class="icon-button"
116
+ aria-label="Zoom out"
117
+ onClick={() => zoomCentered(1 / ZOOM_STEP)}
118
+ >
119
+ −
120
+ </button>
121
+ <output class="zoom" aria-live="polite">
122
+ {Math.round(view.value.zoom * 100)}%
123
+ </output>
124
+ <button
125
+ type="button"
126
+ class="icon-button"
127
+ aria-label="Zoom in"
128
+ onClick={() => zoomCentered(ZOOM_STEP)}
129
+ >
130
+ +
131
+ </button>
132
+ <button
133
+ type="button"
134
+ class="text-button"
135
+ onClick={() => {
136
+ const { width, height } = viewportSize(viewportRef)
137
+ fitView(width, height)
138
+ }}
139
+ >
140
+ Fit
141
+ </button>
142
+ </div>
143
+ )
144
+ }
145
+
146
+ export function Surface({ viewportRef }: SurfaceProps): JSX.Element {
147
+ const page = currentPage.value
148
+ const drag = useRef<{ x: number; y: number } | undefined>(undefined)
149
+
150
+ /* Fits a page once when it opens, never on a reload of the same page. */
151
+ useEffect(() => {
152
+ const { width, height } = viewportSize(viewportRef)
153
+ fitView(width, height)
154
+ }, [page?.name])
155
+
156
+ const handleWheel = (event: WheelEvent) => {
157
+ event.preventDefault()
158
+ if (event.ctrlKey || event.metaKey) {
159
+ const rect = viewportRef.current?.getBoundingClientRect()
160
+ const factor = event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
161
+ zoomAt(
162
+ factor,
163
+ event.clientX - (rect?.left ?? 0),
164
+ event.clientY - (rect?.top ?? 0),
165
+ )
166
+ return
167
+ }
168
+ panBy(-event.deltaX, -event.deltaY)
169
+ }
170
+
171
+ const handlePointerDown = (event: PointerEvent) => {
172
+ const target = event.target as HTMLElement | null
173
+ if (target?.closest('.frame, .toolbar')) return
174
+ drag.current = { x: event.clientX, y: event.clientY }
175
+ viewportRef.current?.setPointerCapture?.(event.pointerId)
176
+ }
177
+
178
+ const handlePointerMove = (event: PointerEvent) => {
179
+ const start = drag.current
180
+ if (!start) return
181
+ panBy(event.clientX - start.x, event.clientY - start.y)
182
+ drag.current = { x: event.clientX, y: event.clientY }
183
+ }
184
+
185
+ const handlePointerUp = () => {
186
+ drag.current = undefined
187
+ }
188
+
189
+ const { x, y, zoom } = view.value
190
+ return (
191
+ <main class="surface" aria-label="Canvas">
192
+ <div
193
+ ref={viewportRef}
194
+ class="viewport"
195
+ onWheel={handleWheel}
196
+ onPointerDown={handlePointerDown}
197
+ onPointerMove={handlePointerMove}
198
+ onPointerUp={handlePointerUp}
199
+ onPointerCancel={handlePointerUp}
200
+ >
201
+ <div
202
+ class="plane"
203
+ style={{ transform: `translate(${x}px, ${y}px) scale(${zoom})` }}
204
+ >
205
+ {page?.frames.map((frame) => (
206
+ <FrameView key={frame.name} page={page.name} frame={frame} />
207
+ ))}
208
+ </div>
209
+ </div>
210
+ <Toolbar viewportRef={viewportRef} />
211
+ </main>
212
+ )
213
+ }
@@ -0,0 +1,326 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { recordDir } from '@/record-root'
12
+
13
+ /**
14
+ * The content format. A page is a folder under the record root's `canvas/`, a
15
+ * frame is one `.html` file in it, and `layout.json` beside them holds each
16
+ * frame's box. The HTML stays a plain file so an agent edits a frame the way it
17
+ * edits any other file, and the box lives apart from it so moving a frame never
18
+ * rewrites its markup.
19
+ */
20
+ export const CANVAS_FOLDER = 'canvas'
21
+
22
+ export const LAYOUT_FILE = 'layout.json'
23
+
24
+ const FRAME_EXTENSION = '.html'
25
+
26
+ /** The size a frame takes when nothing states one. */
27
+ export const DEFAULT_FRAME = { width: 1440, height: 900 } as const
28
+
29
+ /** Horizontal space between frames placed in a row. */
30
+ export const FRAME_GAP = 80
31
+
32
+ /**
33
+ * One path segment with no dot leading it, so a name can neither climb out of
34
+ * the canvas folder nor hide as a dotfile the listing skips.
35
+ */
36
+ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
37
+
38
+ export interface Box {
39
+ readonly x: number
40
+ readonly y: number
41
+ readonly width: number
42
+ readonly height: number
43
+ }
44
+
45
+ export interface Frame extends Box {
46
+ readonly name: string
47
+ /** Relative to the page folder. */
48
+ readonly file: string
49
+ /** Whether the layout stated this box, or the reader placed it in a row. */
50
+ readonly placed: boolean
51
+ }
52
+
53
+ export interface Page {
54
+ readonly name: string
55
+ readonly frames: readonly Frame[]
56
+ /** Set when `layout.json` exists and does not parse. Frames still list. */
57
+ readonly layoutIssue?: 'malformed'
58
+ }
59
+
60
+ export type ContentRefusal =
61
+ | 'invalid-name'
62
+ | 'invalid-size'
63
+ | 'exists'
64
+ | 'no-page'
65
+ | 'malformed-layout'
66
+
67
+ export interface ContentRefused {
68
+ readonly ok: false
69
+ readonly reason: ContentRefusal
70
+ readonly detail: string
71
+ }
72
+
73
+ export type PageOutcome =
74
+ | { readonly ok: true; readonly page: string }
75
+ | ContentRefused
76
+
77
+ export type FrameOutcome =
78
+ | {
79
+ readonly ok: true
80
+ readonly page: string
81
+ readonly frame: string
82
+ readonly file: string
83
+ readonly box: Box
84
+ }
85
+ | ContentRefused
86
+
87
+ export function canvasDir(root: string): string {
88
+ return recordDir(root, CANVAS_FOLDER)
89
+ }
90
+
91
+ function pagePath(root: string, page: string): string {
92
+ return join(canvasDir(root), page)
93
+ }
94
+
95
+ function refuse(reason: ContentRefusal, detail: string): ContentRefused {
96
+ return { ok: false, reason, detail }
97
+ }
98
+
99
+ function isValidName(name: string): boolean {
100
+ return NAME_PATTERN.test(name) && !name.includes('..')
101
+ }
102
+
103
+ function isDirectory(path: string): boolean {
104
+ return statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false
105
+ }
106
+
107
+ function isBox(value: unknown): value is Box {
108
+ if (typeof value !== 'object' || value === null) return false
109
+ const box = value as Record<string, unknown>
110
+ return ['x', 'y', 'width', 'height'].every(
111
+ (key) => typeof box[key] === 'number' && Number.isFinite(box[key]),
112
+ )
113
+ }
114
+
115
+ interface LayoutRead {
116
+ readonly boxes: ReadonlyMap<string, Box>
117
+ readonly malformed: boolean
118
+ }
119
+
120
+ /**
121
+ * Reads what a layout states and nothing else. An entry with a box that is not
122
+ * four finite numbers is skipped rather than refused, since one bad entry
123
+ * should cost that frame its position and not the whole page its frames.
124
+ */
125
+ function readLayout(dir: string): LayoutRead {
126
+ const path = join(dir, LAYOUT_FILE)
127
+ if (!existsSync(path)) return { boxes: new Map(), malformed: false }
128
+
129
+ let parsed: unknown
130
+ try {
131
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
132
+ } catch {
133
+ return { boxes: new Map(), malformed: true }
134
+ }
135
+
136
+ const frames =
137
+ typeof parsed === 'object' && parsed !== null
138
+ ? (parsed as { frames?: unknown }).frames
139
+ : undefined
140
+ if (typeof frames !== 'object' || frames === null) {
141
+ return { boxes: new Map(), malformed: true }
142
+ }
143
+
144
+ const boxes = new Map<string, Box>()
145
+ for (const [name, box] of Object.entries(frames)) {
146
+ if (isBox(box)) {
147
+ boxes.set(name, {
148
+ x: box.x,
149
+ y: box.y,
150
+ width: box.width,
151
+ height: box.height,
152
+ })
153
+ }
154
+ }
155
+ return { boxes, malformed: false }
156
+ }
157
+
158
+ function writeLayout(dir: string, boxes: ReadonlyMap<string, Box>): void {
159
+ const frames = Object.fromEntries(boxes)
160
+ writeFileSync(
161
+ join(dir, LAYOUT_FILE),
162
+ `${JSON.stringify({ frames }, null, 2)}\n`,
163
+ )
164
+ }
165
+
166
+ function frameNames(dir: string): string[] {
167
+ return readdirSync(dir)
168
+ .filter((entry) => entry.endsWith(FRAME_EXTENSION))
169
+ .map((entry) => entry.slice(0, -FRAME_EXTENSION.length))
170
+ .filter(isValidName)
171
+ .sort((a, b) => a.localeCompare(b))
172
+ }
173
+
174
+ /** The x a frame starts at when it joins the row after every box given. */
175
+ function nextX(boxes: Iterable<Box>): number {
176
+ let right: number | undefined
177
+ for (const box of boxes) {
178
+ const edge = box.x + box.width
179
+ right = right === undefined ? edge : Math.max(right, edge)
180
+ }
181
+ return right === undefined ? 0 : right + FRAME_GAP
182
+ }
183
+
184
+ export function readPage(root: string, page: string): Page | undefined {
185
+ if (!isValidName(page)) return undefined
186
+ const dir = pagePath(root, page)
187
+ if (!isDirectory(dir)) return undefined
188
+
189
+ const { boxes, malformed } = readLayout(dir)
190
+ const names = frameNames(dir)
191
+
192
+ const placed = names.filter((name) => boxes.has(name))
193
+ const frames: Frame[] = []
194
+ for (const name of placed) {
195
+ const box = boxes.get(name)
196
+ if (box) {
197
+ frames.push({
198
+ name,
199
+ file: `${name}${FRAME_EXTENSION}`,
200
+ ...box,
201
+ placed: true,
202
+ })
203
+ }
204
+ }
205
+
206
+ for (const name of names.filter((candidate) => !boxes.has(candidate))) {
207
+ frames.push({
208
+ name,
209
+ file: `${name}${FRAME_EXTENSION}`,
210
+ x: nextX(frames),
211
+ y: 0,
212
+ ...DEFAULT_FRAME,
213
+ placed: false,
214
+ })
215
+ }
216
+
217
+ frames.sort((a, b) => a.name.localeCompare(b.name))
218
+ return malformed
219
+ ? { name: page, frames, layoutIssue: 'malformed' }
220
+ : { name: page, frames }
221
+ }
222
+
223
+ export function listPages(root: string): Page[] {
224
+ const dir = canvasDir(root)
225
+ if (!isDirectory(dir)) return []
226
+
227
+ return readdirSync(dir)
228
+ .filter(isValidName)
229
+ .filter((name) => isDirectory(join(dir, name)))
230
+ .sort((a, b) => a.localeCompare(b))
231
+ .flatMap((name) => readPage(root, name) ?? [])
232
+ }
233
+
234
+ export function addPage(root: string, page: string): PageOutcome {
235
+ if (!isValidName(page)) {
236
+ return refuse('invalid-name', `${page} is not a valid page name`)
237
+ }
238
+ const dir = pagePath(root, page)
239
+ if (existsSync(dir)) return refuse('exists', `page ${page} already exists`)
240
+
241
+ mkdirSync(dir, { recursive: true })
242
+ return { ok: true, page }
243
+ }
244
+
245
+ export function renamePage(
246
+ root: string,
247
+ from: string,
248
+ to: string,
249
+ ): PageOutcome {
250
+ if (!isValidName(from) || !isValidName(to)) {
251
+ return refuse(
252
+ 'invalid-name',
253
+ `${isValidName(from) ? to : from} is not a valid page name`,
254
+ )
255
+ }
256
+ const source = pagePath(root, from)
257
+ if (!isDirectory(source))
258
+ return refuse('no-page', `page ${from} does not exist`)
259
+ const target = pagePath(root, to)
260
+ if (existsSync(target)) return refuse('exists', `page ${to} already exists`)
261
+
262
+ renameSync(source, target)
263
+ return { ok: true, page: to }
264
+ }
265
+
266
+ function frameScaffold(name: string): string {
267
+ return `<!doctype html>
268
+ <html lang="en">
269
+ <head>
270
+ <meta charset="utf-8" />
271
+ <title>${name}</title>
272
+ </head>
273
+ <body>
274
+ <main>
275
+ <h1>${name}</h1>
276
+ </main>
277
+ </body>
278
+ </html>
279
+ `
280
+ }
281
+
282
+ export interface FrameSize {
283
+ readonly width: number
284
+ readonly height: number
285
+ }
286
+
287
+ export function addFrame(
288
+ root: string,
289
+ page: string,
290
+ frame: string,
291
+ size: FrameSize,
292
+ ): FrameOutcome {
293
+ if (!isValidName(page)) {
294
+ return refuse('invalid-name', `${page} is not a valid page name`)
295
+ }
296
+ if (!isValidName(frame)) {
297
+ return refuse('invalid-name', `${frame} is not a valid frame name`)
298
+ }
299
+ const isPositive = (value: number) => Number.isFinite(value) && value > 0
300
+ if (!isPositive(size.width) || !isPositive(size.height)) {
301
+ return refuse('invalid-size', 'width and height must be positive numbers')
302
+ }
303
+
304
+ const dir = pagePath(root, page)
305
+ if (!isDirectory(dir)) return refuse('no-page', `page ${page} does not exist`)
306
+ const file = `${frame}${FRAME_EXTENSION}`
307
+ if (existsSync(join(dir, file))) {
308
+ return refuse('exists', `frame ${frame} already exists on ${page}`)
309
+ }
310
+
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.
314
+ */
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`)
320
+ }
321
+ const box: Box = { x: nextX(current), y: 0, ...size }
322
+
323
+ writeFileSync(join(dir, file), frameScaffold(frame))
324
+ writeLayout(dir, new Map([...boxes, [frame, box]]))
325
+ return { ok: true, page, frame, file, box }
326
+ }
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Canvas</title>
7
+ <link rel="icon" href="data:," />
8
+ <link rel="stylesheet" href="./client/shell.css" />
9
+ </head>
10
+ <body>
11
+ <div id="app"></div>
12
+ <script type="module" src="./client/main.tsx"></script>
13
+ </body>
14
+ </html>