@erclx/canon 5.33.0 → 5.34.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 (64) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +1 -2
  3. package/claude/skills/api-design/SKILL.md +2 -3
  4. package/claude/skills/api-design/references/adopted.md +1 -1
  5. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
  6. package/claude/skills/canon-feedback/SKILL.md +10 -28
  7. package/claude/skills/canon-feedback/references/template.md +53 -0
  8. package/claude/skills/canvas-board/SKILL.md +4 -1
  9. package/claude/skills/canvas-board/references/canvas.md +26 -3
  10. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  11. package/claude/skills/code-craft/SKILL.md +0 -1
  12. package/claude/skills/create-rule/SKILL.md +1 -1
  13. package/claude/skills/create-rule/references/rule.md +3 -1
  14. package/claude/skills/create-skill/references/skill.md +1 -1
  15. package/claude/skills/git-issue/references/issue.md +4 -1
  16. package/claude/skills/review-craft/references/ui.md +1 -1
  17. package/claude/skills/review-ui/SKILL.md +7 -5
  18. package/claude/skills/review-ui/references/driving.md +8 -8
  19. package/claude/skills/review-ui/references/post.md +52 -16
  20. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +9 -0
  21. package/claude/skills/target-setup/SKILL.md +10 -4
  22. package/claude/skills/target-setup/references/detect.md +19 -2
  23. package/claude/skills/test-craft/REQUIREMENT.md +2 -3
  24. package/claude/skills/test-craft/SKILL.md +15 -3
  25. package/docs/agents/canvas.md +5 -1
  26. package/docs/agents/commands.md +1 -1
  27. package/docs/agents/gov-detect.md +47 -0
  28. package/docs/agents/index.md +4 -1
  29. package/docs/agents/install-and-sync.md +4 -0
  30. package/docs/agents/pr-evidence-layout.md +70 -0
  31. package/docs/agents/pr-evidence.md +8 -9
  32. package/docs/agents/pr-frames.md +7 -2
  33. package/docs/agents/pr-lift.md +132 -0
  34. package/docs/agents/scripting.md +2 -1
  35. package/docs/target/projects.md +3 -3
  36. package/governance/detect.toml +201 -0
  37. package/governance/rules/code/000-code.md +1 -13
  38. package/governance/rules/code/010-testing.md +2 -30
  39. package/governance/rules/ui/400-ui.md +1 -1
  40. package/governance/rules/ui/480-canvas.md +13 -0
  41. package/package.json +1 -1
  42. package/scripts/core/regen-hero.ts +0 -6
  43. package/scripts/lib/ui.sh +0 -9
  44. package/src/canvas/arrange.ts +66 -0
  45. package/src/canvas/capture.ts +20 -6
  46. package/src/canvas/content.ts +39 -0
  47. package/src/canvas/fit.ts +238 -0
  48. package/src/canvas/tokens.ts +28 -10
  49. package/src/capture/frame-values.ts +2 -32
  50. package/src/commands/canvas.ts +179 -1
  51. package/src/commands/gov.ts +144 -1
  52. package/src/commands/pr.ts +376 -1
  53. package/src/feedback/body.ts +26 -5
  54. package/src/gate/measures.ts +58 -44
  55. package/src/gate/stages.ts +3 -4
  56. package/src/gov/detect.ts +301 -0
  57. package/src/gov/list.ts +23 -2
  58. package/src/gov/stacks.ts +9 -0
  59. package/src/pr/evidence.ts +218 -45
  60. package/src/pr/frames.ts +23 -3
  61. package/src/pr/lift.ts +418 -0
  62. package/src/pr/rest.ts +3 -0
  63. package/src/pr/review-scope.ts +76 -0
  64. package/src/sandbox/equivalence.ts +14 -7
