@erclx/canon 5.68.0 → 5.70.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 +36 -0
  2. package/README.md +33 -159
  3. package/assets/brand/mark.svg +17 -9
  4. package/claude/.claude-plugin/plugin.json +1 -1
  5. package/claude/skills/app-drive/REQUIREMENT.md +5 -5
  6. package/claude/skills/app-drive/SKILL.md +17 -6
  7. package/claude/skills/app-drive/references/drive-record.md +61 -8
  8. package/claude/skills/app-drive/references/isolate.md +1 -1
  9. package/claude/skills/canon-cli/SKILL.md +2 -2
  10. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
  11. package/claude/skills/canon-feedback/SKILL.md +1 -1
  12. package/claude/skills/design-identity/REQUIREMENT.md +4 -4
  13. package/claude/skills/design-identity/SKILL.md +7 -7
  14. package/claude/skills/{pick-by-eye → design-pick}/REQUIREMENT.md +6 -6
  15. package/claude/skills/{pick-by-eye → design-pick}/SKILL.md +19 -10
  16. package/claude/skills/{pick-by-eye → design-pick}/references/board.md +17 -11
  17. package/claude/skills/design-pick/references/design-config.md +33 -0
  18. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  19. package/claude/skills/design-record/SKILL.md +1 -1
  20. package/claude/skills/design-taste/REQUIREMENT.md +2 -2
  21. package/claude/skills/design-taste/SKILL.md +3 -3
  22. package/claude/skills/design-taste/references/preflight.md +1 -1
  23. package/claude/skills/git-issue/REQUIREMENT.md +1 -1
  24. package/claude/skills/git-issue/references/issue.md +1 -1
  25. package/claude/skills/plan-feature/SKILL.md +1 -1
  26. package/claude/skills/plan-feature/references/plan.md +21 -6
  27. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  28. package/claude/skills/pr-evidence/references/evidence.md +2 -2
  29. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +3 -3
  30. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  31. package/claude/skills/role-orchestrator/references/orchestrator-planning-wait.md +24 -0
  32. package/claude/skills/role-planner/SKILL.md +4 -2
  33. package/claude/skills/role-worker/SKILL.md +5 -6
  34. package/claude/skills/task-board/references/tasks.md +1 -1
  35. package/claude/skills/task-complete/references/receipt-sweep.md +1 -1
  36. package/claude/skills/{ux-audit → ui-audit}/REQUIREMENT.md +3 -3
  37. package/claude/skills/{ux-audit → ui-audit}/SKILL.md +7 -7
  38. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  39. package/claude/skills/ui-craft/SKILL.md +1 -1
  40. package/claude/skills/ui-craft/references/copy.md +4 -1
  41. package/claude/skills/{ui-record → ui-film}/REQUIREMENT.md +6 -4
  42. package/claude/skills/{ui-record → ui-film}/SKILL.md +26 -13
  43. package/claude/skills/ui-film/references/compose-craft.md +81 -0
  44. package/claude/skills/{ui-record → ui-film}/references/config.md +9 -4
  45. package/claude/skills/{ui-record → ui-film}/references/handoff.md +17 -7
  46. package/claude/skills/{ui-record → ui-film}/references/pr-clip.md +5 -5
  47. package/claude/skills/{ui-record → ui-film}/references/take-craft.md +28 -2
  48. package/claude/skills/{ux-measure → ui-measure}/REQUIREMENT.md +3 -3
  49. package/claude/skills/{ux-measure → ui-measure}/SKILL.md +6 -6
  50. package/docs/agents/audits.md +1 -1
  51. package/docs/agents/migrate.md +13 -13
  52. package/docs/index.md +1 -0
  53. package/docs/migrations/by-hand.md +107 -0
  54. package/docs/migrations/by-verb.md +118 -0
  55. package/docs/migrations/index.md +14 -0
  56. package/docs/target/index.md +1 -2
  57. package/docs/target/projects.md +3 -3
  58. package/docs/target/sync.md +23 -3
  59. package/docs/workflow/ai-workflow.md +1 -1
  60. package/docs/workflow/skill-map-on-request.md +1 -2
  61. package/docs/workflow/skill-map.md +9 -10
  62. package/docs/workflow/visual-design-workflow.md +6 -1
  63. package/governance/ledger/claude/background.md +26 -0
  64. package/governance/ledger/skills/ui-craft/copy.md +8 -4
  65. package/governance/rules/claude/background.md +19 -0
  66. package/governance/stacks/base.toml +1 -0
  67. package/package.json +6 -4
  68. package/scripts/ci/install-playwright-deps.sh +52 -0
  69. package/scripts/core/design-sync.ts +204 -0
  70. package/scripts/core/regen-web-favicon.ts +27 -12
  71. package/scripts/core/regen-web-previews.ts +8 -1
  72. package/scripts/core/render-film.ts +179 -0
  73. package/src/capture/frame-values.ts +12 -12
  74. package/src/claude/cases/authoring.ts +2 -2
  75. package/src/claude/cases/misc.ts +1 -1
  76. package/src/claude/cases/setup.ts +0 -5
  77. package/src/claude/cases/workflow.ts +6 -6
  78. package/src/commands/fixtures/gh.sh +64 -0
  79. package/src/commands/gov.ts +33 -3
  80. package/src/commands/migrate.ts +12 -2
  81. package/src/commands/records.ts +3 -2
  82. package/src/commands/sandbox.ts +9 -2
  83. package/src/commands/take.ts +159 -11
  84. package/src/commands/tasks.ts +183 -0
  85. package/src/design/favicon.ts +85 -13
  86. package/src/design/reference.ts +98 -0
  87. package/src/design/render.ts +16 -14
  88. package/src/design/tokens.ts +4 -3
  89. package/src/gate/stages.ts +8 -2
  90. package/src/gov/audit.ts +48 -11
  91. package/src/gov/detect.ts +1 -1
  92. package/src/init/steps.ts +2 -2
  93. package/src/markdown/scope.ts +9 -14
  94. package/src/migrate/record-layout.ts +5 -3
  95. package/src/migrate/skill-names.ts +21 -4
  96. package/src/migrate/status.ts +3 -3
  97. package/src/records/validate.ts +15 -2
  98. package/src/sandbox/expect.ts +32 -0
  99. package/src/sandbox/headless/dispatch-shim.sh +15 -0
  100. package/src/sandbox/headless/dispatch.ts +12 -14
  101. package/src/sandbox/headless/run.ts +6 -3
  102. package/src/sandbox/stub-gh.sh +26 -0
  103. package/src/sandbox/stub-remote.ts +8 -29
  104. package/src/sync/engine.ts +1 -1
  105. package/src/take/camera.ts +100 -11
  106. package/src/take/film.ts +55 -4
  107. package/src/take/fixtures/input.html +138 -0
  108. package/src/take/keys.ts +193 -0
  109. package/src/take/log.ts +94 -1
  110. package/src/take/perform.ts +165 -10
  111. package/src/take/plan.ts +101 -6
  112. package/src/take/pointer.ts +2 -1
  113. package/src/take/screencast.ts +28 -3
  114. package/src/take/stepper.ts +141 -23
  115. package/src/take/zoom/NOTICE.md +1 -1
  116. package/src/tasks/launch-wait.ts +160 -0
  117. package/tooling/claude/reference.md +1 -1
  118. package/claude/skills/canon-feedback-triage/REQUIREMENT.md +0 -34
  119. package/claude/skills/canon-feedback-triage/SKILL.md +0 -98
  120. package/claude/skills/plan-ready/REQUIREMENT.md +0 -35
  121. package/claude/skills/plan-ready/SKILL.md +0 -136
  122. package/claude/skills/plan-ready/references/assembly.md +0 -75
  123. package/claude/skills/plan-ready/references/ready.md +0 -128
  124. package/docs/target/migrations.md +0 -203
  125. /package/claude/skills/{pick-by-eye → design-pick}/references/live-arms.md +0 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Lands the board's accepted designs in canon/design/: each frame
