@erclx/canon 5.53.0 → 5.55.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 (127) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +10 -9
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +5 -5
  5. package/claude/skills/create-skill/SKILL.md +1 -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/document-health/SKILL.md +6 -6
  13. package/claude/skills/document-repair/SKILL.md +5 -5
  14. package/claude/skills/draw-figure/REQUIREMENT.md +1 -1
  15. package/claude/skills/draw-figure/SKILL.md +1 -1
  16. package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -4
  17. package/claude/skills/pick-by-eye/SKILL.md +34 -41
  18. package/claude/skills/pick-by-eye/references/board.md +42 -0
  19. package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
  20. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  21. package/claude/skills/target-check/SKILL.md +6 -8
  22. package/claude/skills/target-check/references/domains.md +1 -4
  23. package/claude/skills/target-setup/SKILL.md +8 -8
  24. package/claude/skills/target-update/SKILL.md +5 -5
  25. package/claude/skills/teach-craft/REQUIREMENT.md +1 -1
  26. package/claude/skills/teach-craft/SKILL.md +32 -1
  27. package/claude/skills/teach-craft/references/adopted.md +113 -0
  28. package/claude/skills/teach-craft/references/lesson-craft.md +10 -2
  29. package/claude/skills/teach-craft/references/pedagogy.md +29 -2
  30. package/docs/agents/install-and-sync.md +2 -10
  31. package/docs/agents/output-shape.md +11 -10
  32. package/docs/target/migrations.md +8 -3
  33. package/docs/target/projects.md +1 -1
  34. package/docs/target/sync.md +1 -1
  35. package/docs/workflow/skill-map.md +0 -1
  36. package/docs/workflow/visual-design-workflow.md +1 -4
  37. package/governance/detect.toml +1 -6
  38. package/governance/renames.toml +0 -1
  39. package/package.json +2 -5
  40. package/scripts/core/install-check.sh +1 -1
  41. package/scripts/lib/ui.sh +12 -65
  42. package/src/audits/run.ts +1 -1
  43. package/src/claude/cases/misc.ts +0 -5
  44. package/src/claude/routing.ts +1 -1
  45. package/src/claude/skills-audit.ts +1 -0
  46. package/src/cli/help.ts +0 -3
  47. package/src/cli/run.ts +5 -6
  48. package/src/cli/ui.ts +87 -28
  49. package/src/cli.ts +6 -2
  50. package/src/commands/claude.ts +2 -3
  51. package/src/commands/design.ts +20 -324
  52. package/src/commands/gate.ts +5 -3
  53. package/src/commands/gov.ts +11 -21
  54. package/src/commands/labels.ts +1 -1
  55. package/src/commands/pr.ts +1 -1
  56. package/src/commands/sync.ts +5 -6
  57. package/src/commands/tooling.ts +5 -8
  58. package/src/commands/transcripts.ts +6 -11
  59. package/src/design/regen.ts +5 -32
  60. package/src/design/render.ts +1 -1
  61. package/src/design/tokens.ts +3 -2
  62. package/src/gate/sequencer.ts +1 -1
  63. package/src/gate/stages.ts +4 -20
  64. package/src/gov/audit.ts +1 -6
  65. package/src/hooks/post-merge.ts +2 -5
  66. package/src/markdown/scope.ts +2 -4
  67. package/src/process/harness.ts +3 -4
  68. package/src/sandbox/equivalence.ts +0 -1
  69. package/src/sandbox/provision.ts +8 -13
  70. package/src/sandbox/scenario.ts +6 -12
  71. package/src/shipped/references.ts +1 -2
  72. package/src/sync/check.ts +4 -29
  73. package/src/sync/engine.ts +3 -8
  74. package/src/sync/stamp.ts +6 -6
  75. package/src/sync/target.ts +1 -4
  76. package/src/sync/workflow.ts +3 -5
  77. package/src/tooling/verify-stack.ts +1 -1
  78. package/tooling/base/configs/.husky/post-merge +6 -10
  79. package/tooling/claude/reference.md +1 -1
  80. package/tooling/claude/seeds/canon/DESIGN.md +1 -1
  81. package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
  82. package/claude/skills/canvas-board/SKILL.md +0 -143
  83. package/claude/skills/canvas-board/references/canvas.md +0 -170
  84. package/governance/rules/ui/canvas.md +0 -13
  85. package/scripts/core/check-color-source.sh +0 -51
  86. package/src/canvas/address.ts +0 -227
  87. package/src/canvas/arrange.ts +0 -66
  88. package/src/canvas/capture.ts +0 -391
  89. package/src/canvas/client/app.tsx +0 -188
  90. package/src/canvas/client/inspector/align-grid.tsx +0 -162
  91. package/src/canvas/client/inspector/color-field.tsx +0 -398
  92. package/src/canvas/client/inspector/color-inputs.tsx +0 -139
  93. package/src/canvas/client/inspector/color-picker.tsx +0 -668
  94. package/src/canvas/client/inspector/color-space.ts +0 -240
  95. package/src/canvas/client/inspector/field.tsx +0 -263
  96. package/src/canvas/client/inspector/fields.css +0 -763
  97. package/src/canvas/client/inspector/section.tsx +0 -27
  98. package/src/canvas/client/inspector/size-field.tsx +0 -69
  99. package/src/canvas/client/inspector/token-select.tsx +0 -109
  100. package/src/canvas/client/inspector/values.ts +0 -297
  101. package/src/canvas/client/inspector.tsx +0 -692
  102. package/src/canvas/client/keys.ts +0 -71
  103. package/src/canvas/client/layers.tsx +0 -118
  104. package/src/canvas/client/main.tsx +0 -71
  105. package/src/canvas/client/pages-panel.tsx +0 -212
  106. package/src/canvas/client/panel-handle.tsx +0 -90
  107. package/src/canvas/client/selection.tsx +0 -199
  108. package/src/canvas/client/shell.css +0 -762
  109. package/src/canvas/client/state.ts +0 -792
  110. package/src/canvas/client/surface.tsx +0 -803
  111. package/src/canvas/client/theme-panel.tsx +0 -55
  112. package/src/canvas/client/tools.tsx +0 -153
  113. package/src/canvas/content.ts +0 -781
  114. package/src/canvas/edit.ts +0 -575
  115. package/src/canvas/editing.ts +0 -203
  116. package/src/canvas/fit.ts +0 -238
  117. package/src/canvas/global.d.ts +0 -15
  118. package/src/canvas/history.ts +0 -116
  119. package/src/canvas/index.html +0 -20
  120. package/src/canvas/server.ts +0 -766
  121. package/src/canvas/tokens.ts +0 -221
  122. package/src/commands/canvas.ts +0 -1097
  123. package/src/design/adapter.ts +0 -59
  124. package/src/design/base.css +0 -203
  125. package/src/design/brand-favicon.ts +0 -18
  126. package/src/design/neutral.ts +0 -127
  127. package/src/design/project-fonts.ts +0 -392
