@erclx/canon 5.78.0 → 5.80.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 (104) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +12 -0
  4. package/claude/skills/app-drive/SKILL.md +44 -8
  5. package/claude/skills/app-drive/references/config.md +18 -8
  6. package/claude/skills/app-drive/references/drive-record.md +4 -0
  7. package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/auto-ship/SKILL.md +5 -4
  9. package/claude/skills/auto-ship/references/draft-mark.md +33 -15
  10. package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
  11. package/claude/skills/canon-cli/SKILL.md +13 -10
  12. package/claude/skills/design-pick/SKILL.md +8 -0
  13. package/claude/skills/design-pick/references/design-config.md +9 -6
  14. package/claude/skills/design-references/REQUIREMENT.md +32 -0
  15. package/claude/skills/design-references/SKILL.md +120 -0
  16. package/claude/skills/design-references/references/films.md +90 -0
  17. package/claude/skills/design-references/references/sites.md +36 -0
  18. package/claude/skills/design-taste/SKILL.md +16 -4
  19. package/claude/skills/design-taste/references/adopted.md +5 -1
  20. package/claude/skills/design-taste/references/kinds.md +1 -0
  21. package/claude/skills/design-taste/references/preflight.md +27 -1
  22. package/claude/skills/design-taste/references/tells.md +2 -0
  23. package/claude/skills/git-followup/SKILL.md +1 -0
  24. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  25. package/claude/skills/git-pr/SKILL.md +2 -0
  26. package/claude/skills/git-pr/references/final-command.md +34 -2
  27. package/claude/skills/git-pr/references/pr.md +1 -1
  28. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  29. package/claude/skills/git-ship/SKILL.md +16 -11
  30. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  31. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  32. package/claude/skills/plan-feature/references/plan.md +2 -0
  33. package/claude/skills/review-address/SKILL.md +2 -1
  34. package/claude/skills/review-pr/references/key-changes.md +2 -1
  35. package/claude/skills/review-ui/references/driving.md +33 -1
  36. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  37. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  38. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  39. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  40. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  41. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  42. package/claude/skills/role-worker/SKILL.md +1 -1
  43. package/claude/skills/search-craft/REQUIREMENT.md +3 -3
  44. package/claude/skills/search-craft/SKILL.md +12 -1
  45. package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
  46. package/claude/skills/target-scaffold/SKILL.md +44 -0
  47. package/claude/skills/target-setup/SKILL.md +5 -1
  48. package/claude/skills/task-board/SKILL.md +1 -0
  49. package/claude/skills/task-board/references/tasks.md +1 -0
  50. package/claude/skills/ui-checklist/SKILL.md +5 -1
  51. package/claude/skills/ui-craft/SKILL.md +9 -9
  52. package/claude/skills/ui-craft/references/adopted.md +4 -2
  53. package/claude/skills/ui-craft/references/capture.md +8 -0
  54. package/claude/skills/ui-craft/references/controls.md +6 -0
  55. package/claude/skills/ui-craft/references/motion.md +6 -0
  56. package/claude/skills/ui-film/references/take-craft.md +2 -0
  57. package/docs/agents/output-shape.md +2 -1
  58. package/docs/target/projects.md +2 -2
  59. package/docs/workflow/ai-workflow.md +1 -1
  60. package/docs/workflow/parallel-features.md +2 -1
  61. package/docs/workflow/skill-map.md +7 -5
  62. package/docs/workflow/visual-design-workflow.md +4 -0
  63. package/governance/ledger/skills/design-references/SKILL.md +38 -0
  64. package/governance/ledger/skills/design-taste/SKILL.md +36 -30
  65. package/governance/ledger/skills/design-taste/kinds.md +1 -0
  66. package/governance/ledger/skills/design-taste/tells.md +22 -20
  67. package/governance/ledger/skills/search-craft/SKILL.md +6 -0
  68. package/governance/ledger/skills/ui-craft/capture.md +10 -0
  69. package/governance/ledger/skills/ui-craft/controls.md +8 -0
  70. package/governance/ledger/skills/ui-craft/motion.md +9 -1
  71. package/package.json +3 -3
  72. package/src/capture/render.ts +154 -2
  73. package/src/capture/sections.ts +257 -0
  74. package/src/claude/cases/misc.ts +5 -0
  75. package/src/claude/cases/setup.ts +7 -0
  76. package/src/commands/capture.ts +149 -4
  77. package/src/commands/pr.ts +138 -15
  78. package/src/commands/sessions.ts +88 -6
  79. package/src/commands/tasks.ts +41 -2
  80. package/src/commands/tooling.ts +72 -0
  81. package/src/git/files.ts +5 -2
  82. package/src/indexes/frontmatter.ts +27 -6
  83. package/src/indexes/render.ts +21 -3
  84. package/src/markdown/colons.ts +14 -4
  85. package/src/markdown/heading-case.ts +9 -2
  86. package/src/pr/bijection.ts +8 -0
  87. package/src/pr/evidence.ts +38 -9
  88. package/src/pr/health.ts +63 -15
  89. package/src/pr/links.ts +160 -0
  90. package/src/pr/local.ts +17 -4
  91. package/src/pr/paths.ts +20 -2
  92. package/src/sessions/stop.ts +145 -0
  93. package/src/sessions/transfer/bundle.ts +1 -1
  94. package/src/take/encode.ts +16 -42
  95. package/src/take/frame-clock.ts +46 -0
  96. package/src/take/perform.ts +29 -4
  97. package/src/take/screencast.ts +114 -11
  98. package/src/tasks/holders.ts +198 -0
  99. package/src/tasks/validate.ts +148 -0
  100. package/src/tooling/list.ts +3 -0
  101. package/src/tooling/scaffold.ts +193 -0
  102. package/tooling/cloudflare/manifest.toml +1 -1
  103. package/tooling/cloudflare/reference.md +8 -0
  104. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
