@erclx/canon 5.53.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.
- package/CHANGELOG.md +10 -0
- package/README.md +10 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +5 -5
- package/claude/skills/design-identity/REQUIREMENT.md +2 -2
- package/claude/skills/design-identity/SKILL.md +6 -6
- package/claude/skills/design-record/REQUIREMENT.md +2 -2
- package/claude/skills/design-record/SKILL.md +4 -4
- package/claude/skills/design-record/references/folder.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +1 -1
- package/claude/skills/draw-figure/REQUIREMENT.md +1 -1
- package/claude/skills/draw-figure/SKILL.md +1 -1
- package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -4
- package/claude/skills/pick-by-eye/SKILL.md +34 -41
- package/claude/skills/pick-by-eye/references/board.md +42 -0
- package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/teach-craft/REQUIREMENT.md +1 -1
- package/claude/skills/teach-craft/SKILL.md +32 -1
- package/claude/skills/teach-craft/references/adopted.md +113 -0
- package/claude/skills/teach-craft/references/lesson-craft.md +10 -2
- package/claude/skills/teach-craft/references/pedagogy.md +29 -2
- package/docs/agents/install-and-sync.md +0 -8
- package/docs/target/migrations.md +8 -3
- package/docs/target/projects.md +1 -1
- package/docs/workflow/skill-map.md +0 -1
- package/docs/workflow/visual-design-workflow.md +1 -4
- package/governance/detect.toml +1 -6
- package/governance/renames.toml +0 -1
- package/package.json +2 -5
- package/src/claude/cases/misc.ts +0 -5
- package/src/claude/skills-audit.ts +1 -0
- package/src/cli/help.ts +0 -3
- package/src/cli.ts +0 -2
- package/src/commands/design.ts +16 -315
- package/src/commands/sync.ts +4 -5
- package/src/design/regen.ts +5 -32
- package/src/design/render.ts +1 -1
- package/src/design/tokens.ts +3 -2
- package/src/gate/stages.ts +4 -10
- package/src/gov/audit.ts +1 -6
- package/src/markdown/scope.ts +2 -4
- package/src/shipped/references.ts +1 -2
- package/src/sync/check.ts +4 -29
- package/src/sync/stamp.ts +6 -6
- package/src/sync/target.ts +1 -4
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/canon/DESIGN.md +1 -1
- package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
- package/claude/skills/canvas-board/SKILL.md +0 -143
- package/claude/skills/canvas-board/references/canvas.md +0 -170
- package/governance/rules/ui/canvas.md +0 -13
- package/src/canvas/address.ts +0 -227
- package/src/canvas/arrange.ts +0 -66
- package/src/canvas/capture.ts +0 -391
- package/src/canvas/client/app.tsx +0 -188
- package/src/canvas/client/inspector/align-grid.tsx +0 -162
- package/src/canvas/client/inspector/color-field.tsx +0 -398
- package/src/canvas/client/inspector/color-inputs.tsx +0 -139
- package/src/canvas/client/inspector/color-picker.tsx +0 -668
- package/src/canvas/client/inspector/color-space.ts +0 -240
- package/src/canvas/client/inspector/field.tsx +0 -263
- package/src/canvas/client/inspector/fields.css +0 -763
- package/src/canvas/client/inspector/section.tsx +0 -27
- package/src/canvas/client/inspector/size-field.tsx +0 -69
- package/src/canvas/client/inspector/token-select.tsx +0 -109
- package/src/canvas/client/inspector/values.ts +0 -297
- package/src/canvas/client/inspector.tsx +0 -692
- package/src/canvas/client/keys.ts +0 -71
- package/src/canvas/client/layers.tsx +0 -118
- package/src/canvas/client/main.tsx +0 -71
- package/src/canvas/client/pages-panel.tsx +0 -212
- package/src/canvas/client/panel-handle.tsx +0 -90
- package/src/canvas/client/selection.tsx +0 -199
- package/src/canvas/client/shell.css +0 -762
- package/src/canvas/client/state.ts +0 -792
- package/src/canvas/client/surface.tsx +0 -803
- package/src/canvas/client/theme-panel.tsx +0 -55
- package/src/canvas/client/tools.tsx +0 -153
- package/src/canvas/content.ts +0 -781
- package/src/canvas/edit.ts +0 -575
- package/src/canvas/editing.ts +0 -203
- package/src/canvas/fit.ts +0 -238
- package/src/canvas/global.d.ts +0 -15
- package/src/canvas/history.ts +0 -116
- package/src/canvas/index.html +0 -20
- package/src/canvas/server.ts +0 -766
- package/src/canvas/tokens.ts +0 -221
- package/src/commands/canvas.ts +0 -1097
- package/src/design/adapter.ts +0 -59
- package/src/design/base.css +0 -203
- package/src/design/brand-favicon.ts +0 -18
- package/src/design/neutral.ts +0 -127
- package/src/design/project-fonts.ts +0 -392
|
@@ -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
|
-
}
|