@@ -1,66 +0,0 @@
1
- import { type Box, FRAME_GAP, type Frame } from '@/canvas/content'
2
-
3
- export interface Placement {
4
- readonly name: string
5
- readonly from: Box
6
- readonly to: Box
7
- }
8
-
9
- function boxOf(frame: Frame): Box {
10
- return {
11
- x: frame.x,
12
- y: frame.y,
13
- width: frame.width,
14
- height: frame.height,
15
- }
16
- }
17
-
18
- /**
19
- * Boxes whose vertical spans overlap share a row, so a row the operator dragged
20
- * stays a row. Reading by overlap rather than by an equal `y` lets a frame a
21
- * few pixels off its neighbor still join it.
22
- */
23
- function rowsOf(frames: readonly Frame[]): Frame[][] {
24
- const sorted = [...frames].sort((a, b) => a.y - b.y || a.x - b.x)
25
- const rows: Frame[][] = []
26
- let bottom = Number.NEGATIVE_INFINITY
27
- for (const frame of sorted) {
28
- const row = rows.at(-1)
29
- if (row && frame.y < bottom) {
30
- row.push(frame)
31
- bottom = Math.max(bottom, frame.y + frame.height)
32
- } else {
33
- rows.push([frame])
34
- bottom = frame.y + frame.height
35
- }
36
- }
37
- return rows.map((row) =>
38
- row.sort((a, b) => a.x - b.x || a.name.localeCompare(b.name)),
39
- )
40
- }
41
-
42
- /**
43
- * Re-lays a page's frames: each row left to right on `FRAME_GAP` from the
44
- * page's leftmost edge, rows stacked on their tallest box plus the gap from
45
- * the page's top edge. Returns only the frames whose position changes.
46
- */
47
- export function planArrange(frames: readonly Frame[]): Placement[] {
48
- if (frames.length === 0) return []
49
- const left = Math.min(...frames.map((frame) => frame.x))
50
- let top = Math.min(...frames.map((frame) => frame.y))
51
-
52
- const moves: Placement[] = []
53
- for (const row of rowsOf(frames)) {
54
- let x = left
55
- for (const frame of row) {
56
- const from = boxOf(frame)
57
- const to: Box = { ...from, x, y: top }
58
- if (from.x !== to.x || from.y !== to.y) {
59
- moves.push({ name: frame.name, from, to })
60
- }
61
- x += frame.width + FRAME_GAP
62
- }
63
- top += Math.max(...row.map((frame) => frame.height)) + FRAME_GAP
64
- }
65
- return moves
66
- }
@@ -1,391 +0,0 @@
1
- import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
2
- import { dirname, join } from 'node:path'
3
- import type { CaptureResult } from '@/capture/render'
4
- import {
5
- type ContentRefused,
6
- canvasDir,
7
- type Frame,
8
- readPage,
9
- } from '@/canvas/content'
10
- import {
11
- type CanvasOptions,
12
- type CanvasOutcome,
13
- FRAMES_PREFIX,
14
- startCanvas,
15
- } from '@/canvas/server'
16
- import { recordDir } from '@/roots/record'
17
-
18
- /**
19
- * The element a frame is captured by. The document rather than its body, so a
20
- * frame that paints its background on the root is not cropped to the body's
21
- * box.
22
- */
23
- export const CAPTURE_SELECTOR = 'html'
24
-
25
- export interface CaptureTarget {
26
- readonly page: string
27
- readonly frame: string
28
- /** The served address, which is what carries the injected tokens. */
29
- readonly url: string
30
- /** The viewport width, so a media query resolves at the frame's own width. */
31
- readonly width: number
32
- readonly pngPath: string
33
- }
34
-
35
- export type TargetsOutcome =
36
- | { readonly ok: true; readonly targets: readonly CaptureTarget[] }
37
- | ContentRefused
38
-
39
- function refuse(
40
- reason: ContentRefused['reason'],
41
- detail: string,
42
- ): ContentRefused {
43
- return { ok: false, reason, detail }
44
- }
45
-
46
- function frameUrl(base: string, page: string, frame: Frame): string {
47
- return `${base}${FRAMES_PREFIX.slice(1)}${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
48
- }
49
-
50
- /**
51
- * Turns `<page>` or `<page>/<frame>` into what to capture. Every target is a
52
- * served address, never the file on disk, so a capture can only show what the
53
- * canvas shows. The width comes from the layout and not from the window, so a
54
- * frame narrower than its own breakpoint renders its narrow layout.
55
- *
56
- * `out` is the PNG for a frame and a folder for a page, the way a URL source
57
- * reads `--out` in `canon capture`.
58
- */
59
- export function resolveCaptureTargets(
60
- root: string,
61
- spec: string,
62
- base: string,
63
- out?: string,
64
- ): TargetsOutcome {
65
- const [pageName, frameName, ...extra] = spec.split('/')
66
- if (!pageName || extra.length > 0 || frameName === '') {
67
- return refuse('invalid-name', `${spec} is not <page> or <page>/<frame>`)
68
- }
69
-
70
- const page = readPage(root, pageName)
71
- if (!page) return refuse('no-page', `page ${pageName} does not exist`)
72
-
73
- const frames =
74
- frameName === undefined
75
- ? page.frames
76
- : page.frames.filter((frame) => frame.name === frameName)
77
- if (frames.length === 0) {
78
- return frameName === undefined
79
- ? refuse('no-frame', `page ${pageName} has no frames`)
80
- : refuse('no-frame', `frame ${frameName} does not exist on ${pageName}`)
81
- }
82
-
83
- const scratch = recordDir(root, 'tmp', 'canvas-capture', pageName)
84
- return {
85
- ok: true,
86
- targets: frames.map((frame) => ({
87
- page: pageName,
88
- frame: frame.name,
89
- url: frameUrl(base, pageName, frame),
90
- width: frame.width,
91
- pngPath:
92
- frameName !== undefined && out !== undefined
93
- ? out
94
- : join(out ?? scratch, `${frame.name}.png`),
95
- })),
96
- }
97
- }
98
-
99
- export type CaptureOutcome =
100
- | {
101
- readonly ok: true
102
- readonly captures: readonly {
103
- readonly target: CaptureTarget
104
- readonly result: CaptureResult
105
- }[]
106
- }
107
- | ContentRefused
108
- | {
109
- readonly ok: false
110
- readonly reason: 'no-server' | 'capture-failed'
111
- readonly detail: string
112
- }
113
-
114
- type CaptureEngine = (typeof import('@/capture/render'))['captureSources']
115
-
116
- /** What a test stands in for, which the command never passes. */
117
- export interface CaptureDeps {
118
- readonly start?: (root: string, options: CanvasOptions) => CanvasOutcome
119
- readonly capture?: CaptureEngine
120
- }
121
-
122
- /**
123
- * Serves the canvas for the length of the call and captures through it, so the
124
- * run needs no `canon canvas serve` and still never reads the raw file. The
125
- * port is whatever is free, which keeps it clear of a canvas already running.
126
- * An error the engine throws, such as a browser that will not launch, comes
127
- * back as `capture-failed` rather than escaping, so a caller reading the record
128
- * can tell it from a root that is wrong.
129
- */
130
- export async function captureCanvas(
131
- root: string,
132
- spec: string,
133
- out?: string,
134
- deps: CaptureDeps = {},
135
- ): Promise<CaptureOutcome> {
136
- /*
137
- * Refused before the server starts, since starting it creates the canvas
138
- * folder and a refused capture should leave the project as it found it.
139
- */
140
- const early = resolveCaptureTargets(root, spec, '')
141
- if (!early.ok) return early
142
-
143
- return serveFor(root, deps, async (base, capture) => {
144
- const resolved = resolveCaptureTargets(root, spec, base, out)
145
- if (!resolved.ok) return resolved
146
-
147
- const captures: { target: CaptureTarget; result: CaptureResult }[] = []
148
- for (const target of resolved.targets) {
149
- const [result] = await capture(target.url, {
150
- selector: CAPTURE_SELECTOR,
151
- width: target.width,
152
- outDir: target.pngPath,
153
- })
154
- if (result) captures.push({ target, result })
155
- }
156
- return { ok: true, captures }
157
- })
158
- }
159
-
160
- export type ServeRefused = {
161
- readonly ok: false
162
- readonly reason: 'no-server' | 'capture-failed'
163
- readonly detail: string
164
- }
165
-
166
- async function serveFor<T>(
167
- root: string,
168
- deps: CaptureDeps,
169
- run: (base: string, capture: CaptureEngine) => Promise<T>,
170
- ): Promise<T | ServeRefused> {
171
- return serveCanvas(root, deps.start, async (base) => {
172
- const capture =
173
- deps.capture ?? (await import('@/capture/render')).captureSources
174
- return run(base, capture)
175
- })
176
- }
177
-
178
- /**
179
- * Runs a call against a canvas served on a free port for its length, so a verb
180
- * that renders frames reads the same address the board shows.
181
- */
182
- export async function serveCanvas<T>(
183
- root: string,
184
- startOverride: CaptureDeps['start'],
185
- run: (base: string) => Promise<T>,
186
- ): Promise<T | ServeRefused> {
187
- const start = startOverride ?? startCanvas
188
- const server = start(root, { port: 0, shell: new Response('') })
189
- if (!server.ok) {
190
- return {
191
- ok: false,
192
- reason: 'no-server',
193
- detail: `${server.detail}, so run canon canvas serve and try again`,
194
- }
195
- }
196
-
197
- try {
198
- return await run(server.url)
199
- } catch (error) {
200
- return {
201
- ok: false,
202
- reason: 'capture-failed',
203
- detail: error instanceof Error ? error.message : String(error),
204
- }
205
- } finally {
206
- await server.stop()
207
- }
208
- }
209
-
210
- /** The element a composite is captured by, holding every frame and no chrome. */
211
- export const COMPOSITE_SELECTOR = '#canvas-composite'
212
-
213
- export interface Placement {
214
- readonly frame: string
215
- readonly url: string
216
- readonly left: number
217
- readonly top: number
218
- readonly width: number
219
- readonly height: number
220
- }
221
-
222
- export interface Composite {
223
- readonly placements: readonly Placement[]
224
- readonly width: number
225
- readonly height: number
226
- readonly html: string
227
- }
228
-
229
- /**
230
- * One document showing every frame of a page at its layout position. Offsets
231
- * run from the top left of the union of the boxes, so a negative coordinate
232
- * never clips and a lone frame fills the container exactly. Each iframe takes
233
- * its box, so a document taller than its frame clips as it does on the board,
234
- * and frames paint in the order given, which is the board's.
235
- */
236
- export function buildComposite(
237
- page: string,
238
- frames: readonly Frame[],
239
- base: string,
240
- ): Composite {
241
- const left = Math.min(...frames.map((frame) => frame.x))
242
- const top = Math.min(...frames.map((frame) => frame.y))
243
- const right = Math.max(...frames.map((frame) => frame.x + frame.width))
244
- const bottom = Math.max(...frames.map((frame) => frame.y + frame.height))
245
- const width = right - left
246
- const height = bottom - top
247
-
248
- const placements = frames.map((frame) => ({
249
- frame: frame.name,
250
- url: frameUrl(base, page, frame),
251
- left: frame.x - left,
252
- top: frame.y - top,
253
- width: frame.width,
254
- height: frame.height,
255
- }))
256
-
257
- const iframes = placements.map(
258
- (p) =>
259
- `<iframe title="${escapeAttribute(p.frame)}" src="${escapeAttribute(p.url)}" style="left:${p.left}px;top:${p.top}px;width:${p.width}px;height:${p.height}px"></iframe>`,
260
- )
261
- const html = [
262
- '<!doctype html>',
263
- '<html><head><meta charset="utf-8">',
264
- `<style>html,body{margin:0;background:transparent}#canvas-composite{position:relative}#canvas-composite iframe{position:absolute;display:block;border:0;background:transparent}</style>`,
265
- '</head><body>',
266
- `<div id="canvas-composite" style="width:${width}px;height:${height}px">`,
267
- ...iframes,
268
- '</div></body></html>',
269
- '',
270
- ].join('\n')
271
-
272
- return { placements, width, height, html }
273
- }
274
-
275
- function escapeAttribute(value: string): string {
276
- return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;')
277
- }
278
-
279
- /**
280
- * Where the generated document is served from, inside the canvas folder so it
281
- * shares the frames' origin. Chromium skips painting a cross-origin iframe
282
- * outside the viewport, and the engine's viewport is 720 pixels tall, so a
283
- * document on another origin captured every frame below that line blank. A dot
284
- * folder is no page name, so the board never lists it.
285
- */
286
- const COMPOSITE_FOLDER = '.composite'
287
-
288
- export interface CompositeTarget {
289
- readonly page: string
290
- readonly composite: Composite
291
- readonly htmlPath: string
292
- /** The served address of the document at `htmlPath`. */
293
- readonly url: string
294
- readonly pngPath: string
295
- }
296
-
297
- export type CompositeTargetOutcome =
298
- | { readonly ok: true; readonly target: CompositeTarget }
299
- | ContentRefused
300
-
301
- /**
302
- * Turns `<page>` into one composite. `out` is the PNG, and without it the PNG
303
- * lands at `<page>.png` beside the per-frame folder of the same name, so the
304
- * two captures of one page never collide.
305
- */
306
- export function resolveCompositeTarget(
307
- root: string,
308
- spec: string,
309
- base: string,
310
- out?: string,
311
- ): CompositeTargetOutcome {
312
- if (spec.includes('/')) {
313
- return refuse(
314
- 'invalid-name',
315
- `--composite takes a page, not ${spec}, so capture <page> or drop the flag`,
316
- )
317
- }
318
-
319
- const page = readPage(root, spec)
320
- if (!page) return refuse('no-page', `page ${spec} does not exist`)
321
- if (page.frames.length === 0) {
322
- return refuse('no-frame', `page ${spec} has no frames`)
323
- }
324
-
325
- const file = `${spec}.html`
326
- return {
327
- ok: true,
328
- target: {
329
- page: spec,
330
- composite: buildComposite(spec, page.frames, base),
331
- htmlPath: join(canvasDir(root), COMPOSITE_FOLDER, file),
332
- url: `${base}${FRAMES_PREFIX.slice(1)}${COMPOSITE_FOLDER}/${encodeURIComponent(file)}`,
333
- pngPath:
334
- out ?? join(recordDir(root, 'tmp', 'canvas-capture'), `${spec}.png`),
335
- },
336
- }
337
- }
338
-
339
- export type CompositeOutcome =
340
- | {
341
- readonly ok: true
342
- readonly composite: {
343
- readonly target: CompositeTarget
344
- readonly result: CaptureResult
345
- }
346
- }
347
- | ContentRefused
348
- | ServeRefused
349
-
350
- /**
351
- * Captures a page as one PNG through the served canvas, the way `captureCanvas`
352
- * captures each frame. The viewport is the container's width, so no frame's
353
- * media query resolves against a window narrower than the frame itself. The
354
- * page load the engine waits on waits on every iframe.
355
- */
356
- export async function captureComposite(
357
- root: string,
358
- spec: string,
359
- out?: string,
360
- deps: CaptureDeps = {},
361
- ): Promise<CompositeOutcome> {
362
- const early = resolveCompositeTarget(root, spec, '')
363
- if (!early.ok) return early
364
-
365
- return serveFor(root, deps, async (base, capture) => {
366
- const resolved = resolveCompositeTarget(root, spec, base, out)
367
- if (!resolved.ok) return resolved
368
-
369
- const { target } = resolved
370
- mkdirSync(dirname(target.htmlPath), { recursive: true })
371
- writeFileSync(target.htmlPath, target.composite.html)
372
- let result: CaptureResult | undefined
373
- try {
374
- ;[result] = await capture(target.url, {
375
- selector: COMPOSITE_SELECTOR,
376
- width: target.composite.width,
377
- outDir: target.pngPath,
378
- })
379
- } finally {
380
- rmSync(target.htmlPath, { force: true })
381
- }
382
- if (!result) {
383
- return {
384
- ok: false,
385
- reason: 'capture-failed',
386
- detail: `the engine rendered nothing for ${spec}`,
387
- }
388
- }
389
- return { ok: true, composite: { target, result } }
390
- })
391
- }
@@ -1,188 +0,0 @@
1
- /** @jsxImportSource preact */
2
- import { effect } from '@preact/signals'
3
- import type { JSX } from 'preact'
4
- import { useLayoutEffect, useRef } from 'preact/hooks'
5
- import { Inspector } from '@/canvas/client/inspector'
6
- import { PagesPanel } from '@/canvas/client/pages-panel'
7
- import { PanelHandle } from '@/canvas/client/panel-handle'
8
- import {
9
- clampPanelWidth,
10
- currentPage,
11
- DEFAULT_PANEL_WIDTHS,
12
- focusFrame,
13
- type PanelWidths,
14
- panelsHidden,
15
- panelWidths,
16
- tokens,
17
- } from '@/canvas/client/state'
18
- import { Surface } from '@/canvas/client/surface'
19
-
20
- const SOURCE_LABEL = {
21
- toolkit: 'This toolkit',
22
- installed: 'Installed design',
23
- none: 'None',
24
- } as const
25
-
26
- function TokenDetails(): JSX.Element | null {
27
- const value = tokens.value
28
- if (!value) return null
29
- return (
30
- <section aria-labelledby="tokens-heading">
31
- <h2 id="tokens-heading" class="section-label">
32
- Tokens
33
- </h2>
34
- <p class="detail">{SOURCE_LABEL[value.source]}</p>
35
- {value.files && value.files.length > 0 ? (
36
- <ul class="files">
37
- {value.files.map((file) => (
38
- <li key={file} title={file}>
39
- <code>{file}</code>
40
- </li>
41
- ))}
42
- </ul>
43
- ) : null}
44
- {value.notice ? <p class="notice">{value.notice}</p> : null}
45
- </section>
46
- )
47
- }
48
-
49
- function PageDetails(): JSX.Element | null {
50
- const page = currentPage.value
51
- if (!page) return null
52
- const unplaced = page.frames.filter((frame) => !frame.placed).length
53
- return (
54
- <section aria-labelledby="page-heading">
55
- <h2 id="page-heading" class="section-label">
56
- Page
57
- </h2>
58
- <p class="detail" title={page.name}>
59
- {page.name}
60
- </p>
61
- <p class="detail-meta">
62
- {page.frames.length} {page.frames.length === 1 ? 'frame' : 'frames'}
63
- </p>
64
- {unplaced > 0 ? (
65
- <p class="notice">
66
- {unplaced} {unplaced === 1 ? 'frame has' : 'frames have'} no box in
67
- layout.json and sit in a default row.
68
- </p>
69
- ) : null}
70
- {page.layoutIssue === 'malformed' ? (
71
- <p class="notice" role="alert">
72
- layout.json does not parse, so every frame sits in a default row. Fix
73
- the file to restore their places.
74
- </p>
75
- ) : null}
76
- </section>
77
- )
78
- }
79
-
80
- const PANELS_KEY = 'canon-canvas-panels'
81
-
82
- interface StoredPanels extends PanelWidths {
83
- readonly hidden: boolean
84
- }
85
-
86
- function storedWidth(value: unknown, fallback: number): number {
87
- return typeof value === 'number' && Number.isFinite(value) ? value : fallback
88
- }
89
-
90
- /** What the last visit left, with anything unreadable read as the default. */
91
- function readStoredPanels(): StoredPanels {
92
- const fallback = { hidden: false, ...DEFAULT_PANEL_WIDTHS }
93
- try {
94
- const parsed: unknown = JSON.parse(localStorage.getItem(PANELS_KEY) ?? '{}')
95
- if (typeof parsed !== 'object' || parsed === null) return fallback
96
- const record = parsed as Record<string, unknown>
97
- return {
98
- hidden: record.hidden === true,
99
- left: storedWidth(record.left, fallback.left),
100
- right: storedWidth(record.right, fallback.right),
101
- }
102
- } catch {
103
- return fallback
104
- }
105
- }
106
-
107
- function storePanels(panels: StoredPanels): void {
108
- try {
109
- localStorage.setItem(PANELS_KEY, JSON.stringify(panels))
110
- } catch {
111
- /* A blocked store costs the remembered layout, nothing else. */
112
- }
113
- }
114
-
115
- /**
116
- * Holds both widths inside this window, the details panel giving way first,
117
- * since long layer names are what the pages panel widens for.
118
- */
119
- function fitWidths(widths: PanelWidths): PanelWidths {
120
- const right = clampPanelWidth(
121
- 'right',
122
- widths.right,
123
- window.innerWidth,
124
- widths,
125
- )
126
- const left = clampPanelWidth('left', widths.left, window.innerWidth, {
127
- ...widths,
128
- right,
129
- })
130
- return { left, right }
131
- }
132
-
133
- /**
134
- * Restores the panels the last visit left and stores each change, the way the
135
- * theme pick is kept. The read lands before the first paint, so a hidden
136
- * layout never flashes open. The widths are clamped on that read and on every
137
- * window resize, since a width kept from a wider window could squeeze the
138
- * surface out.
139
- */
140
- function usePanelPreference(): void {
141
- useLayoutEffect(() => {
142
- const stored = readStoredPanels()
143
- panelsHidden.value = stored.hidden
144
- panelWidths.value = fitWidths(stored)
145
- const handleResize = () => {
146
- const fitted = fitWidths(panelWidths.value)
147
- const { left, right } = panelWidths.value
148
- if (fitted.left !== left || fitted.right !== right) {
149
- panelWidths.value = fitted
150
- }
151
- }
152
- window.addEventListener('resize', handleResize)
153
- const stopStoring = effect(() =>
154
- storePanels({ hidden: panelsHidden.value, ...panelWidths.value }),
155
- )
156
- return () => {
157
- window.removeEventListener('resize', handleResize)
158
- stopStoring()
159
- }
160
- }, [])
161
- }
162
-
163
- export function App(): JSX.Element {
164
- const viewportRef = useRef<HTMLDivElement>(null)
165
- usePanelPreference()
166
- const { left, right } = panelWidths.value
167
- return (
168
- <div
169
- class={panelsHidden.value ? 'shell panels-hidden' : 'shell'}
170
- style={{ '--panel-left': `${left}px`, '--panel-right': `${right}px` }}
171
- >
172
- <PagesPanel
173
- onFocusFrame={(frame) => {
174
- const rect = viewportRef.current?.getBoundingClientRect()
175
- focusFrame(frame, rect?.width ?? 0, rect?.height ?? 0)
176
- }}
177
- />
178
- <PanelHandle side="left" controls="pages-panel" />
179
- <Surface viewportRef={viewportRef} />
180
- <aside id="details-panel" class="panel panel-right" aria-label="Details">
181
- <Inspector />
182
- <PageDetails />
183
- <TokenDetails />
184
- </aside>
185
- <PanelHandle side="right" controls="details-panel" />
186
- </div>
187
- )
188
- }