@erclx/canon 5.17.0 → 5.19.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 (51) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
  4. package/claude/skills/deploy-app/SKILL.md +45 -0
  5. package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
  6. package/claude/skills/deploy-app/references/vercel.md +72 -0
  7. package/docs/agents/architecture-record.md +12 -4
  8. package/docs/agents/canvas.md +13 -1
  9. package/docs/agents/commands.md +3 -2
  10. package/docs/agents/context-audit-checks.md +1 -1
  11. package/docs/agents/context-audit.md +4 -4
  12. package/docs/agents/index.md +2 -2
  13. package/docs/agents/teach.md +7 -1
  14. package/docs/workflow/skill-map.md +9 -9
  15. package/package.json +2 -1
  16. package/src/audits/catalog.ts +46 -1
  17. package/src/canvas/address.ts +73 -39
  18. package/src/canvas/client/inspector.tsx +241 -33
  19. package/src/canvas/client/pages-panel.tsx +51 -0
  20. package/src/canvas/client/shell.css +126 -1
  21. package/src/canvas/client/state.ts +120 -4
  22. package/src/canvas/client/theme-panel.tsx +55 -0
  23. package/src/canvas/content.ts +14 -3
  24. package/src/canvas/edit.ts +360 -0
  25. package/src/canvas/server.ts +50 -5
  26. package/src/canvas/tokens.ts +82 -0
  27. package/src/claude/cases/misc.ts +5 -1
  28. package/src/commands/canvas.ts +95 -1
  29. package/src/commands/context.ts +85 -5
  30. package/src/commands/slides.ts +73 -4
  31. package/src/commands/teach.ts +112 -4
  32. package/src/context/architecture.ts +98 -18
  33. package/src/context/gate.ts +30 -3
  34. package/src/context/requirements.ts +77 -0
  35. package/src/gate/measures.ts +69 -28
  36. package/src/gate/stages.ts +5 -5
  37. package/src/serve/live.ts +113 -0
  38. package/src/serve/static.ts +60 -2
  39. package/src/slides/convert/export.ts +323 -0
  40. package/src/slides/convert/shapes.ts +764 -0
  41. package/src/slides/convert/svg.ts +38 -0
  42. package/src/slides/convert/walk.ts +444 -0
  43. package/src/teach/browser/reload.ts +9 -0
  44. package/src/teach/nav.ts +7 -6
  45. package/src/teach/workspace.ts +2 -1
  46. package/src/teach/write-if-changed.ts +17 -0
  47. package/standards/architecture.md +13 -7
  48. package/standards/requirements.md +4 -0
  49. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
  50. package/tooling/cloudflare/reference.md +1 -1
  51. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
@@ -15,7 +15,12 @@ import {
15
15
  TEMP_SUFFIX,
16
16
  writeSelection,
17
17
  } from '@/canvas/content'
18
- import { resolveFrameTokens, type TokenOptions } from '@/canvas/tokens'
18
+ import { type EditRefused, editFrame } from '@/canvas/edit'
19
+ import {
20
+ resolveFrameTokens,
21
+ type TokenOptions,
22
+ tokenGroups,
23
+ } from '@/canvas/tokens'
19
24
  import { buildDesignCss } from '@/design/css'
20
25
  import {
21
26
  bindFirstFree,
@@ -140,7 +145,9 @@ function json(body: unknown, status = 200): Response {
140
145
  })
141
146
  }
142
147
 
