@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.
Files changed (201) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +11 -13
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +5 -8
  5. package/claude/skills/context-fold/REQUIREMENT.md +0 -1
  6. package/claude/skills/design-identity/REQUIREMENT.md +2 -2
  7. package/claude/skills/design-identity/SKILL.md +6 -6
  8. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  9. package/claude/skills/design-record/SKILL.md +4 -4
  10. package/claude/skills/design-record/references/folder.md +1 -1
  11. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  12. package/claude/skills/design-taste/references/kinds.md +0 -1
  13. package/claude/skills/draw-figure/REQUIREMENT.md +3 -4
  14. package/claude/skills/draw-figure/SKILL.md +5 -6
  15. package/claude/skills/draw-figure/references/figures.md +0 -1
  16. package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
  17. package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -5
  18. package/claude/skills/pick-by-eye/SKILL.md +34 -41
  19. package/claude/skills/pick-by-eye/references/board.md +42 -0
  20. package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
  21. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  22. package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
  23. package/claude/skills/teach-craft/SKILL.md +70 -0
  24. package/claude/skills/teach-craft/references/adopted.md +113 -0
  25. package/claude/skills/teach-craft/references/lesson-craft.md +60 -0
  26. package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +41 -6
  27. package/docs/agents/audits.md +0 -1
  28. package/docs/agents/install-and-sync.md +0 -8
  29. package/docs/target/migrations.md +16 -2
  30. package/docs/target/projects.md +1 -1
  31. package/docs/workflow/parallel-features.md +1 -1
  32. package/docs/workflow/skill-map-on-request.md +10 -22
  33. package/docs/workflow/skill-map.md +11 -14
  34. package/docs/workflow/visual-design-workflow.md +1 -4
  35. package/governance/detect.toml +1 -6
  36. package/governance/renames.toml +0 -1
  37. package/package.json +2 -8
  38. package/scripts/core/regen-web-previews.ts +2 -67
  39. package/src/audits/catalog.ts +0 -1
  40. package/src/browser/engine.ts +3 -6
  41. package/src/capture/render.ts +1 -1
  42. package/src/claude/cases/authoring.ts +0 -12
  43. package/src/claude/cases/misc.ts +0 -22
  44. package/src/claude/cases/workflow.ts +2 -38
  45. package/src/claude/skills-audit.ts +1 -1
  46. package/src/cli/help.ts +0 -10
  47. package/src/cli.ts +0 -8
  48. package/src/commands/design.ts +16 -315
  49. package/src/commands/records.ts +0 -1
  50. package/src/commands/serve.ts +2 -2
  51. package/src/commands/sync.ts +4 -5
  52. package/src/design/components.ts +8 -1514
  53. package/src/design/css.ts +5 -6
  54. package/src/design/fonts.ts +3 -5
  55. package/src/design/regen.ts +5 -32
  56. package/src/design/render.ts +1 -1
  57. package/src/design/tokens.ts +3 -2
  58. package/src/driver/drive.ts +3 -4
  59. package/src/driver/steps.ts +2 -5
  60. package/src/gate/stages.ts +4 -10
  61. package/src/gov/audit.ts +1 -6
  62. package/src/inventory/walk.ts +1 -1
  63. package/src/markdown/scope.ts +6 -35
  64. package/src/records/validate.ts +4 -176
  65. package/src/sandbox/equivalence.ts +0 -5
  66. package/src/shipped/references.ts +1 -2
  67. package/src/sync/check.ts +4 -29
  68. package/src/sync/stamp.ts +6 -6
  69. package/src/sync/target.ts +1 -4
  70. package/tooling/claude/reference.md +1 -1
  71. package/tooling/claude/seeds/canon/DESIGN.md +1 -1
  72. package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
  73. package/claude/skills/canvas-board/SKILL.md +0 -143
  74. package/claude/skills/canvas-board/references/canvas.md +0 -170
  75. package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
  76. package/claude/skills/draft-and-pick/SKILL.md +0 -11
  77. package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
  78. package/claude/skills/draft-doc/SKILL.md +0 -11
  79. package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
  80. package/claude/skills/draft-figure/SKILL.md +0 -11
  81. package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
  82. package/claude/skills/draft-identity/SKILL.md +0 -11
  83. package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
  84. package/claude/skills/draft-ready/SKILL.md +0 -11
  85. package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
  86. package/claude/skills/draft-screencast/SKILL.md +0 -11
  87. package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
  88. package/claude/skills/draft-slides/SKILL.md +0 -11
  89. package/claude/skills/make-deck/REQUIREMENT.md +0 -36
  90. package/claude/skills/make-deck/SKILL.md +0 -80
  91. package/claude/skills/make-deck/references/deck.md +0 -183
  92. package/claude/skills/read-frames/REQUIREMENT.md +0 -30
  93. package/claude/skills/read-frames/SKILL.md +0 -55
  94. package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
  95. package/claude/skills/record-screencast/SKILL.md +0 -99
  96. package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
  97. package/claude/skills/script-screencast/SKILL.md +0 -70
  98. package/claude/skills/script-screencast/references/beats-template.md +0 -119
  99. package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
  100. package/claude/skills/teach-workspace/SKILL.md +0 -196
  101. package/claude/skills/teach-workspace/references/glossary.md +0 -90
  102. package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
  103. package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
  104. package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
  105. package/claude/skills/teach-workspace/references/promotion.md +0 -116
  106. package/claude/skills/teach-workspace/references/revisit.md +0 -28
  107. package/claude/skills/teach-workspace/references/teach.md +0 -211
  108. package/claude/skills/video-craft/REQUIREMENT.md +0 -35
  109. package/claude/skills/video-craft/SKILL.md +0 -96
  110. package/claude/skills/video-craft/references/adopted.md +0 -110
  111. package/governance/rules/ui/canvas.md +0 -13
  112. package/src/canvas/address.ts +0 -227
  113. package/src/canvas/arrange.ts +0 -66
  114. package/src/canvas/capture.ts +0 -391
  115. package/src/canvas/client/app.tsx +0 -188
  116. package/src/canvas/client/inspector/align-grid.tsx +0 -162
  117. package/src/canvas/client/inspector/color-field.tsx +0 -398
  118. package/src/canvas/client/inspector/color-inputs.tsx +0 -139
  119. package/src/canvas/client/inspector/color-picker.tsx +0 -668
  120. package/src/canvas/client/inspector/color-space.ts +0 -240
  121. package/src/canvas/client/inspector/field.tsx +0 -263
  122. package/src/canvas/client/inspector/fields.css +0 -763
  123. package/src/canvas/client/inspector/section.tsx +0 -27
  124. package/src/canvas/client/inspector/size-field.tsx +0 -69
  125. package/src/canvas/client/inspector/token-select.tsx +0 -109
  126. package/src/canvas/client/inspector/values.ts +0 -297
  127. package/src/canvas/client/inspector.tsx +0 -692
  128. package/src/canvas/client/keys.ts +0 -71
  129. package/src/canvas/client/layers.tsx +0 -118
  130. package/src/canvas/client/main.tsx +0 -71
  131. package/src/canvas/client/pages-panel.tsx +0 -212
  132. package/src/canvas/client/panel-handle.tsx +0 -90
  133. package/src/canvas/client/selection.tsx +0 -199
  134. package/src/canvas/client/shell.css +0 -762
  135. package/src/canvas/client/state.ts +0 -792
  136. package/src/canvas/client/surface.tsx +0 -803
  137. package/src/canvas/client/theme-panel.tsx +0 -55
  138. package/src/canvas/client/tools.tsx +0 -153
  139. package/src/canvas/content.ts +0 -781
  140. package/src/canvas/edit.ts +0 -575
  141. package/src/canvas/editing.ts +0 -203
  142. package/src/canvas/fit.ts +0 -238
  143. package/src/canvas/global.d.ts +0 -15
  144. package/src/canvas/history.ts +0 -116
  145. package/src/canvas/index.html +0 -20
  146. package/src/canvas/server.ts +0 -766
  147. package/src/canvas/tokens.ts +0 -221
  148. package/src/commands/canvas.ts +0 -1097
  149. package/src/commands/demo.ts +0 -542
  150. package/src/commands/slides.ts +0 -184
  151. package/src/commands/teach.ts +0 -1401
  152. package/src/demo/beats.ts +0 -135
  153. package/src/demo/compile.ts +0 -350
  154. package/src/demo/container.ts +0 -219
  155. package/src/demo/cursors.ts +0 -55
  156. package/src/demo/drive.ts +0 -442
  157. package/src/demo/pointer.ts +0 -178
  158. package/src/demo/theme.ts +0 -112
  159. package/src/demo/timeline.ts +0 -64
  160. package/src/design/adapter.ts +0 -59
  161. package/src/design/base.css +0 -203
  162. package/src/design/brand-favicon.ts +0 -18
  163. package/src/design/neutral.ts +0 -127
  164. package/src/design/project-fonts.ts +0 -392
  165. package/src/slides/convert/chart.ts +0 -165
  166. package/src/slides/convert/deck.ts +0 -244
  167. package/src/slides/convert/export.ts +0 -567
  168. package/src/slides/convert/master.ts +0 -198
  169. package/src/slides/convert/shapes.ts +0 -804
  170. package/src/slides/convert/svg.ts +0 -38
  171. package/src/slides/convert/walk.ts +0 -671
  172. package/src/slides/decks.ts +0 -110
  173. package/src/slides/open.ts +0 -18
  174. package/src/slides/package/fonts.ts +0 -301
  175. package/src/slides/package/motion.ts +0 -316
  176. package/src/slides/package/test-face.ts +0 -113
  177. package/src/teach/browser/compile.ts +0 -44
  178. package/src/teach/browser/dismiss.ts +0 -76
  179. package/src/teach/browser/focus-line.ts +0 -19
  180. package/src/teach/browser/glossary-filter.ts +0 -44
  181. package/src/teach/browser/head.ts +0 -30
  182. package/src/teach/browser/quiz.ts +0 -19
  183. package/src/teach/browser/reload.ts +0 -9
  184. package/src/teach/browser/sidebar.ts +0 -233
  185. package/src/teach/browser/theme.ts +0 -18
  186. package/src/teach/components/code.tsx +0 -24
  187. package/src/teach/components/heading.tsx +0 -11
  188. package/src/teach/components/list.tsx +0 -17
  189. package/src/teach/components/paragraph.tsx +0 -29
  190. package/src/teach/components/refs.tsx +0 -28
  191. package/src/teach/cross-references.ts +0 -78
  192. package/src/teach/fonts.ts +0 -16
  193. package/src/teach/highlight.ts +0 -71
  194. package/src/teach/html/jsx-dev-runtime.ts +0 -13
  195. package/src/teach/html/jsx-runtime.ts +0 -113
  196. package/src/teach/html/raw.ts +0 -28
  197. package/src/teach/lesson.ts +0 -178
  198. package/src/teach/nav.ts +0 -1337
  199. package/src/teach/render.ts +0 -240
  200. package/src/teach/workspace.ts +0 -1164
  201. package/src/teach/write-if-changed.ts +0 -17