3
+ * canon/design/frames.json maps, as its HTML beside a PNG per theme, captured
4
+ * through Frameboard's own `capture` verb from the design board at the main
5
+ * worktree's .canon/board, plus a copy of every project asset those frames
6
+ * load under kit/<project>/assets/. `--check` writes nothing and exits 1
7
+ * when the folder differs from what a sync would write.
8
+ *
9
+ * Ported from Frameboard's own scripts/design-sync.ts. Its frames load a
10
+ * shared kit and it formats each HTML through oxfmt, while this board's frames
11
+ * load their project's ../assets/ and the formatter ignores canon/design/, so
12
+ * the asset handling is the one part that differs. The pure half sits in
13
+ * `src/design/reference.ts`, where tsc and vitest reach it.
14
+ *
15
+ * Frameboard runs through bunx at a pinned version rather than as a
16
+ * devDependency, since only a session holding the board runs this script.
17
+ *
18
+ * Clone-only. This script reads the gitignored board, which neither a
19
+ * registry install nor CI carries.
20
+ */
21
+ import {
22
+ mkdirSync,
23
+ mkdtempSync,
24
+ readdirSync,
25
+ readFileSync,
26
+ rmdirSync,
27
+ rmSync,
28
+ writeFileSync,
29
+ } from 'node:fs'
30
+ import { tmpdir } from 'node:os'
31
+ import { dirname, join, relative } from 'node:path'
32
+
33
+ import {
34
+ boardAssets,
35
+ classifyDrift,
36
+ MappingError,
37
+ missingFrames,
38
+ parseFrameMap,
39
+ toReference,
40
+ } from '../../src/design/reference'
41
+
42
+ type Output = Map<string, string | Buffer>
43
+
44
+ interface Envelope<T> {
45
+ ok: boolean
46
+ data?: T
47
+ error?: { code: string; message: string }
48
+ }
49
+
50
+ interface Listing {
51
+ projects: { pages: { frames: { ref: string; path: string }[] }[] }[]
52
+ }
53
+
54
+ interface Capture {
55
+ images: { theme: string; path: string }[]
56
+ }
57
+
58
+ class SyncRefused extends Error {}
59
+
60
+ const FRAMEBOARD = 'frameboard@0.14.0'
61
+ const ROOT = join(import.meta.dirname, '..', '..')
62
+ const DESIGN = join(ROOT, 'canon', 'design')
63
+ const MAPPING = join(DESIGN, 'frames.json')
64
+ const OWN_FILES = new Set(['index.md', 'frames.json'])
65
+
66
+ /** The main worktree, which holds the gitignored board every worktree reads. */
67
+ function mainRoot(): string {
68
+ const run = Bun.spawnSync(['git', 'worktree', 'list', '--porcelain'], {
69
+ cwd: ROOT,
70
+ })
71
+ const line = run.stdout
72
+ .toString()
73
+ .split('\n')
74
+ .find((l) => l.startsWith('worktree '))
75
+ return line ? line.slice('worktree '.length) : ROOT
76
+ }
77
+
78
+ function frameboard<T>(args: string[]): T {
79
+ const run = Bun.spawnSync(['bunx', '-y', FRAMEBOARD, ...args, '--json'])
80
+ let record: Envelope<T>
81
+ try {
82
+ record = JSON.parse(run.stdout.toString()) as Envelope<T>
83
+ } catch {
84
+ throw new SyncRefused(`frameboard ${args[0]} printed no record.`)
85
+ }
86
+ if (!record.ok || !record.data) {
87
+ throw new SyncRefused(
88
+ `frameboard ${args[0]} refused: ${record.error?.message ?? run.stderr.toString()}`,
89
+ )
90
+ }
91
+ return record.data
92
+ }
93
+
94
+ function trackedFiles(): string[] {
95
+ return readdirSync(DESIGN, { recursive: true, withFileTypes: true })
96
+ .filter((entry) => entry.isFile())
97
+ .map((entry) => relative(DESIGN, join(entry.parentPath, entry.name)))
98
+ .filter((path) => !OWN_FILES.has(path))
99
+ }
100
+
101
+ function pruneEmptyDirs(dir: string): void {
102
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
103
+ if (entry.isDirectory()) pruneEmptyDirs(join(dir, entry.name))
104
+ }
105
+ if (dir !== DESIGN && readdirSync(dir).length === 0) rmdirSync(dir)
106
+ }
107
+
108
+ function readAsset(board: string, project: string, asset: string): Buffer {
109
+ try {
110
+ return readFileSync(join(board, project, 'assets', asset))
111
+ } catch {
112
+ throw new SyncRefused(
113
+ `No board asset ${project}/assets/${asset}. Nothing written.`,
114
+ )
115
+ }
116
+ }
117
+
118
+ function run(check: boolean): number {
119
+ const map = parseFrameMap(readFileSync(MAPPING, 'utf8'))
120
+ const board = join(mainRoot(), '.canon', 'board')
121
+
122
+ const listing = frameboard<Listing>(['list', '--root', board])
123
+ const paths = new Map(
124
+ listing.projects
125
+ .flatMap((project) => project.pages)
126
+ .flatMap((page) => page.frames)
127
+ .map((frame) => [frame.ref, frame.path]),
128
+ )
129
+ const missing = missingFrames(map, new Set(paths.keys()))
130
+ if (missing.length > 0) {
131
+ throw new SyncRefused(
132
+ `No board frame for: ${missing.join(', ')}. Nothing written.`,
133
+ )
134
+ }
135
+
136
+ const output: Output = new Map()
137
+ const captures = mkdtempSync(join(tmpdir(), 'design-sync-'))
138
+ try {
139
+ for (const [reference, ref] of map) {
140
+ const [project = ''] = ref.split('/')
141
+ const html = readFileSync(paths.get(ref) ?? '', 'utf8')
142
+ output.set(`${reference}.html`, toReference(html, project))
143
+ for (const asset of boardAssets(html)) {
144
+ output.set(
145
+ join('kit', project, 'assets', asset),
146
+ readAsset(board, project, asset),
147
+ )
148
+ }
149
+ const { images } = frameboard<Capture>([
150
+ 'capture',
151
+ ref,
152
+ '--root',
153
+ board,
154
+ '--out',
155
+ captures,
156
+ ])
157
+ for (const image of images) {
158
+ output.set(`${reference}-${image.theme}.png`, readFileSync(image.path))
159
+ }
160
+ }
161
+ } finally {
162
+ rmSync(captures, { recursive: true, force: true })
163
+ }
164
+
165
+ if (check) {
166
+ const tracked = new Map<string, string | Buffer>()
167
+ for (const path of trackedFiles()) {
168
+ tracked.set(path, readFileSync(join(DESIGN, path)))
169
+ }
170
+ const drift = classifyDrift(output, tracked)
171
+ for (const path of drift.changed) console.error(`Changed: ${path}`)
172
+ for (const path of drift.missing) console.error(`Missing: ${path}`)
173
+ for (const path of drift.stray) console.error(`Stray: ${path}`)
174
+ for (const path of drift.pngDiffers) {
175
+ console.error(`Warning: PNG bytes differ: ${path}`)
176
+ }
177
+ const owed =
178
+ drift.changed.length + drift.missing.length + drift.stray.length
179
+ if (owed === 0) console.log('canon/design/ matches the board.')
180
+ return owed === 0 ? 0 : 1
181
+ }
182
+
183
+ for (const [path, data] of output) {
184
+ mkdirSync(dirname(join(DESIGN, path)), { recursive: true })
185
+ writeFileSync(join(DESIGN, path), data)
186
+ }
187
+ for (const path of trackedFiles()) {
188
+ if (!output.has(path)) rmSync(join(DESIGN, path))
189
+ }
190
+ pruneEmptyDirs(DESIGN)
191
+ console.log(`Wrote ${output.size} files to canon/design/.`)
192
+ return 0
193
+ }
194
+
195
+ try {
196
+ process.exitCode = run(process.argv.includes('--check'))
197
+ } catch (error) {
198
+ console.error(
199
+ error instanceof SyncRefused || error instanceof MappingError
200
+ ? error.message
201
+ : error,
202
+ )
203
+ process.exitCode = 1
204
+ }
@@ -1,23 +1,30 @@
1
1
  /**
2
- * Writes web/public/favicon.svg from assets/brand/mark.svg and the favicon's
3
- * own colors in src/design/favicon.ts, apart from the page accent tokens.
2
+ * Writes web/public/favicon.svg and the README's `mark-color` pair from
3
+ * assets/brand/mark.svg and the mark's own colors in src/design/favicon.ts,
4
+ * apart from the page accent tokens.
4
5
  *
5
- * The page is the fourth surface to carry the mark as a favicon and the only
6
- * one that can answer for itself. `regen-hero.ts` and `src/design/render.ts`
7
- * each bake a single literal, because both embed the mark as a data URI and a
8
- * data URI has no CSS context. A file served at its own URL does have one, so
9
- * this copy carries a `prefers-color-scheme` branch and tracks the reader's
10
- * theme rather than picking one accent for everybody.
6
+ * The favicon is served at its own URL, so it has a CSS context and carries a
7
+ * `prefers-color-scheme` branch over its tile, c, and pip. The README pair is
8
+ * loaded as an image behind a `<picture>`, which picks the file by theme, so
9
+ * each copy bakes its fills. Its c takes GitHub's own text color rather than
10
+ * canon's ink, since GitHub's ground is what it has to clear.
11
11
  *
12
12
  * `web/public/favicon.svg` was a symlink to the source before this, and the
13
13
  * source fills `currentColor`, which resolves to black with no CSS context.
14
14
  * That is what painted the tab icon black on every surface the page reaches.
15
- * The source keeps `currentColor`, since the hero topbar embeds the same file
16
- * inline and wants it to inherit.
15
+ * The source keeps `currentColor`, since the nav and the hero topbar inline it
16
+ * and want it to inherit.
17
17
  */
