@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,372 @@
1
+ import { computed, signal } from '@preact/signals'
2
+ import type { ElementAddress } from '@/canvas/address'
3
+ import type { Frame, Page } from '@/canvas/content'
4
+
5
+ /**
6
+ * Everything the panels read. Type imports only from outside `client/`, since
7
+ * this runs in the browser and a Node or Bun module would not bundle there.
8
+ * `@/canvas/address` is the one exception the client imports for values, since
9
+ * it imports nothing itself.
10
+ */
11
+
12
+ export type Theme = 'light' | 'dark'
13
+
14
+ export interface TokenSource {
15
+ readonly source: 'toolkit' | 'installed' | 'none'
16
+ readonly files?: readonly string[]
17
+ readonly notice?: string
18
+ }
19
+
20
+ export interface FrameRef {
21
+ readonly page: string
22
+ readonly frame: string
23
+ }
24
+
25
+ export interface ElementRef {
26
+ readonly index: number
27
+ readonly tag: string
28
+ /** Set by the server once the frame file changed since the pick. */
29
+ readonly stale?: boolean
30
+ }
31
+
32
+ export interface SelectionRef extends FrameRef {
33
+ readonly element?: ElementRef
34
+ }
35
+
36
+ export interface PagesRecord {
37
+ readonly pages: readonly Page[]
38
+ readonly tokens: TokenSource
39
+ /** Absent when nothing is selected or the selected frame is gone. */
40
+ readonly selection?: SelectionRef | null
41
+ }
42
+
43
+ export interface ChangeEvent {
44
+ readonly page: string
45
+ readonly file: string
46
+ }
47
+
48
+ export interface View {
49
+ readonly x: number
50
+ readonly y: number
51
+ readonly zoom: number
52
+ }
53
+
54
+ export const MIN_ZOOM = 0.05
55
+ export const MAX_ZOOM = 4
56
+
57
+ export const pages = signal<readonly Page[]>([])
58
+ export const tokens = signal<TokenSource | undefined>(undefined)
59
+ export const isLoaded = signal(false)
60
+ export const loadError = signal<string | undefined>(undefined)
61
+ export const selectedPage = signal<string | undefined>(undefined)
62
+
63
+ /** Undefined follows the system, which `systemTheme` reports. */
64
+ export const themeChoice = signal<Theme | undefined>(undefined)
65
+ export const systemTheme = signal<Theme>('dark')
66
+ export const theme = computed<Theme>(
67
+ () => themeChoice.value ?? systemTheme.value,
68
+ )
69
+
70
+ /** A frame's own theme where the operator switched it, else the chrome's. */
71
+ export const frameThemes = signal<ReadonlyMap<string, Theme>>(new Map())
72
+
73
+ /** Bumped per frame on a change, so only that frame's document reloads. */
74
+ export const frameVersions = signal<ReadonlyMap<string, number>>(new Map())
75
+
76
+ export const view = signal<View>({ x: 0, y: 0, zoom: 0.5 })
77
+
78
+ /**
79
+ * The one frame, or element in it, the operator pointed at, which Claude reads
80
+ * as "this one".
81
+ */
82
+ export const selection = signal<SelectionRef | undefined>(undefined)
83
+
84
+ /** Each loaded frame's document by frame key, replaced on every reload. */
85
+ export const frameDocuments = signal<ReadonlyMap<string, Document>>(new Map())
86
+
87
+ /** The element under the pointer, in a frame or in the layers tree. */
88
+ export const hoveredElement = signal<
89
+ { readonly key: string; readonly index: number } | undefined
90
+ >(undefined)
91
+
92
+ /** Frame keys whose layers are open in the pages panel. */
93
+ export const expandedFrames = signal<ReadonlySet<string>>(new Set())
94
+
95
+ /** Set while a frame follows the pointer, so the surface can style it. */
96
+ export const draggingFrame = signal<string | undefined>(undefined)
97
+
98
+ /** Why the last write to the canvas failed, until the next one succeeds. */
99
+ export const writeError = signal<string | undefined>(undefined)
100
+
101
+ export const currentPage = computed<Page | undefined>(() => {
102
+ const all = pages.value
103
+ return all.find((page) => page.name === selectedPage.value) ?? all[0]
104
+ })
105
+
106
+ /** The selected frame when it is on the page being shown. */
107
+ export const selectedFrame = computed<Frame | undefined>(() => {
108
+ const page = currentPage.value
109
+ const chosen = selection.value
110
+ if (!page || chosen?.page !== page.name) return undefined
111
+ return page.frames.find((frame) => frame.name === chosen.frame)
112
+ })
113
+
114
+ /** The selected element when its frame is on the page being shown. */
115
+ export const selectedElement = computed<
116
+ { readonly frame: Frame; readonly element: ElementRef } | undefined
117
+ >(() => {
118
+ const frame = selectedFrame.value
119
+ const element = selection.value?.element
120
+ return frame && element ? { frame, element } : undefined
121
+ })
122
+
123
+ export function frameKey(page: string, frame: Pick<Frame, 'file'>): string {
124
+ return `${page}/${frame.file}`
125
+ }
126
+
127
+ export function frameTheme(key: string): Theme {
128
+ return frameThemes.value.get(key) ?? theme.value
129
+ }
130
+
131
+ export function toggleTheme(): void {
132
+ themeChoice.value = theme.value === 'dark' ? 'light' : 'dark'
133
+ }
134
+
135
+ export function toggleFrameTheme(key: string): void {
136
+ const next = new Map(frameThemes.value)
137
+ next.set(key, frameTheme(key) === 'dark' ? 'light' : 'dark')
138
+ frameThemes.value = next
139
+ }
140
+
141
+ export function registerFrameDocument(key: string, doc: Document): void {
142
+ const next = new Map(frameDocuments.value)
143
+ next.set(key, doc)
144
+ frameDocuments.value = next
145
+ }
146
+
147
+ export function toggleLayers(key: string): void {
148
+ const next = new Set(expandedFrames.value)
149
+ if (!next.delete(key)) next.add(key)
150
+ expandedFrames.value = next
151
+ }
152
+
153
+ export function applyRecord(record: PagesRecord): void {
154
+ pages.value = record.pages
155
+ tokens.value = record.tokens
156
+ selection.value = record.selection ?? undefined
157
+ loadError.value = undefined
158
+ isLoaded.value = true
159
+ }
160
+
161
+ export async function loadPages(
162
+ fetchImpl: typeof fetch = fetch,
163
+ ): Promise<void> {
164
+ try {
165
+ const response = await fetchImpl('/api/pages')
166
+ if (!response.ok) throw new Error(`status ${response.status}`)
167
+ applyRecord((await response.json()) as PagesRecord)
168
+ } catch (error) {
169
+ loadError.value = `Could not read the canvas (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
170
+ isLoaded.value = true
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Reloads the one frame whose file changed and rereads the page list, which
176
+ * picks up an added frame or a moved box. The view is separate state, so
177
+ * neither resets pan or zoom.
178
+ */
179
+ export async function applyChange(
180
+ change: ChangeEvent,
181
+ fetchImpl: typeof fetch = fetch,
182
+ ): Promise<void> {
183
+ if (change.file.endsWith('.html')) {
184
+ const key = `${change.page}/${change.file}`
185
+ const next = new Map(frameVersions.value)
186
+ next.set(key, (next.get(key) ?? 0) + 1)
187
+ frameVersions.value = next
188
+ }
189
+ await loadPages(fetchImpl)
190
+ }
191
+
192
+ /** What a refused element pick tells the operator, by the server's reason. */
193
+ const PICK_NOTICES: Readonly<Record<string, string | undefined>> = {
194
+ 'address-mismatch':
195
+ 'Could not select that element, since the browser and the file count the elements of this frame differently, as when the browser builds an element the file does not state.',
196
+ 'stale-address':
197
+ 'Could not select that element, since the frame changed before the pick arrived. Pick it again once the frame reloads.',
198
+ }
199
+
200
+ async function refusalReason(response: Response): Promise<string | undefined> {
201
+ try {
202
+ const body: unknown = await response.json()
203
+ return typeof body === 'object' && body !== null && 'reason' in body
204
+ ? String(body.reason)
205
+ : undefined
206
+ } catch {
207
+ return undefined
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Posts one write and reports a refusal in the panel rather than failing
213
+ * silently. A refusal also rereads the page list, so a position the server
214
+ * would not take does not stay on the surface.
215
+ */
216
+ async function write(
217
+ path: string,
218
+ body: unknown,
219
+ fetchImpl: typeof fetch,
220
+ ): Promise<void> {
221
+ try {
222
+ const response = await fetchImpl(path, {
223
+ method: 'POST',
224
+ headers: { 'content-type': 'application/json' },
225
+ body: JSON.stringify(body),
226
+ })
227
+ if (!response.ok) {
228
+ const reason = await refusalReason(response)
229
+ writeError.value =
230
+ (reason && PICK_NOTICES[reason]) ??
231
+ `Could not save (status ${response.status}). Check canon canvas serve is still running.`
232
+ await loadPages(fetchImpl)
233
+ return
234
+ }
235
+ writeError.value = undefined
236
+ } catch (error) {
237
+ writeError.value = `Could not save (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
238
+ await loadPages(fetchImpl)
239
+ }
240
+ }
241
+
242
+ /** Selects a frame here at once and records it where Claude reads it. */
243
+ export async function selectFrame(
244
+ ref: FrameRef | undefined,
245
+ fetchImpl: typeof fetch = fetch,
246
+ ): Promise<void> {
247
+ selection.value = ref
248
+ await write('/api/selection', ref ?? {}, fetchImpl)
249
+ }
250
+
251
+ /**
252
+ * Selects one element of a frame, opens that frame's layers so the tree shows
253
+ * the pick, and records it where Claude reads it. The server checks the
254
+ * address against the file and refuses one it cannot match.
255
+ */
256
+ export async function selectElement(
257
+ ref: FrameRef,
258
+ key: string,
259
+ address: ElementAddress,
260
+ fetchImpl: typeof fetch = fetch,
261
+ ): Promise<void> {
262
+ selection.value = {
263
+ ...ref,
264
+ element: { index: address.index, tag: address.tag },
265
+ }
266
+ if (!expandedFrames.value.has(key)) toggleLayers(key)
267
+ await write('/api/selection', { ...ref, element: address }, fetchImpl)
268
+ }
269
+
270
+ /**
271
+ * Moves a frame on the surface without writing, which a drag calls on every
272
+ * pointer move. The next reread replaces it with what the layout holds.
273
+ */
274
+ export function previewMove(ref: FrameRef, x: number, y: number): void {
275
+ pages.value = pages.value.map((candidate) =>
276
+ candidate.name === ref.page
277
+ ? {
278
+ ...candidate,
279
+ frames: candidate.frames.map((frame) =>
280
+ frame.name === ref.frame ? { ...frame, x, y, placed: true } : frame,
281
+ ),
282
+ }
283
+ : candidate,
284
+ )
285
+ }
286
+
287
+ /** Shows the move and writes it through the server's shared layout writer. */
288
+ export async function moveFrameTo(
289
+ ref: FrameRef,
290
+ x: number,
291
+ y: number,
292
+ fetchImpl: typeof fetch = fetch,
293
+ ): Promise<void> {
294
+ previewMove(ref, x, y)
295
+ await write('/api/frames/move', { ...ref, x, y }, fetchImpl)
296
+ }
297
+
298
+ export function clampZoom(zoom: number): number {
299
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom))
300
+ }
301
+
302
+ /** Zooms about a point in surface coordinates, keeping that point still. */
303
+ export function zoomAt(factor: number, originX: number, originY: number): void {
304
+ const current = view.value
305
+ const zoom = clampZoom(current.zoom * factor)
306
+ const scale = zoom / current.zoom
307
+ view.value = {
308
+ zoom,
309
+ x: originX - (originX - current.x) * scale,
310
+ y: originY - (originY - current.y) * scale,
311
+ }
312
+ }
313
+
314
+ export function panBy(dx: number, dy: number): void {
315
+ const current = view.value
316
+ view.value = { ...current, x: current.x + dx, y: current.y + dy }
317
+ }
318
+
319
+ /** Fits every frame on the current page into a viewport of the given size. */
320
+ export function fitView(width: number, height: number, padding = 48): void {
321
+ const frames = currentPage.value?.frames ?? []
322
+ if (frames.length === 0 || width <= 0 || height <= 0) {
323
+ view.value = { x: padding, y: padding, zoom: 0.5 }
324
+ return
325
+ }
326
+ const left = Math.min(...frames.map((frame) => frame.x))
327
+ const top = Math.min(...frames.map((frame) => frame.y))
328
+ const right = Math.max(...frames.map((frame) => frame.x + frame.width))
329
+ const bottom = Math.max(...frames.map((frame) => frame.y + frame.height))
330
+ const zoom = clampZoom(
331
+ Math.min(
332
+ (width - padding * 2) / (right - left),
333
+ (height - padding * 2) / (bottom - top),
334
+ 1,
335
+ ),
336
+ )
337
+ view.value = {
338
+ zoom,
339
+ x: padding - left * zoom,
340
+ y: padding - top * zoom,
341
+ }
342
+ }
343
+
344
+ /** Centers one frame in a viewport of the given size at the current zoom. */
345
+ export function focusFrame(frame: Frame, width: number, height: number): void {
346
+ const { zoom } = view.value
347
+ view.value = {
348
+ zoom,
349
+ x: width / 2 - (frame.x + frame.width / 2) * zoom,
350
+ y: height / 2 - (frame.y + frame.height / 2) * zoom,
351
+ }
352
+ }
353
+
354
+ /** Puts every signal back to its first value. Tests call it between cases. */
355
+ export function resetState(): void {
356
+ pages.value = []
357
+ tokens.value = undefined
358
+ isLoaded.value = false
359
+ loadError.value = undefined
360
+ selectedPage.value = undefined
361
+ themeChoice.value = undefined
362
+ systemTheme.value = 'dark'
363
+ frameThemes.value = new Map()
364
+ frameVersions.value = new Map()
365
+ view.value = { x: 0, y: 0, zoom: 0.5 }
366
+ selection.value = undefined
367
+ frameDocuments.value = new Map()
368
+ hoveredElement.value = undefined
369
+ expandedFrames.value = new Set()
370
+ draggingFrame.value = undefined
371
+ writeError.value = undefined
372
+ }