@erclx/canon 5.77.0 → 5.79.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 (125) hide show
  1. package/CHANGELOG.md +43 -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/auto-ship/references/ui-checklist.md +9 -1
  12. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  13. package/claude/skills/code-craft/SKILL.md +7 -0
  14. package/claude/skills/code-craft/references/adopted.md +8 -0
  15. package/claude/skills/git-followup/SKILL.md +1 -0
  16. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  17. package/claude/skills/git-pr/SKILL.md +2 -0
  18. package/claude/skills/git-pr/references/final-command.md +34 -2
  19. package/claude/skills/git-pr/references/pr.md +1 -1
  20. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  21. package/claude/skills/git-ship/SKILL.md +16 -11
  22. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  23. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  24. package/claude/skills/plan-feature/references/plan.md +2 -0
  25. package/claude/skills/pr-evidence/SKILL.md +6 -1
  26. package/claude/skills/pr-evidence/references/evidence.md +1 -0
  27. package/claude/skills/pr-evidence/references/reel.md +9 -1
  28. package/claude/skills/review-address/SKILL.md +2 -1
  29. package/claude/skills/review-craft/references/ui.md +13 -1
  30. package/claude/skills/review-pr/references/body-variants.md +2 -0
  31. package/claude/skills/review-pr/references/close-out.md +2 -0
  32. package/claude/skills/review-pr/references/key-changes.md +2 -1
  33. package/claude/skills/review-pr/references/post.md +1 -0
  34. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  35. package/claude/skills/review-ui/SKILL.md +2 -2
  36. package/claude/skills/review-ui/references/driving.md +32 -4
  37. package/claude/skills/review-ui/references/post.md +6 -2
  38. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  39. package/claude/skills/role-orchestrator/SKILL.md +4 -4
  40. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  41. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  42. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  43. package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
  44. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
  45. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
  46. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  47. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  48. package/claude/skills/role-worker/SKILL.md +2 -1
  49. package/claude/skills/task-board/SKILL.md +1 -0
  50. package/claude/skills/task-board/references/tasks.md +1 -0
  51. package/claude/skills/test-craft/SKILL.md +1 -0
  52. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  53. package/claude/skills/ui-checklist/SKILL.md +2 -1
  54. package/claude/skills/ui-craft/references/capture.md +16 -0
  55. package/claude/skills/ui-film/REQUIREMENT.md +2 -2
  56. package/claude/skills/ui-film/SKILL.md +6 -1
  57. package/claude/skills/ui-film/references/compose-craft.md +68 -11
  58. package/claude/skills/ui-film/references/config.md +2 -2
  59. package/claude/skills/ui-film/references/handoff.md +1 -1
  60. package/claude/skills/ui-film/references/harness.md +71 -0
  61. package/claude/skills/ui-film/references/take-craft.md +42 -17
  62. package/docs/workflow/ai-workflow.md +1 -1
  63. package/docs/workflow/operating-model.md +6 -5
  64. package/docs/workflow/parallel-features.md +2 -1
  65. package/docs/workflow/visual-design-workflow.md +2 -1
  66. package/governance/ledger/skills/code-craft/SKILL.md +9 -0
  67. package/governance/ledger/skills/test-craft/SKILL.md +1 -0
  68. package/governance/ledger/skills/ui-craft/capture.md +18 -5
  69. package/package.json +4 -3
  70. package/src/commands/pr.ts +255 -46
  71. package/src/commands/sessions.ts +88 -6
  72. package/src/commands/take.ts +12 -1
  73. package/src/commands/tasks.ts +41 -2
  74. package/src/design/components.ts +5 -107
  75. package/src/design/favicon.ts +21 -17
  76. package/src/design/render.ts +9 -27
  77. package/src/design/template.ts +42 -0
  78. package/src/design/templates/favicon.svg +17 -0
  79. package/src/design/templates/hand-drawn-figure.css +54 -0
  80. package/src/design/templates/preview.html +27 -0
  81. package/src/design/templates/scrollbar.css +29 -0
  82. package/src/design/templates/status.css +24 -0
  83. package/src/gate/embedded.ts +400 -0
  84. package/src/gate/stages.ts +8 -0
  85. package/src/git/files.ts +5 -2
  86. package/src/indexes/frontmatter.ts +27 -6
  87. package/src/indexes/render.ts +21 -3
  88. package/src/markdown/colons.ts +14 -4
  89. package/src/markdown/heading-case.ts +9 -2
  90. package/src/pr/bijection.ts +8 -0
  91. package/src/pr/evidence.ts +81 -13
  92. package/src/pr/frames.ts +1 -6
  93. package/src/pr/health.ts +63 -15
  94. package/src/pr/lift.ts +77 -9
  95. package/src/pr/links.ts +160 -0
  96. package/src/pr/local.ts +17 -4
  97. package/src/pr/paths.ts +20 -2
  98. package/src/pr/rest.ts +2 -0
  99. package/src/pr/review-scope.ts +28 -0
  100. package/src/pr/review-state.ts +5 -5
  101. package/src/pr/waiver.ts +45 -0
  102. package/src/sessions/stop.ts +145 -0
  103. package/src/sessions/transfer/bundle.ts +1 -1
  104. package/src/take/camera.ts +337 -208
  105. package/src/take/encode.ts +16 -42
  106. package/src/take/film.ts +7 -1
  107. package/src/take/frame-clock.ts +46 -0
  108. package/src/take/log.ts +43 -2
  109. package/src/take/perform.ts +85 -13
  110. package/src/take/plan.ts +26 -5
  111. package/src/take/reel.ts +30 -5
  112. package/src/take/screencast.ts +223 -13
  113. package/src/take/stepper.ts +24 -4
  114. package/src/tasks/holders.ts +198 -0
  115. package/src/tasks/validate.ts +148 -0
  116. package/src/commands/fixtures/gh.sh +0 -64
  117. package/src/take/fixtures/busy.html +0 -107
  118. package/src/take/fixtures/fade.html +0 -49
  119. package/src/take/fixtures/fetch.html +0 -38
  120. package/src/take/fixtures/input.html +0 -138
  121. package/src/take/zoom/NOTICE.md +0 -45
  122. package/src/take/zoom/math-utils.ts +0 -69
  123. package/src/take/zoom/region-utils.ts +0 -55
  124. package/src/take/zoom/spring.ts +0 -133
  125. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