@@ -1,227 +0,0 @@
1
- /**
2
- * The element address: a frame plus the element's index in document order.
3
- * The shell counts it over the frame's parsed DOM and the server counts it over
4
- * the file through `HTMLRewriter`, so the two have to count the same elements.
5
- * This module imports nothing, which keeps it bundling into the shell, and the
6
- * server half reaches `HTMLRewriter` as a Bun global only when called.
7
- */
8
-
9
- /** Marks the token stylesheet the server injects, which the file never holds. */
10
- export const TOKENS_ATTRIBUTE = 'data-canvas-tokens'
11
-
12
- /**
13
- * Marks a document wrapper the server writes because the file leaves it to the
14
- * browser to imply, so the shell counts only what the file states.
15
- */
16
- export const IMPLIED_ATTRIBUTE = 'data-canvas-implied'
17
-
18
- /** On that same element, the hash of the file the frame was served from. */
19
- export const HASH_ATTRIBUTE = 'data-canvas-hash'
20
-
21
- const EXCERPT_LENGTH = 80
22
-
23
- /** Elements whose content is raw text, which a browser never parses as markup. */
24
- const RAW_TEXT = new Set(['script', 'style', 'template', 'textarea', 'title'])
25
-
26
- export interface ElementAddress {
27
- readonly index: number
28
- readonly tag: string
29
- /**
30
- * Every element the shell counted. A browser implies elements the file never
31
- * states, such as a `tbody`, and each one shifts the count, so a total that
32
- * differs from the server's is what exposes it. The server marks an implied
33
- * document wrapper itself, so only the marked ones leave the count.
34
- */
35
- readonly count: number
36
- /**
37
- * The served file's hash, so the server can refuse a pick made against a
38
- * version of the frame the file has since moved past.
39
- */
40
- readonly hash?: string
41
- }
42
-
43
- export interface SourceElement {
44
- readonly tag: string
45
- readonly classes: readonly string[]
46
- readonly text: string
47
- }
48
-
49
- export type AddressCheck =
50
- | { readonly ok: true; readonly element: SourceElement }
51
- | {
52
- readonly ok: false
53
- readonly reason: 'address-mismatch'
54
- readonly detail: string
55
- }
56
-
57
- export function excerpt(text: string): string {
58
- return text.replace(/\s+/g, ' ').trim().slice(0, EXCERPT_LENGTH)
59
- }
60
-
61
- export function documentElements(doc: Document): Element[] {
62
- return [...doc.querySelectorAll('*')].filter(
63
- (element) =>
64
- !element.hasAttribute(TOKENS_ATTRIBUTE) &&
65
- !element.hasAttribute(IMPLIED_ATTRIBUTE),
66
- )
67
- }
68
-
69
- export function addressOf(
70
- doc: Document,
71
- element: Element,
72
- ): ElementAddress | undefined {
73
- const all = documentElements(doc)
74
- const index = all.indexOf(element)
75
- if (index === -1) return undefined
76
- const address = {
77
- index,
78
- tag: element.tagName.toLowerCase(),
79
- count: all.length,
80
- }
81
- const hash = doc
82
- .querySelector(`[${TOKENS_ATTRIBUTE}]`)
83
- ?.getAttribute(HASH_ATTRIBUTE)
84
- return hash ? { ...address, hash } : address
85
- }
86
-
87
- /** The element an address names, or nothing once the tag there differs. */
88
- export function elementAt(
89
- doc: Document,
90
- address: Pick<ElementAddress, 'index' | 'tag'>,
91
- ): Element | undefined {
92
- const element = documentElements(doc)[address.index]
93
- return element?.tagName.toLowerCase() === address.tag ? element : undefined
94
- }
95
-
96
- interface Collected {
97
- readonly tag: string
98
- readonly classes: readonly string[]
99
- text: string
100
- }
101
-
102
- /**
103
- * Called on each element the address counts, with its index. What it returns
104
- * runs at the element's end tag, since a second `onEndTag` on one element
105
- * replaces the first rather than adding to it.
106
- */
107
- export type ElementVisitor = (
108
- element: HTMLRewriterTypes.Element,
109
- index: number,
110
- ) => (() => void) | undefined
111
-
112
- /**
113
- * Registers the one counting rule both the reader and the edit writer follow,
114
- * so an index means the same element to each.
115
- */
116
- export function countElements(
117
- rewriter: HTMLRewriter,
118
- visit: ElementVisitor,
119
- ): HTMLRewriter {
120
- let count = 0
121
- /* A template's content is a fragment `querySelectorAll` never reaches. */
122
- let templateDepth = 0
123
-
124
- return rewriter.on('*', {
125
- element(element) {
126
- const tag = element.tagName.toLowerCase()
127
- if (templateDepth > 0) {
128
- if (tag === 'template') {
129
- templateDepth += 1
130
- element.onEndTag(() => {
131
- templateDepth -= 1
132
- })
133
- }
134
- return
135
- }
136
- const end = visit(element, count)
137
- count += 1
138
- if (!element.canHaveContent || element.selfClosing) return
139
- const isTemplate = tag === 'template'
140
- if (isTemplate) templateDepth += 1
141
- element.onEndTag(() => {
142
- if (isTemplate) templateDepth -= 1
143
- end?.()
144
- })
145
- },
146
- })
147
- }
148
-
149
- /** Whether an element's content is raw text rather than parsed markup. */
150
- export function isRawText(tag: string): boolean {
151
- return RAW_TEXT.has(tag)
152
- }
153
-
154
- /**
155
- * Every element the file states, in document order. Text is gathered into
156
- * each open element, which an element closed only by implication keeps open to
157
- * the end, so its excerpt can run past where a browser would end it.
158
- */
159
- export function sourceElements(html: string): SourceElement[] {
160
- const elements: Collected[] = []
161
- const open: number[] = []
162
- let rawDepth = 0
163
-
164
- const appendText = (text: string) => {
165
- for (const index of open) {
166
- const element = elements[index]
167
- if (element && element.text.length < EXCERPT_LENGTH * 4) {
168
- element.text += text
169
- }
170
- }
171
- }
172
-
173
- countElements(new HTMLRewriter(), (element, index) => {
174
- const tag = element.tagName.toLowerCase()
175
- /* A tag boundary separates words the way a rendered block would. */
176
- appendText(' ')
177
- elements.push({
178
- tag,
179
- classes: (element.getAttribute('class') ?? '')
180
- .split(/\s+/)
181
- .filter(Boolean),
182
- text: '',
183
- })
184
- if (!element.canHaveContent || element.selfClosing) return undefined
185
- const isRaw = isRawText(tag)
186
- open.push(index)
187
- if (isRaw) rawDepth += 1
188
- return () => {
189
- const at = open.lastIndexOf(index)
190
- if (at !== -1) open.splice(at, 1)
191
- if (isRaw) rawDepth -= 1
192
- appendText(' ')
193
- }
194
- })
195
- .onDocument({
196
- text(chunk) {
197
- if (rawDepth === 0) appendText(chunk.text)
198
- },
199
- })
200
- .transform(html)
201
-
202
- return elements.map(({ tag, classes, text }) => ({
203
- tag,
204
- classes,
205
- text: excerpt(text),
206
- }))
207
- }
208
-
209
- /**
210
- * Finds the element the shell addressed in the file, and refuses when the two
211
- * counts disagree rather than naming whatever sits at that index on disk.
212
- */
213
- export function resolveAddress(
214
- html: string,
215
- address: ElementAddress,
216
- ): AddressCheck {
217
- const elements = sourceElements(html)
218
- const element = elements[address.index]
219
- if (elements.length !== address.count || element?.tag !== address.tag) {
220
- return {
221
- ok: false,
222
- reason: 'address-mismatch',
223
- detail: `the browser counted ${address.count} elements and the file states ${elements.length}, so element ${address.index} cannot be matched`,
224
- }
225
- }
226
- return { ok: true, element }
227
- }
@@ -1,66 +0,0 @@
1
- import { type Box, FRAME_GAP, type Frame } from '@/canvas/content'
2
-
3
- export interface Placement {
4
- readonly name: string
5
- readonly from: Box
6
- readonly to: Box
7
- }
8
-
9
- function boxOf(frame: Frame): Box {
10
- return {
11
- x: frame.x,
12
- y: frame.y,
13
- width: frame.width,
14
- height: frame.height,
15
- }
16
- }
17
-
18
- /**
19
- * Boxes whose vertical spans overlap share a row, so a row the operator dragged
20
- * stays a row. Reading by overlap rather than by an equal `y` lets a frame a
21
- * few pixels off its neighbor still join it.
22
- */
23
- function rowsOf(frames: readonly Frame[]): Frame[][] {
24
- const sorted = [...frames].sort((a, b) => a.y - b.y || a.x - b.x)
25
- const rows: Frame[][] = []
26
- let bottom = Number.NEGATIVE_INFINITY
27
- for (const frame of sorted) {
28
- const row = rows.at(-1)
29
- if (row && frame.y < bottom) {
30
- row.push(frame)
31
- bottom = Math.max(bottom, frame.y + frame.height)
32
- } else {
33
- rows.push([frame])
34
- bottom = frame.y + frame.height
35
- }
36
- }
37
- return rows.map((row) =>
38
- row.sort((a, b) => a.x - b.x || a.name.localeCompare(b.name)),
39
- )
40
- }
41
-
42
- /**
43
- * Re-lays a page's frames: each row left to right on `FRAME_GAP` from the
44
- * page's leftmost edge, rows stacked on their tallest box plus the gap from
45
- * the page's top edge. Returns only the frames whose position changes.
46
- */
47
- export function planArrange(frames: readonly Frame[]): Placement[] {
48
- if (frames.length === 0) return []
49
- const left = Math.min(...frames.map((frame) => frame.x))
50
- let top = Math.min(...frames.map((frame) => frame.y))
51
-
52
- const moves: Placement[] = []
53
- for (const row of rowsOf(frames)) {
54
- let x = left
55
- for (const frame of row) {
56
- const from = boxOf(frame)
57
- const to: Box = { ...from, x, y: top }
58
- if (from.x !== to.x || from.y !== to.y) {
59
- moves.push({ name: frame.name, from, to })
60
- }
61
- x += frame.width + FRAME_GAP
62
- }
63
- top += Math.max(...row.map((frame) => frame.height)) + FRAME_GAP
64
- }
65
- return moves
66
- }
@@ -1,391 +0,0 @@
1
- import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
2
- import { dirname, join } from 'node:path'
3
- import type { CaptureResult } from '@/capture/render'
4
- import {
5
- type ContentRefused,
6
- canvasDir,
7
- type Frame,
8
- readPage,
9
- } from '@/canvas/content'
10
- import {
11
- type CanvasOptions,
12
- type CanvasOutcome,
13
- FRAMES_PREFIX,
14
- startCanvas,
15
- } from '@/canvas/server'
16
- import { recordDir } from '@/roots/record'
17
-
18
- /**
19
- * The element a frame is captured by. The document rather than its body, so a
20
- * frame that paints its background on the root is not cropped to the body's
21
- * box.
22
- */
23
- export const CAPTURE_SELECTOR = 'html'
24
-
25
- export interface CaptureTarget {
26
- readonly page: string
27
- readonly frame: string
28
- /** The served address, which is what carries the injected tokens. */
29
- readonly url: string
30
- /** The viewport width, so a media query resolves at the frame's own width. */
31
- readonly width: number
32
- readonly pngPath: string
33
- }
34
-
35
- export type TargetsOutcome =
36
- | { readonly ok: true; readonly targets: readonly CaptureTarget[] }
37
- | ContentRefused
38
-
39
- function refuse(
40
- reason: ContentRefused['reason'],
41
- detail: string,
42
- ): ContentRefused {
43
- return { ok: false, reason, detail }
44
- }
45
-
46
- function frameUrl(base: string, page: string, frame: Frame): string {
47
- return `${base}${FRAMES_PREFIX.slice(1)}${encodeURIComponent(page)}/${encodeURIComponent(frame.file)}`
48
- }
49
-
50
- /**
51
- * Turns `<page>` or `<page>/<frame>` into what to capture. Every target is a
52
- * served address, never the file on disk, so a capture can only show what the
53
- * canvas shows. The width comes from the layout and not from the window, so a
54
- * frame narrower than its own breakpoint renders its narrow layout.
55
- *
56
- * `out` is the PNG for a frame and a folder for a page, the way a URL source
57
- * reads `--out` in `canon capture`.
58
- */
59
- export function resolveCaptureTargets(
60
- root: string,
61
- spec: string,
62
- base: string,
63
- out?: string,
64
- ): TargetsOutcome {
65
- const [pageName, frameName, ...extra] = spec.split('/')
66
- if (!pageName || extra.length > 0 || frameName === '') {
67
- return refuse('invalid-name', `${spec} is not <page> or <page>/<frame>`)
68
- }
69
-
70
- const page = readPage(root, pageName)
71
- if (!page) return refuse('no-page', `page ${pageName} does not exist`)
72
-
73
- const frames =
74
- frameName === undefined
75
- ? page.frames
76
- : page.frames.filter((frame) => frame.name === frameName)
77
- if (frames.length === 0) {
78
- return frameName === undefined
79
- ? refuse('no-frame', `page ${pageName} has no frames`)
80
- : refuse('no-frame', `frame ${frameName} does not exist on ${pageName}`)
81
- }
82
-
83
- const scratch = recordDir(root, 'tmp', 'canvas-capture', pageName)
84
- return {
85
- ok: true,
86
- targets: frames.map((frame) => ({
87
- page: pageName,
88
- frame: frame.name,
89
- url: frameUrl(base, pageName, frame),
90
- width: frame.width,
91
- pngPath:
92
- frameName !== undefined && out !== undefined
93
- ? out
94
- : join(out ?? scratch, `${frame.name}.png`),
95
- })),
96
- }
97
- }
98
-
99
- export type CaptureOutcome =
100
- | {
101
- readonly ok: true
102
- readonly captures: readonly {
103
- readonly target: CaptureTarget
104
- readonly result: CaptureResult
105
- }[]
106
- }
107
- | ContentRefused
108
- | {
109
- readonly ok: false
110
- readonly reason: 'no-server' | 'capture-failed'
111
- readonly detail: string
112
- }
113
-
114
- type CaptureEngine = (typeof import('@/capture/render'))['captureSources']
115
-
116
- /** What a test stands in for, which the command never passes. */
117
- export interface CaptureDeps {
118
- readonly start?: (root: string, options: CanvasOptions) => CanvasOutcome
119
- readonly capture?: CaptureEngine
120
- }
121
-
122
- /**
123
- * Serves the canvas for the length of the call and captures through it, so the
124
- * run needs no `canon canvas serve` and still never reads the raw file. The
125
- * port is whatever is free, which keeps it clear of a canvas already running.
126
- * An error the engine throws, such as a browser that will not launch, comes
127
- * back as `capture-failed` rather than escaping, so a caller reading the record
128
- * can tell it from a root that is wrong.
129
- */
130
- export async function captureCanvas(
131
- root: string,
132
- spec: string,
133
- out?: string,
134
- deps: CaptureDeps = {},
135
- ): Promise<CaptureOutcome> {
136
- /*
137
- * Refused before the server starts, since starting it creates the canvas
138
- * folder and a refused capture should leave the project as it found it.
139
- */
140
- const early = resolveCaptureTargets(root, spec, '')
141
- if (!early.ok) return early
142
-
143
- return serveFor(root, deps, async (base, capture) => {
144
- const resolved = resolveCaptureTargets(root, spec, base, out)
145
- if (!resolved.ok) return resolved
146
-
147
- const captures: { target: CaptureTarget; result: CaptureResult }[] = []
148
- for (const target of resolved.targets) {
149
- const [result] = await capture(target.url, {
150
- selector: CAPTURE_SELECTOR,
151
- width: target.width,
152
- outDir: target.pngPath,
153
- })
154
- if (result) captures.push({ target, result })
155
- }
156
- return { ok: true, captures }
157
- })
158
- }
159
-
160
- export type ServeRefused = {
161
- readonly ok: false
162
- readonly reason: 'no-server' | 'capture-failed'
163
- readonly detail: string
164
- }
165
-
166
- async function serveFor<T>(
167
- root: string,
168
- deps: CaptureDeps,
169
- run: (base: string, capture: CaptureEngine) => Promise<T>,
170
- ): Promise<T | ServeRefused> {
171
- return serveCanvas(root, deps.start, async (base) => {
172
- const capture =
173
- deps.capture ?? (await import('@/capture/render')).captureSources
174
- return run(base, capture)
175
- })
176
- }
177
-
178
- /**
179
- * Runs a call against a canvas served on a free port for its length, so a verb
180
- * that renders frames reads the same address the board shows.
181
- */
182
- export async function serveCanvas<T>(
183
- root: string,
184
- startOverride: CaptureDeps['start'],
185
- run: (base: string) => Promise<T>,
186
- ): Promise<T | ServeRefused> {
187
- const start = startOverride ?? startCanvas
188
- const server = start(root, { port: 0, shell: new Response('') })
189
- if (!server.ok) {
190
- return {
191
- ok: false,
192
- reason: 'no-server',
193
- detail: `${server.detail}, so run canon canvas serve and try again`,
194
- }
195
- }
196
-
197
- try {
198
- return await run(server.url)
199
- } catch (error) {
200
- return {
201
- ok: false,
202
- reason: 'capture-failed',
203
- detail: error instanceof Error ? error.message : String(error),
204
- }
205
- } finally {
206
- await server.stop()
207
- }
208
- }
209
-
210
- /** The element a composite is captured by, holding every frame and no chrome. */
211
- export const COMPOSITE_SELECTOR = '#canvas-composite'
212
-
213
- export interface Placement {
214
- readonly frame: string
215
- readonly url: string
216
- readonly left: number
217
- readonly top: number
218
- readonly width: number
219
- readonly height: number
220
- }
221
-
222
- export interface Composite {
223
- readonly placements: readonly Placement[]
224
- readonly width: number
225
- readonly height: number
226
- readonly html: string
227
- }
228
-
229
- /**
230
- * One document showing every frame of a page at its layout position. Offsets
231
- * run from the top left of the union of the boxes, so a negative coordinate
232
- * never clips and a lone frame fills the container exactly. Each iframe takes
233
- * its box, so a document taller than its frame clips as it does on the board,
234
- * and frames paint in the order given, which is the board's.
235
- */
236
- export function buildComposite(
237
- page: string,
238
- frames: readonly Frame[],
239
- base: string,
240
- ): Composite {
241
- const left = Math.min(...frames.map((frame) => frame.x))
242
- const top = Math.min(...frames.map((frame) => frame.y))
243
- const right = Math.max(...frames.map((frame) => frame.x + frame.width))
244
- const bottom = Math.max(...frames.map((frame) => frame.y + frame.height))
245
- const width = right - left
246
- const height = bottom - top
247
-
248
- const placements = frames.map((frame) => ({
249
- frame: frame.name,
250
- url: frameUrl(base, page, frame),
251
- left: frame.x - left,
252
- top: frame.y - top,
253
- width: frame.width,
254
- height: frame.height,
255
- }))
256
-
257
- const iframes = placements.map(
258
- (p) =>
259
- `<iframe title="${escapeAttribute(p.frame)}" src="${escapeAttribute(p.url)}" style="left:${p.left}px;top:${p.top}px;width:${p.width}px;height:${p.height}px"></iframe>`,
260
- )
261
- const html = [
262
- '<!doctype html>',
263
- '<html><head><meta charset="utf-8">',
264
- `<style>html,body{margin:0;background:transparent}#canvas-composite{position:relative}#canvas-composite iframe{position:absolute;display:block;border:0;background:transparent}</style>`,
265
- '</head><body>',
266
- `<div id="canvas-composite" style="width:${width}px;height:${height}px">`,
267
- ...iframes,
268
- '</div></body></html>',
269
- '',
270
- ].join('\n')
271
-
272
- return { placements, width, height, html }
273
- }
274
-
275
- function escapeAttribute(value: string): string {
276
- return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;')
277
- }
278
-
279
- /**
280
- * Where the generated document is served from, inside the canvas folder so it
281
- * shares the frames' origin. Chromium skips painting a cross-origin iframe
282
- * outside the viewport, and the engine's viewport is 720 pixels tall, so a
283
- * document on another origin captured every frame below that line blank. A dot
284
- * folder is no page name, so the board never lists it.
285
- */
286
- const COMPOSITE_FOLDER = '.composite'
287
-
288
- export interface CompositeTarget {
289
- readonly page: string
290
- readonly composite: Composite
291
- readonly htmlPath: string
292
- /** The served address of the document at `htmlPath`. */
293
- readonly url: string
294
- readonly pngPath: string
295
- }
296
-
297
- export type CompositeTargetOutcome =
298
- | { readonly ok: true; readonly target: CompositeTarget }
299
- | ContentRefused
300
-
301
- /**
302
- * Turns `<page>` into one composite. `out` is the PNG, and without it the PNG
303
- * lands at `<page>.png` beside the per-frame folder of the same name, so the
304
- * two captures of one page never collide.
305
- */
306
- export function resolveCompositeTarget(
307
- root: string,
308
- spec: string,
309
- base: string,
310
- out?: string,
311
- ): CompositeTargetOutcome {
312
- if (spec.includes('/')) {
313
- return refuse(
314
- 'invalid-name',
315
- `--composite takes a page, not ${spec}, so capture <page> or drop the flag`,
316
- )
317
- }
318
-
319
- const page = readPage(root, spec)
320
- if (!page) return refuse('no-page', `page ${spec} does not exist`)
321
- if (page.frames.length === 0) {
322
- return refuse('no-frame', `page ${spec} has no frames`)
323
- }
324
-
325
- const file = `${spec}.html`
326
- return {
327
- ok: true,
328
- target: {
329
- page: spec,
330
- composite: buildComposite(spec, page.frames, base),
331
- htmlPath: join(canvasDir(root), COMPOSITE_FOLDER, file),
332
- url: `${base}${FRAMES_PREFIX.slice(1)}${COMPOSITE_FOLDER}/${encodeURIComponent(file)}`,
333
- pngPath:
334
- out ?? join(recordDir(root, 'tmp', 'canvas-capture'), `${spec}.png`),
335
- },
336
- }
337
- }
338
-
339
- export type CompositeOutcome =
340
- | {
341
- readonly ok: true
342
- readonly composite: {
343
- readonly target: CompositeTarget
344
- readonly result: CaptureResult
345
- }
346
- }
347
- | ContentRefused
348
- | ServeRefused
349
-
350
- /**
351
- * Captures a page as one PNG through the served canvas, the way `captureCanvas`
352
- * captures each frame. The viewport is the container's width, so no frame's
353
- * media query resolves against a window narrower than the frame itself. The
354
- * page load the engine waits on waits on every iframe.
355
- */
356
- export async function captureComposite(
357
- root: string,
358
- spec: string,
359
- out?: string,
360
- deps: CaptureDeps = {},
361
- ): Promise<CompositeOutcome> {
362
- const early = resolveCompositeTarget(root, spec, '')
363
- if (!early.ok) return early
364
-
365
- return serveFor(root, deps, async (base, capture) => {
366
- const resolved = resolveCompositeTarget(root, spec, base, out)
367
- if (!resolved.ok) return resolved
368
-
369
- const { target } = resolved
370
- mkdirSync(dirname(target.htmlPath), { recursive: true })
371
- writeFileSync(target.htmlPath, target.composite.html)
372
- let result: CaptureResult | undefined
373
- try {
374
- ;[result] = await capture(target.url, {
375
- selector: COMPOSITE_SELECTOR,
376
- width: target.composite.width,
377
- outDir: target.pngPath,
378
- })
379
- } finally {
380
- rmSync(target.htmlPath, { force: true })
381
- }
382
- if (!result) {
383
- return {
384
- ok: false,
385
- reason: 'capture-failed',
386
- detail: `the engine rendered nothing for ${spec}`,
387
- }
388
- }
389
- return { ok: true, composite: { target, result } }
390
- })
391
- }