@@ -1,11 +1,18 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
- import { basename, dirname } from 'node:path'
2
+ import { basename, dirname, join } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import { chromium } from 'playwright-core'
5
5
  import type { Browser, Locator, Page } from 'playwright-core'
6
+ import {
7
+ mergeBlocks,
8
+ preparePage,
9
+ revealSection,
10
+ splitBlocks,
11
+ } from '@/capture/sections'
6
12
  import type { CaptureSource } from '@/capture/sources'
7
13
  import {
8
14
  isGenericFamily,
15
+ isUrlSource,
9
16
  resolveCaptureSources,
10
17
  textFamilies,
11
18
  } from '@/capture/sources'
@@ -141,6 +148,148 @@ async function captureOne(
141
148
  }
142
149
  }
143
150
 
151
+ export interface SectionOptions {
152
+ outDir: string
153
+ root?: string
154
+ width?: number
155
+ colorScheme?: ColorScheme
156
+ }
157
+
158
+ export interface SectionShot {
159
+ readonly index: number
160
+ readonly path: string
161
+ readonly top: number
162
+ readonly height: number
163
+ }
164
+
165
+ /**
166
+ * Families the page draws text in that a shot cannot promise to reproduce,
167
+ * recorded rather than refused, since a reference shows a site as a browser
168
+ * draws it and most outside sites declare a generic family somewhere.
169
+ */
170
+ export interface SectionFonts {
171
+ readonly generic: readonly string[]
172
+ readonly missing: readonly string[]
173
+ }
174
+
175
+ export type SectionsResult =
176
+ | {
177
+ status: 'captured'
178
+ source: string
179
+ sections: SectionShot[]
180
+ failed: { index: number; reason: string }[]
181
+ fonts: SectionFonts
182
+ }
183
+ | { status: 'failed'; source: string; reason: string }
184
+
185
+ /**
186
+ * Shoots one page as one PNG per section, in page order, each with its stamp.
187
+ * A section that fails to shoot is reported beside the rest rather than
188
+ * dropping them, and a page that never loads or names no root fails whole.
189
+ *
190
+ * Each shot is a document-coordinate clip rather than an element shot, since a
191
+ * merged section spans several elements and one element cannot frame them.
192
+ */
193
+ export async function captureSections(
194
+ sourcePath: string,
195
+ { outDir, root, width, colorScheme }: SectionOptions,
196
+ ): Promise<SectionsResult> {
197
+ const pageSource: CaptureSource = isUrlSource(sourcePath)
198
+ ? { kind: 'url', url: sourcePath, pngPath: outDir }
199
+ : { kind: 'file', htmlPath: sourcePath, pngPath: outDir }
200
+ const browser = await chromium.launch()
201
+ try {
202
+ const page = await browser.newPage({
203
+ deviceScaleFactor: DEVICE_SCALE_FACTOR,
204
+ ...(width === undefined
205
+ ? {}
206
+ : { viewport: { width, height: DEFAULT_VIEWPORT_HEIGHT } }),
207
+ ...(colorScheme === undefined ? {} : { colorScheme }),
208
+ })
209
+ await page.goto(
210
+ pageSource.kind === 'url'
211
+ ? pageSource.url
212
+ : pathToFileURL(pageSource.htmlPath).href,
213
+ )
214
+ if (colorScheme !== undefined) await applyColorScheme(page, colorScheme)
215
+ await page.evaluate(() => document.fonts.ready.then(() => undefined))
216
+ await preparePage(page)
217
+
218
+ const blocks = await splitBlocks(page, root)
219
+ if (blocks === null) {
220
+ return failed(pageSource, `no element matched ${root}`)
221
+ }
222
+ if (blocks.length === 0) {
223
+ return failed(
224
+ pageSource,
225
+ `${root ?? 'main, or body without one,'} drew no box to split into sections`,
226
+ )
227
+ }
228
+ const fonts = await unresolvedFonts(page)
229
+
230
+ mkdirSync(outDir, { recursive: true })
231
+ const shots: SectionShot[] = []
232
+ const misses: { index: number; reason: string }[] = []
233
+ for (const [offset, section] of mergeBlocks(blocks).entries()) {
234
+ const index = offset + 1
235
+ const pngPath = join(
236
+ outDir,
237
+ `section-${String(index).padStart(2, '0')}.png`,
238
+ )
239
+ try {
240
+ const clip = await revealSection(page, section)
241
+ if (clip === null) {
242
+ misses.push({ index, reason: 'the section drew no box' })
243
+ continue
244
+ }
245
+ const png = await page.screenshot({ clip, fullPage: true })
246
+ writeFileSync(pngPath, png)
247
+ writeStamp({ ...pageSource, pngPath }, png)
248
+ shots.push({ index, path: pngPath, top: clip.y, height: clip.height })
249
+ } catch (error) {
250
+ misses.push({
251
+ index,
252
+ reason: error instanceof Error ? error.message : String(error),
253
+ })
254
+ }
255
+ }
256
+ return {
257
+ status: 'captured',
258
+ source: sourceIdentifier(pageSource),
259
+ sections: shots,
260
+ failed: misses,
261
+ fonts,
262
+ }
263
+ } catch (error) {
264
+ return failed(
265
+ pageSource,
266
+ error instanceof Error ? error.message : String(error),
267
+ )
268
+ } finally {
269
+ await browser.close()
270
+ }
271
+ }
272
+
273
+ /**
274
+ * The generic and the absent families among those the page's text draws in,
275
+ * read once over the whole body, which is the probe single-element capture
276
+ * refuses on, turned into a record.
277
+ */
278
+ async function unresolvedFonts(page: Page): Promise<SectionFonts> {
279
+ const declarations = await renderedTextFamilies(page.locator('body'))
280
+ const generic = textFamilies(declarations.filter(isGenericFamily))
281
+ const named = textFamilies(
282
+ declarations.filter((declaration) => !isGenericFamily(declaration)),
283
+ )
284
+ const resolved = await Promise.all(
285
+ named.map((family) => resolvesFont(page, family)),
286
+ )
287
+ return {
288
+ generic,
289
+ missing: named.filter((_, index) => !resolved[index]),
290
+ }
291
+ }
292
+
144
293
  /**
145
294
  * Runs inside the render rather than in a wrapper around it, so a capture
146
295
  * cannot succeed and leave the provenance unrecorded. A throw here reaches the
@@ -202,7 +351,10 @@ async function applyColorScheme(
202
351
  }, scheme)
203
352
  }
204
353
 
205
- function failed(source: CaptureSource, reason: string): CaptureResult {
354
+ function failed(
355
+ source: CaptureSource,
356
+ reason: string,
357
+ ): Extract<CaptureResult, { status: 'failed' }> {
206
358
  return { status: 'failed', source: sourceIdentifier(source), reason }
207
359
  }
208
360
 
@@ -0,0 +1,257 @@
1
+ import type { Page } from 'playwright-core'
2
+
3
+ /**
4
+ * The split, the merge, and the page preparation behind `canon capture
5
+ * --sections`. The thresholds are the ones track 218 measured on Notion,
6
+ * Vercel, Linear, and ElevenLabs, and nothing beyond those four sites tuned
7
+ * them.
8
+ */
9
+
10
+ /** A block taller than this many viewports splits into its children. */
11
+ const SPLIT_VIEWPORTS = 1.6
12
+ /** A child shorter than this is decoration rather than a block of its own. */
13
+ const MIN_CHILD_HEIGHT = 120
14
+ /** A child narrower than this share of the viewport is a column, not a band. */
15
+ const MIN_WIDTH_SHARE = 0.6
16
+ /** A block shorter than this joins the section above it. */
17
+ export const MERGE_BELOW_PX = 400
18
+ /** The tallest shot a section takes, in CSS pixels. Taller is cut. */
19
+ export const MAX_SECTION_PX = 2400
20
+
21
+ const SECTION_ATTRIBUTE = 'data-canon-section'
22
+ const WHEEL_STEP_PX = 600
23
+ const WHEEL_PAUSE_MS = 150
24
+ const SETTLE_MS = 800
25
+ const NETWORK_IDLE_MS = 5_000
26
+
27
+ /** One block the split found, in document coordinates and page order. */
28
+ export interface Block {
29
+ readonly index: number
30
+ readonly top: number
31
+ readonly height: number
32
+ }
33
+
34
+ /** One or more adjacent blocks shot as one image. */
35
+ export interface Section {
36
+ readonly blocks: readonly number[]
37
+ readonly top: number
38
+ readonly height: number
39
+ }
40
+
41
+ /**
42
+ * Loads what a reader would see by the time they reached each part of the
43
+ * page, then hides every fixed and sticky layer so none paints into the middle
44
+ * of a shot. Hiding is how a consent banner leaves, since a click on one
45
+ * decides something on the operator's behalf.
46
+ *
47
+ * Wheeling rather than `scrollTo`, because a lazy loader listening for wheel
48
+ * or for intersection sees each step, and a jump to the bottom skips them.
49
+ */
50
+ export async function preparePage(page: Page): Promise<void> {
51
+ await page
52
+ .waitForLoadState('networkidle', { timeout: NETWORK_IDLE_MS })
53
+ .catch(() => undefined)
54
+ const height = await page.evaluate(
55
+ () => document.documentElement.scrollHeight,
56
+ )
57
+ for (let y = 0; y < height; y += WHEEL_STEP_PX) {
58
+ await page.mouse.wheel(0, WHEEL_STEP_PX)
59
+ await page.waitForTimeout(WHEEL_PAUSE_MS)
60
+ }
61
+ await page.evaluate(() => window.scrollTo(0, 0))
62
+ await page.waitForTimeout(SETTLE_MS)
63
+ await hideFixedLayers(page)
64
+ }
65
+
66
+ /**
67
+ * Runs again before every shot as well as once in preparation, since a consent
68
+ * banner commonly mounts on a timer after load and a single pass misses it.
69
+ * Starts at the root element rather than the body, since a consent manager
70
+ * can attach its host straight to `<html>`, and walks into open shadow roots,
71
+ * where such widgets mount and a document-level query never reaches. A closed
72
+ * root stays out of reach.
73
+ */
74
+ async function hideFixedLayers(page: Page): Promise<void> {
75
+ await page.evaluate(() => {
76
+ const hide = (root: ParentNode): void => {
77
+ for (const element of root.querySelectorAll<HTMLElement>('*')) {
78
+ const position = getComputedStyle(element).position
79
+ if (position === 'fixed' || position === 'sticky') {
80
+ element.style.setProperty('visibility', 'hidden', 'important')
81
+ }
82
+ if (element.shadowRoot) hide(element.shadowRoot)
83
+ }
84
+ }
85
+ hide(document.documentElement)
86
+ })
87
+ }
88
+
89
+ /**
90
+ * Walks down from the root, splitting any block taller than the split
91
+ * threshold into its full-width children, and tags each block it keeps so a
92
+ * later step can find it again. Null when the root selector matched nothing.
93
+ *
94
+ * With no root named, `main` is tried and then `body`, since track 218 found
95
+ * `main` on every site it tried and a page without one still has a body.
96
+ */
97
+ export async function splitBlocks(
98
+ page: Page,
99
+ root: string | undefined,
100
+ ): Promise<Block[] | null> {
101
+ return page.evaluate(
102
+ ({ root, attribute, splitViewports, minChildHeight, minWidthShare }) => {
103
+ const start = root
104
+ ? document.querySelector(root)
105
+ : (document.querySelector('main') ?? document.body)
106
+ if (!start) return null
107
+
108
+ const viewportWidth = window.innerWidth
109
+ const viewportHeight = window.innerHeight
110
+ const measure = (element: Element) => {
111
+ const rect = element.getBoundingClientRect()
112
+ return {
113
+ element,
114
+ top: Math.round(rect.top + window.scrollY),
115
+ height: Math.round(rect.height),
116
+ width: Math.round(rect.width),
117
+ }
118
+ }
119
+ type Measured = ReturnType<typeof measure>
120
+ const split = (element: Element): Measured[] => {
121
+ const box = measure(element)
122
+ if (box.height <= viewportHeight * splitViewports) return [box]
123
+ const children = [...element.children]
124
+ .map(measure)
125
+ .filter(
126
+ (child) =>
127
+ child.height >= minChildHeight &&
128
+ child.width >= viewportWidth * minWidthShare,
129
+ )
130
+ if (children.length === 0) return [box]
131
+ return children.flatMap((child) => split(child.element))
132
+ }
133
+
134
+ return split(start)
135
+ .filter((box) => box.height > 0)
136
+ .map((box, index) => {
137
+ box.element.setAttribute(attribute, String(index))
138
+ return { index, top: box.top, height: box.height }
139
+ })
140
+ },
141
+ {
142
+ root,
143
+ attribute: SECTION_ATTRIBUTE,
144
+ splitViewports: SPLIT_VIEWPORTS,
145
+ minChildHeight: MIN_CHILD_HEIGHT,
146
+ minWidthShare: MIN_WIDTH_SHARE,
147
+ },
148
+ )
149
+ }
150
+
151
+ /**
152
+ * Joins each block shorter than the merge threshold to the section above it,
153
+ * so a page built of thin bands comes back as the sections a person would
154
+ * name. A short first block has nothing above it and joins the one below.
155
+ *
156
+ * A merge that would carry a section past the shot cap starts a new section
157
+ * instead, so a run of bands under a tall block is not cut off by the cap.
158
+ */
159
+ export function mergeBlocks(
160
+ blocks: readonly Block[],
161
+ minHeight: number = MERGE_BELOW_PX,
162
+ maxHeight: number = MAX_SECTION_PX,
163
+ ): Section[] {
164
+ const sections: Section[] = []
165
+ let leading: Block[] = []
166
+ for (const block of blocks) {
167
+ const last = sections.at(-1)
168
+ if (last === undefined) {
169
+ if (leading.length && spanOf([...leading, block]).height > maxHeight) {
170
+ sections.push(spanOf(leading))
171
+ leading = []
172
+ }
173
+ leading.push(block)
174
+ const span = spanOf(leading)
175
+ if (span.height >= minHeight) {
176
+ sections.push(span)
177
+ leading = []
178
+ }
179
+ continue
180
+ }
181
+ const merged = spanOf([last, block])
182
+ if (block.height < minHeight && merged.height <= maxHeight) {
183
+ sections[sections.length - 1] = {
184
+ ...merged,
185
+ blocks: [...last.blocks, block.index],
186
+ }
187
+ } else {
188
+ sections.push(spanOf([block]))
189
+ }
190
+ }
191
+ if (leading.length) sections.push(spanOf(leading))
192
+ return sections
193
+ }
194
+
195
+ function spanOf(parts: readonly (Block | Section)[]): Section {
196
+ const top = Math.min(...parts.map((part) => part.top))
197
+ const bottom = Math.max(...parts.map((part) => part.top + part.height))
198
+ const blocks = parts.flatMap((part) =>
199
+ 'blocks' in part ? part.blocks : [part.index],
200
+ )
201
+ return { blocks, top, height: bottom - top }
202
+ }
203
+
204
+ /** The clip a section's shot takes, measured again after it scrolled in. */
205
+ export interface Clip {
206
+ readonly x: number
207
+ readonly y: number
208
+ readonly width: number
209
+ readonly height: number
210
+ }
211
+
212
+ /**
213
+ * Scrolls a section's first block into view so a reveal-on-view block paints,
214
+ * waits for it to settle, then measures the union of its blocks in document
215
+ * coordinates, cut at the shot cap. Measured after the scroll rather than
216
+ * taken from the split, since content loading above can move it.
217
+ */
218
+ export async function revealSection(
219
+ page: Page,
220
+ section: Section,
221
+ ): Promise<Clip | null> {
222
+ const first = section.blocks[0]
223
+ if (first === undefined) return null
224
+ await page
225
+ .locator(`[${SECTION_ATTRIBUTE}="${first}"]`)
226
+ .scrollIntoViewIfNeeded()
227
+ .catch(() => undefined)
228
+ await page.waitForTimeout(SETTLE_MS)
229
+ await hideFixedLayers(page)
230
+ return page.evaluate(
231
+ ({ attribute, blocks, cap }) => {
232
+ const rects = blocks
233
+ .map((index) => document.querySelector(`[${attribute}="${index}"]`))
234
+ .filter((element): element is Element => element !== null)
235
+ .map((element) => element.getBoundingClientRect())
236
+ if (!rects.length) return null
237
+ const left = Math.min(...rects.map((rect) => rect.left))
238
+ const right = Math.max(...rects.map((rect) => rect.right))
239
+ const top = Math.min(...rects.map((rect) => rect.top))
240
+ const bottom = Math.max(...rects.map((rect) => rect.bottom))
241
+ const width = Math.round(right - left)
242
+ const height = Math.round(Math.min(bottom - top, cap))
243
+ if (width <= 0 || height <= 0) return null
244
+ return {
245
+ x: Math.max(0, Math.round(left + window.scrollX)),
246
+ y: Math.max(0, Math.round(top + window.scrollY)),
247
+ width,
248
+ height,
249
+ }
250
+ },
251
+ {
252
+ attribute: SECTION_ATTRIBUTE,
253
+ blocks: section.blocks,
254
+ cap: MAX_SECTION_PX,
255
+ },
256
+ )
257
+ }
@@ -26,6 +26,11 @@ export const MISC_CASES: readonly SkillCase[] = [
26
26
  'Show me a few different treatments for this callout so I can pick one by looking.',
27
27
  expect: 'design-pick',
28
28
  },