@@ -0,0 +1,238 @@
1
+ import {
2
+ type Box,
3
+ type ContentRefused,
4
+ readPage,
5
+ resizeFrame,
6
+ } from '@/canvas/content'
7
+ import {
8
+ type CaptureDeps,
9
+ resolveCaptureTargets,
10
+ type ServeRefused,
11
+ serveCanvas,
12
+ } from '@/canvas/capture'
13
+ import { resolveFrameTokens, servedFamilies } from '@/canvas/tokens'
14
+
15
+ /** What the capture browser read off one frame at its box width. */
16
+ export interface Measured {
17
+ readonly page: string
18
+ readonly frame: string
19
+ readonly box: Box
20
+ readonly contentHeight: number
21
+ readonly contentWidth: number
22
+ /**
23
+ * The lowest edge of any element in the body moved when the viewport
24
+ * height changed, so a viewport unit is sizing something and the content
25
+ * measure is not the height a viewer draws.
26
+ */
27
+ readonly viewportBound: boolean
28
+ /** The first family of the body text, unquoted. */
29
+ readonly family: string
30
+ }
31
+
32
+ export interface FitRow {
33
+ readonly page: string
34
+ readonly frame: string
35
+ /** The box height now. */
36
+ readonly height: number
37
+ /** The content's height, which is what the box should be. */
38
+ readonly fitted: number
39
+ /** The content is wider than the box, which fit reports and never widens. */
40
+ readonly wide: boolean
41
+ /** The first family is not one the canvas serves, so the height may not hold. */
42
+ readonly font: boolean
43
+ /**
44
+ * The document height depends on the viewport, as a height in `vh` does, so
45
+ * the content measure is not the height a viewer draws and nothing is written.
46
+ */
47
+ readonly viewport: boolean
48
+ readonly family: string
49
+ }
50
+
51
+ /**
52
+ * Turns measurements into rows. The box width is the frame's declared render
53
+ * width, so an overflow in it is a finding and never a resize. `served` is the
54
+ * set of families the served sheet carries a face for.
55
+ *
56
+ * A frame whose elements moved with the viewport height is sized by a unit
57
+ * such as `vh`, so the row keeps its height and reports `viewport`. Scroll
58
+ * height cannot show it alone, since a single `100vh` section reads as short
59
+ * content drawn in a taller viewport.
60
+ */
61
+ export function planFit(
62
+ measured: readonly Measured[],
63
+ served: ReadonlySet<string>,
64
+ ): FitRow[] {
65
+ return measured.map((item) => {
66
+ const content = Math.max(1, Math.ceil(item.contentHeight))
67
+ const viewport = item.viewportBound
68
+ return {
69
+ page: item.page,
70
+ frame: item.frame,
71
+ height: item.box.height,
72
+ fitted: viewport ? item.box.height : content,
73
+ wide: item.contentWidth > item.box.width,
74
+ font: !served.has(item.family),
75
+ viewport,
76
+ family: item.family,
77
+ }
78
+ })
79
+ }
80
+
81
+ /** Whether a row asks anything of the operator or the session. */
82
+ export function isOwed(row: FitRow): boolean {
83
+ return row.fitted !== row.height || row.wide || row.font || row.viewport
84
+ }
85
+
86
+ export type FitOutcome =
87
+ | {
88
+ readonly ok: true
89
+ readonly rows: readonly FitRow[]
90
+ /** Whether heights were written, which is false under a check. */
91
+ readonly written: boolean
92
+ }
93
+ | ContentRefused
94
+ | ServeRefused
95
+
96
+ export interface FitDeps extends Pick<CaptureDeps, 'start'> {
97
+ /** What a test stands in for, which the command never passes. */
98
+ readonly measure?: (targets: readonly MeasureTarget[]) => Promise<Measured[]>
99
+ }
100
+
101
+ export interface MeasureTarget {
102
+ readonly page: string
103
+ readonly frame: string
104
+ readonly url: string
105
+ readonly box: Box
106
+ }
107
+
108
+ /**
109
+ * Loads each frame at its box width through the served address, which is what
110
+ * injects the tokens and the faces, and reads the document once fonts and the
111
+ * load event have settled, the moment a capture waits for. The content is read
112
+ * at a one pixel tall viewport, since a document shorter than the viewport
113
+ * reports the viewport and a frame could then never shrink. The lowest element
114
+ * edge is then read at the box height and at twice it, and an edge that moved
115
+ * is a height set in viewport units.
116
+ */
117
+ async function measureInBrowser(
118
+ targets: readonly MeasureTarget[],
119
+ ): Promise<Measured[]> {
120
+ const { chromium } = await import('playwright-core')
121
+ const browser = await chromium.launch()
122
+ try {
123
+ const measured: Measured[] = []
124
+ for (const target of targets) {
125
+ const page = await browser.newPage({
126
+ viewport: { width: target.box.width, height: 1 },
127
+ })
128
+ try {
129
+ await page.goto(target.url, { waitUntil: 'load' })
130
+ const read = await page.evaluate(async () => {
131
+ await document.fonts.ready
132
+ const root = document.documentElement
133
+ const family = getComputedStyle(document.body).fontFamily
134
+ return {
135
+ contentHeight: root.scrollHeight,
136
+ contentWidth: root.scrollWidth,
137
+ family,
138
+ }
139
+ })
140
+ const lowestEdge = () =>
141
+ page.evaluate(() => {
142
+ let bottom = 0
143
+ for (const element of document.body.querySelectorAll('*')) {
144
+ bottom = Math.max(
145
+ bottom,
146
+ element.getBoundingClientRect().bottom + window.scrollY,
147
+ )
148
+ }
149
+ return Math.ceil(bottom)
150
+ })
151
+ const heights = [target.box.height, target.box.height * 2]
152
+ const edges: number[] = []
153
+ for (const height of heights) {
154
+ await page.setViewportSize({ width: target.box.width, height })
155
+ edges.push(await lowestEdge())
156
+ }
157
+ measured.push({
158
+ page: target.page,
159
+ frame: target.frame,
160
+ box: target.box,
161
+ contentHeight: read.contentHeight,
162
+ contentWidth: read.contentWidth,
163
+ viewportBound: edges[0] !== edges[1],
164
+ family: firstFamily(read.family),
165
+ })
166
+ } finally {
167
+ await page.close()
168
+ }
169
+ }
170
+ return measured
171
+ } finally {
172
+ await browser.close()
173
+ }
174
+ }
175
+
176
+ /** The first entry of a computed `font-family` list, without its quotes. */
177
+ export function firstFamily(list: string): string {
178
+ const first = list.split(',')[0] ?? ''
179
+ return first.trim().replace(/^["']|["']$/g, '')
180
+ }
181
+
182
+ /**
183
+ * Measures the frames `spec` names and, unless `write` is false, sets each
184
+ * box's height to its content through `resizeFrame`, the writer the operator's
185
+ * drags share. Serves the canvas for the call, so it needs no running board.
186
+ */
187
+ export async function fitCanvas(
188
+ root: string,
189
+ spec: string,
190
+ write: boolean,
191
+ deps: FitDeps = {},
192
+ ): Promise<FitOutcome> {
193
+ const early = resolveCaptureTargets(root, spec, '')
194
+ if (!early.ok) return early
195
+
196
+ const served = await serveCanvas(root, deps.start, async (base) => {
197
+ const resolved = resolveCaptureTargets(root, spec, base)
198
+ if (!resolved.ok) return resolved
199
+ const frames = readPage(root, resolved.targets[0]?.page ?? '')?.frames ?? []
200
+
201
+ const targets: MeasureTarget[] = []
202
+ for (const item of resolved.targets) {
203
+ const frame = frames.find((candidate) => candidate.name === item.frame)
204
+ if (!frame) continue
205
+ targets.push({
206
+ page: item.page,
207
+ frame: item.frame,
208
+ url: item.url,
209
+ box: {
210
+ x: frame.x,
211
+ y: frame.y,
212
+ width: frame.width,
213
+ height: frame.height,
214
+ },
215
+ })
216
+ }
217
+ const measure = deps.measure ?? measureInBrowser
218
+ return { ok: true as const, targets, measured: await measure(targets) }
219
+ })
220
+ if (!served.ok) return served
221
+
222
+ const rows = planFit(
223
+ served.measured,
224
+ servedFamilies(resolveFrameTokens(root).css),
225
+ )
226
+ if (!write) return { ok: true, rows, written: false }
227
+
228
+ for (const row of rows.filter((item) => item.fitted !== item.height)) {
229
+ const target = served.targets.find((item) => item.frame === row.frame)
230
+ if (!target) continue
231
+ const outcome = resizeFrame(root, row.page, row.frame, {
232
+ ...target.box,
233
+ height: row.fitted,
234
+ })
235
+ if (!outcome.ok) return outcome
236
+ }
237
+ return { ok: true, rows, written: true }
238
+ }
@@ -10,7 +10,6 @@ import {
10
10
  import {
11
11
  type DroppedFace,
12
12
  inlineProjectFaces,
13
- namedFamilies,
14
13
  projectSheets,
15
14
  } from '@/design/project-fonts'
16
15
  import { isOwnCheckout } from '@/roots/project'
@@ -31,7 +30,7 @@ export type FrameTokens =
31
30
  /** Project faces left out of the sheet, present only when one was. */
32
31
  readonly dropped?: readonly DroppedFace[]
33
32
  }
34
- | { readonly source: 'none'; readonly css: ''; readonly notice: string }
33
+ | { readonly source: 'none'; readonly css: string; readonly notice: string }
35
34
 
36
35
  export interface TokenOptions {
37
36
  /** Overrides the checkout test, which reads this package's own name. */
@@ -124,6 +123,21 @@ export function tokenGroups(css: string): TokenGroup[] {
124
123
  })
125
124
  }
126
125
 
126
+ /**
127
+ * The families a served sheet declares a face for, so a caller reads what the
128
+ * frames carry from the same sheet the server builds rather than from a list
129
+ * of its own.
130
+ */
131
+ export function servedFamilies(css: string): Set<string> {
132
+ const families = new Set<string>()
133
+ for (const [, family] of css.matchAll(
134
+ /@font-face\s*{[^}]*?font-family:\s*["']?([^"';}]+)/g,
135
+ )) {
136
+ if (family) families.add(family.trim())
137
+ }
138
+ return families
139
+ }
140
+
127
141
  /** The installed files in cascade order: the base first, then each override. */
128
142
  function installedFiles(root: string): string[] {
129
143
  const base = join(DESIGN_INSTALL_DIR, BASE_FILE)
@@ -134,14 +148,14 @@ function installedFiles(root: string): string[] {
134
148
  }
135
149
 
136
150
  /**
137
- * The vendored faces the installed sheets name and no project rule already
138
- * declares, so a target renders the base's families on a machine that never
139
- * installed them, and a project face of the same family wins outright.
151
+ * The vendored faces no project rule already declares, so a frame naming
152
+ * `Geist Variable` renders the same face in the capture and in any browser
153
+ * whether or not a sheet names it, and a project face of the same family wins
154
+ * outright.
140
155
  */
141
- function toolkitFaces(css: string, declared: ReadonlySet<string>): FontFace[] {
142
- const named = namedFamilies(css)
156
+ function toolkitFaces(declared: ReadonlySet<string>): FontFace[] {
143
157
  return [...FONT_FACES, ...HAND_DRAWN_FONT_FACES].filter(
144
- (face) => named.has(face.family) && !declared.has(face.family),
158
+ (face) => !declared.has(face.family),
145
159
  )
146
160
  }
147
161
 
@@ -190,7 +204,7 @@ export function resolveFrameTokens(
190
204
  const files = installedFiles(root)
191
205
  if (files.length > 0) {
192
206
  const { css, dropped, declared } = installedSheets(root, files)
193
- const faces = toolkitFaces(css, declared)
207
+ const faces = toolkitFaces(declared)
194
208
  return {
195
209
  source: 'installed',
196
210
  css: faces.length > 0 ? `${fontFaceBlock(faces)}\n${css}` : css,
@@ -199,5 +213,9 @@ export function resolveFrameTokens(
199
213
  }
200
214
  }
201
215
 
202
- return { source: 'none', css: '', notice: NONE_NOTICE }
216
+ return {
217
+ source: 'none',
218
+ css: fontFaceBlock(toolkitFaces(new Set())),
219
+ notice: NONE_NOTICE,
220
+ }
203
221
  }
@@ -31,15 +31,9 @@ const GLOB_WIDTH = 44
31
31
  /** A refusal the script prints as `regen-hero: <message>` and exits 1 on. */
32
32
  export class FrameError extends Error {}
33
33
 
34
- export interface StandardRow {
35
- readonly name: string
36
- readonly appliesTo?: readonly string[]
37
- }
38
-
39
34
  export interface FrameCatalogs {
40
35
  readonly skills: readonly string[]
41
36
  readonly gov: GovCatalog
42
- readonly standards: readonly StandardRow[]
43
37
  readonly toolingStacks: readonly StackSummary[]
44
38
  readonly commandCount: number
45
39
  readonly tokenCss: string
@@ -63,7 +57,7 @@ const escape = (value: string): string =>
63
57
  /**
64
58
  * Even spacing across the sorted catalog rather than its first N, so a column
65
59
  * whose names share a prefix does not read as a narrower catalog than the one
66
- * that ships. Rules and standards sample. Skills do not, because the even step
60
+ * that ships. Rules sample. Skills do not, because the even step
67
61
  * lands on the entries closest to commodity and on none of the workflow the
68
62
  * toolkit exists to carry.
69
63
  */
@@ -186,7 +180,7 @@ function faviconUri(markSvg: string, tokenCss: string): string {
186
180
  * rather than shipping a frame claiming the domain has nothing in it.
187
181
  */
188
182
  export function buildFrameValues(catalogs: FrameCatalogs): FrameValues {
189
- const { gov, toolingStacks, standards, skills } = catalogs
183
+ const { gov, toolingStacks, skills } = catalogs
190
184
  const markSvg = catalogs.markSvg.trim()
191
185
  if (!markSvg) {
192
186
  throw new FrameError(
@@ -207,12 +201,10 @@ export function buildFrameValues(catalogs: FrameCatalogs): FrameValues {
207
201
  const stacked = new Set(gov.stacks.flatMap((stack) => stack.rules))
208
202
  const deliveredEntries = gov.rules.filter((entry) => stacked.has(entry.name))
209
203
  const deliveredRules = deliveredEntries.map((entry) => slug(entry.name))
210
- const standardNames = standards.map((entry) => entry.name)
211
204
 
212
205
  for (const [label, list] of [
213
206
  ['skills', skills],
214
207
  ['rules', rules],
215
- ['standards', standardNames],
216
208
  ['tooling stacks', toolingStacks],
217
209
  ['gov stacks', gov.stacks],
218
210
  ['stack-delivered rules', deliveredRules],
@@ -251,23 +243,6 @@ export function buildFrameValues(catalogs: FrameCatalogs): FrameValues {
251
243
  })
252
244
  .join('\n')
253
245
 
254
- // A standard either governs an artifact by path or is opened by name when a
255
- // session decides it needs it. Naming the second is the contrast, since
256
- // nothing else tells a reader the corpus installs into no project.
257
- const standardRows = sample(standards)
258
- .map((entry) => {
259
- const applies = entry.appliesTo ?? []
260
- const governs =
261
- applies.length > 0
262
- ? clip(applies.join(' '), GLOB_WIDTH)
263
- : 'read by name'
264
- return frameRow(
265
- `<span class="name">${escape(pad(entry.name, 18))}</span>` +
266
- `<span class="muted">${escape(governs)}</span>`,
267
- )
268
- })
269
- .join('\n')
270
-
271
246
  // A tooling stack is counted rather than listed, because what it lays down is
272
247
  // dev dependencies, run scripts, and ignore groups rather than named entries.
273
248
  // The inheritance is the part worth showing, since it is why a stack carrying
@@ -286,7 +261,6 @@ export function buildFrameValues(catalogs: FrameCatalogs): FrameValues {
286
261
  for (const [label, rows] of [
287
262
  ['governance stack rows', govStackRows],
288
263
  ['governance rule rows', govRuleRows],
289
- ['standard rows', standardRows],
290
264
  ['tooling stack rows', toolingStackRows],
291
265
  ] as const) {
292
266
  if (rows === '') {
@@ -300,11 +274,9 @@ export function buildFrameValues(catalogs: FrameCatalogs): FrameValues {
300
274
  TOKENS: tokenCss,
301
275
  GOV_STACK_ROWS: govStackRows,
302
276
  GOV_RULE_ROWS: govRuleRows,
303
- STANDARD_ROWS: standardRows,
304
277
  TOOLING_STACK_ROWS: toolingStackRows,
305
278
  SKILL_COUNT: String(skills.length),
306
279
  RULE_COUNT: String(rules.length),
307
- STANDARD_COUNT: String(standardNames.length),
308
280
  GOV_STACK_COUNT: String(gov.stacks.length),
309
281
  TOOLING_STACK_COUNT: String(toolingStacks.length),
310
282
  COMMAND_COUNT: String(catalogs.commandCount),
@@ -312,10 +284,8 @@ export function buildFrameValues(catalogs: FrameCatalogs): FrameValues {
312
284
  FAVICON: faviconUri(markSvg, tokenCss),
313
285
  SKILL_ENTRIES: markup(featured(skills, FEATURED_SKILLS)),
314
286
  RULE_ENTRIES: markup(sample(deliveredRules)),
315
- STANDARD_ENTRIES: markup(sample(standardNames)),
316
287
  SKILL_MORE: remaining(skills),
317
288
  RULE_MORE: remaining(rules),
318
- STANDARD_MORE: remaining(standardNames),
319
289
  }
320
290
 
321
291
  const light = lightTokens(tokenCss)
@@ -9,12 +9,15 @@ import {
9
9
  DEFAULT_FRAME,
10
10
  listPages,
11
11
  moveFrame,
12
+ pruneLayout,
12
13
  readPage,
13
14
  readSelection,
14
15
  renamePage,
15
16
  resizeFrame,
16
17
  } from '@/canvas/content'
18
+ import { planArrange } from '@/canvas/arrange'
17
19
  import { captureCanvas, captureComposite } from '@/canvas/capture'
20
+ import { fitCanvas, isOwed } from '@/canvas/fit'
18
21
  import {
19
22
  type EditRefused,
20
23
  editFrameAtIndex,
@@ -148,7 +151,7 @@ export function register(program: Command): void {
148
151
  .command('canvas')
149
152
  .helpOption('-h, --help', 'Show this help message')
150
153
  .description(
151
- 'Local canvas of pages and HTML frames (serve, list, page, frame, selection, edit, editing, capture)',
154
+ 'Local canvas of pages and HTML frames (serve, list, page, frame, selection, edit, editing, capture, fit, arrange)',
152
155
  )
153
156
  .addHelpText(
154
157
  'after',
@@ -167,6 +170,8 @@ export function register(program: Command): void {
167
170
  " canon canvas edit drafts/hero --element 4 --set 'color=var(--color-accent)'",
168
171
  ' canon canvas editing drafts/hero',
169
172
  ' canon canvas capture drafts/hero',
173
+ ' canon canvas fit drafts/hero --json',
174
+ ' canon canvas arrange drafts --check',
170
175
  ' canon canvas list --json',
171
176
  '',
172
177
  ].join('\n'),
@@ -728,6 +733,179 @@ export function register(program: Command): void {
728
733
  outro()
729
734
  if (failed) process.exitCode = 1
730
735
  })
736
+
737
+ withRoot(
738
+ canvas
739
+ .command('fit')
740
+ .description("Set each frame's height to its content")
741
+ .argument('<target>', '<page>/<frame> for one frame, <page> for all')
742
+ .option('--check', 'Report what is owed and write nothing'),
743
+ )
744
+ .addHelpText(
745
+ 'after',
746
+ [
747
+ '',
748
+ 'Measures each frame in the capture browser at its box width, through',
749
+ 'the served address, so the faces the canvas serves are the ones read.',
750
+ 'The height is the content height rounded up, with no margin. A',
751
+ 'content wider than the box is reported and the box is never widened.',
752
+ 'A frame whose body text names a family the canvas does not serve is',
753
+ 'reported as font, since its height may not hold in another browser.',
754
+ '',
755
+ 'Run canvas arrange after a fit that grew a frame, since rows below',
756
+ 'do not move on their own.',
757
+ '',
758
+ 'Exit codes:',
759
+ ' 0 written, or nothing owed under --check',
760
+ ' 1 refused or the browser failed',
761
+ ' 2 --check found a height off, a wide frame, or a font',
762
+ '',
763
+ ].join('\n'),
764
+ )
765
+ .action(async (target: string, opts: FitOptions) => {
766
+ process.exitCode = await runFit(target, opts)
767
+ })
768
+
769
+ withRoot(
770
+ canvas
771
+ .command('arrange')
772
+ .description('Re-lay a page in rows on the frame gap')
773
+ .argument('<page>', 'Page to arrange')
774
+ .option('--check', 'Report what is owed and write nothing'),
775
+ )
776
+ .addHelpText(
777
+ 'after',
778
+ [
779
+ '',
780
+ 'Keeps the rows the frames already sit in, orders each left to right on',
781
+ 'the frame gap, stacks the rows on their tallest frame, and drops',
782
+ 'layout entries whose frame file is gone.',
783
+ '',
784
+ 'Exit codes:',
785
+ ' 0 written, or nothing owed under --check',
786
+ ' 1 refused',
787
+ ' 2 --check found frames to move or entries to drop',
788
+ '',
789
+ ].join('\n'),
790
+ )
791
+ .action(async (pageName: string, opts: FitOptions) => {
792
+ process.exitCode = await runArrange(pageName, opts)
793
+ })
794
+ }
795
+
796
+ interface FitOptions extends RootOptions {
797
+ readonly check?: boolean
798
+ }
799
+
800
+ async function runFit(target: string, opts: FitOptions): Promise<number> {
801
+ const emitJson = opts.json ?? false
802
+ const resolved = await resolveRoot(opts)
803
+ if (!resolved.ok) return refuse(resolved, emitJson)
804
+
805
+ const check = opts.check ?? false
806
+ let outcome: Awaited<ReturnType<typeof fitCanvas>>
807
+ try {
808
+ outcome = await fitCanvas(resolved.root, target, !check)
809
+ } catch (error) {
810
+ const detail = error instanceof Error ? error.message : String(error)
811
+ return refuse({ ok: false, reason: 'capture-failed', detail }, emitJson)
812
+ }
813
+ if (!outcome.ok) return refuse(outcome, emitJson)
814
+
815
+ const frames = outcome.rows.map((row) => ({ ...row, owed: isOwed(row) }))
816
+ const owed = frames.some((row) => row.owed)
817
+ if (emitJson) writeJson({ ok: true, written: outcome.written, frames })
818
+ intro(BANNER)
819
+ for (const row of frames) {
820
+ const name = `${row.page}/${row.frame}`
821
+ if (row.fitted === row.height) logInfo(`${name} fits at ${row.height}`)
822
+ else if (outcome.written) logAdd(`${name} ${row.height} to ${row.fitted}`)
823
+ else logStep(`${name} owes ${row.height} to ${row.fitted}`)
824
+ if (row.wide) logWarn(`${name} is wider than its box, so widen the box`)
825
+ if (row.viewport) {
826
+ logWarn(
827
+ `${name} sets a height in viewport units, so its height was not written and a fixed height fits it`,
828
+ )
829
+ }
830
+ if (row.font) {
831
+ logWarn(
832
+ `${name} sets ${row.family}, which the canvas does not serve, so its height may not hold in another browser`,
833
+ )
834
+ }
835
+ }
836
+ outro()
837
+ return check && owed ? 2 : 0
838
+ }
839
+
840
+ async function runArrange(pageName: string, opts: FitOptions): Promise<number> {
841
+ const emitJson = opts.json ?? false
842
+ const resolved = await resolveRoot(opts)
843
+ if (!resolved.ok) return refuse(resolved, emitJson)
844
+ const { root } = resolved
845
+
846
+ const page = readPage(root, pageName)
847
+ if (!page) {
848
+ return refuse(
849
+ {
850
+ ok: false,
851
+ reason: 'no-page',
852
+ detail: `page ${pageName} does not exist`,
853
+ },
854
+ emitJson,
855
+ )
856
+ }
857
+ if (page.layoutIssue) {
858
+ return refuse(
859
+ {
860
+ ok: false,
861
+ reason: 'malformed-layout',
862
+ detail: `${pageName}/layout.json does not parse`,
863
+ },
864
+ emitJson,
865
+ )
866
+ }
867
+
868
+ const check = opts.check ?? false
869
+ const pruned = pruneLayout(root, pageName, !check)
870
+ if (!pruned.ok) return refuse(pruned, emitJson)
871
+
872
+ const moves = planArrange(page.frames)
873
+ if (!check) {
874
+ for (const move of moves) {
875
+ const outcome = resizeFrame(root, pageName, move.name, move.to)
876
+ if (!outcome.ok) return refuse(outcome, emitJson)
877
+ }
878
+ }
879
+
880
+ if (emitJson) {
881
+ writeJson({
882
+ ok: true,
883
+ page: pageName,
884
+ written: !check,
885
+ moved: moves.map((move) => ({
886
+ frame: move.name,
887
+ from: { x: move.from.x, y: move.from.y },
888
+ to: { x: move.to.x, y: move.to.y },
889
+ })),
890
+ dropped: pruned.dropped,
891
+ })
892
+ }
893
+ intro(BANNER)
894
+ for (const move of moves) {
895
+ const text = `${pageName}/${move.name} ${move.from.x}, ${move.from.y} to ${move.to.x}, ${move.to.y}`
896
+ if (check) logStep(`${text} is owed`)
897
+ else logAdd(text)
898
+ }
899
+ for (const name of pruned.dropped) {
900
+ logWarn(
901
+ `${pageName}/${name} has no frame file, so its entry is ${check ? 'owed a drop' : 'dropped'}`,
902
+ )
903
+ }
904
+ if (moves.length === 0 && pruned.dropped.length === 0) {
905
+ logInfo(`${pageName} is already laid out`)
906
+ }
907
+ outro()
908
+ return check && (moves.length > 0 || pruned.dropped.length > 0) ? 2 : 0
731
909
  }
732
910
 
733
911
  interface CaptureOptions extends RootOptions {