@erclx/canon 5.52.0 → 5.54.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 (201) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +11 -13
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +5 -8
  5. package/claude/skills/context-fold/REQUIREMENT.md +0 -1
  6. package/claude/skills/design-identity/REQUIREMENT.md +2 -2
  7. package/claude/skills/design-identity/SKILL.md +6 -6
  8. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  9. package/claude/skills/design-record/SKILL.md +4 -4
  10. package/claude/skills/design-record/references/folder.md +1 -1
  11. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  12. package/claude/skills/design-taste/references/kinds.md +0 -1
  13. package/claude/skills/draw-figure/REQUIREMENT.md +3 -4
  14. package/claude/skills/draw-figure/SKILL.md +5 -6
  15. package/claude/skills/draw-figure/references/figures.md +0 -1
  16. package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
  17. package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -5
  18. package/claude/skills/pick-by-eye/SKILL.md +34 -41
  19. package/claude/skills/pick-by-eye/references/board.md +42 -0
  20. package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
  21. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  22. package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
  23. package/claude/skills/teach-craft/SKILL.md +70 -0
  24. package/claude/skills/teach-craft/references/adopted.md +113 -0
  25. package/claude/skills/teach-craft/references/lesson-craft.md +60 -0
  26. package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +41 -6
  27. package/docs/agents/audits.md +0 -1
  28. package/docs/agents/install-and-sync.md +0 -8
  29. package/docs/target/migrations.md +16 -2
  30. package/docs/target/projects.md +1 -1
  31. package/docs/workflow/parallel-features.md +1 -1
  32. package/docs/workflow/skill-map-on-request.md +10 -22
  33. package/docs/workflow/skill-map.md +11 -14
  34. package/docs/workflow/visual-design-workflow.md +1 -4
  35. package/governance/detect.toml +1 -6
  36. package/governance/renames.toml +0 -1
  37. package/package.json +2 -8
  38. package/scripts/core/regen-web-previews.ts +2 -67
  39. package/src/audits/catalog.ts +0 -1
  40. package/src/browser/engine.ts +3 -6
  41. package/src/capture/render.ts +1 -1
  42. package/src/claude/cases/authoring.ts +0 -12
  43. package/src/claude/cases/misc.ts +0 -22
  44. package/src/claude/cases/workflow.ts +2 -38
  45. package/src/claude/skills-audit.ts +1 -1
  46. package/src/cli/help.ts +0 -10
  47. package/src/cli.ts +0 -8
  48. package/src/commands/design.ts +16 -315
  49. package/src/commands/records.ts +0 -1
  50. package/src/commands/serve.ts +2 -2
  51. package/src/commands/sync.ts +4 -5
  52. package/src/design/components.ts +8 -1514
  53. package/src/design/css.ts +5 -6
  54. package/src/design/fonts.ts +3 -5
  55. package/src/design/regen.ts +5 -32
  56. package/src/design/render.ts +1 -1
  57. package/src/design/tokens.ts +3 -2
  58. package/src/driver/drive.ts +3 -4
  59. package/src/driver/steps.ts +2 -5
  60. package/src/gate/stages.ts +4 -10
  61. package/src/gov/audit.ts +1 -6
  62. package/src/inventory/walk.ts +1 -1
  63. package/src/markdown/scope.ts +6 -35
  64. package/src/records/validate.ts +4 -176
  65. package/src/sandbox/equivalence.ts +0 -5
  66. package/src/shipped/references.ts +1 -2
  67. package/src/sync/check.ts +4 -29
  68. package/src/sync/stamp.ts +6 -6
  69. package/src/sync/target.ts +1 -4
  70. package/tooling/claude/reference.md +1 -1
  71. package/tooling/claude/seeds/canon/DESIGN.md +1 -1
  72. package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
  73. package/claude/skills/canvas-board/SKILL.md +0 -143
  74. package/claude/skills/canvas-board/references/canvas.md +0 -170
  75. package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
  76. package/claude/skills/draft-and-pick/SKILL.md +0 -11
  77. package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
  78. package/claude/skills/draft-doc/SKILL.md +0 -11
  79. package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
  80. package/claude/skills/draft-figure/SKILL.md +0 -11
  81. package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
  82. package/claude/skills/draft-identity/SKILL.md +0 -11
  83. package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
  84. package/claude/skills/draft-ready/SKILL.md +0 -11
  85. package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
  86. package/claude/skills/draft-screencast/SKILL.md +0 -11
  87. package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
  88. package/claude/skills/draft-slides/SKILL.md +0 -11
  89. package/claude/skills/make-deck/REQUIREMENT.md +0 -36
  90. package/claude/skills/make-deck/SKILL.md +0 -80
  91. package/claude/skills/make-deck/references/deck.md +0 -183
  92. package/claude/skills/read-frames/REQUIREMENT.md +0 -30
  93. package/claude/skills/read-frames/SKILL.md +0 -55
  94. package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
  95. package/claude/skills/record-screencast/SKILL.md +0 -99
  96. package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
  97. package/claude/skills/script-screencast/SKILL.md +0 -70
  98. package/claude/skills/script-screencast/references/beats-template.md +0 -119
  99. package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
  100. package/claude/skills/teach-workspace/SKILL.md +0 -196
  101. package/claude/skills/teach-workspace/references/glossary.md +0 -90
  102. package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
  103. package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
  104. package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
  105. package/claude/skills/teach-workspace/references/promotion.md +0 -116
  106. package/claude/skills/teach-workspace/references/revisit.md +0 -28
  107. package/claude/skills/teach-workspace/references/teach.md +0 -211
  108. package/claude/skills/video-craft/REQUIREMENT.md +0 -35
  109. package/claude/skills/video-craft/SKILL.md +0 -96
  110. package/claude/skills/video-craft/references/adopted.md +0 -110
  111. package/governance/rules/ui/canvas.md +0 -13
  112. package/src/canvas/address.ts +0 -227
  113. package/src/canvas/arrange.ts +0 -66
  114. package/src/canvas/capture.ts +0 -391
  115. package/src/canvas/client/app.tsx +0 -188
  116. package/src/canvas/client/inspector/align-grid.tsx +0 -162
  117. package/src/canvas/client/inspector/color-field.tsx +0 -398
  118. package/src/canvas/client/inspector/color-inputs.tsx +0 -139
  119. package/src/canvas/client/inspector/color-picker.tsx +0 -668
  120. package/src/canvas/client/inspector/color-space.ts +0 -240
  121. package/src/canvas/client/inspector/field.tsx +0 -263
  122. package/src/canvas/client/inspector/fields.css +0 -763
  123. package/src/canvas/client/inspector/section.tsx +0 -27
  124. package/src/canvas/client/inspector/size-field.tsx +0 -69
  125. package/src/canvas/client/inspector/token-select.tsx +0 -109
  126. package/src/canvas/client/inspector/values.ts +0 -297
  127. package/src/canvas/client/inspector.tsx +0 -692
  128. package/src/canvas/client/keys.ts +0 -71
  129. package/src/canvas/client/layers.tsx +0 -118
  130. package/src/canvas/client/main.tsx +0 -71
  131. package/src/canvas/client/pages-panel.tsx +0 -212
  132. package/src/canvas/client/panel-handle.tsx +0 -90
  133. package/src/canvas/client/selection.tsx +0 -199
  134. package/src/canvas/client/shell.css +0 -762
  135. package/src/canvas/client/state.ts +0 -792
  136. package/src/canvas/client/surface.tsx +0 -803
  137. package/src/canvas/client/theme-panel.tsx +0 -55
  138. package/src/canvas/client/tools.tsx +0 -153
  139. package/src/canvas/content.ts +0 -781
  140. package/src/canvas/edit.ts +0 -575
  141. package/src/canvas/editing.ts +0 -203
  142. package/src/canvas/fit.ts +0 -238
  143. package/src/canvas/global.d.ts +0 -15
  144. package/src/canvas/history.ts +0 -116
  145. package/src/canvas/index.html +0 -20
  146. package/src/canvas/server.ts +0 -766
  147. package/src/canvas/tokens.ts +0 -221
  148. package/src/commands/canvas.ts +0 -1097
  149. package/src/commands/demo.ts +0 -542
  150. package/src/commands/slides.ts +0 -184
  151. package/src/commands/teach.ts +0 -1401
  152. package/src/demo/beats.ts +0 -135
  153. package/src/demo/compile.ts +0 -350
  154. package/src/demo/container.ts +0 -219
  155. package/src/demo/cursors.ts +0 -55
  156. package/src/demo/drive.ts +0 -442
  157. package/src/demo/pointer.ts +0 -178
  158. package/src/demo/theme.ts +0 -112
  159. package/src/demo/timeline.ts +0 -64
  160. package/src/design/adapter.ts +0 -59
  161. package/src/design/base.css +0 -203
  162. package/src/design/brand-favicon.ts +0 -18
  163. package/src/design/neutral.ts +0 -127
  164. package/src/design/project-fonts.ts +0 -392
  165. package/src/slides/convert/chart.ts +0 -165
  166. package/src/slides/convert/deck.ts +0 -244
  167. package/src/slides/convert/export.ts +0 -567
  168. package/src/slides/convert/master.ts +0 -198
  169. package/src/slides/convert/shapes.ts +0 -804
  170. package/src/slides/convert/svg.ts +0 -38
  171. package/src/slides/convert/walk.ts +0 -671
  172. package/src/slides/decks.ts +0 -110
  173. package/src/slides/open.ts +0 -18
  174. package/src/slides/package/fonts.ts +0 -301
  175. package/src/slides/package/motion.ts +0 -316
  176. package/src/slides/package/test-face.ts +0 -113
  177. package/src/teach/browser/compile.ts +0 -44
  178. package/src/teach/browser/dismiss.ts +0 -76
  179. package/src/teach/browser/focus-line.ts +0 -19
  180. package/src/teach/browser/glossary-filter.ts +0 -44
  181. package/src/teach/browser/head.ts +0 -30
  182. package/src/teach/browser/quiz.ts +0 -19
  183. package/src/teach/browser/reload.ts +0 -9
  184. package/src/teach/browser/sidebar.ts +0 -233
  185. package/src/teach/browser/theme.ts +0 -18
  186. package/src/teach/components/code.tsx +0 -24
  187. package/src/teach/components/heading.tsx +0 -11
  188. package/src/teach/components/list.tsx +0 -17
  189. package/src/teach/components/paragraph.tsx +0 -29
  190. package/src/teach/components/refs.tsx +0 -28
  191. package/src/teach/cross-references.ts +0 -78
  192. package/src/teach/fonts.ts +0 -16
  193. package/src/teach/highlight.ts +0 -71
  194. package/src/teach/html/jsx-dev-runtime.ts +0 -13
  195. package/src/teach/html/jsx-runtime.ts +0 -113
  196. package/src/teach/html/raw.ts +0 -28
  197. package/src/teach/lesson.ts +0 -178
  198. package/src/teach/nav.ts +0 -1337
  199. package/src/teach/render.ts +0 -240
  200. package/src/teach/workspace.ts +0 -1164
  201. package/src/teach/write-if-changed.ts +0 -17