29
+ {
30
+ prompt:
31
+ 'Before we draft the landing page, pull references from linear.app so the round has a real site to look at.',
32
+ expect: 'design-references',
33
+ },
29
34
  {
30
35
  prompt:
31
36
  'Which layer of this landing page should I settle first, and what has to stay constant across it?',
@@ -35,6 +35,13 @@ export const SETUP_CASES: readonly SkillCase[] = [
35
35
  'Check that the dev server actually starts and the end-to-end suite passes against the scaffold.',
36
36
  expect: 'target-setup',
37
37
  },
38
+ // An empty folder has no files for setup's detection to read, which is what
39
+ // separates starting a project from bootstrapping one that already exists.
40
+ {
41
+ prompt:
42
+ 'This folder is empty, start a new astro project in it and get it on the toolkit.',
43
+ expect: 'target-scaffold',
44
+ },
38
45
  {
39
46
  prompt:
40
47
  "Before I run this sync, tell me exactly what it's going to overwrite.",
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, statSync } from 'node:fs'
2
2
  import { relative, resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import {
@@ -32,6 +32,7 @@ const DEFAULT_SOURCE = 'assets'
32
32
  type Renderer = typeof import('@/capture/render')
33
33
  type CaptureResult = Awaited<ReturnType<Renderer['captureSources']>>[number]
34
34
  type ColorScheme = import('@/capture/render').ColorScheme
35
+ type SectionsResult = import('@/capture/render').SectionsResult
35
36
 
36
37
  const COLOR_SCHEMES: readonly ColorScheme[] = ['light', 'dark']
37
38
 
@@ -56,7 +57,15 @@ export function register(program: Command): void {
56
57
  '-o, --out <path>',
57
58
  'Output directory for a file source, or the destination PNG for a URL source; defaults beside the source',
58
59
  )
59
- .option('-s, --selector <selector>', 'Element to capture')
60
+ .option(
61
+ '-s, --selector <selector>',
62
+ 'Element to capture, or with --sections the root to split, defaulting to main and then body',
63
+ )
64
+ .option(
65
+ '--sections',
66
+ 'Write one PNG per section of one page into the --out folder, in page order',
67
+ )
68
+ .option('--json', 'With --sections, print the result as one JSON record')
60
69
  .option(
61
70
  '--width <px>',
62
71
  'Viewport width in pixels, so a media query resolves at that width',
@@ -69,8 +78,8 @@ export function register(program: Command): void {
69
78
  'after',
70
79
  [
71
80
  '',
72
- 'The selector has no default, since the element to crop to belongs to the',
73
- 'page. Each element holding text inside it must resolve its first font',
81
+ 'Outside --sections the selector has no default, since the element to',
82
+ 'crop to belongs to the page. Each element holding text inside it must resolve its first font',
74
83
  'family, and a generic keyword such as monospace or system-ui fails,',
75
84
  'since it resolves differently on each machine. The user agent sets',
76
85
  'monospace on pre, code, kbd, and samp, so each needs its own',
@@ -81,6 +90,21 @@ export function register(program: Command): void {
81
90
  'a light or dark class on the root, so a stylesheet keyed on any of the',
82
91
  'three renders that theme. Run it once per scheme with its own --out.',
83
92
  '',
93
+ '--sections takes one HTML file or URL and needs --out naming a folder.',
94
+ 'It wheels down the page so lazy content loads, hides fixed and sticky',
95
+ 'layers, splits any block taller than 1.6 viewports into its children,',
96
+ 'and joins a block under 400px to the one above. Each shot is cut at',
97
+ '2400px and writes section-NN.png with its stamp. A generic or missing',
98
+ 'font family is recorded rather than refused. --json prints',
99
+ '{ source, sections: [{ index, path, top, height }], failed: [{ index,',
100
+ 'reason }], fonts: { generic, missing } }, or { source, reason } when',
101
+ 'the page fails whole.',
102
+ '',
103
+ 'Examples:',
104
+ ' canon capture assets --selector .window',
105
+ ' canon capture https://example.com --sections --out refs/example --width 1440',
106
+ ' canon capture https://example.com --sections --out refs/example --json',
107
+ '',
84
108
  ].join('\n'),
85
109
  )
86
110
  .action(
@@ -91,8 +115,20 @@ export function register(program: Command): void {
91
115
  selector?: string
92
116
  width?: string
93
117
  colorScheme?: string
118
+ sections?: boolean
119
+ json?: boolean
94
120
  },
95
121
  ): Promise<void> => {
122
+ if (opts.json && !opts.sections) {
123
+ frameError('--json applies to --sections. See canon capture --help.')
124
+ process.exitCode = 1
125
+ return
126
+ }
127
+ if (opts.sections) {
128
+ await captureSectionsAction(source, opts)
129
+ return
130
+ }
131
+
96
132
  /**
97
133
  * Refused rather than defaulted, and refused ahead of every other
98
134
  * check, so the message names the invocation rather than whatever the
@@ -176,6 +212,115 @@ export function register(program: Command): void {
176
212
  )
177
213
  }
178
214
 
215
+ interface SectionFlags {
216
+ out?: string
217
+ selector?: string
218
+ width?: string
219
+ colorScheme?: string
220
+ json?: boolean
221
+ }
222
+
223
+ /**
224
+ * Section mode reads `--out` as a folder for every source kind, so it is
225
+ * required here even for a file source, and a directory source is refused,
226
+ * since a section run describes one page.
227
+ */
228
+ async function captureSectionsAction(
229
+ source: string,
230
+ opts: SectionFlags,
231
+ ): Promise<void> {
232
+ const width = parseWidth(opts.width)
233
+ if (Number.isNaN(width)) {
234
+ frameError(
235
+ `--width takes a whole number of pixels of 1 or more, got ${opts.width}.`,
236
+ )
237
+ process.exitCode = 1
238
+ return
239
+ }
240
+ const colorScheme = parseColorScheme(opts.colorScheme)
241
+ if (colorScheme === null) {
242
+ frameError(
243
+ `--color-scheme takes ${COLOR_SCHEMES.join(' or ')}, got ${JSON.stringify(opts.colorScheme)}.`,
244
+ )
245
+ process.exitCode = 1
246
+ return
247
+ }
248
+ if (!opts.out) {
249
+ frameError('--sections needs --out naming the folder the shots go in.')
250
+ process.exitCode = 1
251
+ return
252
+ }
253
+
254
+ const isUrl = isUrlSource(source)
255
+ const sourcePath = isUrl ? source : resolve(process.cwd(), source)
256
+ if (!isUrl && !existsSync(sourcePath)) {
257
+ frameError(`${source} not found`)
258
+ process.exitCode = 1
259
+ return
260
+ }
261
+ if (!isUrl && statSync(sourcePath).isDirectory()) {
262
+ frameError(
263
+ `--sections takes one HTML file or URL, and ${source} is a directory.`,
264
+ )
265
+ process.exitCode = 1
266
+ return
267
+ }
268
+
269
+ const json = opts.json ?? false
270
+ if (!json) intro('Capture sections')
271
+ let result: SectionsResult
272
+ try {
273
+ const renderer = await import('@/capture/render')
274
+ result = await renderer.captureSections(sourcePath, {
275
+ outDir: resolve(process.cwd(), opts.out),
276
+ root: opts.selector,
277
+ width,
278
+ colorScheme,
279
+ })
280
+ } catch (error) {
281
+ if (json) {
282
+ logError(error instanceof Error ? error.message : String(error))
283
+ } else {
284
+ reportInFrame(error)
285
+ }
286
+ process.exitCode = 1
287
+ return
288
+ }
289
+
290
+ if (result.status === 'failed') {
291
+ if (json) {
292
+ process.stdout.write(
293
+ `${JSON.stringify({ source: result.source, reason: result.reason })}\n`,
294
+ )
295
+ }
296
+ logError(`${displayPath(result.source)}: ${result.reason}`)
297
+ if (!json) outro()
298
+ process.exitCode = 1
299
+ return
300
+ }
301
+
302
+ const { source: page, sections, failed, fonts } = result
303
+ if (json) {
304
+ process.stdout.write(
305
+ `${JSON.stringify({ source: page, sections, failed, fonts })}\n`,
306
+ )
307
+ }
308
+ for (const shot of json ? [] : sections) {
309
+ logInfo(`${displayPath(shot.path)} top ${shot.top} height ${shot.height}`)
310
+ }
311
+ for (const fault of failed) {
312
+ logError(`section ${fault.index}: ${fault.reason}`)
313
+ }
314
+ if (!json && fonts.generic.length) {
315
+ logWarn(`generic families drawn: ${fonts.generic.join(', ')}`)
316
+ }
317
+ if (!json && fonts.missing.length) {
318
+ logWarn(`families not installed: ${fonts.missing.join(', ')}`)
319
+ }
320
+ if (!json) outro()
321
+ if (failed.length || !sections.length) process.exitCode = 1
322
+ }
323
+
179
324
  /**
180
325
  * Undefined for an absent flag and `NaN` for a value that is not a positive
181
326
  * integer, so the caller can tell no width from a bad one.