@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +10 -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/design-identity/REQUIREMENT.md +2 -2
  6. package/claude/skills/design-identity/SKILL.md +6 -6
  7. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  8. package/claude/skills/design-record/SKILL.md +4 -4
  9. package/claude/skills/design-record/references/folder.md +1 -1
  10. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  11. package/claude/skills/draw-figure/REQUIREMENT.md +1 -1
  12. package/claude/skills/draw-figure/SKILL.md +1 -1
  13. package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -4
  14. package/claude/skills/pick-by-eye/SKILL.md +34 -41
  15. package/claude/skills/pick-by-eye/references/board.md +42 -0
  16. package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
  17. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  18. package/claude/skills/teach-craft/REQUIREMENT.md +1 -1
  19. package/claude/skills/teach-craft/SKILL.md +32 -1
  20. package/claude/skills/teach-craft/references/adopted.md +113 -0
  21. package/claude/skills/teach-craft/references/lesson-craft.md +10 -2
  22. package/claude/skills/teach-craft/references/pedagogy.md +29 -2
  23. package/docs/agents/install-and-sync.md +0 -8
  24. package/docs/target/migrations.md +8 -3
  25. package/docs/target/projects.md +1 -1
  26. package/docs/workflow/skill-map.md +0 -1
  27. package/docs/workflow/visual-design-workflow.md +1 -4
  28. package/governance/detect.toml +1 -6
  29. package/governance/renames.toml +0 -1
  30. package/package.json +2 -5
  31. package/src/claude/cases/misc.ts +0 -5
  32. package/src/claude/skills-audit.ts +1 -0
  33. package/src/cli/help.ts +0 -3
  34. package/src/cli.ts +0 -2
  35. package/src/commands/design.ts +16 -315
  36. package/src/commands/sync.ts +4 -5
  37. package/src/design/regen.ts +5 -32
  38. package/src/design/render.ts +1 -1
  39. package/src/design/tokens.ts +3 -2
  40. package/src/gate/stages.ts +4 -10
  41. package/src/gov/audit.ts +1 -6
  42. package/src/markdown/scope.ts +2 -4
  43. package/src/shipped/references.ts +1 -2
  44. package/src/sync/check.ts +4 -29
  45. package/src/sync/stamp.ts +6 -6
  46. package/src/sync/target.ts +1 -4
  47. package/tooling/claude/reference.md +1 -1
  48. package/tooling/claude/seeds/canon/DESIGN.md +1 -1
  49. package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
  50. package/claude/skills/canvas-board/SKILL.md +0 -143
  51. package/claude/skills/canvas-board/references/canvas.md +0 -170
  52. package/governance/rules/ui/canvas.md +0 -13
  53. package/src/canvas/address.ts +0 -227
  54. package/src/canvas/arrange.ts +0 -66
  55. package/src/canvas/capture.ts +0 -391
  56. package/src/canvas/client/app.tsx +0 -188
  57. package/src/canvas/client/inspector/align-grid.tsx +0 -162
  58. package/src/canvas/client/inspector/color-field.tsx +0 -398
  59. package/src/canvas/client/inspector/color-inputs.tsx +0 -139
  60. package/src/canvas/client/inspector/color-picker.tsx +0 -668
  61. package/src/canvas/client/inspector/color-space.ts +0 -240
  62. package/src/canvas/client/inspector/field.tsx +0 -263
  63. package/src/canvas/client/inspector/fields.css +0 -763
  64. package/src/canvas/client/inspector/section.tsx +0 -27
  65. package/src/canvas/client/inspector/size-field.tsx +0 -69
  66. package/src/canvas/client/inspector/token-select.tsx +0 -109
  67. package/src/canvas/client/inspector/values.ts +0 -297
  68. package/src/canvas/client/inspector.tsx +0 -692
  69. package/src/canvas/client/keys.ts +0 -71
  70. package/src/canvas/client/layers.tsx +0 -118
  71. package/src/canvas/client/main.tsx +0 -71
  72. package/src/canvas/client/pages-panel.tsx +0 -212
  73. package/src/canvas/client/panel-handle.tsx +0 -90
  74. package/src/canvas/client/selection.tsx +0 -199
  75. package/src/canvas/client/shell.css +0 -762
  76. package/src/canvas/client/state.ts +0 -792
  77. package/src/canvas/client/surface.tsx +0 -803
  78. package/src/canvas/client/theme-panel.tsx +0 -55
  79. package/src/canvas/client/tools.tsx +0 -153
  80. package/src/canvas/content.ts +0 -781
  81. package/src/canvas/edit.ts +0 -575
  82. package/src/canvas/editing.ts +0 -203
  83. package/src/canvas/fit.ts +0 -238
  84. package/src/canvas/global.d.ts +0 -15
  85. package/src/canvas/history.ts +0 -116
  86. package/src/canvas/index.html +0 -20
  87. package/src/canvas/server.ts +0 -766
  88. package/src/canvas/tokens.ts +0 -221
  89. package/src/commands/canvas.ts +0 -1097
  90. package/src/design/adapter.ts +0 -59
  91. package/src/design/base.css +0 -203
  92. package/src/design/brand-favicon.ts +0 -18
  93. package/src/design/neutral.ts +0 -127
  94. package/src/design/project-fonts.ts +0 -392