@@ -1,792 +0,0 @@
1
- import { computed, signal } from '@preact/signals'
2
- import type { ElementAddress } from '@/canvas/address'
3
- import type { Box, Frame, Page } from '@/canvas/content'
4
- import type { TokenGroup } from '@/canvas/tokens'
5
-
6
- /**
7
- * Everything the panels read. Type imports only from outside `client/`, since
8
- * this runs in the browser and a Node or Bun module would not bundle there.
9
- * `@/canvas/address` is the one exception the client imports for values, since
10
- * it imports nothing itself.
11
- */
12
-
13
- export type Theme = 'light' | 'dark'
14
-
15
- export interface TokenSource {
16
- readonly source: 'toolkit' | 'installed' | 'none'
17
- readonly files?: readonly string[]
18
- readonly notice?: string
19
- /** What the stylesheet defines, which the token picker and Theme tab list. */
20
- readonly groups?: readonly TokenGroup[]
21
- }
22
-
23
- export type LeftTab = 'pages' | 'theme'
24
-
25
- export type Tool = 'move' | 'pan'
26
-
27
- /** A Space pan held, and whether a drag has moved the view while it was. */
28
- export type SpacePan = 'held' | 'dragged'
29
-
30
- /** One property change to the selected element, as the inspector posts it. */
31
- export interface PendingEdit {
32
- readonly key: string
33
- readonly index: number
34
- readonly property: string
35
- }
36
-
37
- export interface FrameRef {
38
- readonly page: string
39
- readonly frame: string
40
- }
41
-
42
- export interface ElementRef {
43
- readonly index: number
44
- readonly tag: string
45
- /** Set by the server once the frame file changed since the pick. */
46
- readonly stale?: boolean
47
- }
48
-
49
- export interface SelectionRef extends FrameRef {
50
- readonly element?: ElementRef
51
- }
52
-
53
- /** A frame a session says it is editing, until the stamp it expires at. */
54
- export interface EditingRef extends FrameRef {
55
- readonly by: string
56
- readonly until: string
57
- }
58
-
59
- export interface PagesRecord {
60
- readonly pages: readonly Page[]
61
- readonly tokens: TokenSource
62
- /** Absent when nothing is selected or the selected frame is gone. */
63
- readonly selection?: SelectionRef | null
64
- /** The live marks, which a server older than the mark leaves out. */
65
- readonly editing?: readonly EditingRef[]
66
- /** Whether the server holds an edit to undo or redo, absent before undo shipped. */
67
- readonly history?: HistoryFlags
68
- }
69
-
70
- export interface HistoryFlags {
71
- readonly canUndo: boolean
72
- readonly canRedo: boolean
73
- }
74
-
75
- export interface ChangeEvent {
76
- readonly page: string
77
- readonly file: string
78
- }
79
-
80
- export interface View {
81
- readonly x: number
82
- readonly y: number
83
- readonly zoom: number
84
- }
85
-
86
- export const MIN_ZOOM = 0.05
87
- export const MAX_ZOOM = 4
88
-
89
- export const pages = signal<readonly Page[]>([])
90
- export const tokens = signal<TokenSource | undefined>(undefined)
91
- export const isLoaded = signal(false)
92
- export const loadError = signal<string | undefined>(undefined)
93
- export const selectedPage = signal<string | undefined>(undefined)
94
-
95
- /** Undefined follows the system, which `systemTheme` reports. */
96
- export const themeChoice = signal<Theme | undefined>(undefined)
97
- export const systemTheme = signal<Theme>('dark')
98
- export const theme = computed<Theme>(
99
- () => themeChoice.value ?? systemTheme.value,
100
- )
101
-
102
- /** A frame's own theme where the operator switched it, else the chrome's. */
103
- export const frameThemes = signal<ReadonlyMap<string, Theme>>(new Map())
104
-
105
- /** Bumped per frame on a change, so only that frame's document reloads. */
106
- export const frameVersions = signal<ReadonlyMap<string, number>>(new Map())
107
-
108
- export const view = signal<View>({ x: 0, y: 0, zoom: 0.5 })
109
-
110
- /**
111
- * The one frame, or element in it, the operator pointed at, which Claude reads
112
- * as "this one".
113
- */
114
- export const selection = signal<SelectionRef | undefined>(undefined)
115
-
116
- /** Each loaded frame's document by frame key, replaced on every reload. */
117
- export const frameDocuments = signal<ReadonlyMap<string, Document>>(new Map())
118
-
119
- /** The element under the pointer, in a frame or in the layers tree. */
120
- export const hoveredElement = signal<
121
- { readonly key: string; readonly index: number } | undefined
122
- >(undefined)
123
-
124
- /** Each marked frame's mark by frame key, dropped at its `until`. */
125
- export const editingFrames = signal<ReadonlyMap<string, EditingRef>>(new Map())
126
-
127
- /** Frame keys whose layers are open in the pages panel. */
128
- export const expandedFrames = signal<ReadonlySet<string>>(new Set())
129
-
130
- /** Set while a frame follows the pointer, so the surface can style it. */
131
- export const draggingFrame = signal<string | undefined>(undefined)
132
-
133
- /** Why the last write to the canvas failed, until the next one succeeds. */
134
- export const writeError = signal<string | undefined>(undefined)
135
-
136
- export const leftTab = signal<LeftTab>('pages')
137
-
138
- /** Whether the operator hid both side panels to see the whole surface. */
139
- export const panelsHidden = signal(false)
140
-
141
- export function togglePanels(): void {
142
- panelsHidden.value = !panelsHidden.value
143
- }
144
-
145
- export type PanelSide = 'left' | 'right'
146
-
147
- export interface PanelWidths {
148
- readonly left: number
149
- readonly right: number
150
- }
151
-
152
- /** The 15rem and 16rem columns the shell opened at before panels resized. */
153
- export const DEFAULT_PANEL_WIDTHS: PanelWidths = { left: 240, right: 256 }
154
- export const MIN_PANEL_WIDTH = 180
155
- export const MAX_PANEL_WIDTH = 480
156
- /** What a panel never squeezes the surface below, the reflow floor. */
157
- export const MIN_SURFACE_WIDTH = 320
158
-
159
- export const panelWidths = signal<PanelWidths>(DEFAULT_PANEL_WIDTHS)
160
-
161
- /**
162
- * Holds one panel between the floor and the ceiling, and under whatever keeps
163
- * the surface at its own floor beside the other panel. The floor wins on a
164
- * window too narrow for both.
165
- */
166
- export function clampPanelWidth(
167
- side: PanelSide,
168
- width: number,
169
- windowWidth: number,
170
- widths: PanelWidths = panelWidths.value,
171
- ): number {
172
- const other = side === 'left' ? widths.right : widths.left
173
- const ceiling = Math.min(
174
- MAX_PANEL_WIDTH,
175
- windowWidth - other - MIN_SURFACE_WIDTH,
176
- )
177
- const fallback = DEFAULT_PANEL_WIDTHS[side]
178
- const wanted = Number.isFinite(width) ? width : fallback
179
- return Math.round(Math.max(MIN_PANEL_WIDTH, Math.min(ceiling, wanted)))
180
- }
181
-
182
- export function setPanelWidth(
183
- side: PanelSide,
184
- width: number,
185
- windowWidth: number,
186
- ): void {
187
- const next = clampPanelWidth(side, width, windowWidth)
188
- if (panelWidths.value[side] === next) return
189
- panelWidths.value = { ...panelWidths.value, [side]: next }
190
- }
191
-
192
- /** The tool the strip has lit, which a Space pan overrides while held. */
193
- export const activeTool = signal<Tool>('move')
194
-
195
- export const spacePan = signal<SpacePan | undefined>(undefined)
196
-
197
- /** The tool in effect, so Space shows Pan lit and releasing it restores it. */
198
- export const shownTool = computed<Tool>(() =>
199
- spacePan.value ? 'pan' : activeTool.value,
200
- )
201
-
202
- /** The edit in flight, which holds its field until the server answers. */
203
- export const pendingEdit = signal<PendingEdit | undefined>(undefined)
204
-
205
- /** The edit that last landed, for a moment after it does. */
206
- export const savedEdit = signal<PendingEdit | undefined>(undefined)
207
-
208
- const SAVED_MS = 2500
209
-
210
- /** Why the last edit wrote nothing, until the next one lands. */
211
- export const editRefusal = signal<string | undefined>(undefined)
212
-
213
- const NO_HISTORY: HistoryFlags = { canUndo: false, canRedo: false }
214
-
215
- /** What the server's history holds, as the last page record reported it. */
216
- export const history = signal<HistoryFlags>(NO_HISTORY)
217
-
218
- /** Why the last undo or redo wrote nothing, until the next one runs. */
219
- export const historyNotice = signal<string | undefined>(undefined)
220
-
221
- /**
222
- * The hash an edit answered by frame key, until that frame reloads. A second
223
- * edit sent before the reload is made from the file the first one wrote, not
224
- * from the document still on screen.
225
- */
226
- export const editedHashes = signal<ReadonlyMap<string, string>>(new Map())
227
-
228
- export const currentPage = computed<Page | undefined>(() => {
229
- const all = pages.value
230
- return all.find((page) => page.name === selectedPage.value) ?? all[0]
231
- })
232
-
233
- /** The selected frame when it is on the page being shown. */
234
- export const selectedFrame = computed<Frame | undefined>(() => {
235
- const page = currentPage.value
236
- const chosen = selection.value
237
- if (!page || chosen?.page !== page.name) return undefined
238
- return page.frames.find((frame) => frame.name === chosen.frame)
239
- })
240
-
241
- /** The selected element when its frame is on the page being shown. */
242
- export const selectedElement = computed<
243
- { readonly frame: Frame; readonly element: ElementRef } | undefined
244
- >(() => {
245
- const frame = selectedFrame.value
246
- const element = selection.value?.element
247
- return frame && element ? { frame, element } : undefined
248
- })
249
-
250
- export function frameKey(page: string, frame: Pick<Frame, 'file'>): string {
251
- return `${page}/${frame.file}`
252
- }
253
-
254
- export function frameTheme(key: string): Theme {
255
- return frameThemes.value.get(key) ?? theme.value
256
- }
257
-
258
- export function toggleTheme(): void {
259
- themeChoice.value = theme.value === 'dark' ? 'light' : 'dark'
260
- }
261
-
262
- export function toggleFrameTheme(key: string): void {
263
- const next = new Map(frameThemes.value)
264
- next.set(key, frameTheme(key) === 'dark' ? 'light' : 'dark')
265
- frameThemes.value = next
266
- }
267
-
268
- export function registerFrameDocument(key: string, doc: Document): void {
269
- const next = new Map(frameDocuments.value)
270
- next.set(key, doc)
271
- frameDocuments.value = next
272
- if (editedHashes.value.has(key)) {
273
- const hashes = new Map(editedHashes.value)
274
- hashes.delete(key)
275
- editedHashes.value = hashes
276
- }
277
- }
278
-
279
- function reloadFrame(key: string): void {
280
- const next = new Map(frameVersions.value)
281
- next.set(key, (next.get(key) ?? 0) + 1)
282
- frameVersions.value = next
283
- }
284
-
285
- export function toggleLayers(key: string): void {
286
- const next = new Set(expandedFrames.value)
287
- if (!next.delete(key)) next.add(key)
288
- expandedFrames.value = next
289
- }
290
-
291
- /*
292
- * An expiry writes no file and raises no change event, so nothing rereads the
293
- * page list when a mark lapses. One timer, set for the earliest `until`, drops
294
- * it instead, and every record resets it.
295
- */
296
- let editingTimer: ReturnType<typeof setTimeout> | undefined
297
-
298
- /*
299
- * The longest delay a timer holds. A longer one fires at once, so an `until`
300
- * far out would reschedule on every tick rather than wait.
301
- */
302
- const MAX_TIMER_MS = 2 ** 31 - 1
303
-
304
- function liveMarks(
305
- marks: Iterable<readonly [string, EditingRef]>,
306
- now: number,
307
- ): ReadonlyMap<string, EditingRef> {
308
- return new Map([...marks].filter(([, mark]) => Date.parse(mark.until) > now))
309
- }
310
-
311
- function scheduleExpiry(): void {
312
- clearTimeout(editingTimer)
313
- editingTimer = undefined
314
- const next = Math.min(
315
- ...[...editingFrames.value.values()].map((mark) => Date.parse(mark.until)),
316
- )
317
- if (!Number.isFinite(next)) return
318
- editingTimer = setTimeout(
319
- () => {
320
- editingFrames.value = liveMarks(editingFrames.value, Date.now())
321
- scheduleExpiry()
322
- },
323
- Math.min(MAX_TIMER_MS, Math.max(0, next - Date.now())),
324
- )
325
- }
326
-
327
- /** Keys each mark by the file of the frame it names, as the surface reads. */
328
- function applyEditing(
329
- all: readonly Page[],
330
- marks: readonly EditingRef[],
331
- ): void {
332
- const keyed = marks.flatMap((mark) => {
333
- const frame = all
334
- .find((candidate) => candidate.name === mark.page)
335
- ?.frames.find((candidate) => candidate.name === mark.frame)
336
- return frame ? [[frameKey(mark.page, frame), mark] as const] : []
337
- })
338
- editingFrames.value = liveMarks(keyed, Date.now())
339
- scheduleExpiry()
340
- }
341
-
342
- export function applyRecord(record: PagesRecord): void {
343
- pages.value = record.pages
344
- tokens.value = record.tokens
345
- selection.value = record.selection ?? undefined
346
- applyEditing(record.pages, record.editing ?? [])
347
- history.value = record.history ?? NO_HISTORY
348
- loadError.value = undefined
349
- isLoaded.value = true
350
- }
351
-
352
- export async function loadPages(
353
- fetchImpl: typeof fetch = fetch,
354
- ): Promise<void> {
355
- try {
356
- const response = await fetchImpl('/api/pages')
357
- if (!response.ok) throw new Error(`status ${response.status}`)
358
- applyRecord((await response.json()) as PagesRecord)
359
- } catch (error) {
360
- loadError.value = `Could not read the canvas (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
361
- isLoaded.value = true
362
- }
363
- }
364
-
365
- /**
366
- * Reloads the one frame whose file changed and rereads the page list, which
367
- * picks up an added frame or a moved box. The view is separate state, so
368
- * neither resets pan or zoom.
369
- */
370
- export async function applyChange(
371
- change: ChangeEvent,
372
- fetchImpl: typeof fetch = fetch,
373
- ): Promise<void> {
374
- if (change.file.endsWith('.html')) {
375
- reloadFrame(`${change.page}/${change.file}`)
376
- }
377
- await loadPages(fetchImpl)
378
- }
379
-
380
- /** What a refused element pick tells the operator, by the server's reason. */
381
- const PICK_NOTICES: Readonly<Record<string, string | undefined>> = {
382
- 'address-mismatch':
383
- '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.',
384
- 'stale-address':
385
- 'Could not select that element, since the frame changed before the pick arrived. Pick it again once the frame reloads.',
386
- }
387
-
388
- async function refusalReason(response: Response): Promise<string | undefined> {
389
- try {
390
- const body: unknown = await response.json()
391
- return typeof body === 'object' && body !== null && 'reason' in body
392
- ? String(body.reason)
393
- : undefined
394
- } catch {
395
- return undefined
396
- }
397
- }
398
-
399
- /**
400
- * Posts one write and reports a refusal in the panel rather than failing
401
- * silently. A refusal also rereads the page list, so a position the server
402
- * would not take does not stay on the surface.
403
- */
404
- async function write(
405
- path: string,
406
- body: unknown,
407
- fetchImpl: typeof fetch,
408
- ): Promise<void> {
409
- try {
410
- const response = await fetchImpl(path, {
411
- method: 'POST',
412
- headers: { 'content-type': 'application/json' },
413
- body: JSON.stringify(body),
414
- })
415
- if (!response.ok) {
416
- const reason = await refusalReason(response)
417
- writeError.value =
418
- (reason && PICK_NOTICES[reason]) ??
419
- `Could not save (status ${response.status}). Check canon canvas serve is still running.`
420
- await loadPages(fetchImpl)
421
- return
422
- }
423
- writeError.value = undefined
424
- } catch (error) {
425
- writeError.value = `Could not save (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
426
- await loadPages(fetchImpl)
427
- }
428
- }
429
-
430
- /** Selects a frame here at once and records it where Claude reads it. */
431
- export async function selectFrame(
432
- ref: FrameRef | undefined,
433
- fetchImpl: typeof fetch = fetch,
434
- ): Promise<void> {
435
- selection.value = ref
436
- await write('/api/selection', ref ?? {}, fetchImpl)
437
- }
438
-
439
- /**
440
- * Selects one element of a frame, opens that frame's layers so the tree shows
441
- * the pick, and records it where Claude reads it. The server checks the
442
- * address against the file and refuses one it cannot match.
443
- */
444
- export async function selectElement(
445
- ref: FrameRef,
446
- key: string,
447
- address: ElementAddress,
448
- fetchImpl: typeof fetch = fetch,
449
- ): Promise<void> {
450
- selection.value = {
451
- ...ref,
452
- element: { index: address.index, tag: address.tag },
453
- }
454
- if (!expandedFrames.value.has(key)) toggleLayers(key)
455
- await write('/api/selection', { ...ref, element: address }, fetchImpl)
456
- }
457
-
458
- /** What a refused edit tells the operator, by the server's reason. */
459
- const EDIT_NOTICES: Readonly<Record<string, string | undefined>> = {
460
- 'stale-address':
461
- 'The frame changed before this edit arrived, so nothing was saved. It reloads with the file as it stands now. Make the edit again there.',
462
- 'address-mismatch':
463
- 'Could not save, since the browser and the file count the elements of this frame differently.',
464
- 'not-text-only':
465
- 'Could not save the text, since this element holds other elements.',
466
- }
467
-
468
- let stepCount = 0
469
-
470
- /**
471
- * A token the edits of one gesture share, so the server merges them into one
472
- * undo. The time keeps it apart from a token an earlier shell load sent.
473
- */
474
- export function newStep(): string {
475
- stepCount += 1
476
- return `${Date.now().toString(36)}-${stepCount}`
477
- }
478
-
479
- /**
480
- * Posts one property change to the element the address names. A refusal
481
- * says why and reloads the frame, so a pending value never sits over a file
482
- * that moved under it. Resolves to what the write answered, or undefined when
483
- * nothing was written. Edits carrying one step undo as one.
484
- */
485
- export async function editElement(
486
- ref: FrameRef,
487
- key: string,
488
- address: ElementAddress,
489
- property: string,
490
- value: string,
491
- step?: string,
492
- fetchImpl: typeof fetch = fetch,
493
- ): Promise<{ readonly hash?: string } | undefined> {
494
- const hash = editedHashes.value.get(key) ?? address.hash
495
- pendingEdit.value = { key, index: address.index, property }
496
- try {
497
- const response = await fetchImpl('/api/frames/edit', {
498
- method: 'POST',
499
- headers: { 'content-type': 'application/json' },
500
- body: JSON.stringify({
501
- ...ref,
502
- element: { ...address, hash },
503
- property,
504
- value,
505
- ...(step !== undefined && { step }),
506
- }),
507
- })
508
- const body: unknown = await response.json().catch(() => undefined)
509
- const record =
510
- typeof body === 'object' && body !== null
511
- ? (body as Record<string, unknown>)
512
- : {}
513
- if (!response.ok) {
514
- const reason = typeof record.reason === 'string' ? record.reason : ''
515
- const detail = typeof record.detail === 'string' ? record.detail : ''
516
- editRefusal.value =
517
- EDIT_NOTICES[reason] ??
518
- (reason === 'invalid-edit'
519
- ? `Could not save that value (${detail}).`
520
- : `Could not save (status ${response.status}). Check canon canvas serve is still running.`)
521
- reloadFrame(key)
522
- await loadPages(fetchImpl)
523
- return undefined
524
- }
525
- editRefusal.value = undefined
526
- historyNotice.value = undefined
527
- const saved = pendingEdit.value
528
- savedEdit.value = saved
529
- setTimeout(() => {
530
- if (savedEdit.value === saved) savedEdit.value = undefined
531
- }, SAVED_MS)
532
- if (typeof record.hash !== 'string') return {}
533
- const hashes = new Map(editedHashes.value)
534
- hashes.set(key, record.hash)
535
- editedHashes.value = hashes
536
- return { hash: record.hash }
537
- } catch (error) {
538
- editRefusal.value = `Could not save (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
539
- return undefined
540
- } finally {
541
- pendingEdit.value = undefined
542
- }
543
- }
544
-
545
- /**
546
- * Writes an element's new size as one edit per changed axis, width first,
547
- * sharing one step so a single undo restores both. The second carries the
548
- * hash the first answered, so a frame reloading between the two cannot leave
549
- * it holding the hash of a file already rewritten. A refused first edit sends
550
- * no second.
551
- */
552
- export async function resizeElement(
553
- ref: FrameRef,
554
- key: string,
555
- address: ElementAddress,
556
- width: number | undefined,
557
- height: number | undefined,
558
- fetchImpl: typeof fetch = fetch,
559
- ): Promise<void> {
560
- const step = newStep()
561
- let current = address
562
- if (width !== undefined) {
563
- const written = await editElement(
564
- ref,
565
- key,
566
- current,
567
- 'width',
568
- `${width}px`,
569
- step,
570
- fetchImpl,
571
- )
572
- if (!written) return
573
- if (written.hash) current = { ...current, hash: written.hash }
574
- }
575
- if (height !== undefined) {
576
- await editElement(
577
- ref,
578
- key,
579
- current,
580
- 'height',
581
- `${height}px`,
582
- step,
583
- fetchImpl,
584
- )
585
- }
586
- }
587
-
588
- const HISTORY_NOTICES = {
589
- undo: 'Could not undo, since that element or frame changed after your edit. Nothing was written.',
590
- redo: 'Could not redo, since that element or frame changed after the undo. Nothing was written.',
591
- } as const
592
-
593
- /**
594
- * Asks the server to step its history and rereads the page list for the new
595
- * flags. A written step clears every hash an edit answered, since the frame
596
- * it named was just rewritten under it. The frame itself reloads on the
597
- * change event the write raises.
598
- */
599
- async function stepHistory(
600
- direction: 'undo' | 'redo',
601
- fetchImpl: typeof fetch,
602
- ): Promise<void> {
603
- try {
604
- const response = await fetchImpl(`/api/history/${direction}`, {
605
- method: 'POST',
606
- headers: { 'content-type': 'application/json' },
607
- body: '{}',
608
- })
609
- const body: unknown = await response.json().catch(() => undefined)
610
- const outcome =
611
- typeof body === 'object' && body !== null && 'outcome' in body
612
- ? body.outcome
613
- : undefined
614
- if (!response.ok) {
615
- historyNotice.value = `Could not ${direction} (status ${response.status}). Try again.`
616
- } else if (outcome === 'dropped') {
617
- historyNotice.value = HISTORY_NOTICES[direction]
618
- } else if (outcome === 'applied') {
619
- const done = direction === 'undo' ? 'Undone' : 'Redone'
620
- historyNotice.value = done
621
- setTimeout(() => {
622
- if (historyNotice.value === done) historyNotice.value = undefined
623
- }, SAVED_MS)
624
- } else {
625
- historyNotice.value = undefined
626
- }
627
- if (outcome === 'applied') editedHashes.value = new Map()
628
- } catch (error) {
629
- historyNotice.value = `Could not ${direction} (${error instanceof Error ? error.message : 'unknown'}). Check canon canvas serve is still running.`
630
- }
631
- await loadPages(fetchImpl)
632
- }
633
-
634
- export function undo(fetchImpl: typeof fetch = fetch): Promise<void> {
635
- return stepHistory('undo', fetchImpl)
636
- }
637
-
638
- export function redo(fetchImpl: typeof fetch = fetch): Promise<void> {
639
- return stepHistory('redo', fetchImpl)
640
- }
641
-
642
- /**
643
- * Moves a frame on the surface without writing, which a drag calls on every
644
- * pointer move. The next reread replaces it with what the layout holds.
645
- */
646
- export function previewMove(ref: FrameRef, x: number, y: number): void {
647
- pages.value = pages.value.map((candidate) =>
648
- candidate.name === ref.page
649
- ? {
650
- ...candidate,
651
- frames: candidate.frames.map((frame) =>
652
- frame.name === ref.frame ? { ...frame, x, y, placed: true } : frame,
653
- ),
654
- }
655
- : candidate,
656
- )
657
- }
658
-
659
- /** Shows the move and writes it through the server's shared layout writer. */
660
- export async function moveFrameTo(
661
- ref: FrameRef,
662
- x: number,
663
- y: number,
664
- fetchImpl: typeof fetch = fetch,
665
- ): Promise<void> {
666
- previewMove(ref, x, y)
667
- await write('/api/frames/move', { ...ref, x, y }, fetchImpl)
668
- }
669
-
670
- /** Resizes a frame on the surface without writing, as a handle drag does. */
671
- export function previewResize(ref: FrameRef, box: Box): void {
672
- pages.value = pages.value.map((candidate) =>
673
- candidate.name === ref.page
674
- ? {
675
- ...candidate,
676
- frames: candidate.frames.map((frame) =>
677
- frame.name === ref.frame
678
- ? { ...frame, ...box, placed: true }
679
- : frame,
680
- ),
681
- }
682
- : candidate,
683
- )
684
- }
685
-
686
- /** Shows the new box and writes it through the server's shared layout writer. */
687
- export async function resizeFrameTo(
688
- ref: FrameRef,
689
- box: Box,
690
- fetchImpl: typeof fetch = fetch,
691
- ): Promise<void> {
692
- previewResize(ref, box)
693
- await write('/api/frames/resize', { ...ref, ...box }, fetchImpl)
694
- }
695
-
696
- export function clampZoom(zoom: number): number {
697
- return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom))
698
- }
699
-
700
- /** Zooms about a point in surface coordinates, keeping that point still. */
701
- export function zoomAt(factor: number, originX: number, originY: number): void {
702
- const current = view.value
703
- const zoom = clampZoom(current.zoom * factor)
704
- const scale = zoom / current.zoom
705
- view.value = {
706
- zoom,
707
- x: originX - (originX - current.x) * scale,
708
- y: originY - (originY - current.y) * scale,
709
- }
710
- }
711
-
712
- export function panBy(dx: number, dy: number): void {
713
- const current = view.value
714
- view.value = { ...current, x: current.x + dx, y: current.y + dy }
715
- }
716
-
717
- /**
718
- * Fits every frame on the current page into a viewport of the given size,
719
- * clear of `insetLeft` pixels of chrome floating over its left edge.
720
- */
721
- export function fitView(
722
- width: number,
723
- height: number,
724
- padding = 48,
725
- insetLeft = 0,
726
- ): void {
727
- const frames = currentPage.value?.frames ?? []
728
- if (frames.length === 0 || width <= 0 || height <= 0) {
729
- view.value = { x: insetLeft + padding, y: padding, zoom: 0.5 }
730
- return
731
- }
732
- const left = Math.min(...frames.map((frame) => frame.x))
733
- const top = Math.min(...frames.map((frame) => frame.y))
734
- const right = Math.max(...frames.map((frame) => frame.x + frame.width))
735
- const bottom = Math.max(...frames.map((frame) => frame.y + frame.height))
736
- const zoom = clampZoom(
737
- Math.min(
738
- (width - insetLeft - padding * 2) / (right - left),
739
- (height - padding * 2) / (bottom - top),
740
- 1,
741
- ),
742
- )
743
- view.value = {
744
- zoom,
745
- x: insetLeft + padding - left * zoom,
746
- y: padding - top * zoom,
747
- }
748
- }
749
-
750
- /** Centers one frame in a viewport of the given size at the current zoom. */
751
- export function focusFrame(frame: Frame, width: number, height: number): void {
752
- const { zoom } = view.value
753
- view.value = {
754
- zoom,
755
- x: width / 2 - (frame.x + frame.width / 2) * zoom,
756
- y: height / 2 - (frame.y + frame.height / 2) * zoom,
757
- }
758
- }
759
-
760
- /** Puts every signal back to its first value. Tests call it between cases. */
761
- export function resetState(): void {
762
- pages.value = []
763
- tokens.value = undefined
764
- isLoaded.value = false
765
- loadError.value = undefined
766
- selectedPage.value = undefined
767
- themeChoice.value = undefined
768
- systemTheme.value = 'dark'
769
- frameThemes.value = new Map()
770
- frameVersions.value = new Map()
771
- view.value = { x: 0, y: 0, zoom: 0.5 }
772
- selection.value = undefined
773
- frameDocuments.value = new Map()
774
- hoveredElement.value = undefined
775
- editingFrames.value = new Map()
776
- clearTimeout(editingTimer)
777
- editingTimer = undefined
778
- expandedFrames.value = new Set()
779
- draggingFrame.value = undefined
780
- writeError.value = undefined
781
- leftTab.value = 'pages'
782
- activeTool.value = 'move'
783
- spacePan.value = undefined
784
- pendingEdit.value = undefined
785
- savedEdit.value = undefined
786
- editRefusal.value = undefined
787
- editedHashes.value = new Map()
788
- history.value = NO_HISTORY
789
- historyNotice.value = undefined
790
- panelsHidden.value = false
791
- panelWidths.value = DEFAULT_PANEL_WIDTHS
792
- }