143
- const REFUSAL_STATUS: Record<ContentRefused['reason'], number> = {
148
+ const REFUSAL_STATUS: Record<EditRefused['reason'], number> = {
149
+ 'invalid-edit': 400,
150
+ 'not-text-only': 409,
144
151
  'invalid-name': 400,
145
152
  'invalid-size': 400,
146
153
  'invalid-position': 400,
@@ -154,7 +161,7 @@ const REFUSAL_STATUS: Record<ContentRefused['reason'], number> = {
154
161
  'stale-address': 409,
155
162
  }
156
163
 
157
- function refusal(refused: ContentRefused): Response {
164
+ function refusal(refused: ContentRefused | EditRefused): Response {
158
165
  return json(refused, REFUSAL_STATUS[refused.reason])
159
166
  }
160
167
 
@@ -351,11 +358,11 @@ export function startCanvas(
351
358
  '/': options.shell,
352
359
  '/api/pages': guarded(() => {
353
360
  const resolved = resolveFrameTokens(root, tokens)
354
- const { css: _css, ...source } = resolved
361
+ const { css, ...source } = resolved
355
362
  const selected = readSelection(root)
356
363
  return json({
357
364
  pages: listPages(root),
358
- tokens: source,
365
+ tokens: { ...source, groups: tokenGroups(css) },
359
366
  selection: selected && {
360
367
  page: selected.page,
361
368
  frame: selected.frame,
@@ -389,6 +396,44 @@ export function startCanvas(
389
396
  }),
390
397
  ),
391
398
  },
399
+ '/api/frames/edit': {
400
+ POST: guarded(
401
+ mutation((body) => {
402
+ if (
403
+ !isRecord(body) ||
404
+ typeof body.page !== 'string' ||
405
+ typeof body.frame !== 'string' ||
406
+ typeof body.property !== 'string' ||
407
+ typeof body.value !== 'string'
408
+ ) {
409
+ return badBody(
410
+ 'send page, frame, element, property, and value',
411
+ )
412
+ }
413
+ const { element } = body
414
+ /*
415
+ * The hash is what keeps an edit off an element the file has
416
+ * since moved, so the shell's edit never goes without it.
417
+ */
418
+ if (!isAddressShape(element) || element.hash === undefined) {
419
+ return badBody(
420
+ 'send an element as index, tag, count, and the hash it was served with',
421
+ )
422
+ }
423
+ const outcome = editFrame(
424
+ root,
425
+ body.page,
426
+ body.frame,
427
+ element,
428
+ {
429
+ property: body.property,
430
+ value: body.value,
431
+ },
432
+ )
433
+ return outcome.ok ? json(outcome) : refusal(outcome)
434
+ }),
435
+ ),
436
+ },
392
437
  '/api/selection': {
393
438
  POST: guarded(
394
439
  mutation((body) => {
@@ -29,6 +29,88 @@ const BASE_FILE = 'base.css'
29
29
 
30
30
  const NONE_NOTICE = `No token stylesheet, so frames render unstyled. Run canon design install to add ${join(DESIGN_INSTALL_DIR, BASE_FILE)}, put overrides under ${join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)}/, or link a stylesheet from the frame.`
31
31
 
32
+ export type TokenKind =
33
+ | 'color'
34
+ | 'spacing'
35
+ | 'radius'
36
+ | 'font-family'
37
+ | 'font-size'
38
+ | 'other'
39
+
40
+ export interface Token {
41
+ readonly name: string
42
+ readonly value: string
43
+ /** The literal a `var()` value ends at, where the sheet defines it. */
44
+ readonly resolved?: string
45
+ }
46
+
47
+ export interface TokenGroup {
48
+ readonly kind: TokenKind
49
+ readonly tokens: readonly Token[]
50
+ }
51
+
52
+ const KIND_ORDER: readonly TokenKind[] = [
53
+ 'color',
54
+ 'spacing',
55
+ 'radius',
56
+ 'font-family',
57
+ 'font-size',
58
+ 'other',
59
+ ]
60
+
61
+ /** By the naming the toolkit's module and most token sheets share. */
62
+ function kindOf(name: string): TokenKind {
63
+ if (name.startsWith('--color-')) return 'color'
64
+ if (/^--(space|spacing)-/.test(name)) return 'spacing'
65
+ if (name.startsWith('--radius-')) return 'radius'
66
+ if (/-family$|^--font-family-/.test(name)) return 'font-family'
67
+ if (/-size$|^--font-size-|^--text-/.test(name)) return 'font-size'
68
+ return 'other'
69
+ }
70
+
71
+ const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:\s*([^;}]*)/g
72
+
73
+ const REFERENCE = /^var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,[^)]*)?\)$/
74
+
75
+ /**
76
+ * The custom properties a token sheet defines, grouped by kind. A property
77
+ * defined twice keeps its first value, which is the dark theme's in this
78
+ * toolkit's module and the base's in an installed sheet. Empty groups drop.
79
+ */
80
+ export function tokenGroups(css: string): TokenGroup[] {
81
+ const values = new Map<string, string>()
82
+ for (const [, name, value] of css
83
+ .replace(/\/\*[\s\S]*?\*\//g, '')
84
+ .matchAll(DECLARATION)) {
85
+ if (name && value !== undefined && !values.has(name)) {
86
+ values.set(name, value.trim())
87
+ }
88
+ }
89
+
90
+ const resolve = (value: string, seen: Set<string>): string | undefined => {
91
+ const reference = value.match(REFERENCE)?.[1]
92
+ if (!reference) return value
93
+ const next = values.get(reference)
94
+ if (next === undefined || seen.has(reference)) return undefined
95
+ return resolve(next, new Set([...seen, reference]))
96
+ }
97
+
98
+ const grouped = new Map<TokenKind, Token[]>()
99
+ for (const [name, value] of values) {
100
+ const resolved = REFERENCE.test(value)
101
+ ? resolve(value, new Set([name]))
102
+ : undefined
103
+ const token =
104
+ resolved === undefined ? { name, value } : { name, value, resolved }
105
+ const kind = kindOf(name)
106
+ grouped.set(kind, [...(grouped.get(kind) ?? []), token])
107
+ }
108
+ return KIND_ORDER.flatMap((kind) => {
109
+ const tokens = grouped.get(kind)
110
+ return tokens ? [{ kind, tokens }] : []
111
+ })
112
+ }
113
+
32
114
  /** The installed files in cascade order: the base first, then each override. */
33
115
  function installedFiles(root: string): string[] {
34
116
  const files: string[] = []
@@ -104,6 +104,10 @@ export const MISC_CASES: readonly SkillCase[] = [
104
104
  },
105
105
  {
106
106
  prompt: 'Connect this repo to Cloudflare Pages and set up the deploy.',
107
- expect: 'deploy-cloudflare',
107
+ expect: 'deploy-app',
108
+ },
109
+ {
110
+ prompt: 'Set up a Vercel project for this app and wire up its secrets.',
111
+ expect: 'deploy-app',
108
112
  },
109
113
  ]
@@ -14,6 +14,11 @@ import {
14
14
  renamePage,
15
15
  } from '@/canvas/content'
16
16
  import { captureCanvas } from '@/canvas/capture'
17
+ import {
18
+ type EditRefused,
19
+ editFrameAtIndex,
20
+ STYLE_PROPERTIES,
21
+ } from '@/canvas/edit'
17
22
  import { CANVAS_PORT, startCanvas } from '@/canvas/server'
18
23
  import { resolveFrameTokens } from '@/canvas/tokens'
19
24
  import { displayPath, parsePort, waitForInterrupt } from '@/serve/report'
@@ -40,6 +45,7 @@ interface Refused {
40
45
  readonly ok: false
41
46
  readonly reason:
42
47
  | ContentRefused['reason']
48
+ | EditRefused['reason']
43
49
  | 'no-root'
44
50
  | 'no-server'
45
51
  | 'capture-failed'
@@ -102,7 +108,7 @@ export function register(program: Command): void {
102
108
  .command('canvas')
103
109
  .helpOption('-h, --help', 'Show this help message')
104
110
  .description(
105
- 'Local canvas of pages and HTML frames (serve, list, page, frame, selection, capture)',
111
+ 'Local canvas of pages and HTML frames (serve, list, page, frame, selection, edit, capture)',
106
112
  )
107
113
  .addHelpText(
108
114
  'after',
@@ -118,6 +124,7 @@ export function register(program: Command): void {
118
124
  ' canon canvas frame add drafts hero --width 1440 --height 900',
119
125
  ' canon canvas frame move drafts hero --x 200 --y 120',
120
126
  ' canon canvas selection --json',
127
+ " canon canvas edit drafts/hero --element 4 --set 'color=var(--color-accent)'",
121
128
  ' canon canvas capture drafts/hero',
122
129
  ' canon canvas list --json',
123
130
  '',
@@ -366,6 +373,93 @@ export function register(program: Command): void {
366
373
  outro()
367
374
  })
368
375
 
376
+ withRoot(
377
+ canvas
378
+ .command('edit')
379
+ .description(
380
+ 'Set one property of an element in a frame, the writer the inspector uses',
381
+ )
382
+ .argument('<target>', '<page>/<frame>')
383
+ .requiredOption(
384
+ '--element <index>',
385
+ 'The element by its index in document order, as canvas selection reports it',
386
+ )
387
+ .requiredOption(
388
+ '--set <property=value>',
389
+ 'The property and its value, where an empty value drops a style property',
390
+ ),
391
+ )
392
+ .addHelpText(
393
+ 'after',
394
+ [
395
+ '',
396
+ `Properties: text, ${STYLE_PROPERTIES.join(', ')}.`,
397
+ 'A style property is written into the element inline style and',
398
+ 'leaves its other properties as they were. text replaces the text of',
399
+ 'an element holding text alone, and refuses one holding elements.',
400
+ 'Every byte outside the element stays as it was.',
401
+ '',
402
+ ].join('\n'),
403
+ )
404
+ .action(
405
+ async (
406
+ target: string,
407
+ opts: RootOptions & { element: string; set: string },
408
+ ) => {
409
+ const emitJson = opts.json ?? false
410
+ const resolved = await resolveRoot(opts)
411
+ if (!resolved.ok) {
412
+ process.exitCode = refuse(resolved, emitJson)
413
+ return
414
+ }
415
+ const { root } = resolved
416
+ const [pageName, frameName, ...rest] = target.split('/')
417
+ if (!pageName || !frameName || rest.length > 0) {
418
+ process.exitCode = refuse(
419
+ {
420
+ ok: false,
421
+ reason: 'invalid-name',
422
+ detail: `${target} is not <page>/<frame>`,
423
+ },
424
+ emitJson,
425
+ )
426
+ return
427
+ }
428
+ const split = opts.set.indexOf('=')
429
+ if (split <= 0) {
430
+ process.exitCode = refuse(
431
+ {
432
+ ok: false,
433
+ reason: 'invalid-edit',
434
+ detail: `${opts.set} is not <property>=<value>`,
435
+ },
436
+ emitJson,
437
+ )
438
+ return
439
+ }
440
+ const property = opts.set.slice(0, split).trim()
441
+ const value = opts.set.slice(split + 1)
442
+ const index =
443
+ opts.element.trim() === '' ? Number.NaN : Number(opts.element)
444
+ const outcome = editFrameAtIndex(root, pageName, frameName, index, {
445
+ property,
446
+ value,
447
+ })
448
+ if (!outcome.ok) {
449
+ process.exitCode = refuse(outcome, emitJson)
450
+ return
451
+ }
452
+ const path = resolve(canvasDir(root), outcome.page, outcome.file)
453
+ if (emitJson) writeJson({ ...outcome, property, value, path })
454
+ intro(BANNER)
455
+ logAdd(`${outcome.page}/${outcome.file}`)
456
+ logInfo(
457
+ `element ${index}: ${property} ${value === '' ? 'dropped' : `= ${value}`}`,
458
+ )
459
+ outro()
460
+ },
461
+ )
462
+
369
463
  withRoot(
370
464
  canvas
371
465
  .command('capture')
@@ -24,8 +24,10 @@ import {
24
24
  coveredCount,
25
25
  isOverCount,
26
26
  isOverLength,
27
+ isOverRisks,
27
28
  measureArchitecture,
28
29
  missingRevisit,
30
+ overWords,
29
31
  testableCount,
30
32
  } from '@/context/architecture'
31
33
  import { auditCitations, type CitationReport } from '@/context/citations'
@@ -56,6 +58,12 @@ import {
56
58
  resolveFolders,
57
59
  } from '@/context/folders'
58
60
  import { isGating } from '@/context/gate'
61
+ import {
62
+ isOverWordCap,
63
+ measureRequirements,
64
+ requirementsRel,
65
+ type RequirementsReport,
66
+ } from '@/context/requirements'
59
67
  import { auditIndexes, type FolderDrift } from '@/context/index-drift'
60
68
  import {
61
69
  measureWireframeFolder,
@@ -96,7 +104,7 @@ export function register(program: Command): void {
96
104
  context
97
105
  .command('audit')
98
106
  .description(
99
- 'Report required sections, entry length, citations, reference form, catalog tables, provenance, index drift, the architecture record against its own ceiling, its own entry cap, and its word weight, and wireframe states against their evidence folders',
107
+ 'Report required sections, entry length, citations, reference form, catalog tables, provenance, index drift, the architecture record against its own ceiling, entry cap, word cap, and risk cap, the requirements record against its own word cap, and wireframe states against their evidence folders',
100
108
  )
101
109
  .argument('[path]', 'Project root, defaulting to the current directory')
102
110
  .helpOption('-h, --help', 'Show this help message')
@@ -125,8 +133,10 @@ export function register(program: Command): void {
125
133
  'measures it. A record stating an entry cap gates the same way when',
126
134
  'it holds more decisions than the cap, and a record stating that',
127
135
  'every decision closes with a revisit sentence gates when one lacks',
128
- 'it. A record stating none of the three is reported and never',
129
- 'gated. --gate widens the gate to the other two',
136
+ 'it. A record stating a word cap a decision or a risk bullet cap',
137
+ 'gates past either, and a requirements record stating a word cap',
138
+ 'gates past it. A record stating none of these is reported and',
139
+ 'never gated. --gate widens the gate to the other two',
130
140
  'findings that are facts rather than judgments: a missing required',
131
141
  'section and index drift. A context entry requires Overview and',
132
142
  'Layout, and a wireframe requires Regions, States, Copy, and Not on',
@@ -658,6 +668,7 @@ async function runAudit(
658
668
  // record. A run that never looked and a project with nothing to look at are
659
669
  // different answers, and one value for both reports the second as the first.
660
670
  const record = gateOnly ? undefined : await measureArchitecture(root)
671
+ const requirements = gateOnly ? undefined : await measureRequirements(root)
661
672
  const wireframes = gateOnly
662
673
  ? []
663
674
  : (
@@ -681,6 +692,7 @@ async function runAudit(
681
692
  reportProvenance(entries, folders)
682
693
  reportDrift(drift)
683
694
  reportRecord(record, root)
695
+ reportRequirements(requirements, root)
684
696
  reportWireframeStates(wireframes)
685
697
  outro()
686
698
  }
@@ -719,6 +731,8 @@ async function runAudit(
719
731
  // target that never wrote one is entitled to. Absent says the run
720
732
  // never looked, which is `--citations-only`.
721
733
  architecture: gateOnly ? undefined : (record ?? null),
734
+ // The same three states for the requirements record.
735
+ requirements: gateOnly ? undefined : (requirements ?? null),
722
736
  // Absent under `--citations-only`, for the same reason as above. An
723
737
  // empty array under the ordinary run says the project carries no
724
738
  // wireframes folder or no entry carrying a States table, which is a
@@ -744,6 +758,10 @@ async function runAudit(
744
758
  recordOverCount: record !== undefined && isOverCount(record),
745
759
  recordMissingRevisit:
746
760
  record !== undefined && missingRevisit(record).length > 0,
761
+ recordOverWords: record !== undefined && overWords(record).length > 0,
762
+ recordOverRisks: record !== undefined && isOverRisks(record),
763
+ requirementsOverWords:
764
+ requirements !== undefined && isOverWordCap(requirements),
747
765
  sections,
748
766
  drift,
749
767
  wireframes,
@@ -1152,11 +1170,44 @@ function reportRecord(
1152
1170
  const { allowances } = report
1153
1171
 
1154
1172
  logInfo(
1155
- `${plural(report.words, 'word')} across ${plural(report.lines, 'line')}, read alongside the weight judgment a session makes by reading the file. This never gates.`,
1173
+ `${plural(report.words, 'word')} across ${plural(report.lines, 'line')}. The whole file's count never gates.`,
1156
1174
  )
1157
1175
  if (report.risksWords !== undefined) {
1158
1176
  logInfo(
1159
- `\`## Risks / open questions\` holds ${plural(report.risksWords, 'word')}, weighed the same way and read alongside the same judgment.`,
1177
+ `\`## Risks / open questions\` holds ${plural(report.risksWords, 'word')}.`,
1178
+ )
1179
+ }
1180
+
1181
+ const longDecisions = overWords(report)
1182
+ if (report.wordCap === undefined) {
1183
+ logInfo(
1184
+ 'No word cap a decision stated, so each decision word count is reported and never gated.',
1185
+ )
1186
+ } else if (longDecisions.length > 0) {
1187
+ for (const entry of longDecisions) {
1188
+ logError(
1189
+ `"${entry.heading}" holds ${entry.words} words against a cap of ${report.wordCap} a decision.`,
1190
+ )
1191
+ }
1192
+ } else {
1193
+ logInfo(
1194
+ `Every decision fits the cap of ${report.wordCap} words a decision.`,
1195
+ )
1196
+ }
1197
+
1198
+ if (report.riskCap === undefined) {
1199
+ logInfo('No risk bullet cap stated, so the Risks section is never gated.')
1200
+ } else if (report.risksBullets === undefined) {
1201
+ logWarn(
1202
+ `The record states a cap of ${report.riskCap} risk bullets and carries no \`## Risks / open questions\` section to count.`,
1203
+ )
1204
+ } else if (isOverRisks(report)) {
1205
+ logError(
1206
+ `${plural(report.risksBullets, 'risk bullet')} against a cap of ${report.riskCap}, nested bullets counted.`,
1207
+ )
1208
+ } else {
1209
+ logInfo(
1210
+ `${plural(report.risksBullets, 'risk bullet')} against a cap of ${report.riskCap}, nested bullets counted.`,
1160
1211
  )
1161
1212
  }
1162
1213
 
@@ -1257,6 +1308,35 @@ function reportRecord(
1257
1308
  )
1258
1309
  }
1259
1310
 
1311
+ /** Reports the requirements record against the word cap it states, if any. */
1312
+ function reportRequirements(
1313
+ report: RequirementsReport | undefined,
1314
+ root: string,
1315
+ ): void {
1316
+ logStep('Requirements record')
1317
+
1318
+ if (report === undefined) {
1319
+ logInfo(
1320
+ `Out of scope. The project carries no ${requirementsRel(root)}, so there was no record to measure.`,
1321
+ )
1322
+ return
1323
+ }
1324
+
1325
+ if (report.wordCap === undefined) {
1326
+ logInfo(
1327
+ `${plural(report.words, 'word')} in ${report.rel} and no word cap stated, so the count is reported and never gated.`,
1328
+ )
1329
+ } else if (isOverWordCap(report)) {
1330
+ logError(
1331
+ `${plural(report.words, 'word')} against a cap of ${report.wordCap} in ${report.rel}.`,
1332
+ )
1333
+ } else {
1334
+ logInfo(
1335
+ `${plural(report.words, 'word')} against a cap of ${report.wordCap} in ${report.rel}.`,
1336
+ )
1337
+ }
1338
+ }
1339
+
1260
1340
  function reportDrift(drift: readonly FolderDrift[]): void {
1261
1341
  logStep('Index drift')
1262
1342
 
@@ -1,6 +1,7 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, statSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
+ import { INSTALL_BROWSER } from '@/browser/engine'
4
5
  import { creationRel, SCRATCH } from '@/record-root'
5
6
  import { LAYOUTS } from '@/slides/layouts'
6
7
  import { openDeck } from '@/slides/open'
@@ -17,8 +18,14 @@ export function register(program: Command): void {
17
18
  slides
18
19
  .command('render')
19
20
  .helpOption('-h, --help', 'Show this help message')
20
- .description('Render a SLIDES.md source into a PowerPoint deck')
21
- .option('-s, --source <path>', 'Source SLIDES.md path', '.claude/SLIDES.md')
21
+ .description(
22
+ 'Render a SLIDES.md source, or a folder of HTML slides, into a PowerPoint deck',
23
+ )
24
+ .option(
25
+ '-s, --source <path>',
26
+ 'Source SLIDES.md file, or a folder of .html slides read in filename order',
27
+ '.claude/SLIDES.md',
28
+ )
22
29
  .option(
23
30
  '-o, --out <path>',
24
31
  'Output directory',
@@ -44,8 +51,15 @@ export function register(program: Command): void {
44
51
  if (!existsSync(sourcePath)) {
45
52
  fail(`${opts.source} not found`)
46
53
  }
47
- const variant = parseVariant(opts.variant)
48
54
  const mirror = resolveMirror(opts.mirror)
55
+ if (statSync(sourcePath).isDirectory()) {
56
+ if (opts.variant !== undefined) {
57
+ fail('--variant applies to a SLIDES.md source, not a folder')
58
+ }
59
+ await renderHtmlFolder(sourcePath, outDir, mirror, opts.open)
60
+ return
61
+ }
62
+ const variant = parseVariant(opts.variant)
49
63
  const { GREEN, GREY, NC, RED } = palette(process.stderr)
50
64
  intro('Render slides')
51
65
  const result = await renderSlidesDoc(sourcePath, outDir, {
@@ -103,6 +117,61 @@ export function register(program: Command): void {
103
117
  })
104
118
  }
105
119
 
120
+ /**
121
+ * The converter loads on demand, so no other command resolves pptxgenjs,
122
+ * jszip, or the token module at startup. A fallback or a refused link reports
123
+ * one line each and keeps the exit at 0, as an unrecognized layout does on the
124
+ * markdown path, since the deck is written either way.
125
+ */
126
+ async function renderHtmlFolder(
127
+ sourceDir: string,
128
+ outDir: string,
129
+ mirror: string | undefined,
130
+ shouldOpen: boolean | undefined,
131
+ ): Promise<void> {
132
+ const { exportHtmlDeck } = await import('@/slides/convert/export')
133
+ const { GREEN, GREY, NC, RED } = palette(process.stderr)
134
+ const result = await exportHtmlDeck(sourceDir, outDir, {
135
+ root: process.cwd(),
136
+ mirror,
137
+ })
138
+ if (result.status === 'refused') {
139
+ fail(
140
+ result.reason === 'browser-missing'
141
+ ? `Chromium is not installed. Run ${INSTALL_BROWSER}`
142
+ : result.message,
143
+ )
144
+ }
145
+ intro('Render slides')
146
+ process.stderr.write(
147
+ `${GREY}│${NC} ${GREEN}✓${NC} ${result.slideCount} slides\n${GREY}│${NC} ${GREEN}✓${NC} ${result.pptxPath}\n`,
148
+ )
149
+ for (const { slide, selector, properties } of result.fallbacks) {
150
+ process.stderr.write(
151
+ `${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} drawn as a picture for ${properties.join(', ')}\n`,
152
+ )
153
+ }
154
+ for (const { slide, selector, href, reason } of result.refusedLinks) {
155
+ process.stderr.write(
156
+ `${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} link "${href}" left out: ${reason}\n`,
157
+ )
158
+ }
159
+ if (result.mirrorPath) {
160
+ process.stderr.write(
161
+ `${GREY}│${NC} ${GREEN}✓${NC} mirrored to ${result.mirrorPath}\n`,
162
+ )
163
+ }
164
+ if (shouldOpen) {
165
+ const target = result.mirrorPath ?? result.pptxPath
166
+ const opened = await openDeck(target)
167
+ const mark = opened ? `${GREEN}✓${NC}` : `${RED}✗${NC}`
168
+ process.stderr.write(
169
+ `${GREY}│${NC} ${mark} ${opened ? 'opened' : 'could not open'} ${target}\n`,
170
+ )
171
+ }
172
+ outro()
173
+ }
174
+
106
175
  function parseVariant(value: string | undefined): Variant | undefined {
107
176
  if (value === undefined) return undefined
108
177
  if (value === 'light' || value === 'dark') return value