@@ -1,1097 +0,0 @@
1
- import { statSync } from 'node:fs'
2
- import { resolve } from 'node:path'
3
- import type { Command } from 'commander'
4
- import {
5
- addFrame,
6
- addPage,
7
- type ContentRefused,
8
- canvasDir,
9
- DEFAULT_FRAME,
10
- listPages,
11
- moveFrame,
12
- pruneLayout,
13
- readPage,
14
- readSelection,
15
- renamePage,
16
- resizeFrame,
17
- } from '@/canvas/content'
18
- import { planArrange } from '@/canvas/arrange'
19
- import { captureCanvas, captureComposite } from '@/canvas/capture'
20
- import { fitCanvas, isOwed } from '@/canvas/fit'
21
- import {
22
- type EditRefused,
23
- editFrameAtIndex,
24
- STYLE_PROPERTIES,
25
- } from '@/canvas/edit'
26
- import {
27
- clearEditing,
28
- EDITING_TTL_MS,
29
- markEditing,
30
- readEditing,
31
- } from '@/canvas/editing'
32
- import { CANVAS_PORT, startCanvas } from '@/canvas/server'
33
- import { PROJECT_ROOT } from '@/roots/project'
34
- import { resolveFrameTokens } from '@/canvas/tokens'
35
- import { displayPath, parsePort, waitForInterrupt } from '@/serve/report'
36
- import {
37
- callerIdentity,
38
- type Located,
39
- resolveSessions,
40
- selfOf,
41
- } from '@/sessions/resolve'
42
- import {
43
- intro,
44
- logAdd,
45
- logError,
46
- logInfo,
47
- logStep,
48
- logWarn,
49
- outro,
50
- plural,
51
- } from '@/cli/ui'
52
- import { mainWorktreeRoot } from '@/git/worktree'
53
-
54
- const BANNER = 'canon canvas'
55
-
56
- interface RootOptions {
57
- readonly root?: string
58
- readonly json?: boolean
59
- }
60
-
61
- interface Refused {
62
- readonly ok: false
63
- readonly reason:
64
- | ContentRefused['reason']
65
- | EditRefused['reason']
66
- | 'no-root'
67
- | 'no-server'
68
- | 'capture-failed'
69
- | 'missing-client-deps'
70
- readonly detail: string
71
- }
72
-
73
- const CLIENT_DEPS = ['preact', '@preact/signals'] as const
74
-
75
- /**
76
- * Refuses when the shell's client packages do not resolve from `from`, since
77
- * the bundler then answers `/` with an empty page rather than an error. The
78
- * caller passes the package root the binary runs from, never the served
79
- * project's, so a global install serving a target finds its own copies.
80
- *
81
- * `Bun.resolveSync` and `createRequire(...).resolve` fall back to Bun's global
82
- * install cache when no ancestor of the starting directory holds a
83
- * `node_modules`, so a presence check from such a directory passes where the
84
- * bundler fails. The package root always sits under one.
85
- */
86
- export function missingClientDeps(from: string): Refused | undefined {
87
- const missing = CLIENT_DEPS.filter((name) => {
88
- try {
89
- Bun.resolveSync(name, from)
90
- return false
91
- } catch {
92
- return true
93
- }
94
- })
95
- if (missing.length === 0) return undefined
96
- return {
97
- ok: false,
98
- reason: 'missing-client-deps',
99
- detail: `${missing.join(', ')} not installed in ${from}, so the canvas shell cannot load. Run bun install there`,
100
- }
101
- }
102
-
103
- type RootOutcome = { readonly ok: true; readonly root: string } | Refused
104
-
105
- /**
106
- * Refuses a root that is not a directory rather than letting the first write
107
- * scaffold `.canon/canvas/` under a mistyped path, the way `canon serve`
108
- * refuses a directory it cannot find.
109
- */
110
- async function resolveRoot(opts: RootOptions): Promise<RootOutcome> {
111
- const root = opts.root
112
- ? resolve(process.cwd(), opts.root)
113
- : await mainWorktreeRoot()
114
- if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
115
- return {
116
- ok: false,
117
- reason: 'no-root',
118
- detail: `${root} is not a directory`,
119
- }
120
- }
121
- return { ok: true, root }
122
- }
123
-
124
- function writeJson(record: unknown): void {
125
- process.stdout.write(`${JSON.stringify(record)}\n`)
126
- }
127
-
128
- /** Every verb's refusal, framed on stderr and mirrored on stdout under --json. */
129
- function refuse(refusal: Refused, emitJson: boolean): number {
130
- if (emitJson) writeJson(refusal)
131
- intro(BANNER)
132
- logError(refusal.detail)
133
- outro()
134
- return 1
135
- }
136
-
137
- /**
138
- * Parses a size flag. NaN for a value that is not a positive integer, which
139
- * the content module then refuses as an invalid size.
140
- */
141
- function parseSize(raw: string | undefined, fallback: number): number {
142
- if (raw === undefined) return fallback
143
- const value = Number(raw)
144
- return Number.isInteger(value) && value > 0 ? value : Number.NaN
145
- }
146
-
147
- function withRoot(command: Command): Command {
148
- return command
149
- .helpOption('-h, --help', 'Show this help message')
150
- .option('--root <path>', 'Project root, defaulting to the main worktree')
151
- .option('--json', 'Emit a machine-readable record on stdout')
152
- }
153
-
154
- export function register(program: Command): void {
155
- const canvas = program
156
- .command('canvas')
157
- .helpOption('-h, --help', 'Show this help message')
158
- .description(
159
- 'Local canvas of pages and HTML frames (serve, list, page, frame, selection, edit, editing, capture, fit, arrange)',
160
- )
161
- .addHelpText(
162
- 'after',
163
- [
164
- '',
165
- 'A page is a folder under .canon/canvas/, a frame is one HTML file in',
166
- 'it, and layout.json beside them holds each frame box. Edit a frame by',
167
- 'editing its file, and an open canvas reloads that frame.',
168
- '',
169
- 'Reasons, on stderr or in the JSON record:',
170
- ' no-root no .canon/canvas/ folder under the root',
171
- ' no-page, no-frame the target is not on disk',
172
- ' invalid-name a name or target breaks the page or frame form',
173
- ' invalid-size a width or height is not a whole number of pixels',
174
- ' invalid-position an x or y is not a whole number',
175
- ' exists the page or frame is already there',
176
- ' malformed-layout layout.json does not read as a frame map',
177
- ' busy another write holds the page',
178
- ' invalid-address an element address is not a path of positions',
179
- ' address-mismatch the file builds elements the address does not',
180
- ' stale-address the file changed since the address was served',
181
- ' invalid-edit an edit key or value is not one the writer takes',
182
- ' not-text-only text targets an element holding other elements',
183
- ' missing-client-deps preact or @preact/signals does not resolve, so run bun install',
184
- ' no-server no canvas server answers at the address',
185
- ' capture-failed a frame did not render, with the font or browser reason',
186
- '',
187
- 'Examples:',
188
- ' canon canvas serve',
189
- ' canon canvas page add drafts',
190
- ' canon canvas frame add drafts hero --width 1440 --height 900',
191
- ' canon canvas frame move drafts hero --x 200 --y 120',
192
- ' canon canvas selection --json',
193
- " canon canvas edit drafts/hero --element 4 --set 'color=var(--color-accent)'",
194
- ' canon canvas editing drafts/hero',
195
- ' canon canvas capture drafts/hero',
196
- ' canon canvas fit drafts/hero --json',
197
- ' canon canvas arrange drafts --check',
198
- ' canon canvas list --json',
199
- '',
200
- ].join('\n'),
201
- )
202
-
203
- withRoot(
204
- canvas
205
- .command('serve')
206
- .description('Serve the canvas on localhost and print the link')
207
- .option('--port <number>', `Port to try first, default ${CANVAS_PORT}`),
208
- )
209
- .addHelpText(
210
- 'after',
211
- [
212
- '',
213
- 'Exit codes:',
214
- ' 0 the server stopped after running',
215
- ' 1 refused, with the reason on stderr or in the JSON record',
216
- '',
217
- 'The server binds 127.0.0.1 and nothing else. It runs until',
218
- 'interrupted, so a session wanting the link starts it in the',
219
- 'background and reads the record off stdout.',
220
- '',
221
- ].join('\n'),
222
- )
223
- .action(async (opts: RootOptions & { port?: string }) => {
224
- process.exitCode = await runServe(opts)
225
- })
226
-
227
- withRoot(
228
- canvas.command('list').description('List pages and the frames on each'),
229
- ).action(async (opts: RootOptions) => {
230
- const resolved = await resolveRoot(opts)
231
- if (!resolved.ok) {
232
- process.exitCode = refuse(resolved, opts.json ?? false)
233
- return
234
- }
235
- const { root } = resolved
236
- const pages = listPages(root)
237
- if (opts.json) {
238
- writeJson({ ok: true, root, content: canvasDir(root), pages })
239
- }
240
- intro(BANNER)
241
- if (pages.length === 0) {
242
- logStep('No pages')
243
- logInfo('canon canvas page add <name> adds one')
244
- }
245
- for (const page of pages) {
246
- logStep(`${page.name} (${plural(page.frames.length, 'frame')})`)
247
- for (const frame of page.frames) {
248
- logInfo(`${frame.name} ${frame.width} × ${frame.height}`)
249
- }
250
- if (page.layoutIssue === 'malformed') {
251
- logWarn('layout.json does not parse, so frames sit in a default row')
252
- }
253
- }
254
- outro()
255
- })
256
-
257
- const page = canvas
258
- .command('page')
259
- .helpOption('-h, --help', 'Show this help message')
260
- .description('Add or rename a page')
261
-
262
- withRoot(
263
- page
264
- .command('add')
265
- .description('Add a page')
266
- .argument('<name>', 'Page name, one path segment'),
267
- ).action(async (name: string, opts: RootOptions) => {
268
- const resolved = await resolveRoot(opts)
269
- const outcome = resolved.ok ? addPage(resolved.root, name) : resolved
270
- if (!outcome.ok) {
271
- process.exitCode = refuse(outcome, opts.json ?? false)
272
- return
273
- }
274
- if (opts.json) writeJson(outcome)
275
- intro(BANNER)
276
- logAdd(`page ${outcome.page}`)
277
- outro()
278
- })
279
-
280
- withRoot(
281
- page
282
- .command('rename')
283
- .description('Rename a page, keeping its frames and layout')
284
- .argument('<from>', 'Current page name')
285
- .argument('<to>', 'New page name'),
286
- ).action(async (from: string, to: string, opts: RootOptions) => {
287
- const resolved = await resolveRoot(opts)
288
- const outcome = resolved.ok ? renamePage(resolved.root, from, to) : resolved
289
- if (!outcome.ok) {
290
- process.exitCode = refuse(outcome, opts.json ?? false)
291
- return
292
- }
293
- if (opts.json) writeJson({ ...outcome, from })
294
- intro(BANNER)
295
- logAdd(`page ${from} → ${outcome.page}`)
296
- outro()
297
- })
298
-
299
- const frame = canvas
300
- .command('frame')
301
- .helpOption('-h, --help', 'Show this help message')
302
- .description('Add a frame to a page, or move one')
303
-
304
- withRoot(
305
- frame
306
- .command('add')
307
- .description('Add a frame as a new HTML file, placed after the others')
308
- .argument('<page>', 'Page to add the frame to')
309
- .argument('<name>', 'Frame name, which becomes <name>.html')
310
- .option('--width <px>', `Frame width, default ${DEFAULT_FRAME.width}`)
311
- .option('--height <px>', `Frame height, default ${DEFAULT_FRAME.height}`),
312
- ).action(
313
- async (
314
- pageName: string,
315
- name: string,
316
- opts: RootOptions & { width?: string; height?: string },
317
- ) => {
318
- const resolved = await resolveRoot(opts)
319
- if (!resolved.ok) {
320
- process.exitCode = refuse(resolved, opts.json ?? false)
321
- return
322
- }
323
- const { root } = resolved
324
- const outcome = addFrame(root, pageName, name, {
325
- width: parseSize(opts.width, DEFAULT_FRAME.width),
326
- height: parseSize(opts.height, DEFAULT_FRAME.height),
327
- })
328
- if (!outcome.ok) {
329
- process.exitCode = refuse(outcome, opts.json ?? false)
330
- return
331
- }
332
- if (opts.json) {
333
- writeJson({
334
- ...outcome,
335
- path: resolve(canvasDir(root), outcome.page, outcome.file),
336
- })
337
- }
338
- intro(BANNER)
339
- logAdd(`${outcome.page}/${outcome.file}`)
340
- logInfo(
341
- `${outcome.box.width} × ${outcome.box.height} at ${outcome.box.x}, ${outcome.box.y}`,
342
- )
343
- outro()
344
- },
345
- )
346
-
347
- withRoot(
348
- frame
349
- .command('move')
350
- .description('Move a frame, keeping its size')
351
- .argument('<page>', 'Page the frame is on')
352
- .argument('<name>', 'Frame name')
353
- .option('--x <px>', 'New x, defaulting to where the frame is')
354
- .option('--y <px>', 'New y, defaulting to where the frame is'),
355
- ).action(
356
- async (
357
- pageName: string,
358
- name: string,
359
- opts: RootOptions & { x?: string; y?: string },
360
- ) => {
361
- const resolved = await resolveRoot(opts)
362
- if (!resolved.ok) {
363
- process.exitCode = refuse(resolved, opts.json ?? false)
364
- return
365
- }
366
- const { root } = resolved
367
- const current = readPage(root, pageName)?.frames.find(
368
- (candidate) => candidate.name === name,
369
- )
370
- const outcome = moveFrame(root, pageName, name, {
371
- x: parsePosition(opts.x, current?.x ?? 0),
372
- y: parsePosition(opts.y, current?.y ?? 0),
373
- })
374
- if (!outcome.ok) {
375
- process.exitCode = refuse(outcome, opts.json ?? false)
376
- return
377
- }
378
- if (opts.json) writeJson(outcome)
379
- intro(BANNER)
380
- logAdd(`${outcome.page}/${outcome.file}`)
381
- logInfo(`at ${outcome.box.x}, ${outcome.box.y}`)
382
- outro()
383
- },
384
- )
385
-
386
- withRoot(
387
- frame
388
- .command('resize')
389
- .description('Resize a frame, and move it when given a position')
390
- .argument('<page>', 'Page the frame is on')
391
- .argument('<name>', 'Frame name')
392
- .option('--width <px>', 'New width, defaulting to the frame width')
393
- .option('--height <px>', 'New height, defaulting to the frame height')
394
- .option('--x <px>', 'New x, defaulting to where the frame is')
395
- .option('--y <px>', 'New y, defaulting to where the frame is'),
396
- ).action(
397
- async (
398
- pageName: string,
399
- name: string,
400
- opts: RootOptions & {
401
- width?: string
402
- height?: string
403
- x?: string
404
- y?: string
405
- },
406
- ) => {
407
- const resolved = await resolveRoot(opts)
408
- if (!resolved.ok) {
409
- process.exitCode = refuse(resolved, opts.json ?? false)
410
- return
411
- }
412
- const { root } = resolved
413
- const current = readPage(root, pageName)?.frames.find(
414
- (candidate) => candidate.name === name,
415
- )
416
- const outcome = resizeFrame(root, pageName, name, {
417
- x: parsePosition(opts.x, current?.x ?? 0),
418
- y: parsePosition(opts.y, current?.y ?? 0),
419
- width: parseSize(opts.width, current?.width ?? DEFAULT_FRAME.width),
420
- height: parseSize(opts.height, current?.height ?? DEFAULT_FRAME.height),
421
- })
422
- if (!outcome.ok) {
423
- process.exitCode = refuse(outcome, opts.json ?? false)
424
- return
425
- }
426
- if (opts.json) writeJson(outcome)
427
- intro(BANNER)
428
- logAdd(`${outcome.page}/${outcome.file}`)
429
- logInfo(
430
- `${outcome.box.width} × ${outcome.box.height} at ${outcome.box.x}, ${outcome.box.y}`,
431
- )
432
- outro()
433
- },
434
- )
435
-
436
- withRoot(
437
- canvas
438
- .command('selection')
439
- .description(
440
- 'Report the frame, or the element in it, the operator selected',
441
- ),
442
- )
443
- .addHelpText(
444
- 'after',
445
- [
446
- '',
447
- 'Reports none when nothing is selected or when the selected frame has',
448
- 'since been removed. Read this when the operator says "this one".',
449
- '',
450
- 'An element is named by its index in document order, with the tag,',
451
- 'classes, and text it had when picked. stale is true once the frame',
452
- 'file changed since, since the index may then name another element.',
453
- '',
454
- ].join('\n'),
455
- )
456
- .action(async (opts: RootOptions) => {
457
- const resolved = await resolveRoot(opts)
458
- if (!resolved.ok) {
459
- process.exitCode = refuse(resolved, opts.json ?? false)
460
- return
461
- }
462
- const { root } = resolved
463
- const selected = readSelection(root)
464
- if (opts.json) {
465
- writeJson({
466
- ok: true,
467
- selection: selected
468
- ? {
469
- ...selected,
470
- path: resolve(canvasDir(root), selected.page, selected.file),
471
- }
472
- : null,
473
- })
474
- }
475
- intro(BANNER)
476
- if (!selected) {
477
- logStep('Nothing selected')
478
- } else {
479
- logStep(`${selected.page}/${selected.frame}`)
480
- const { box, element } = selected
481
- logInfo(`${box.width} × ${box.height} at ${box.x}, ${box.y}`)
482
- if (element) {
483
- const classes = element.classes.map((name) => `.${name}`).join('')
484
- logInfo(
485
- `<${element.tag}${classes}> at index ${element.index}: ${element.text}`,
486
- )
487
- if (element.stale) {
488
- logWarn('The frame changed since, so the index may have moved')
489
- }
490
- }
491
- }
492
- outro()
493
- })
494
-
495
- withRoot(
496
- canvas
497
- .command('edit')
498
- .description(
499
- 'Set one property of an element in a frame, the writer the inspector uses',
500
- )
501
- .argument('<target>', '<page>/<frame>')
502
- .requiredOption(
503
- '--element <index>',
504
- 'The element by its index in document order, as canvas selection reports it',
505
- )
506
- .requiredOption(
507
- '--set <property=value>',
508
- 'The property and its value, where an empty value drops a style property',
509
- ),
510
- )
511
- .addHelpText(
512
- 'after',
513
- [
514
- '',
515
- `Properties: text, ${STYLE_PROPERTIES.join(', ')}.`,
516
- 'A style property is written into the element inline style and',
517
- 'leaves its other properties as they were. text replaces the text of',
518
- 'an element holding text alone, and refuses one holding elements.',
519
- 'Every byte outside the element stays as it was.',
520
- '',
521
- ].join('\n'),
522
- )
523
- .action(
524
- async (
525
- target: string,
526
- opts: RootOptions & { element: string; set: string },
527
- ) => {
528
- const emitJson = opts.json ?? false
529
- const resolved = await resolveRoot(opts)
530
- if (!resolved.ok) {
531
- process.exitCode = refuse(resolved, emitJson)
532
- return
533
- }
534
- const { root } = resolved
535
- const [pageName, frameName, ...rest] = target.split('/')
536
- if (!pageName || !frameName || rest.length > 0) {
537
- process.exitCode = refuse(
538
- {
539
- ok: false,
540
- reason: 'invalid-name',
541
- detail: `${target} is not <page>/<frame>`,
542
- },
543
- emitJson,
544
- )
545
- return
546
- }
547
- const split = opts.set.indexOf('=')
548
- if (split <= 0) {
549
- process.exitCode = refuse(
550
- {
551
- ok: false,
552
- reason: 'invalid-edit',
553
- detail: `${opts.set} is not <property>=<value>`,
554
- },
555
- emitJson,
556
- )
557
- return
558
- }
559
- const property = opts.set.slice(0, split).trim()
560
- const value = opts.set.slice(split + 1)
561
- const index =
562
- opts.element.trim() === '' ? Number.NaN : Number(opts.element)
563
- const outcome = editFrameAtIndex(root, pageName, frameName, index, {
564
- property,
565
- value,
566
- })
567
- if (!outcome.ok) {
568
- process.exitCode = refuse(outcome, emitJson)
569
- return
570
- }
571
- const path = resolve(canvasDir(root), outcome.page, outcome.file)
572
- if (emitJson) writeJson({ ...outcome, property, value, path })
573
- intro(BANNER)
574
- logAdd(`${outcome.page}/${outcome.file}`)
575
- logInfo(
576
- `element ${index}: ${property} ${value === '' ? 'dropped' : `= ${value}`}`,
577
- )
578
- outro()
579
- },
580
- )
581
-
582
- withRoot(
583
- canvas
584
- .command('editing')
585
- .description('Mark a frame as being edited, clear it, or list the marks')
586
- .argument('[target]', '<page>/<frame>, or none to list the marks')
587
- .option('--done', 'Clear the mark once the edits are written')
588
- .option('--by <name>', 'The label the mark shows, over the session name'),
589
- )
590
- .addHelpText(
591
- 'after',
592
- [
593
- '',
594
- 'Mark a frame before writing it and clear it with --done after, so an',
595
- `open canvas shows who is editing it. A mark lapses after ${EDITING_TTL_MS / 60_000} minutes`,
596
- 'unless marked again, so re-mark during a long edit. A mark gates no',
597
- 'write, and --done on a frame holding no mark succeeds.',
598
- '',
599
- ].join('\n'),
600
- )
601
- .action(
602
- async (
603
- target: string | undefined,
604
- opts: RootOptions & { done?: boolean; by?: string },
605
- ) => {
606
- const emitJson = opts.json ?? false
607
- const resolved = await resolveRoot(opts)
608
- if (!resolved.ok) {
609
- process.exitCode = refuse(resolved, emitJson)
610
- return
611
- }
612
- const { root } = resolved
613
- const now = new Date()
614
-
615
- if (target !== undefined) {
616
- const [pageName, frameName, ...rest] = target.split('/')
617
- if (!pageName || !frameName || rest.length > 0) {
618
- process.exitCode = refuse(
619
- {
620
- ok: false,
621
- reason: 'invalid-name',
622
- detail: `${target} is not <page>/<frame>`,
623
- },
624
- emitJson,
625
- )
626
- return
627
- }
628
- const outcome = opts.done
629
- ? clearEditing(root, pageName, frameName)
630
- : markEditing(
631
- root,
632
- pageName,
633
- frameName,
634
- opts.by ?? (await sessionLabel()),
635
- now,
636
- )
637
- if (!outcome.ok) {
638
- process.exitCode = refuse(outcome, emitJson)
639
- return
640
- }
641
- } else if (opts.done) {
642
- process.exitCode = refuse(
643
- {
644
- ok: false,
645
- reason: 'invalid-name',
646
- detail: '--done needs the <page>/<frame> to clear',
647
- },
648
- emitJson,
649
- )
650
- return
651
- }
652
-
653
- const marks = readEditing(root, now)
654
- if (emitJson) writeJson({ ok: true, editing: marks })
655
- intro(BANNER)
656
- if (target !== undefined) {
657
- logStep(`${target} ${opts.done ? 'cleared' : 'marked'}`)
658
- }
659
- if (marks.length === 0) logInfo('No frame is marked as being edited')
660
- for (const mark of marks) {
661
- logInfo(`${mark.page}/${mark.frame} ${mark.by} until ${mark.until}`)
662
- }
663
- outro()
664
- },
665
- )
666
-
667
- withRoot(
668
- canvas
669
- .command('capture')
670
- .description('Capture a frame, or every frame of a page, as a PNG')
671
- .argument('<target>', '<page>/<frame> for one frame, <page> for all')
672
- .option(
673
- '-o, --out <path>',
674
- 'The PNG for a frame, or the folder for a page; defaults to session scratch',
675
- )
676
- .option(
677
- '--composite',
678
- 'Capture a page as one PNG with every frame at its layout position; --out is then the PNG',
679
- ),
680
- )
681
- .addHelpText(
682
- 'after',
683
- [
684
- '',
685
- 'Serves the canvas for the call and captures through that address, so',
686
- 'the injected tokens are in the PNG and each frame renders at its own',
687
- 'width. The raw file on disk is never captured.',
688
- '',
689
- 'With --composite, each frame is clipped to its box as on the board and',
690
- 'shows in its default theme, with no captions or selection outline.',
691
- '',
692
- 'Exit codes:',
693
- ' 0 every frame rendered',
694
- ' 1 refused or a frame failed, with the reason on stderr or in the JSON record',
695
- '',
696
- ].join('\n'),
697
- )
698
- .action(async (target: string, opts: CaptureOptions) => {
699
- const resolved = await resolveRoot(opts)
700
- if (!resolved.ok) {
701
- process.exitCode = refuse(resolved, opts.json ?? false)
702
- return
703
- }
704
- if (opts.composite) {
705
- process.exitCode = await runComposite(resolved.root, target, opts)
706
- return
707
- }
708
- let outcome: Awaited<ReturnType<typeof captureCanvas>>
709
- try {
710
- outcome = await captureCanvas(
711
- resolved.root,
712
- target,
713
- opts.out ? resolve(process.cwd(), opts.out) : undefined,
714
- )
715
- } catch (error) {
716
- const detail = error instanceof Error ? error.message : String(error)
717
- process.exitCode = refuse(
718
- { ok: false, reason: 'capture-failed', detail },
719
- opts.json ?? false,
720
- )
721
- return
722
- }
723
- if (!outcome.ok) {
724
- process.exitCode = refuse(outcome, opts.json ?? false)
725
- return
726
- }
727
-
728
- const rows = outcome.captures.map(({ target: item, result }) =>
729
- result.status === 'rendered'
730
- ? {
731
- page: item.page,
732
- frame: item.frame,
733
- status: result.status,
734
- path: result.pngPath,
735
- width: result.width,
736
- height: result.height,
737
- }
738
- : {
739
- page: item.page,
740
- frame: item.frame,
741
- status: result.status,
742
- reason: result.reason,
743
- },
744
- )
745
- const failed = rows.some((row) => row.status === 'failed')
746
- if (opts.json) writeJson({ ok: !failed, captures: rows })
747
- intro(BANNER)
748
- for (const row of rows) {
749
- if (row.status === 'rendered') {
750
- logAdd(`${row.page}/${row.frame}`)
751
- logInfo(`${displayPath(row.path)} ${row.width}x${row.height}`)
752
- } else {
753
- logError(`${row.page}/${row.frame}: ${row.reason}`)
754
- }
755
- }
756
- outro()
757
- if (failed) process.exitCode = 1
758
- })
759
-
760
- withRoot(
761
- canvas
762
- .command('fit')
763
- .description("Set each frame's height to its content")
764
- .argument('<target>', '<page>/<frame> for one frame, <page> for all')
765
- .option('--check', 'Report what is owed and write nothing'),
766
- )
767
- .addHelpText(
768
- 'after',
769
- [
770
- '',
771
- 'Measures each frame in the capture browser at its box width, through',
772
- 'the served address, so the faces the canvas serves are the ones read.',
773
- 'The height is the content height rounded up, with no margin. A',
774
- 'content wider than the box is reported and the box is never widened.',
775
- 'A frame whose body text names a family the canvas does not serve is',
776
- 'reported as font, since its height may not hold in another browser.',
777
- '',
778
- 'Run canvas arrange after a fit that grew a frame, since rows below',
779
- 'do not move on their own.',
780
- '',
781
- 'Exit codes:',
782
- ' 0 written, or nothing owed under --check',
783
- ' 1 refused or the browser failed',
784
- ' 2 --check found a height off, a wide frame, or a font',
785
- '',
786
- ].join('\n'),
787
- )
788
- .action(async (target: string, opts: FitOptions) => {
789
- process.exitCode = await runFit(target, opts)
790
- })
791
-
792
- withRoot(
793
- canvas
794
- .command('arrange')
795
- .description('Re-lay a page in rows on the frame gap')
796
- .argument('<page>', 'Page to arrange')
797
- .option('--check', 'Report what is owed and write nothing'),
798
- )
799
- .addHelpText(
800
- 'after',
801
- [
802
- '',
803
- 'Keeps the rows the frames already sit in, orders each left to right on',
804
- 'the frame gap, stacks the rows on their tallest frame, and drops',
805
- 'layout entries whose frame file is gone.',
806
- '',
807
- 'Exit codes:',
808
- ' 0 written, or nothing owed under --check',
809
- ' 1 refused',
810
- ' 2 --check found frames to move or entries to drop',
811
- '',
812
- ].join('\n'),
813
- )
814
- .action(async (pageName: string, opts: FitOptions) => {
815
- process.exitCode = await runArrange(pageName, opts)
816
- })
817
- }
818
-
819
- interface FitOptions extends RootOptions {
820
- readonly check?: boolean
821
- }
822
-
823
- async function runFit(target: string, opts: FitOptions): Promise<number> {
824
- const emitJson = opts.json ?? false
825
- const resolved = await resolveRoot(opts)
826
- if (!resolved.ok) return refuse(resolved, emitJson)
827
-
828
- const check = opts.check ?? false
829
- let outcome: Awaited<ReturnType<typeof fitCanvas>>
830
- try {
831
- outcome = await fitCanvas(resolved.root, target, !check)
832
- } catch (error) {
833
- const detail = error instanceof Error ? error.message : String(error)
834
- return refuse({ ok: false, reason: 'capture-failed', detail }, emitJson)
835
- }
836
- if (!outcome.ok) return refuse(outcome, emitJson)
837
-
838
- const frames = outcome.rows.map((row) => ({ ...row, owed: isOwed(row) }))
839
- const owed = frames.some((row) => row.owed)
840
- if (emitJson) writeJson({ ok: true, written: outcome.written, frames })
841
- intro(BANNER)
842
- for (const row of frames) {
843
- const name = `${row.page}/${row.frame}`
844
- if (row.fitted === row.height) logInfo(`${name} fits at ${row.height}`)
845
- else if (outcome.written) logAdd(`${name} ${row.height} to ${row.fitted}`)
846
- else logStep(`${name} owes ${row.height} to ${row.fitted}`)
847
- if (row.wide) logWarn(`${name} is wider than its box, so widen the box`)
848
- if (row.viewport) {
849
- logWarn(
850
- `${name} sets a height in viewport units, so its height was not written and a fixed height fits it`,
851
- )
852
- }
853
- if (row.font) {
854
- logWarn(
855
- `${name} sets ${row.family}, which the canvas does not serve, so its height may not hold in another browser`,
856
- )
857
- }
858
- }
859
- outro()
860
- return check && owed ? 2 : 0
861
- }
862
-
863
- async function runArrange(pageName: string, opts: FitOptions): Promise<number> {
864
- const emitJson = opts.json ?? false
865
- const resolved = await resolveRoot(opts)
866
- if (!resolved.ok) return refuse(resolved, emitJson)
867
- const { root } = resolved
868
-
869
- const page = readPage(root, pageName)
870
- if (!page) {
871
- return refuse(
872
- {
873
- ok: false,
874
- reason: 'no-page',
875
- detail: `page ${pageName} does not exist`,
876
- },
877
- emitJson,
878
- )
879
- }
880
- if (page.layoutIssue) {
881
- return refuse(
882
- {
883
- ok: false,
884
- reason: 'malformed-layout',
885
- detail: `${pageName}/layout.json does not parse`,
886
- },
887
- emitJson,
888
- )
889
- }
890
-
891
- const check = opts.check ?? false
892
- const pruned = pruneLayout(root, pageName, !check)
893
- if (!pruned.ok) return refuse(pruned, emitJson)
894
-
895
- const moves = planArrange(page.frames)
896
- if (!check) {
897
- for (const move of moves) {
898
- const outcome = resizeFrame(root, pageName, move.name, move.to)
899
- if (!outcome.ok) return refuse(outcome, emitJson)
900
- }
901
- }
902
-
903
- if (emitJson) {
904
- writeJson({
905
- ok: true,
906
- page: pageName,
907
- written: !check,
908
- moved: moves.map((move) => ({
909
- frame: move.name,
910
- from: { x: move.from.x, y: move.from.y },
911
- to: { x: move.to.x, y: move.to.y },
912
- })),
913
- dropped: pruned.dropped,
914
- })
915
- }
916
- intro(BANNER)
917
- for (const move of moves) {
918
- const text = `${pageName}/${move.name} ${move.from.x}, ${move.from.y} to ${move.to.x}, ${move.to.y}`
919
- if (check) logStep(`${text} is owed`)
920
- else logAdd(text)
921
- }
922
- for (const name of pruned.dropped) {
923
- logWarn(
924
- `${pageName}/${name} has no frame file, so its entry is ${check ? 'owed a drop' : 'dropped'}`,
925
- )
926
- }
927
- if (moves.length === 0 && pruned.dropped.length === 0) {
928
- logInfo(`${pageName} is already laid out`)
929
- }
930
- outro()
931
- return check && (moves.length > 0 || pruned.dropped.length > 0) ? 2 : 0
932
- }
933
-
934
- interface CaptureOptions extends RootOptions {
935
- readonly out?: string
936
- readonly composite?: boolean
937
- }
938
-
939
- async function runComposite(
940
- root: string,
941
- target: string,
942
- opts: CaptureOptions,
943
- ): Promise<number> {
944
- const emitJson = opts.json ?? false
945
- let outcome: Awaited<ReturnType<typeof captureComposite>>
946
- try {
947
- outcome = await captureComposite(
948
- root,
949
- target,
950
- opts.out ? resolve(process.cwd(), opts.out) : undefined,
951
- )
952
- } catch (error) {
953
- const detail = error instanceof Error ? error.message : String(error)
954
- return refuse({ ok: false, reason: 'capture-failed', detail }, emitJson)
955
- }
956
- if (!outcome.ok) return refuse(outcome, emitJson)
957
-
958
- const { target: item, result } = outcome.composite
959
- if (result.status === 'failed') {
960
- if (emitJson) {
961
- writeJson({
962
- ok: false,
963
- composite: {
964
- page: item.page,
965
- status: result.status,
966
- reason: result.reason,
967
- },
968
- })
969
- }
970
- intro(BANNER)
971
- logError(`${item.page}: ${result.reason}`)
972
- outro()
973
- return 1
974
- }
975
-
976
- if (emitJson) {
977
- writeJson({
978
- ok: true,
979
- composite: {
980
- page: item.page,
981
- status: result.status,
982
- path: result.pngPath,
983
- width: result.width,
984
- height: result.height,
985
- },
986
- })
987
- }
988
- intro(BANNER)
989
- logAdd(
990
- `${item.page} composite of ${plural(item.composite.placements.length, 'frame')}`,
991
- )
992
- logInfo(`${displayPath(result.pngPath)} ${result.width}x${result.height}`)
993
- outro()
994
- return 0
995
- }
996
-
997
- async function unlocated(): Promise<Located> {
998
- return {
999
- repository: null,
1000
- worktree: null,
1001
- branch: null,
1002
- unresolved: 'git-unavailable',
1003
- }
1004
- }
1005
-
1006
- /**
1007
- * The caller's roster name, or a generic label when the environment names no
1008
- * session or the roster holds no row for it, as in a sandbox or a CI run.
1009
- */
1010
- async function sessionLabel(): Promise<string> {
1011
- const identity = callerIdentity()
1012
- if (identity.sessionId === null && identity.pid === null) return 'a session'
1013
- /* The label needs the name alone, so skip the git probe each row's branch costs. */
1014
- const report = await resolveSessions({ locate: unlocated })
1015
- if (report.kind === 'absent') return 'a session'
1016
- const self = selfOf(report.sessions, identity)
1017
- return self.kind === 'self' ? self.session.name : 'a session'
1018
- }
1019
-
1020
- /** NaN for a value that is not a number, which the content module refuses. */
1021
- function parsePosition(raw: string | undefined, fallback: number): number {
1022
- if (raw === undefined) return fallback
1023
- return raw.trim() === '' ? Number.NaN : Number(raw)
1024
- }
1025
-
1026
- async function runServe(
1027
- opts: RootOptions & { port?: string },
1028
- ): Promise<number> {
1029
- const emitJson = opts.json ?? false
1030
- const port = opts.port === undefined ? CANVAS_PORT : parsePort(opts.port)
1031
- if (port === undefined) {
1032
- return refuseServe('no-port', `${opts.port} is not a port`, emitJson)
1033
- }
1034
-
1035
- const resolved = await resolveRoot(opts)
1036
- if (!resolved.ok) {
1037
- return refuseServe(resolved.reason, resolved.detail, emitJson)
1038
- }
1039
- const { root } = resolved
1040
- const missing = missingClientDeps(PROJECT_ROOT)
1041
- if (missing) return refuseServe(missing.reason, missing.detail, emitJson)
1042
- /*
1043
- * Imported here rather than at the top, so a command module the test runner
1044
- * loads never asks it for an HTML module it cannot parse.
1045
- */
1046
- const { default: shell } = await import('@/canvas/index.html')
1047
- const outcome = startCanvas(root, { port, shell })
1048
- if (!outcome.ok) {
1049
- return refuseServe(outcome.reason, outcome.detail, emitJson)
1050
- }
1051
-
1052
- const tokens = resolveFrameTokens(root)
1053
- const { css: _css, ...tokenSource } = tokens
1054
- if (emitJson) {
1055
- writeJson({
1056
- ok: true,
1057
- root: outcome.root,
1058
- content: outcome.content,
1059
- host: outcome.host,
1060
- port: outcome.port,
1061
- url: outcome.url,
1062
- tokens: tokenSource,
1063
- })
1064
- }
1065
- intro(BANNER)
1066
- logStep('Serving')
1067
- logInfo(displayPath(outcome.content))
1068
- logStep('Open')
1069
- logInfo(outcome.url)
1070
- logStep('Tokens')
1071
- if (tokens.source === 'none') logWarn(tokens.notice)
1072
- else if (tokens.source === 'installed') {
1073
- for (const file of tokens.files) logInfo(file)
1074
- for (const face of tokens.dropped ?? []) {
1075
- logWarn(
1076
- `${face.sheet}: dropped the face at ${face.file} (${face.reason})`,
1077
- )
1078
- }
1079
- } else logInfo('this toolkit’s own token module')
1080
- logStep('Stop')
1081
- logInfo('Ctrl-C')
1082
-
1083
- await waitForInterrupt(outcome.stop)
1084
- return 0
1085
- }
1086
-
1087
- function refuseServe(
1088
- reason: string,
1089
- detail: string,
1090
- emitJson: boolean,
1091
- ): number {
1092
- if (emitJson) writeJson({ ok: false, reason, detail })
1093
- intro(BANNER)
1094
- logError(detail)
1095
- outro()
1096
- return 1
1097
- }