@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
package/src/canvas/arrange.ts
DELETED
|
@@ -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
|
-
}
|
package/src/canvas/capture.ts
DELETED
|
@@ -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('&', '&').replaceAll('"', '"')
|
|
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
|
-
}
|