@erclx/canon 5.52.0 → 5.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +11 -13
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +5 -8
- package/claude/skills/context-fold/REQUIREMENT.md +0 -1
- 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/design-taste/references/kinds.md +0 -1
- package/claude/skills/draw-figure/REQUIREMENT.md +3 -4
- package/claude/skills/draw-figure/SKILL.md +5 -6
- package/claude/skills/draw-figure/references/figures.md +0 -1
- package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
- package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -5
- 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 +30 -0
- package/claude/skills/teach-craft/SKILL.md +70 -0
- package/claude/skills/teach-craft/references/adopted.md +113 -0
- package/claude/skills/teach-craft/references/lesson-craft.md +60 -0
- package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +41 -6
- package/docs/agents/audits.md +0 -1
- package/docs/agents/install-and-sync.md +0 -8
- package/docs/target/migrations.md +16 -2
- package/docs/target/projects.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +10 -22
- package/docs/workflow/skill-map.md +11 -14
- 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 -8
- package/scripts/core/regen-web-previews.ts +2 -67
- package/src/audits/catalog.ts +0 -1
- package/src/browser/engine.ts +3 -6
- package/src/capture/render.ts +1 -1
- package/src/claude/cases/authoring.ts +0 -12
- package/src/claude/cases/misc.ts +0 -22
- package/src/claude/cases/workflow.ts +2 -38
- package/src/claude/skills-audit.ts +1 -1
- package/src/cli/help.ts +0 -10
- package/src/cli.ts +0 -8
- package/src/commands/design.ts +16 -315
- package/src/commands/records.ts +0 -1
- package/src/commands/serve.ts +2 -2
- package/src/commands/sync.ts +4 -5
- package/src/design/components.ts +8 -1514
- package/src/design/css.ts +5 -6
- package/src/design/fonts.ts +3 -5
- package/src/design/regen.ts +5 -32
- package/src/design/render.ts +1 -1
- package/src/design/tokens.ts +3 -2
- package/src/driver/drive.ts +3 -4
- package/src/driver/steps.ts +2 -5
- package/src/gate/stages.ts +4 -10
- package/src/gov/audit.ts +1 -6
- package/src/inventory/walk.ts +1 -1
- package/src/markdown/scope.ts +6 -35
- package/src/records/validate.ts +4 -176
- package/src/sandbox/equivalence.ts +0 -5
- 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/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
- package/claude/skills/draft-and-pick/SKILL.md +0 -11
- package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
- package/claude/skills/draft-doc/SKILL.md +0 -11
- package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
- package/claude/skills/draft-figure/SKILL.md +0 -11
- package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
- package/claude/skills/draft-identity/SKILL.md +0 -11
- package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
- package/claude/skills/draft-ready/SKILL.md +0 -11
- package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
- package/claude/skills/draft-screencast/SKILL.md +0 -11
- package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
- package/claude/skills/draft-slides/SKILL.md +0 -11
- package/claude/skills/make-deck/REQUIREMENT.md +0 -36
- package/claude/skills/make-deck/SKILL.md +0 -80
- package/claude/skills/make-deck/references/deck.md +0 -183
- package/claude/skills/read-frames/REQUIREMENT.md +0 -30
- package/claude/skills/read-frames/SKILL.md +0 -55
- package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
- package/claude/skills/record-screencast/SKILL.md +0 -99
- package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
- package/claude/skills/script-screencast/SKILL.md +0 -70
- package/claude/skills/script-screencast/references/beats-template.md +0 -119
- package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
- package/claude/skills/teach-workspace/SKILL.md +0 -196
- package/claude/skills/teach-workspace/references/glossary.md +0 -90
- package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
- package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
- package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
- package/claude/skills/teach-workspace/references/promotion.md +0 -116
- package/claude/skills/teach-workspace/references/revisit.md +0 -28
- package/claude/skills/teach-workspace/references/teach.md +0 -211
- package/claude/skills/video-craft/REQUIREMENT.md +0 -35
- package/claude/skills/video-craft/SKILL.md +0 -96
- package/claude/skills/video-craft/references/adopted.md +0 -110
- 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/commands/demo.ts +0 -542
- package/src/commands/slides.ts +0 -184
- package/src/commands/teach.ts +0 -1401
- package/src/demo/beats.ts +0 -135
- package/src/demo/compile.ts +0 -350
- package/src/demo/container.ts +0 -219
- package/src/demo/cursors.ts +0 -55
- package/src/demo/drive.ts +0 -442
- package/src/demo/pointer.ts +0 -178
- package/src/demo/theme.ts +0 -112
- package/src/demo/timeline.ts +0 -64
- 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/slides/convert/chart.ts +0 -165
- package/src/slides/convert/deck.ts +0 -244
- package/src/slides/convert/export.ts +0 -567
- package/src/slides/convert/master.ts +0 -198
- package/src/slides/convert/shapes.ts +0 -804
- package/src/slides/convert/svg.ts +0 -38
- package/src/slides/convert/walk.ts +0 -671
- package/src/slides/decks.ts +0 -110
- package/src/slides/open.ts +0 -18
- package/src/slides/package/fonts.ts +0 -301
- package/src/slides/package/motion.ts +0 -316
- package/src/slides/package/test-face.ts +0 -113
- package/src/teach/browser/compile.ts +0 -44
- package/src/teach/browser/dismiss.ts +0 -76
- package/src/teach/browser/focus-line.ts +0 -19
- package/src/teach/browser/glossary-filter.ts +0 -44
- package/src/teach/browser/head.ts +0 -30
- package/src/teach/browser/quiz.ts +0 -19
- package/src/teach/browser/reload.ts +0 -9
- package/src/teach/browser/sidebar.ts +0 -233
- package/src/teach/browser/theme.ts +0 -18
- package/src/teach/components/code.tsx +0 -24
- package/src/teach/components/heading.tsx +0 -11
- package/src/teach/components/list.tsx +0 -17
- package/src/teach/components/paragraph.tsx +0 -29
- package/src/teach/components/refs.tsx +0 -28
- package/src/teach/cross-references.ts +0 -78
- package/src/teach/fonts.ts +0 -16
- package/src/teach/highlight.ts +0 -71
- package/src/teach/html/jsx-dev-runtime.ts +0 -13
- package/src/teach/html/jsx-runtime.ts +0 -113
- package/src/teach/html/raw.ts +0 -28
- package/src/teach/lesson.ts +0 -178
- package/src/teach/nav.ts +0 -1337
- package/src/teach/render.ts +0 -240
- package/src/teach/workspace.ts +0 -1164
- package/src/teach/write-if-changed.ts +0 -17
package/src/commands/canvas.ts
DELETED
|
@@ -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
|
-
}
|