@@ -11,6 +11,7 @@ import {
11
11
  type SessionReport,
12
12
  selfOf,
13
13
  } from '@/sessions/resolve'
14
+ import { readStop, type StopReading } from '@/sessions/stop'
14
15
  import {
15
16
  type Behind,
16
17
  exportSession,
@@ -32,8 +33,11 @@ interface ListCommandOptions {
32
33
  readonly branch?: string
33
34
  readonly repository?: string
34
35
  readonly self?: boolean
36
+ readonly stopped?: boolean
35
37
  }
36
38
 
39
+ type ListedSession = ResolvedSession & StopReading
40
+
37
41
  const REASONS: Record<string, string> = {
38
42
  'not-a-repository': 'working outside any git repository',
39
43
  'detached-head': 'detached HEAD, so the worktree holds no branch name',
@@ -67,6 +71,7 @@ export function register(program: Command): void {
67
71
  '--self',
68
72
  "Report the caller's own row, and refuse where the roster holds none",
69
73
  )
74
+ .option('--stopped', 'Report only the sessions a usage limit stopped')
70
75
  .addHelpText(
71
76
  'after',
72
77
  [
@@ -161,12 +166,30 @@ export function register(program: Command): void {
161
166
  'framed listing renders the same dwell beside the status, at the',
162
167
  'coarsest unit that keeps it a whole number.',
163
168
  '',
169
+ 'Each row also carries "stopped" and "stopResets", read from the tail of',
170
+ 'the session transcript. "stopped" is "usage-limit" when the last',
171
+ 'assistant entry is a limit stop, passing over a synthetic entry with no',
172
+ 'error, such as the one a client restart writes. "stopResets" holds the',
173
+ 'text the client wrote after "resets", or null when it wrote none.',
174
+ '"stopped" is "unknown" when the transcript could not be read or a',
175
+ 'limit-worded entry has another shape, so the row may be stopped, and',
176
+ 'null otherwise. A "busy" row reads',
177
+ 'null without opening its transcript, since a turn is running.',
178
+ '',
179
+ '--stopped keeps the "usage-limit" rows after --branch and --self apply.',
180
+ 'It drops "unknown" rows, and "stopsUnread" counts them before it does,',
181
+ 'so a count above zero means a row here may be stopped and is not listed.',
182
+ '"foreignDead" above zero leaves the same doubt about a session this',
183
+ 'reader could not check, and an empty result says so on stderr.',
184
+ 'An empty result exits 0. "claimed" still reads the whole roster.',
185
+ '',
164
186
  'Examples:',
165
187
  ' canon sessions list',
166
188
  ' canon sessions list --json',
167
189
  ' canon sessions list --branch feat/parser --json',
168
190
  ' canon sessions list --branch chore/agents --repository ../caret --json',
169
191
  ' canon sessions list --self --json',
192
+ ' canon sessions list --stopped --json',
170
193
  '',
171
194
  ].join('\n'),
172
195
  )
@@ -477,15 +500,37 @@ async function runList(opts: ListCommandOptions): Promise<number> {
477
500
  ? await checkClaim(opts.branch, { cwd: at, resolve: async () => report })
478
501
  : null
479
502
 
503
+ const read: readonly ListedSession[] = shown.map((session) => ({
504
+ ...session,
505
+ ...readStop(session),
506
+ }))
507
+ const stopsUnread = read.filter(
508
+ (session) => session.stopped === 'unknown',
509
+ ).length
510
+ const listed = opts.stopped
511
+ ? read.filter((session) => session.stopped === 'usage-limit')
512
+ : read
513
+
480
514
  intro('canon sessions list')
481
- reportConfidence(shown, report)
515
+ reportConfidence(listed, report)
482
516
  reportSessions(
483
- shown,
517
+ listed,
484
518
  opts.branch,
485
519
  repository,
486
520
  own !== null,
487
521
  report.foreignDead,
522
+ opts.stopped ? stopsUnread : null,
488
523
  )
524
+ if (opts.stopped && stopsUnread > 0) {
525
+ logWarn(
526
+ `${plural(stopsUnread, 'row')} could not be read and may be stopped, so this list can be short.`,
527
+ )
528
+ }
529
+ if (opts.stopped && own === null && report.foreignDead > 0) {
530
+ logWarn(
531
+ `${report.foreignDead} records from another pid namespace have no answering socket, so a stopped session may be missing from this list.`,
532
+ )
533
+ }
489
534
  if (claim) reportClaim(claim)
490
535
  outro()
491
536
 
@@ -494,8 +539,9 @@ async function runList(opts: ListCommandOptions): Promise<number> {
494
539
  `${JSON.stringify({
495
540
  dir: report.dir,
496
541
  // Over the rows shown, so an unrelated row --branch dropped cannot weaken it.
497
- confidence: confidenceOf(shown),
542
+ confidence: confidenceOf(listed),
498
543
  foreignDead: report.foreignDead,
544
+ stopsUnread,
499
545
  branch: opts.branch ?? null,
500
546
  repository,
501
547
  worktree: claim?.worktree ?? null,
@@ -503,7 +549,7 @@ async function runList(opts: ListCommandOptions): Promise<number> {
503
549
  claimed: claim?.claimed ?? null,
504
550
  sessionsReadable: claim?.sessionsReadable ?? null,
505
551
  refsReadable: claim?.refsReadable ?? null,
506
- sessions: shown,
552
+ sessions: listed,
507
553
  })}\n`,
508
554
  )
509
555
  }
@@ -578,11 +624,12 @@ function reportConfidence(
578
624
  }
579
625
 
580
626
  function reportSessions(
581
- sessions: readonly ResolvedSession[],
627
+ sessions: readonly ListedSession[],
582
628
  branch: string | undefined,
583
629
  repository: string | null,
584
630
  scoped: boolean,
585
631
  foreignDead: number,
632
+ stopsUnread: number | null,
586
633
  ): void {
587
634
  logStep('Sessions')
588
635
 
@@ -596,6 +643,11 @@ function reportSessions(
596
643
  // was narrowed to one row before the branch filter ran. Reporting the wider
597
644
  // answer there would claim a reading this run never took.
598
645
  if (sessions.length === 0) {
646
+ if (stopsUnread !== null) {
647
+ logInfo(noneStopped(branch, scoped, foreignDead, stopsUnread))
648
+ return
649
+ }
650
+
599
651
  if (branch) {
600
652
  logInfo(
601
653
  scoped
@@ -630,12 +682,42 @@ function reportSessions(
630
682
  `unresolved: ${REASONS[session.unresolved ?? ''] ?? 'unknown'}`
631
683
  const dwell = formatDwell(session.statusDwellMs)
632
684
  const status = dwell ? `${session.status} ${dwell}` : session.status
633
- return `${session.name} ${status} ${held}\n ${session.cwd}`
685
+ return [
686
+ `${session.name} ${status} ${held}`,
687
+ ` ${session.cwd}`,
688
+ ...stopLine(session),
689
+ ].join('\n')
634
690
  })
635
691
  .join('\n'),
636
692
  )
637
693
  }
638
694
 
695
+ /**
696
+ * Words an empty `--stopped` result by what was read, since "no session is
697
+ * stopped" is a claim about the machine that a row this run could not read, or
698
+ * a record it could not check, leaves open.
699
+ */
700
+ function noneStopped(
701
+ branch: string | undefined,
702
+ scoped: boolean,
703
+ foreignDead: number,
704
+ stopsUnread: number,
705
+ ): string {
706
+ if (stopsUnread > 0 || (!scoped && foreignDead > 0)) {
707
+ return 'No session this reader could check is stopped.'
708
+ }
709
+ if (scoped) return 'This session is not stopped.'
710
+ return branch
711
+ ? `No session holding ${branch} is stopped.`
712
+ : 'No session is stopped.'
713
+ }
714
+
715
+ function stopLine(session: StopReading): string[] {
716
+ if (session.stopped === null) return []
717
+ const resets = session.stopResets ? `, resets ${session.stopResets}` : ''
718
+ return [` stopped: ${session.stopped}${resets}`]
719
+ }
720
+
639
721
  /**
640
722
  * Renders the dwell at the coarsest unit that keeps it a whole number, since a
641
723
  * reader scanning a roster wants an age at a glance rather than a millisecond
@@ -114,6 +114,9 @@ export function register(program: Command): void {
114
114
  'WebP at 960 wide and 20 fps, keeping every third frame, with the cursor',
115
115
  'and click ring drawn from each camera track. It carries no zoom, so a',
116
116
  "reviewer sees the whole page. clip.mp4 stays each beat's raw take.",
117
+ 'The reel is lossless, so a fade-out leaves no ghost, and it falls back',
118
+ 'to lossy past 10 MB so a long reel still posts. The record says which',
119
+ 'in `lossless`.',
117
120
  '',
118
121
  ' {',
119
122
  ' "beats": [',
@@ -127,7 +130,8 @@ export function register(program: Command): void {
127
130
  ' }',
128
131
  '',
129
132
  'Step kinds:',
130
- ' move <target>|<x,y> glide the pointer to a target or a point',
133
+ ' move <target>|<x,y> glide the pointer to a target or a point, and',
134
+ ' with hold <ms>, zoom onto its end for that long',
131
135
  ' click <target> glide to the target and press it',
132
136
  ' dblclick <target> glide to the target and press it twice',
133
137
  ' drag <from> <to> press the from target, glide held, release on to',
@@ -143,6 +147,13 @@ export function register(program: Command): void {
143
147
  'PageDown, the four arrows as ArrowUp and the like, and F1 to F12. A key',
144
148
  'moves no pointer, so the camera neither aims at it nor ends a zoom.',
145
149
  '',
150
+ 'A take paces each press on its own: the pointer rests 300 ms on the',
151
+ 'target before a click, dblclick, or drag, and holds 500 ms on the',
152
+ 'result before the next step. A fill waits 400 ms between pressing the',
153
+ 'field and its first key, and holds 500 ms after its last. Setup steps',
154
+ 'run unpaced. The camera zooms in over the glide reaching a target and',
155
+ 'out over the glide leaving it, scaling about the target.',
156
+ '',
146
157
  'drag drives pointer events, the kind a canvas board listens for. It',
147
158
  'starts no native HTML5 drag of a draggable element on the stepped route,',
148
159
  'so a beat needing one films the same only on the screencast route.',
@@ -72,6 +72,7 @@ import {
72
72
  type ValidateOutcome,
73
73
  validateBoard,
74
74
  } from '@/tasks/validate'
75
+ import { liveReaders } from '@/tasks/holders'
75
76
  import {
76
77
  intro,
77
78
  logAdd,
@@ -110,6 +111,7 @@ interface DeclineCommandOptions {
110
111
  interface ValidateCommandOptions {
111
112
  readonly json?: boolean
112
113
  readonly root?: string
114
+ readonly live?: boolean
113
115
  }
114
116
 
115
117
  interface CitationsCommandOptions {
@@ -323,6 +325,10 @@ export function register(program: Command): void {
323
325
  .helpOption('-h, --help', 'Show this help message')
324
326
  .option('--json', 'Add a machine-readable record on stdout')
325
327
  .option('--root <path>', 'Board root, defaulting to the main worktree')
328
+ .option(
329
+ '--live',
330
+ 'Also read the session roster and open pull requests for held rows',
331
+ )
326
332
  .addHelpText(
327
333
  'after',
328
334
  [
@@ -338,6 +344,12 @@ export function register(program: Command): void {
338
344
  ` no Waiting on cell renders past ${WAITING_LIMIT} characters`,
339
345
  ' a blocker citing a bare vNNN.N label settles like a linked one',
340
346
  ' a file two Run now rows share is listed, never failed',
347
+ ' with --live, no Up next or Needs a plan row is held by a live session',
348
+ " or an open pull request on its plan's branch",
349
+ '',
350
+ 'The `live` field states each --live source as read, unread, or not-asked.',
351
+ 'A source that fails reads as unread and moves no exit code, so read that',
352
+ 'field rather than taking exit 0 as clean. A run without --live reads neither.',
341
353
  '',
342
354
  'Refusals, read off `reason`:',
343
355
  ' no-board the repository holds no `.canon/tasks/` board',
@@ -356,6 +368,7 @@ export function register(program: Command): void {
356
368
  'Examples:',
357
369
  ' canon tasks validate',
358
370
  ' canon tasks validate --json',
371
+ ' canon tasks validate --live --json',
359
372
  '',
360
373
  ].join('\n'),
361
374
  )
@@ -1911,7 +1924,10 @@ function reportNextLabel(
1911
1924
 
1912
1925
  async function runValidate(opts: ValidateCommandOptions): Promise<number> {
1913
1926
  const root = opts.root ?? (await mainWorktreeRoot())
1914
- const outcome = await validateBoard(root)
1927
+ const outcome = await validateBoard(
1928
+ root,
1929
+ opts.live ? { live: liveReaders(root) } : {},
1930
+ )
1915
1931
 
1916
1932
  return reportValidation(outcome, opts.json ?? false, root)
1917
1933
  }
@@ -2390,8 +2406,30 @@ function reportValidation(
2390
2406
  } else {
2391
2407
  for (const task of outcome.unplaced) logWarn(describeUnplaced(task))
2392
2408
  }
2393
- outro()
2409
+
2410
+ if (outcome.live.sessions !== 'not-asked') {
2411
+ logStep('Held rows')
2412
+ if (
2413
+ outcome.live.sessions === 'read' &&
2414
+ outcome.live.pulls === 'read' &&
2415
+ !outcome.findings.some((finding) => finding.kind === 'row-held')
2416
+ ) {
2417
+ logInfo('no row outside run now is held by a session or a pull request')
2418
+ }
2419
+ }
2420
+ }
2421
+
2422
+ // Unread is a warning in both modes, since diagnostics reach stderr with --json too.
2423
+ if (outcome.live.sessions === 'unread') {
2424
+ logWarn('the session roster could not be read, so no session was checked')
2394
2425
  }
2426
+ if (outcome.live.pulls === 'unread') {
2427
+ logWarn(
2428
+ 'pull requests could not be read, so a hold may be missed or already merged',
2429
+ )
2430
+ }
2431
+
2432
+ if (!emitJson) outro()
2395
2433
 
2396
2434
  if (emitJson) {
2397
2435
  process.stdout.write(
@@ -2408,6 +2446,7 @@ function reportValidation(
2408
2446
  shared: outcome.shared,
2409
2447
  wide: outcome.wide,
2410
2448
  unplaced: outcome.unplaced,
2449
+ live: outcome.live,
2411
2450
  })}\n`,
2412
2451
  )
2413
2452
  }
@@ -15,6 +15,8 @@
15
15
  * names out of that record and says they live in code, which is here.
16
16
  */
17
17
 
18
+ import { readTemplate } from '@/design/template'
19
+
18
20
  export interface Component {
19
21
  readonly name: string
20
22
  /** Why the component exists, carried into the emitted stylesheet as a comment. */
@@ -43,30 +45,7 @@ const STATUS: Component = {
43
45
  '--color-muted',
44
46
  '--radius-marker',
45
47
  ],
46
- rules: `.status {
47
- display: inline-flex;
48
- align-items: center;
49
- gap: 0.42rem;
50
- font-size: 0.75rem;
51
- font-weight: 500;
52
- letter-spacing: 0;
53
- text-transform: none;
54
- color: var(--color-muted);
55
- white-space: nowrap;
56
- }
57
-
58
- .status::before {
59
- content: '';
60
- width: 6px;
61
- height: 6px;
62
- flex: none;
63
- border-radius: var(--radius-marker);
64
- background: var(--color-border);
65
- }
66
-
67
- .status.is-done::before {
68
- background: var(--color-accent);
69
- }`,
48
+ rules: readTemplate('status.css'),
70
49
  }
71
50
 
72
51
  /**
@@ -84,35 +63,7 @@ const SCROLLBAR: Component = {
84
63
  'so the two engines cannot drift apart.',
85
64
  ].join('\n '),
86
65
  reads: ['--color-border', '--color-muted', '--color-background'],
87
- rules: `* {
88
- scrollbar-width: thin;
89
- scrollbar-color: var(--color-border) transparent;
90
- }
91
-
92
- *::-webkit-scrollbar {
93
- width: 10px;
94
- height: 10px;
95
- }
96
-
97
- *::-webkit-scrollbar-track {
98
- background: transparent;
99
- }
100
-
101
- *::-webkit-scrollbar-thumb {
102
- border-radius: var(--radius-marker);
103
- background: var(--color-border);
104
- /* Inset by painting a border in the page color, which is what keeps the
105
- thumb off the edges without a second element. */
106
- border: 3px solid var(--color-background);
107
- }
108
-
109
- *::-webkit-scrollbar-thumb:hover {
110
- background: var(--color-muted);
111
- }
112
-
113
- *::-webkit-scrollbar-corner {
114
- background: transparent;
115
- }`,
66
+ rules: readTemplate('scrollbar.css'),
116
67
  }
117
68
 
118
69
  /**
@@ -130,60 +81,7 @@ const HAND_DRAWN_FIGURE: Component = {
130
81
  'reading measure and cleared past an outline rail on a wide viewport.',
131
82
  ].join('\n '),
132
83
  reads: ['--color-muted', '--figure-hand', '--figure-wide'],
133
- rules: `/* ---- Figures: hand-drawn, and wider than the measure ---- */
134
-
135
- :root {
136
- --figure-hand: 'Virgil', 'Excalifont', cursive;
137
- --figure-wide: 64rem;
138
- }
139
-
140
- figure {
141
- margin: 2.75rem 0;
142
- width: var(--figure-wide);
143
- max-width: 92vw;
144
- margin-left: 50%;
145
- transform: translateX(-50%);
146
- }
147
-
148
- figure svg {
149
- width: 100%;
150
- height: auto;
151
- display: block;
152
- }
153
-
154
- /* CSS beats an SVG presentation attribute, so the diagrams pick up the
155
- hand face without editing a single lesson. */
156
- figure svg text {
157
- font-family: var(--figure-hand);
158
- }
159
-
160
- figcaption {
161
- font-family: var(--figure-hand);
162
- font-size: 1rem;
163
- color: var(--color-muted);
164
- margin: 1rem auto 0;
165
- line-height: 1.5;
166
- max-width: 52rem;
167
- }
168
-
169
- /* A figure breaks the measure and must still clear an outline rail. Deriving
170
- the ceiling from the viewport does not hold, since a rail positioned from
171
- the centre keeps overlapping as the window widens, so the ceiling is fixed
172
- instead. */
173
- @media (min-width: 1421px) {
174
- figure {
175
- max-width: 54rem;
176
- }
177
- }
178
-
179
- @media (max-width: 640px) {
180
- figure {
181
- width: 100%;
182
- max-width: 100%;
183
- margin-left: 0;
184
- transform: none;
185
- }
186
- }`,
84
+ rules: readTemplate('hand-drawn-figure.css'),
187
85
  }
188
86
 
189
87
  export const COMPONENTS: readonly Component[] = [
@@ -1,3 +1,6 @@
1
+ // Relative, since web/ bundles this module for FAVICON_COLORS and its `@` means web/src.
2
+ import { fillTemplate, readTemplate } from './template'
3
+
1
4
  export interface ThemePair {
2
5
  readonly light: string
3
6
  readonly dark: string
@@ -68,23 +71,24 @@ export function renderFavicon(mark: string, colors: FaviconColors): string {
68
71
  const inset = EDGE_WIDTH / 2
69
72
  const shapes = markShapes(mark)
70
73
 
71
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${left} ${top} ${round(side)} ${round(side)}">
72
- <style>
73
- .tile { fill: ${colors.tile.dark}; stroke: ${colors.tile.dark}; }
74
- .c { fill: ${colors.c.dark}; }
75
- .pip { fill: ${colors.pip.dark}; }
76
- @media (prefers-color-scheme: light) {
77
- .tile { fill: ${colors.tile.light}; stroke: ${colors.edge}; }
78
- .c { fill: ${colors.c.light}; }
79
- .pip { fill: ${colors.pip.light}; }
80
- }
81
- </style>
82
- <rect class="tile" x="${round(left + inset)}" y="${round(top + inset)}" width="${round(side - EDGE_WIDTH)}" height="${round(side - EDGE_WIDTH)}" rx="${round(side * TILE_RADIUS - inset)}" stroke-width="${EDGE_WIDTH}" />
83
- <g class="c">
84
- ${shapes.replace(/\n\s*/g, '\n ')}
85
- </g>
86
- </svg>
87
- `
74
+ return fillTemplate(readTemplate('favicon.svg'), {
75
+ left: String(left),
76
+ top: String(top),
77
+ side: String(round(side)),
78
+ tileX: String(round(left + inset)),
79
+ tileY: String(round(top + inset)),
80
+ tileSide: String(round(side - EDGE_WIDTH)),
81
+ tileRadius: String(round(side * TILE_RADIUS - inset)),
82
+ edgeWidth: String(EDGE_WIDTH),
83
+ tileDark: colors.tile.dark,
84
+ tileLight: colors.tile.light,
85
+ edge: colors.edge,
86
+ cDark: colors.c.dark,
87
+ cLight: colors.c.light,
88
+ pipDark: colors.pip.dark,
89
+ pipLight: colors.pip.light,
90
+ shapes: shapes.replace(/\n\s*/g, '\n '),
91
+ })
88
92
  }
89
93
 
90
94
  /**
@@ -6,6 +6,7 @@ import { fontFaceBlock } from '@/design/css'
6
6
  import { faviconLink } from '@/design/favicon'
7
7
  import type { FontFace } from '@/design/fonts'
8
8
  import { FONT_FACES } from '@/design/fonts'
9
+ import { fillTemplate, readTemplate } from '@/design/template'
9
10
  import { colorValue } from '@/design/tokens'
10
11
 
11
12
  export interface RenderResult {
@@ -239,33 +240,14 @@ function buildHtml(
239
240
  const summary = tagged
240
241
  ? `\n<p class="note">${total - tagged} of ${total} cells are anchored to a source. The other ${tagged} ${verb} <code>? verify</code>, so nothing anchors them yet.</p>`
241
242
  : ''
242
- return `<!doctype html>
243
- <html lang="en">
244
- <head>
245
- <meta charset="utf-8">${favicon === undefined ? '' : `\n${faviconLink(favicon)}`}
246
- <title>Design tokens</title>
247
- <link rel="stylesheet" href="design.css">
248
- <style>
249
- ${previewChrome()}
250
- body { font-family: ${bodyStack}; margin: 2rem; max-width: 960px; color: var(--preview-ink); background: var(--preview-paper); }
251
- h1 { margin-top: 0; }
252
- h2 { margin-top: 2rem; border-bottom: 1px solid var(--preview-rule); padding-bottom: 0.25rem; }
253
- table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }
254
- th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--preview-rule); font-size: 14px; }
255
- th { background: var(--preview-panel); font-weight: 600; }
256
- .swatch { display: inline-block; width: 1.5rem; height: 1.5rem; border-radius: 4px; border: 1px solid var(--preview-rule); vertical-align: middle; margin-right: 0.5rem; }
257
- .bar { display: inline-block; height: 1rem; background: var(--preview-muted); border-radius: 2px; vertical-align: middle; }
258
- .note { color: var(--preview-muted); font-size: 13px; margin-top: 0.5rem; }
259
- .empty { color: var(--preview-muted); font-style: italic; }${verifyStyle}
260
- </style>
261
- </head>
262
- <body>
263
- <h1>Design tokens</h1>
264
- <p class="note">Generated from <code>canon/DESIGN.md</code> by <code>bun run web:previews</code>. Token preview only, not a screen mock.</p>${summary}
265
- ${sections.join('\n')}
266
- </body>
267
- </html>
268
- `
243
+ return fillTemplate(readTemplate('preview.html'), {
244
+ favicon: favicon === undefined ? '' : `\n${faviconLink(favicon)}`,
245
+ chrome: previewChrome(),
246
+ bodyStack,
247
+ verifyStyle,
248
+ summary,
249
+ sections: sections.join('\n'),
250
+ })
269
251
  }
270
252
 
271
253
  function sectionPersonality(text: string): string {
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ /**
4
+ * A file under `templates/`, resolved from this module so the published CLI
5
+ * finds it wherever the package is installed. Each file ends on one newline
6
+ * that the emitted text does not carry, so that newline is dropped.
7
+ *
8
+ * The folder is outside the formatter, since a reflowed template changes the
9
+ * bytes every generator emits.
10
+ */
11
+ export function readTemplate(file: string): string {
12
+ return readFileSync(
13
+ new URL(`./templates/${file}`, import.meta.url),
14
+ 'utf8',
15
+ ).replace(/\n$/, '')
16
+ }
17
+
18
+ const SLOT = /\{\{(\w+)\}\}/g
19
+
20
+ /**
21
+ * Fills each `{{name}}` slot from `values`. A slot with no value, or a value no
22
+ * slot asks for, throws, so a template and its caller cannot drift apart
23
+ * silently.
24
+ */
25
+ export function fillTemplate(
26
+ template: string,
27
+ values: Readonly<Record<string, string>>,
28
+ ): string {
29
+ const used = new Set<string>()
30
+ const filled = template.replace(SLOT, (_, name: string) => {
31
+ const value = values[name]
32
+ if (value === undefined)
33
+ throw new Error(`template slot {{${name}}} has no value`)
34
+ used.add(name)
35
+ return value
36
+ })
37
+ const unused = Object.keys(values).filter((name) => !used.has(name))
38
+ if (unused.length > 0) {
39
+ throw new Error(`template has no slot for ${unused.join(', ')}`)
40
+ }
41
+ return filled
42
+ }
@@ -0,0 +1,17 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="{{left}} {{top}} {{side}} {{side}}">
2
+ <style>
3
+ .tile { fill: {{tileDark}}; stroke: {{tileDark}}; }
4
+ .c { fill: {{cDark}}; }
5
+ .pip { fill: {{pipDark}}; }
6
+ @media (prefers-color-scheme: light) {
7
+ .tile { fill: {{tileLight}}; stroke: {{edge}}; }
8
+ .c { fill: {{cLight}}; }
9
+ .pip { fill: {{pipLight}}; }
10
+ }
11
+ </style>
12
+ <rect class="tile" x="{{tileX}}" y="{{tileY}}" width="{{tileSide}}" height="{{tileSide}}" rx="{{tileRadius}}" stroke-width="{{edgeWidth}}" />
13
+ <g class="c">
14
+ {{shapes}}
15
+ </g>
16
+ </svg>
17
+
@@ -0,0 +1,54 @@
1
+ /* ---- Figures: hand-drawn, and wider than the measure ---- */
2
+
3
+ :root {
4
+ --figure-hand: 'Virgil', 'Excalifont', cursive;
5
+ --figure-wide: 64rem;
6
+ }
7
+
8
+ figure {
9
+ margin: 2.75rem 0;
10
+ width: var(--figure-wide);
11
+ max-width: 92vw;
12
+ margin-left: 50%;
13
+ transform: translateX(-50%);
14
+ }
15
+
16
+ figure svg {
17
+ width: 100%;
18
+ height: auto;
19
+ display: block;
20
+ }
21
+
22
+ /* CSS beats an SVG presentation attribute, so the diagrams pick up the
23
+ hand face without editing a single lesson. */
24
+ figure svg text {
25
+ font-family: var(--figure-hand);
26
+ }
27
+
28
+ figcaption {
29
+ font-family: var(--figure-hand);
30
+ font-size: 1rem;
31
+ color: var(--color-muted);
32
+ margin: 1rem auto 0;
33
+ line-height: 1.5;
34
+ max-width: 52rem;
35
+ }
36
+
37
+ /* A figure breaks the measure and must still clear an outline rail. Deriving
38
+ the ceiling from the viewport does not hold, since a rail positioned from
39
+ the centre keeps overlapping as the window widens, so the ceiling is fixed
40
+ instead. */
41
+ @media (min-width: 1421px) {
42
+ figure {
43
+ max-width: 54rem;
44
+ }
45
+ }
46
+
47
+ @media (max-width: 640px) {
48
+ figure {
49
+ width: 100%;
50
+ max-width: 100%;
51
+ margin-left: 0;
52
+ transform: none;
53
+ }
54
+ }