@erclx/canon 5.33.0 → 5.35.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 (190) hide show
  1. package/README.md +3 -5
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/REQUIREMENT.md +2 -3
  4. package/claude/skills/api-design/SKILL.md +2 -3
  5. package/claude/skills/api-design/references/adopted.md +1 -1
  6. package/claude/skills/auto-ship/references/draft-mark.md +1 -1
  7. package/claude/skills/canon-cli/SKILL.md +10 -11
  8. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
  9. package/claude/skills/canon-feedback/SKILL.md +10 -28
  10. package/claude/skills/canon-feedback/references/template.md +53 -0
  11. package/claude/skills/canvas-board/SKILL.md +4 -1
  12. package/claude/skills/canvas-board/references/canvas.md +26 -3
  13. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  14. package/claude/skills/code-craft/SKILL.md +0 -1
  15. package/claude/skills/create-rule/SKILL.md +1 -1
  16. package/claude/skills/create-rule/references/rule.md +3 -1
  17. package/claude/skills/create-skill/SKILL.md +2 -1
  18. package/claude/skills/create-skill/references/skill-paths.md +12 -15
  19. package/{standards/standard.md → claude/skills/create-skill/references/skill-reference.md} +50 -45
  20. package/claude/skills/create-skill/references/skill.md +10 -4
  21. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  22. package/claude/skills/git-commit/references/versioning.md +13 -13
  23. package/claude/skills/git-issue/references/issue.md +4 -1
  24. package/claude/skills/git-pr/SKILL.md +1 -1
  25. package/claude/skills/git-pr/references/publish.md +1 -1
  26. package/claude/skills/review-address/REQUIREMENT.md +1 -1
  27. package/claude/skills/review-address/SKILL.md +2 -2
  28. package/claude/skills/review-craft/references/ui.md +1 -1
  29. package/claude/skills/review-pr/SKILL.md +3 -3
  30. package/claude/skills/review-pr/references/post.md +1 -1
  31. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  32. package/claude/skills/review-ui/SKILL.md +8 -6
  33. package/claude/skills/review-ui/references/driving.md +8 -8
  34. package/claude/skills/review-ui/references/post.md +52 -16
  35. package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -2
  36. package/claude/skills/role-orchestrator/SKILL.md +11 -12
  37. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +3 -1
  38. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -8
  39. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +10 -4
  40. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +11 -2
  41. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -12
  42. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +12 -15
  43. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +6 -4
  44. package/claude/skills/role-orchestrator/scripts/poll.ts +50 -1
  45. package/claude/skills/role-reviewer/REQUIREMENT.md +17 -7
  46. package/claude/skills/role-reviewer/SKILL.md +17 -10
  47. package/claude/skills/role-worker/REQUIREMENT.md +1 -1
  48. package/claude/skills/role-worker/SKILL.md +6 -6
  49. package/claude/skills/session-compact/references/handoff-note.md +1 -1
  50. package/claude/skills/target-setup/SKILL.md +12 -6
  51. package/claude/skills/target-setup/references/detect.md +22 -5
  52. package/claude/skills/test-craft/REQUIREMENT.md +2 -3
  53. package/claude/skills/test-craft/SKILL.md +15 -3
  54. package/docs/agents/audits.md +1 -1
  55. package/docs/agents/canvas.md +5 -1
  56. package/docs/agents/commands.md +3 -5
  57. package/docs/agents/counts.md +5 -6
  58. package/docs/agents/drift-surfaces.md +15 -19
  59. package/docs/agents/gov-detect.md +47 -0
  60. package/docs/agents/index.md +6 -4
  61. package/docs/agents/install-and-sync.md +11 -31
  62. package/docs/agents/markdown-audit.md +10 -12
  63. package/docs/agents/migrate.md +1 -1
  64. package/docs/agents/pr-evidence-layout.md +70 -0
  65. package/docs/agents/pr-evidence.md +8 -9
  66. package/docs/agents/pr-frames.md +7 -2
  67. package/docs/agents/pr-lift.md +132 -0
  68. package/docs/agents/records-stale.md +1 -1
  69. package/docs/agents/records-validate.md +11 -23
  70. package/docs/agents/records.md +1 -1
  71. package/docs/agents/routing.md +1 -1
  72. package/docs/agents/rule-citations.md +11 -14
  73. package/docs/agents/scripting.md +4 -21
  74. package/docs/agents/state-scoped-risk.md +1 -1
  75. package/docs/agents/tasks-validate.md +1 -1
  76. package/docs/agents/teach.md +1 -1
  77. package/docs/target/index.md +1 -0
  78. package/docs/target/migrations.md +4 -0
  79. package/docs/target/projects.md +11 -11
  80. package/docs/target/scenarios.md +5 -6
  81. package/docs/target/standards-moved.md +32 -0
  82. package/docs/target/sync.md +5 -5
  83. package/docs/workflow/operating-model.md +16 -18
  84. package/docs/workflow/skill-map.md +1 -1
  85. package/governance/detect.toml +197 -0
  86. package/governance/renames.toml +0 -1
  87. package/governance/rules/code/000-code.md +1 -13
  88. package/governance/rules/code/010-testing.md +2 -30
  89. package/governance/rules/ui/400-ui.md +1 -1
  90. package/governance/rules/ui/480-canvas.md +13 -0
  91. package/governance/stacks/base.toml +15 -8
  92. package/governance/stacks/go.toml +2 -0
  93. package/governance/stacks/node.toml +1 -1
  94. package/governance/stacks/php.toml +2 -0
  95. package/governance/stacks/python.toml +1 -1
  96. package/package.json +1 -4
  97. package/scripts/core/install-check.sh +2 -3
  98. package/scripts/core/regen-hero.ts +0 -6
  99. package/scripts/lib/ui.sh +0 -9
  100. package/src/audits/catalog.ts +0 -9
  101. package/src/canvas/arrange.ts +66 -0
  102. package/src/canvas/capture.ts +20 -6
  103. package/src/canvas/content.ts +39 -0
  104. package/src/canvas/fit.ts +238 -0
  105. package/src/canvas/tokens.ts +28 -10
  106. package/src/capture/frame-values.ts +2 -32
  107. package/src/claude/routing.ts +1 -1
  108. package/src/claude/skills-reach.ts +4 -6
  109. package/src/cli/help.ts +3 -4
  110. package/src/cli.ts +0 -2
  111. package/src/commands/canvas.ts +179 -1
  112. package/src/commands/gov.ts +149 -10
  113. package/src/commands/init.ts +1 -1
  114. package/src/commands/labels.ts +4 -44
  115. package/src/commands/migrate.ts +1 -1
  116. package/src/commands/pr.ts +376 -1
  117. package/src/commands/records.ts +4 -20
  118. package/src/commands/tooling.ts +2 -4
  119. package/src/counts/catalogs.ts +0 -6
  120. package/src/feedback/body.ts +26 -5
  121. package/src/gate/boundaries.ts +2 -2
  122. package/src/gate/measures.ts +64 -72
  123. package/src/gate/sequencer.ts +51 -2
  124. package/src/gate/stages.ts +3 -22
  125. package/src/gov/citations.ts +11 -43
  126. package/src/gov/detect.ts +301 -0
  127. package/src/gov/list.ts +23 -2
  128. package/src/gov/stacks.ts +9 -0
  129. package/src/markdown/bans.ts +1 -1
  130. package/src/migrate/scratch-evidence.ts +2 -2
  131. package/src/pr/evidence.ts +218 -45
  132. package/src/pr/frames.ts +23 -3
  133. package/src/pr/lift.ts +418 -0
  134. package/src/pr/rest.ts +3 -0
  135. package/src/pr/review-scope.ts +76 -0
  136. package/src/records/validate.ts +6 -194
  137. package/src/sandbox/equivalence.ts +14 -7
  138. package/src/sandbox/provision.ts +0 -5
  139. package/src/secrets/shipped.ts +1 -2
  140. package/src/serve/static.ts +1 -2
  141. package/src/shipped/references.ts +29 -80
  142. package/src/sync/check.ts +18 -14
  143. package/src/tooling/inject.ts +6 -37
  144. package/src/tooling/read.ts +2 -3
  145. package/src/tooling/scan.ts +4 -17
  146. package/src/tooling/subfolder.ts +2 -66
  147. package/tooling/base/configs/scripts/verify.json +1 -1
  148. package/tooling/base/configs/scripts/verify.ts +1 -12
  149. package/tooling/base/manifest.toml +1 -2
  150. package/tooling/base/reference.md +10 -16
  151. package/tooling/base/seeds/.github/workflows/verify.yml +0 -3
  152. package/tooling/base/seeds/.lintstagedrc +1 -2
  153. package/tooling/base/seeds/canon/context/ci.md +0 -1
  154. package/tooling/base/seeds/canon/context/development.md +1 -1
  155. package/tooling/claude/reference.md +1 -1
  156. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +13 -1
  157. package/tooling/go/reference.md +1 -1
  158. package/tooling/php/reference.md +1 -1
  159. package/tooling/python/reference.md +2 -2
  160. package/tooling/web/configs/.github/workflows/verify.yml +0 -3
  161. package/tooling/web/manifest.toml +0 -1
  162. package/tooling/web/reference.md +2 -4
  163. package/docs/agents/standards-audit.md +0 -40
  164. package/governance/rules/tooling/765-spelling.md +0 -19
  165. package/governance/stacks/astro.toml +0 -2
  166. package/governance/stacks/nextjs.toml +0 -2
  167. package/governance/stacks/node-server.toml +0 -2
  168. package/governance/stacks/python-fastapi.toml +0 -2
  169. package/governance/stacks/react.toml +0 -2
  170. package/src/commands/standards.ts +0 -278
  171. package/src/labels/spelling.ts +0 -120
  172. package/src/standards/audit.ts +0 -146
  173. package/src/standards/list.ts +0 -33
  174. package/src/standards/read.ts +0 -177
  175. package/src/standards/scope.ts +0 -56
  176. package/standards/index.md +0 -10
  177. package/tooling/base/configs/.vscode/extensions.json +0 -9
  178. package/tooling/base/configs/.vscode/settings.json +0 -3
  179. package/tooling/base/seeds/.cspell/project-terms.txt +0 -0
  180. package/tooling/base/seeds/.cspell/tech-stack.txt +0 -25
  181. package/tooling/base/seeds/cspell.json +0 -20
  182. package/tooling/go/seeds/.cspell/tech-stack.txt +0 -16
  183. package/tooling/nextjs/seeds/.cspell/tech-stack.txt +0 -3
  184. package/tooling/php/seeds/.cspell/tech-stack.txt +0 -5
  185. package/tooling/python/seeds/.cspell/tech-stack.txt +0 -18
  186. package/tooling/vite-react/seeds/.cspell/project-terms.txt +0 -1
  187. package/tooling/vite-react/seeds/.cspell/tech-stack.txt +0 -2
  188. package/tooling/web/configs/.vscode/extensions.json +0 -13
  189. package/tooling/web/configs/.vscode/settings.json +0 -10
  190. package/tooling/web/seeds/.cspell/tech-stack.txt +0 -21