18
18
  import { writeFileSync } from 'node:fs'
19
19
 
20
- import { FAVICON_COLORS, renderFavicon } from '../../src/design/favicon'
20
+ import {
21
+ FAVICON_COLORS,
22
+ renderFavicon,
23
+ renderMark,
24
+ } from '../../src/design/favicon'
25
+
26
+ /** GitHub's default foreground on its dark and light grounds. */
27
+ const GITHUB_TEXT = { light: '#1f2328', dark: '#f0f6fc' }
21
28
 
22
29
  const root = new URL('../..', import.meta.url).pathname
23
30
 
@@ -31,6 +38,14 @@ writeFileSync(
31
38
  `${root}web/public/favicon.svg`,
32
39
  renderFavicon(mark, FAVICON_COLORS),
33
40
  )
41
+ writeFileSync(
42
+ `${root}assets/brand/mark-color.svg`,
43
+ renderMark(mark, { c: GITHUB_TEXT.dark, pip: FAVICON_COLORS.pip.dark }),
44
+ )
45
+ writeFileSync(
46
+ `${root}assets/brand/mark-color-light.svg`,
47
+ renderMark(mark, { c: GITHUB_TEXT.light, pip: FAVICON_COLORS.pip.light }),
48
+ )
34
49
  console.log(
35
- `regen-web-favicon: wrote web/public/favicon.svg (${FAVICON_COLORS.light} / ${FAVICON_COLORS.dark})`,
50
+ `regen-web-favicon: wrote web/public/favicon.svg and assets/brand/mark-color{,-light}.svg (pip ${FAVICON_COLORS.pip.dark} / ${FAVICON_COLORS.pip.light})`,
36
51
  )
