@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.
- package/CHANGELOG.md +40 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +12 -0
- package/claude/skills/app-drive/SKILL.md +44 -8
- package/claude/skills/app-drive/references/config.md +18 -8
- package/claude/skills/app-drive/references/drive-record.md +4 -0
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
- package/claude/skills/auto-ship/SKILL.md +5 -4
- package/claude/skills/auto-ship/references/draft-mark.md +33 -15
- package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
- package/claude/skills/canon-cli/SKILL.md +13 -10
- package/claude/skills/design-pick/SKILL.md +8 -0
- package/claude/skills/design-pick/references/design-config.md +9 -6
- package/claude/skills/design-references/REQUIREMENT.md +32 -0
- package/claude/skills/design-references/SKILL.md +120 -0
- package/claude/skills/design-references/references/films.md +90 -0
- package/claude/skills/design-references/references/sites.md +36 -0
- package/claude/skills/design-taste/SKILL.md +16 -4
- package/claude/skills/design-taste/references/adopted.md +5 -1
- package/claude/skills/design-taste/references/kinds.md +1 -0
- package/claude/skills/design-taste/references/preflight.md +27 -1
- package/claude/skills/design-taste/references/tells.md +2 -0
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/final-command.md +34 -2
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +2 -2
- package/claude/skills/git-ship/SKILL.md +16 -11
- package/claude/skills/git-ship/references/ci-watch.md +28 -4
- package/claude/skills/markdown-craft/references/markdown.md +4 -4
- package/claude/skills/plan-feature/references/plan.md +2 -0
- package/claude/skills/review-address/SKILL.md +2 -1
- package/claude/skills/review-pr/references/key-changes.md +2 -1
- package/claude/skills/review-ui/references/driving.md +33 -1
- package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
- package/claude/skills/role-worker/SKILL.md +1 -1
- package/claude/skills/search-craft/REQUIREMENT.md +3 -3
- package/claude/skills/search-craft/SKILL.md +12 -1
- package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
- package/claude/skills/target-scaffold/SKILL.md +44 -0
- package/claude/skills/target-setup/SKILL.md +5 -1
- package/claude/skills/task-board/SKILL.md +1 -0
- package/claude/skills/task-board/references/tasks.md +1 -0
- package/claude/skills/ui-checklist/SKILL.md +5 -1
- package/claude/skills/ui-craft/SKILL.md +9 -9
- package/claude/skills/ui-craft/references/adopted.md +4 -2
- package/claude/skills/ui-craft/references/capture.md +8 -0
- package/claude/skills/ui-craft/references/controls.md +6 -0
- package/claude/skills/ui-craft/references/motion.md +6 -0
- package/claude/skills/ui-film/references/take-craft.md +2 -0
- package/docs/agents/output-shape.md +2 -1
- package/docs/target/projects.md +2 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/parallel-features.md +2 -1
- package/docs/workflow/skill-map.md +7 -5
- package/docs/workflow/visual-design-workflow.md +4 -0
- package/governance/ledger/skills/design-references/SKILL.md +38 -0
- package/governance/ledger/skills/design-taste/SKILL.md +36 -30
- package/governance/ledger/skills/design-taste/kinds.md +1 -0
- package/governance/ledger/skills/design-taste/tells.md +22 -20
- package/governance/ledger/skills/search-craft/SKILL.md +6 -0
- package/governance/ledger/skills/ui-craft/capture.md +10 -0
- package/governance/ledger/skills/ui-craft/controls.md +8 -0
- package/governance/ledger/skills/ui-craft/motion.md +9 -1
- package/package.json +3 -3
- package/src/capture/render.ts +154 -2
- package/src/capture/sections.ts +257 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/cases/setup.ts +7 -0
- package/src/commands/capture.ts +149 -4
- package/src/commands/pr.ts +138 -15
- package/src/commands/sessions.ts +88 -6
- package/src/commands/tasks.ts +41 -2
- package/src/commands/tooling.ts +72 -0
- package/src/git/files.ts +5 -2
- package/src/indexes/frontmatter.ts +27 -6
- package/src/indexes/render.ts +21 -3
- package/src/markdown/colons.ts +14 -4
- package/src/markdown/heading-case.ts +9 -2
- package/src/pr/bijection.ts +8 -0
- package/src/pr/evidence.ts +38 -9
- package/src/pr/health.ts +63 -15
- package/src/pr/links.ts +160 -0
- package/src/pr/local.ts +17 -4
- package/src/pr/paths.ts +20 -2
- package/src/sessions/stop.ts +145 -0
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/take/encode.ts +16 -42
- package/src/take/frame-clock.ts +46 -0
- package/src/take/perform.ts +29 -4
- package/src/take/screencast.ts +114 -11
- package/src/tasks/holders.ts +198 -0
- package/src/tasks/validate.ts +148 -0
- package/src/tooling/list.ts +3 -0
- package/src/tooling/scaffold.ts +193 -0
- package/tooling/cloudflare/manifest.toml +1 -1
- package/tooling/cloudflare/reference.md +8 -0
- /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
package/src/capture/render.ts
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/src/claude/cases/misc.ts
CHANGED
|
@@ -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.",
|
package/src/commands/capture.ts
CHANGED
|
@@ -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(
|
|
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
|
-
'
|
|
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.
|