@@ -188,23 +188,218 @@ function evidenceImage(url: string, alt: string, width?: number): string {
188
188
 
189
189
  const EVIDENCE_IMAGE = String.raw`(?:!\[\]\([^)]*\)|<img [^>]*>)`
190
190
 
191
- /** A case row in any shape a render has posted: a Base cell before the head image, or the head image alone. */
192
- const EVIDENCE_ROW_PATTERN = new RegExp(
191
+ /** A case row in a shape before the head image carried an alt: a Base cell before the head image, or the head image alone. */
192
+ const LEGACY_ROW_PATTERN = new RegExp(
193
193
  String.raw`^\| .+ \| (?:(?:\*\(new\)\*|${EVIDENCE_IMAGE}) \| )?${EVIDENCE_IMAGE} \|$`,
194
- 'gm',
195
194
  )
196
195
 
196
+ /** A head image, which every render since the alt text was added names with a trailing `head`. A row carries one per case it holds. */
197
+ const HEAD_IMAGE_PATTERN = /<img [^>]*alt="[^"]* head">/g
198
+
199
+ /** The form a case name takes when it names a view, a theme, and a width. Anchored on the right, since a view may carry hyphens. */
200
+ const CASE_NAME = /^(.+)-(dark|light)-(\d+)$/
201
+
202
+ const THEMES = ['dark', 'light'] as const
203
+
204
+ type Theme = (typeof THEMES)[number]
205
+
206
+ interface ViewCase {
207
+ readonly item: EvidenceItem
208
+ readonly theme: Theme
209
+ readonly width: string
210
+ }
211
+
212
+ function isTheme(value: string): value is Theme {
213
+ return value === 'dark' || value === 'light'
214
+ }
215
+
216
+ function readCaseName(stem: string):
217
+ | {
218
+ readonly view: string
219
+ readonly theme: Theme
220
+ readonly width: string
221
+ }
222
+ | undefined {
223
+ const match = CASE_NAME.exec(stem)
224
+ const view = match?.[1]
225
+ const theme = match?.[2]
226
+ const width = match?.[3]
227
+ if (view === undefined || theme === undefined || width === undefined) {
228
+ return undefined
229
+ }
230
+ return isTheme(theme) ? { view, theme, width } : undefined
231
+ }
232
+
233
+ function compareWidths(a: string, b: string): number {
234
+ return Number(a) - Number(b) || a.localeCompare(b)
235
+ }
236
+
237
+ function commitLink(repo: string, sha: string): string {
238
+ return `[\`${sha.slice(0, SHORT_SHA_LENGTH)}\`](https://github.com/${repo}/commit/${sha})`
239
+ }
240
+
241
+ function baseImage(item: EvidenceItem, repo: string, base: string): string {
242
+ return item.added
243
+ ? '*(new)*'
244
+ : evidenceImage(
245
+ rawUrl(repo, base, item.path),
246
+ `${escapeCell(item.stem)} base`,
247
+ item.baseWidth,
248
+ )
249
+ }
250
+
251
+ function headImage(item: EvidenceItem, repo: string, head: string): string {
252
+ return evidenceImage(
253
+ rawUrl(repo, head, item.path),
254
+ `${escapeCell(item.stem)} head`,
255
+ item.width,
256
+ )
257
+ }
258
+
259
+ function block(
260
+ opener: string,
261
+ summary: string,
262
+ header: readonly string[],
263
+ rows: readonly string[],
264
+ ): string {
265
+ return [
266
+ opener,
267
+ `<summary>${summary}</summary>`,
268
+ '',
269
+ `| ${header.join(' | ')} |`,
270
+ `| ${header.map(() => '---').join(' | ')} |`,
271
+ ...rows,
272
+ '',
273
+ '</details>',
274
+ ].join('\n')
275
+ }
276
+
277
+ /** One block for the cases of a state that did not name a view, one case per row. */
278
+ function renderFlat(
279
+ label: string,
280
+ items: readonly EvidenceItem[],
281
+ repo: string,
282
+ base: string,
283
+ head: string,
284
+ opener: string,
285
+ ): string {
286
+ const hasBase = items.some((item) => !item.added)
287
+ const rows = items.map((item) => {
288
+ const stem = escapeCell(item.stem)
289
+ const after = headImage(item, repo, head)
290
+ return hasBase
291
+ ? `| ${stem} | ${baseImage(item, repo, base)} | ${after} |`
292
+ : `| ${stem} | ${after} |`
293
+ })
294
+ return block(
295
+ opener,
296
+ `${label} (${items.length})`,
297
+ hasBase ? ['Case', 'Base', 'Head'] : ['Case', 'Head'],
298
+ rows,
299
+ )
300
+ }
301
+
302
+ /** One block for a view, a width per row and a theme per column, each theme taking a base column when the view already exists at the base. */
303
+ function renderView(
304
+ label: string,
305
+ cases: readonly ViewCase[],
306
+ repo: string,
307
+ base: string,
308
+ head: string,
309
+ opener: string,
310
+ ): string {
311
+ const themes = THEMES.filter((theme) =>
312
+ cases.some((entry) => entry.theme === theme),
313
+ )
314
+ const hasBase = cases.some((entry) => !entry.item.added)
315
+ const widths = [...new Set(cases.map((entry) => entry.width))].sort(
316
+ compareWidths,
317
+ )
318
+ const header = [
319
+ 'Width',
320
+ ...themes.flatMap((theme) => {
321
+ const name = theme === 'dark' ? 'Dark' : 'Light'
322
+ return hasBase ? [`${name} base`, `${name} head`] : [name]
323
+ }),
324
+ ]
325
+ const rows = widths.map((width) => {
326
+ const cells = themes.flatMap((theme) => {
327
+ const found = cases.find(
328
+ (entry) => entry.theme === theme && entry.width === width,
329
+ )
330
+ if (found === undefined) return hasBase ? ['', ''] : ['']
331
+ const after = headImage(found.item, repo, head)
332
+ return hasBase ? [baseImage(found.item, repo, base), after] : [after]
333
+ })
334
+ return `| ${[width, ...cells].join(' | ')} |`
335
+ })
336
+ return block(opener, `${label} (${cases.length})`, header, rows)
337
+ }
338
+
339
+ /**
340
+ * Renders the blocks of one state: a block per view the case names split
341
+ * into, then one flat block for the names that did not. Nothing is dropped,
342
+ * so a state of unparsed names reads as the flat table it always was.
343
+ */
344
+ function renderState(
345
+ entry: EvidenceState,
346
+ repo: string,
347
+ base: string,
348
+ head: string,
349
+ opener: string,
350
+ ): string[] {
351
+ const views = new Map<string, ViewCase[]>()
352
+ const flat: EvidenceItem[] = []
353
+ const taken = new Set<string>()
354
+ for (const item of entry.items) {
355
+ const name = readCaseName(item.stem)
356
+ // Two files can share a stem across extensions, and a view cell holds one.
357
+ const cell =
358
+ name === undefined ? '' : `${name.view}|${name.theme}|${name.width}`
359
+ if (name === undefined || taken.has(cell)) {
360
+ flat.push(item)
361
+ continue
362
+ }
363
+ taken.add(cell)
364
+ const cases = views.get(name.view)
365
+ const viewCase = { item, theme: name.theme, width: name.width }
366
+ if (cases === undefined) views.set(name.view, [viewCase])
367
+ else cases.push(viewCase)
368
+ }
369
+
370
+ const prefix = entry.state === '' ? '' : `${escapeCell(entry.state)} / `
371
+ const blocks = [...views.entries()]
372
+ .sort(([a], [b]) => a.localeCompare(b))
373
+ .map(([view, cases]) =>
374
+ renderView(
375
+ `${prefix}${escapeCell(view)}`,
376
+ cases,
377
+ repo,
378
+ base,
379
+ head,
380
+ opener,
381
+ ),
382
+ )
383
+ if (flat.length > 0) {
384
+ const label = entry.state === '' ? 'evidence' : escapeCell(entry.state)
385
+ blocks.push(renderFlat(label, flat, repo, base, head, opener))
386
+ }
387
+ return blocks
388
+ }
389
+
197
390
  export function evidenceMarker(head: string): string {
198
391
  return `${MARKER_PREFIX} head=${head} -->`
199
392
  }
200
393
 
201
394
  /**
202
- * Renders the whole comment body: one `<details>` block per state, a Base/Head
203
- * row per case, and the trailing marker naming the head this body describes.
395
+ * Renders the whole comment body: one `<details>` block per view within each
396
+ * state, a width per row with a column per theme, a flat Base/Head table for
397
+ * the case names that do not split into a view, and the trailing marker naming
398
+ * the head this body describes.
204
399
  * Every state opens while the comment carries at most `EVIDENCE_OPEN_LIMIT`
205
400
  * images, one per row for the head plus one per row not added, and every state
206
401
  * closes past it, so a comment is never half open. A visible line under
207
- * `## Evidence` names the base and head as short shas. Every image URL is
402
+ * `## Evidence` names the base and head as short shas, each linking its commit. Every image URL is
208
403
  * pinned to a commit sha rather than a branch, so the comment keeps showing
209
404
  * what it claimed even after the branch moves.
210
405
  *
@@ -252,42 +447,13 @@ export function renderEvidenceBody(
252
447
  const opener =
253
448
  imageCount <= EVIDENCE_OPEN_LIMIT ? '<details open>' : '<details>'
254
449
 
255
- const sections = states.map((entry) => {
256
- const hasBase = entry.items.some((item) => !item.added)
257
- const rows = entry.items.map((item) => {
258
- const stem = escapeCell(item.stem)
259
- const before = item.added
260
- ? '*(new)*'
261
- : evidenceImage(
262
- rawUrl(repo, base, item.path),
263
- `${stem} base`,
264
- item.baseWidth,
265
- )
266
- const after = evidenceImage(
267
- rawUrl(repo, head, item.path),
268
- `${stem} head`,
269
- item.width,
270
- )
271
- return hasBase
272
- ? `| ${stem} | ${before} | ${after} |`
273
- : `| ${stem} | ${after} |`
274
- })
275
-
276
- return [
277
- opener,
278
- `<summary>${entry.state === '' ? 'evidence' : entry.state} (${entry.items.length})</summary>`,
279
- '',
280
- hasBase ? '| Case | Base | Head |' : '| Case | Head |',
281
- hasBase ? '| --- | --- | --- |' : '| --- | --- |',
282
- ...rows,
283
- '',
284
- '</details>',
285
- ].join('\n')
286
- })
450
+ const sections = states.flatMap((entry) =>
451
+ renderState(entry, repo, base, head, opener),
452
+ )
287
453
 
288
454
  return [
289
455
  ...opening,
290
- `${BASE_PREFIX} \`${base.slice(0, SHORT_SHA_LENGTH)}\` · **Head:** \`${head.slice(0, SHORT_SHA_LENGTH)}\``,
456
+ `${BASE_PREFIX} ${commitLink(repo, base)} · **Head:** ${commitLink(repo, head)}`,
291
457
  '',
292
458
  ...sections,
293
459
  '',
@@ -297,14 +463,21 @@ export function renderEvidenceBody(
297
463
  }
298
464
 
299
465
  /**
300
- * How many case rows a rendered body carries, read off the row shapes
301
- * `renderEvidenceBody` writes. A row always ends in a head image, which a
302
- * checklist line or a preview address never does. The pattern also reads the
303
- * `![](url)` three-column shape an earlier render posted, so a comment posted
304
- * before the change still counts its cases and the `would-empty` guard holds.
466
+ * How many cases a rendered body carries. Every case has exactly one head
467
+ * image, so a row counts the head images it holds, which is one in the flat
468
+ * table and up to two in a view's row. A row from before the alt text named
469
+ * its head image counts as one case when it matches the old row shape, so a
470
+ * comment posted before the change still counts its cases and the
471
+ * `would-empty` guard holds.
305
472
  */
306
473
  export function countEvidenceCases(body: string): number {
307
- return body.match(EVIDENCE_ROW_PATTERN)?.length ?? 0
474
+ let total = 0
475
+ for (const line of body.split('\n')) {
476
+ if (!line.startsWith('| ')) continue
477
+ const heads = line.match(HEAD_IMAGE_PATTERN)?.length ?? 0
478
+ total += heads > 0 ? heads : LEGACY_ROW_PATTERN.test(line) ? 1 : 0
479
+ }
480
+ return total
308
481
  }
309
482
 
310
483
  export interface EvidenceComment {
package/src/pr/frames.ts CHANGED
@@ -20,6 +20,10 @@ const FOLDER_PATTERN = /^pr-(\d+)$/
20
20
 
21
21
  const DAY_MS = 86_400_000
22
22
 
23
+ const THEMES = ['dark', 'light'] as const
24
+
25
+ export type FrameTheme = (typeof THEMES)[number]
26
+
23
27
  export interface TreeEntry {
24
28
  readonly path: string
25
29
  readonly mode: string
@@ -71,6 +75,7 @@ export interface FrameInput {
71
75
  readonly head: string
72
76
  readonly pass: string
73
77
  readonly box: number
78
+ readonly theme?: FrameTheme
74
79
  readonly bytes: Uint8Array
75
80
  }
76
81
 
@@ -113,18 +118,27 @@ export function isFramePass(pass: string): boolean {
113
118
  return PASS_PATTERN.test(pass)
114
119
  }
115
120
 
121
+ /** Whether a value names a theme a box can be shot at, the only theme segment the path takes. */
122
+ export function isFrameTheme(theme: string): theme is FrameTheme {
123
+ return (THEMES as readonly string[]).includes(theme)
124
+ }
125
+
116
126
  /**
117
127
  * Where a frame lands. The pass segment keeps a second pass at the same head
118
128
  * and box from replacing the image an earlier review comment shows, since a
119
- * branch-path link reads whatever the path holds now.
129
+ * branch-path link reads whatever the path holds now. The theme segment keeps
130
+ * two shots of one box in one pass apart, and a frame without one keeps the
131
+ * plain `box-<n>.png` form.
120
132
  */
121
133
  export function framePath(
122
134
  number: number,
123
135
  head: string,
124
136
  pass: string,
125
137
  box: number,
138
+ theme?: FrameTheme,
126
139
  ): string {
127
- return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/box-${box}.png`
140
+ const name = theme === undefined ? `box-${box}` : `box-${box}-${theme}`
141
+ return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/${name}.png`
128
142
  }
129
143
 
130
144
  /**
@@ -160,7 +174,13 @@ export async function pushFrame(
160
174
  api: FramesApi,
161
175
  input: FrameInput,
162
176
  ): Promise<PushResult> {
163
- const path = framePath(input.number, input.head, input.pass, input.box)
177
+ const path = framePath(
178
+ input.number,
179
+ input.head,
180
+ input.pass,
181
+ input.box,
182
+ input.theme,
183
+ )
164
184
  const blob = await api.createBlob(Buffer.from(input.bytes).toString('base64'))
165
185
  if (blob === undefined) return { kind: 'refused', reason: 'push-failed' }
166
186
  const entry: TreeEntry = { path, mode: '100644', type: 'blob', sha: blob }