@@ -8,6 +8,7 @@
8
8
  * `<main>`.
9
9
  */
10
10
  import { readFileSync } from 'node:fs'
11
+ import { FAVICON_COLORS, renderFavicon } from '@/design/favicon'
11
12
  import { DESIGN_DOCUMENT } from '@/design/regen'
12
13
  import { renderDesignDoc } from '@/design/render'
13
14
 
@@ -16,7 +17,13 @@ const root = new URL('../..', import.meta.url).pathname
16
17
  const { cssPath } = renderDesignDoc(
17
18
  `${root}${DESIGN_DOCUMENT}`,
18
19
  `${root}web/public/previews/design-tokens`,
19
- { embedFonts: true },
20
+ {
21
+ embedFonts: true,
22
+ favicon: renderFavicon(
23
+ readFileSync(`${root}assets/brand/mark.svg`, 'utf8'),
24
+ FAVICON_COLORS,
25
+ ),
26
+ },
20
27
  )
21
28
 
22
29
  const css = readFileSync(cssPath, 'utf8')
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Renders the launch film in assets/film/ once per theme and writes the
3
+ * README loops to assets/loops/launch.gif and launch-light.gif, and beside
4
+ * them the landing page's web MP4 and poster PNG per theme.
5
+ *
6
+ * Each theme renders to an MP4 at the canvas size under assets/film/renders/,
7
+ * which git ignores, and the GIF comes from that MP4 through the ffmpeg pass
8
+ * `assets/film/SPEC.md` gives, since no render preset scales below the canvas.
9
+ * Every output is written to the renders folder first and copied into
10
+ * assets/loops/ only once both themes exist, so a failed theme leaves the
11
+ * committed set as it was rather than a half set.
12
+ *
13
+ * The web MP4 drops every audio stream with `-an` whatever the source
14
+ * carries, since the repository is public and no music track's terms are
15
+ * cleared for use outside the tool that made it.
16
+ *
17
+ * Run by hand, not by the gate. The film shows one merged change, so no
18
+ * catalog edit makes it stale, and a render costs minutes of chromium.
19
+ *
20
+ * Clone-only. This script reads assets/film/, which a registry install does
21
+ * not carry.
22
+ */
23
+ import { copyFileSync, mkdirSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+
26
+ const HYPERFRAMES = 'hyperframes@0.8.206'
27
+
28
+ /** The README pass from SPEC.md: 960x600 at 12 fps with a 64-color palette. */
29
+ const GIF_FILTER =
30
+ 'fps=12,scale=960:600:flags=lanczos,split[a][b];[a]palettegen=max_colors=64:stats_mode=diff[p];[b][p]paletteuse=dither=none:diff_mode=rectangle'
31
+
32
+ /** The landing page pass: 1280 wide, silent, and playable before it finishes downloading. */
33
+ const WEB_ARGS = [
34
+ '-an',
35
+ '-vf',
36
+ 'scale=1280:-2:flags=lanczos',
37
+ '-c:v',
38
+ 'libx264',
39
+ '-pix_fmt',
40
+ 'yuv420p',
41
+ '-crf',
42
+ '24',
43
+ '-preset',
44
+ 'slow',
45
+ '-movflags',
46
+ '+faststart',
47
+ ]
48
+
49
+ /** The poster frame: the mark centered before its pip leaves. */
50
+ const POSTER_AT = '0.6'
51
+
52
+ const THEMES = [
53
+ {
54
+ theme: 'dark',
55
+ loop: 'launch.gif',
56
+ web: 'launch.mp4',
57
+ poster: 'launch-poster.png',
58
+ },
59
+ {
60
+ theme: 'light',
61
+ loop: 'launch-light.gif',
62
+ web: 'launch-light.mp4',
63
+ poster: 'launch-poster-light.png',
64
+ },
65
+ ] as const
66
+
67
+ const root = new URL('../..', import.meta.url).pathname
68
+ const filmDir = join(root, 'assets', 'film')
69
+ const renderDir = join(filmDir, 'renders')
70
+ const loopDir = join(root, 'assets', 'loops')
71
+
72
+ function fail(message: string): never {
73
+ console.error(`render-film: ${message}`)
74
+ process.exit(1)
75
+ }
76
+
77
+ function run(cmd: string[], cwd: string): void {
78
+ const result = Bun.spawnSync(cmd, {
79
+ cwd,
80
+ stdout: 'inherit',
81
+ stderr: 'inherit',
82
+ })
83
+ if (result.exitCode !== 0) {
84
+ fail(`${cmd.slice(0, 3).join(' ')} exited ${result.exitCode}`)
85
+ }
86
+ }
87
+
88
+ if (process.argv.length > 2) {
89
+ fail(`unknown argument ${process.argv[2]}`)
90
+ }
91
+
92
+ const ffmpeg = Bun.which('ffmpeg')
93
+ if (!ffmpeg) {
94
+ fail(
95
+ 'ffmpeg is not on PATH, and each GIF is derived with it. Install ffmpeg and re-run.',
96
+ )
97
+ }
98
+
99
+ mkdirSync(renderDir, { recursive: true })
100
+
101
+ for (const { theme } of THEMES) {
102
+ const mp4 = join(renderDir, `launch-${theme}.mp4`)
103
+ const gif = join(renderDir, `readme-${theme}.gif`)
104
+ console.log(`render-film: rendering ${theme}`)
105
+ run(
106
+ [
107
+ 'bunx',
108
+ '-y',
109
+ HYPERFRAMES,
110
+ 'render',
111
+ '--variables-file',
112
+ `themes/${theme}.json`,
113
+ '--strict-variables',
114
+ '--quiet',
115
+ '--output',
116
+ mp4,
117
+ ],
118
+ filmDir,
119
+ )
120
+ run(
121
+ [
122
+ ffmpeg,
123
+ '-y',
124
+ '-loglevel',
125
+ 'error',
126
+ '-i',
127
+ mp4,
128
+ '-vf',
129
+ GIF_FILTER,
130
+ '-loop',
131
+ '0',
132
+ gif,
133
+ ],
134
+ filmDir,
135
+ )
136
+ run(
137
+ [
138
+ ffmpeg,
139
+ '-y',
140
+ '-loglevel',
141
+ 'error',
142
+ '-i',
143
+ mp4,
144
+ ...WEB_ARGS,
145
+ join(renderDir, `web-${theme}.mp4`),
146
+ ],
147
+ filmDir,
148
+ )
149
+ run(
150
+ [
151
+ ffmpeg,
152
+ '-y',
153
+ '-loglevel',
154
+ 'error',
155
+ '-ss',
156
+ POSTER_AT,
157
+ '-i',
158
+ mp4,
159
+ '-frames:v',
160
+ '1',
161
+ '-vf',
162
+ 'scale=1280:-2:flags=lanczos',
163
+ join(renderDir, `poster-${theme}.png`),
164
+ ],
165
+ filmDir,
166
+ )
167
+ }
168
+
169
+ mkdirSync(loopDir, { recursive: true })
170
+ const written: string[] = []
171
+ for (const { theme, loop, web, poster } of THEMES) {
172
+ copyFileSync(join(renderDir, `readme-${theme}.gif`), join(loopDir, loop))
173
+ copyFileSync(join(renderDir, `web-${theme}.mp4`), join(loopDir, web))
174
+ copyFileSync(join(renderDir, `poster-${theme}.png`), join(loopDir, poster))
175
+ written.push(loop, web, poster)
176
+ }
177
+ console.log(
178
+ `render-film: wrote assets/loops/${written.join(', assets/loops/')}`,
179
+ )
@@ -1,3 +1,4 @@
1
+ import { FAVICON_COLORS, renderFavicon } from '@/design/favicon'
1
2
  import type { GovCatalog } from '@/gov/list'
2
3
  import type { StackSummary } from '@/tooling/list'
3
4
 
@@ -130,8 +131,9 @@ const indentTokens = (css: string): string =>
130
131
  .join('\n')
131
132
 
132
133
  /**
133
- * The frames a README serves in both themes. The hero is a landing-page frame
134
- * no README shows, so it stays dark.
134
+ * The frames rendered in both themes. No README shows the install pair now,
135
+ * and it stays for the landing page redesign to pick from. The hero is a
136
+ * landing-page frame, so it stays dark.
135
137
  */
136
138
  const LIGHT_TEMPLATES = new Set(['install.html.tmpl'])
137
139
 
@@ -156,18 +158,15 @@ function lightTokens(tokenCss: string): string {
156
158
  ].join('\n')
157
159
  }
158
160
 
159
- function faviconUri(markSvg: string, tokenCss: string): string {
160
- const accent = tokenCss.match(/--color-accent:\s*(#[0-9a-fA-F]{3,8})/)
161
- if (!accent) {
161
+ /** The favicon the web page serves, so the hero's tab matches it rather than the accent. */
162
+ function faviconUri(markSvg: string): string {
163
+ try {
164
+ return `data:image/svg+xml,${encodeURIComponent(renderFavicon(markSvg, FAVICON_COLORS))}`
165
+ } catch (error) {
162
166
  throw new FrameError(
163
- 'token css carries no --color-accent value, refusing to write a colorless favicon',
167
+ `assets/brand/mark.svg cannot be drawn as a favicon: ${error instanceof Error ? error.message : String(error)}`,
164
168
  )
165
169
  }
166
- const svg = markSvg
167
- .replace(/<!--[\s\S]*?-->/, '')
168
- .trim()
169
- .replaceAll('currentColor', accent[1])
170
- return `data:image/svg+xml,${encodeURIComponent(svg)}`
171
170
  }
172
171
 
173
172
  /**
@@ -278,7 +277,8 @@ export function buildFrameValues(catalogs: FrameCatalogs): FrameValues {
278
277
  TOOLING_STACK_COUNT: String(toolingStacks.length),
279
278
  COMMAND_COUNT: String(catalogs.commandCount),
280
279
  MARK_SVG: markSvg,
281
- FAVICON: faviconUri(markSvg, tokenCss),
280
+ MARK_PIP: FAVICON_COLORS.pip.dark,
281
+ FAVICON: faviconUri(markSvg),
282
282
  SKILL_ENTRIES: markup(featured(skills, FEATURED_SKILLS)),
283
283
  RULE_ENTRIES: markup(sample(deliveredRules)),
284
284
  SKILL_MORE: remaining(skills),
@@ -26,8 +26,8 @@ export const AUTHORING_CASES: readonly SkillCase[] = [
26
26
  },
27
27
  {
28
28
  prompt:
29
- 'I already wrote the finished skill files, package them as a ready folder with a plan and a task for a worker to copy.',
30
- expect: 'plan-ready',
29
+ 'I already wrote the finished wording for a small new reference file, put it in a plan so a worker lands it exactly as written.',
30
+ expect: 'plan-feature',
31
31
  },
32
32
  {
33
33
  prompt: 'This project has no README.md at all, write one from scratch.',
@@ -10,7 +10,7 @@ export const MISC_CASES: readonly SkillCase[] = [
10
10
  {
11
11
  prompt:
12
12
  'Show me a few different treatments for this callout so I can pick one by looking.',
13
- expect: 'pick-by-eye',
13
+ expect: 'design-pick',
14
14
  },
15
15
  {
16
16
  prompt:
@@ -54,11 +54,6 @@ export const SETUP_CASES: readonly SkillCase[] = [
54
54
  "Whatever the right toolkit command is, get this project's rules installed.",
55
55
  expect: 'target-setup',
56
56
  },
57
- {
58
- prompt:
59
- 'File the open feedback issues on the toolkit repo into an intake folder.',
60
- expect: 'canon-feedback-triage',
61
- },
62
57
  // This answers one fixed question across a fixed domain list and repairs
63
58
  // nothing, which is what separates it from the setup skill above.
64
59
  {
@@ -143,11 +143,11 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
143
143
  {
144
144
  prompt:
145
145
  'Look over the interface and tell me what feels unfinished or confusing.',
146
- expect: 'ux-audit',
146
+ expect: 'ui-audit',
147
147
  },
148
148
  {
149
149
  prompt: 'Tell me the paint and layout cost of this page right now.',
150
- expect: 'ux-measure',
150
+ expect: 'ui-measure',
151
151
  },
152
152
  {
153
153
  prompt:
@@ -162,12 +162,12 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
162
162
  {
163
163
  prompt:
164
164
  'I need real footage of the board import flow for the launch video. Film it from our app so HyperFrames can zoom in on the clicks.',
165
- expect: 'ui-record',
165
+ expect: 'ui-film',
166
166
  },
167
167
  {
168
168
  prompt:
169
169
  'Shoot the takes for the storyboard we wrote, one clip per beat of the app doing its thing.',
170
- expect: 'ui-record',
170
+ expect: 'ui-film',
171
171
  },
172
172
  {
173
173
  prompt:
@@ -216,10 +216,10 @@ export const WORKFLOW_CASES: readonly SkillCase[] = [
216
216
  },
217
217
  {
218
218
  prompt: 'Find the rough, unfinished-feeling spots in this interface.',
219
- expect: 'ux-audit',
219
+ expect: 'ui-audit',
220
220
  },
221
221
  {
222
222
  prompt: 'Tell me the render cost of this page in the browser.',
223
- expect: 'ux-measure',
223
+ expect: 'ui-measure',
224
224
  },
225
225
  ]
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ # Fake gh for src/commands/pr.test.ts. Reads FAKE_GH_FX, FAKE_GH_COMMENTS, and FAKE_GH_API_LOG.
3
+ set -o pipefail
4
+ fx="$FAKE_GH_FX"
5
+ f=.
6
+ p=
7
+ for a in "$@"; do
8
+ [ "$p" = --jq ] && f=$a
9
+ p=$a
10
+ done
11
+ case "$*" in
12
+ *graphql* | "pr "* | "repo view"*) exit 1 ;;
13
+ api*pulls/7/files*)
14
+ echo "$*" >>"$fx/files-calls.log"
15
+ [ -f "$fx/files-fail" ] && exit 1
16
+ {
17
+ if [ -f "$fx/files.tsv" ]; then
18
+ cat "$fx/files.tsv"
19
+ else
20
+ git diff --name-status --no-renames main...HEAD | sed -e 's/^A/added/' -e 's/^M/modified/' -e 's/^D/removed/'
21
+ fi
22
+ } | jq -Rn '[inputs | select(length > 0) | split("\t") | {status: .[0], filename: .[1]}]' | jq -r "$f"
23
+ ;;
24
+ api*git/trees/*)
25
+ [ -f "$fx/base-tree.json" ] || exit 1
26
+ jq -r "$f" "$fx/base-tree.json"
27
+ ;;
28
+ api*contents/*)
29
+ a="$*"
30
+ p=${a##*contents/}
31
+ p=${p%%\?*}
32
+ r=${a##*ref=}
33
+ [ -f "$fx/img/$r-$(basename "$p")" ] && cat "$fx/img/$r-$(basename "$p")" || exit 1
34
+ ;;
35
+ api*compare/*)
36
+ if [ -f "$fx/merge-base" ]; then cat "$fx/merge-base"; else git merge-base main HEAD; fi
37
+ ;;
38
+ "api -X PUT repos/{owner}/{repo}/pulls/7/reviews/"*)
39
+ id=${4##*/}
40
+ echo "$id" >>"$fx/review-puts.log"
41
+ [ -f "$fx/review-put-fail-$id" ] && exit 1
42
+ printf '%s\n' "${@: -1}" >>"$fx/review-bodies.log"
43
+ echo "{}"
44
+ ;;
45
+ api*pulls/7/reviews*) [ -f "$fx/ui-reviews.json" ] && jq -r "$f" "$fx/ui-reviews.json" ;;
46
+ api*issues/7/comments*) jq '[.comments[] | {html_url: .url, body}]' "$FAKE_GH_COMMENTS" | jq -r "$f" ;;
47
+ "api -X PATCH repos/{owner}/{repo}/pulls/7 "*)
48
+ [ -f "$fx/body-fail" ] && exit 1
49
+ printf '%s' "${@: -1}" >"$fx/body-write.log"
50
+ echo "{}"
51
+ ;;
52
+ "api repos/{owner}/{repo}/pulls/7")
53
+ if [ -f "$fx/head" ]; then h=$(cat "$fx/head"); else h=$(git rev-parse HEAD); fi
54
+ b=
55
+ [ -f "$fx/pr-body" ] && b=$(cat "$fx/pr-body")
56
+ jq -n --arg h "$h" --arg b "$b" '{number:7,head:{ref:"feat/x",sha:$h},base:{ref:"main"},mergeable_state:"clean",body:$b}'
57
+ ;;
58
+ "api repos/{owner}/{repo}") echo "{\"full_name\":\"o/r\"}" ;;
59
+ api*)
60
+ printf '%s' "${@: -1}" >"$FAKE_GH_API_LOG"
61
+ echo "{}"
62
+ ;;
63
+ *) exit 1 ;;
64
+ esac