@erclx/canon 5.69.0 → 5.71.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.
- package/CHANGELOG.md +25 -0
- package/README.md +33 -159
- package/assets/brand/mark.svg +17 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/SKILL.md +3 -2
- package/claude/skills/app-drive/references/drive-record.md +52 -10
- package/claude/skills/app-drive/references/isolate.md +1 -1
- package/claude/skills/design-pick/SKILL.md +1 -0
- package/claude/skills/git-pr/references/task-number.md +13 -0
- package/claude/skills/markdown-craft/references/markdown.md +1 -0
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +1 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -20
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +10 -0
- package/claude/skills/role-orchestrator/references/orchestrator-talk-launch.md +30 -0
- package/claude/skills/task-board/references/tasks-record-lines.md +40 -0
- package/claude/skills/task-board/references/tasks.md +4 -8
- package/claude/skills/ui-craft/references/copy.md +4 -1
- package/claude/skills/ui-film/REQUIREMENT.md +4 -2
- package/claude/skills/ui-film/SKILL.md +24 -11
- package/claude/skills/ui-film/references/compose-craft.md +81 -0
- package/claude/skills/ui-film/references/config.md +6 -1
- package/claude/skills/ui-film/references/handoff.md +13 -3
- package/claude/skills/ui-film/references/take-craft.md +27 -1
- package/docs/index.md +1 -0
- package/docs/migrations/by-hand.md +107 -0
- package/docs/migrations/by-verb.md +118 -0
- package/docs/migrations/index.md +14 -0
- package/docs/target/index.md +1 -2
- package/docs/target/projects.md +2 -2
- package/docs/target/sync.md +22 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/governance/ledger/skills/ui-craft/copy.md +8 -4
- package/package.json +3 -1
- package/scripts/core/design-sync.ts +204 -0
- package/scripts/core/regen-web-favicon.ts +27 -12
- package/scripts/core/regen-web-previews.ts +8 -1
- package/scripts/core/render-film.ts +179 -0
- package/src/capture/frame-values.ts +12 -12
- package/src/commands/markdown.ts +6 -1
- package/src/commands/records.ts +3 -2
- package/src/commands/sandbox.ts +9 -2
- package/src/commands/take.ts +159 -11
- package/src/commands/tasks.ts +133 -0
- package/src/design/favicon.ts +85 -13
- package/src/design/reference.ts +98 -0
- package/src/design/render.ts +16 -14
- package/src/design/tokens.ts +4 -3
- package/src/gate/stages.ts +6 -0
- package/src/init/steps.ts +2 -2
- package/src/markdown/colons.ts +102 -16
- package/src/migrate/status.ts +3 -3
- package/src/records/validate.ts +14 -2
- package/src/sandbox/expect.ts +32 -0
- package/src/sandbox/headless/run.ts +6 -3
- package/src/take/camera.ts +99 -10
- package/src/take/film.ts +35 -2
- package/src/take/fixtures/input.html +138 -0
- package/src/take/keys.ts +193 -0
- package/src/take/log.ts +94 -1
- package/src/take/perform.ts +165 -10
- package/src/take/plan.ts +101 -6
- package/src/take/screencast.ts +28 -3
- package/src/take/stepper.ts +122 -18
- package/src/tasks/archive.ts +54 -1
- package/src/tasks/record.ts +118 -0
- package/src/tasks/validate.ts +37 -0
- package/docs/target/migrations.md +0 -205
package/docs/target/sync.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Target sync
|
|
3
|
-
description: Check what drifted in a toolkit-managed project and pull it in, per domain or all at once
|
|
3
|
+
description: Check what drifted in a toolkit-managed project and pull it in, per domain or all at once, and update canon itself
|
|
4
4
|
category: Agent surface
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Target sync
|
|
8
8
|
|
|
9
|
-
A target pulls toolkit changes per domain, through one catch-all and several targeted entry points. A project scaffolded before a layout move runs [target migrations](migrations.md) first. See [install and sync](../agents/install-and-sync.md) for what each verb writes.
|
|
9
|
+
A target pulls toolkit changes per domain, through one catch-all and several targeted entry points. A project scaffolded before a layout move runs [target migrations](../migrations/index.md) first. See [install and sync](../agents/install-and-sync.md) for what each verb writes.
|
|
10
10
|
|
|
11
11
|
## Check first
|
|
12
12
|
|
|
@@ -36,6 +36,26 @@ Attribution reads `canon/config/config.json`, falling back to `.claude/canon/con
|
|
|
36
36
|
|
|
37
37
|
Add `--json` for a machine-readable report and `--exit-code` to fail CI when a target falls behind. Project-authored files, superseded artifacts, seed drift, tooling, and `reverse` never count toward that code, and an unmigrated domain does.
|
|
38
38
|
|
|
39
|
+
## Update canon itself
|
|
40
|
+
|
|
41
|
+
Nothing refreshes on its own. Claude Code ships auto-update off for third-party marketplaces, so an installed copy serves whatever version it was installed at until you refresh it.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
claude plugin marketplace update canon
|
|
45
|
+
claude plugin update canon@canon
|
|
46
|
+
canon upgrade
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The first two update the skills, the third updates the CLI, and they move independently. Restart Claude Code, or run `/reload-plugins`, to pick the skills up.
|
|
50
|
+
|
|
51
|
+
`canon upgrade` reads the package manager off its own install path and reinstalls with that one. It names what it detected before it runs anything, and it refuses a source checkout rather than reinstalling over your clone.
|
|
52
|
+
|
|
53
|
+
### Checking for drift
|
|
54
|
+
|
|
55
|
+
`canon sync --check` and `canon claude skills drift` both report the installed version against the newest published one, and neither changes its exit code over it, so an offline machine reads unknown rather than red.
|
|
56
|
+
|
|
57
|
+
To stop doing this by hand, turn auto-update on once under `/plugin` in the Marketplaces tab. Confirm what you are running with `canon --version` and `claude plugin list`.
|
|
58
|
+
|
|
39
59
|
## Catch-all
|
|
40
60
|
|
|
41
61
|
`canon sync <path>` runs every installed domain's sync and never touches user-owned seeds.
|
|
@@ -27,7 +27,7 @@ canon/
|
|
|
27
27
|
└── tmp/ ← deletable scratch
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
A project scaffolded before the move keeps its records under `.claude/`, and every command reads either root. See [target migrations](../
|
|
30
|
+
A project scaffolded before the move keeps its records under `.claude/`, and every command reads either root. See [target migrations by verb](../migrations/by-verb.md).
|
|
31
31
|
|
|
32
32
|
Context loads always (`CLAUDE.md` and `canon/REQUIREMENTS.md`), by path (`.claude/rules/` with a `paths:` glob), or on demand (`canon/context/`, found through its `index.md`). Run `canon init` to seed `.claude/`, `CLAUDE.md`, and the rules.
|
|
33
33
|
|
|
@@ -13,10 +13,13 @@ The four casing sections rest on the GOV.UK style guide, which sets sentence cas
|
|
|
13
13
|
|
|
14
14
|
## Punctuation
|
|
15
15
|
|
|
16
|
-
| Bullet
|
|
17
|
-
|
|
|
18
|
-
| No em dash or semicolon
|
|
19
|
-
| No
|
|
16
|
+
| Bullet | Source | Read |
|
|
17
|
+
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
18
|
+
| No em dash or semicolon | House choice: both read as generated copy in an interface, and a comma, a period, or a split sentence carries the same meaning in a shorter line | 2026-10-18 |
|
|
19
|
+
| No end punctuation but a question mark | [A to Z of GOV.UK style](https://www.gov.uk/guidance/style-guide/a-to-z-of-gov-uk-style): page titles should "not have a full stop at the end", and [Button](https://design-system.service.gov.uk/components/button/) labels such as "Start now" and "Continue" carry none. Every other kind in the list, from headings to taglines, is a house choice by analogy to the title and the button. [Question pages](https://design-system.service.gov.uk/patterns/question-pages/) keep the question mark, since "Page headings can be questions or statements", as in "What is your date of birth?". | 2026-10-27 |
|
|
20
|
+
| One-sentence tagline | House choice: a period inside a tagline makes it prose, which the short-string rule then has to end with a period, so one sentence keeps it a title-like string | 2026-10-27 |
|
|
21
|
+
| One-sentence error, no period | [Error message](https://design-system.service.gov.uk/components/error-message/): every example, such as "Enter how many hours you work a week" and "Name must be 35 characters or less", ends with no full stop | 2026-10-27 |
|
|
22
|
+
| Prose keeps its period | House choice by analogy to [A to Z of GOV.UK style](https://www.gov.uk/guidance/style-guide/a-to-z-of-gov-uk-style), whose summaries should "end with a full stop". The reach to body copy, multi-sentence messages, and quoted text is canon's. | 2026-10-27 |
|
|
20
23
|
|
|
21
24
|
## Button copy
|
|
22
25
|
|
|
@@ -86,6 +89,7 @@ Every bullet the rewrite removed or reshaped, grouped by why.
|
|
|
86
89
|
|
|
87
90
|
### Merged or reworded
|
|
88
91
|
|
|
92
|
+
- Widened the button-label punctuation bullet to every short rendered string, since board frames shipped tagline periods under the narrower wording.
|
|
89
93
|
- Merged the imperative verb and padded phrasing bullets. The `Save` over `Save changes` example went, since GOV.UK adds words "to better describe the action".
|
|
90
94
|
- Merged "keep messages short and actionable" and "one sentence maximum" into the plain language bullet.
|
|
91
95
|
- Reworded the error example to `Enter an email address`, the instruction form GOV.UK prefers over a description.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.71.0",
|
|
5
5
|
"description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
"canon:sandbox:reset": "canon sandbox reset",
|
|
43
43
|
"web:tokens": "bun src/cli.ts design css --no-components > web/src/styles/tokens.css.new && (echo '/* Generated by `canon design css --no-components`. Regenerate with `bun run web:tokens`. Do not hand-edit. */'; cat web/src/styles/tokens.css.new) > web/src/styles/tokens.css && rm web/src/styles/tokens.css.new",
|
|
44
44
|
"web:dev": "cd web && astro dev",
|
|
45
|
+
"film:render": "bun scripts/core/render-film.ts",
|
|
46
|
+
"design:sync": "bun scripts/core/design-sync.ts",
|
|
45
47
|
"web:favicon": "bun scripts/core/regen-web-favicon.ts",
|
|
46
48
|
"web:previews": "bun scripts/core/regen-web-previews.ts",
|
|
47
49
|
"web:build": "bun run web:tokens && bun run web:favicon && bun run web:previews && cd web && astro check && astro build",
|
|
@@ -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
|
|
3
|
-
* own colors in src/design/favicon.ts,
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
16
|
-
*
|
|
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 {
|
|
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.
|
|
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
|
-
{
|
|
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
|
|
134
|
-
*
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
package/src/commands/markdown.ts
CHANGED
|
@@ -167,7 +167,9 @@ export function register(program: Command): void {
|
|
|
167
167
|
'',
|
|
168
168
|
'A mid-sentence colon and a heading with a capital after its first word',
|
|
169
169
|
'report the same way. A proper noun is the one case the second cannot',
|
|
170
|
-
'tell from title case, and the first is sometimes right.',
|
|
170
|
+
'tell from title case, and the first is sometimes right. A list whose',
|
|
171
|
+
'every item opens on a lead and a colon reads each lead as a label, and so',
|
|
172
|
+
'does a lead made only of code spans, links, commas, and and, or, to.',
|
|
171
173
|
'',
|
|
172
174
|
'Length reports a whole document past the ceiling and never exits 2.',
|
|
173
175
|
'The Document ceiling gate stage owns that verdict, since this audit also',
|
|
@@ -842,6 +844,9 @@ function reportColons(reports: readonly FileReport[]): void {
|
|
|
842
844
|
logInfo(
|
|
843
845
|
'A colon followed by more text in a prose line, outside a label, a code span, and a link.',
|
|
844
846
|
)
|
|
847
|
+
logInfo(
|
|
848
|
+
'A list where every item opens on a lead and a colon reads each lead as a label.',
|
|
849
|
+
)
|
|
845
850
|
logInfo(
|
|
846
851
|
'A heading, a table row, a fenced block, and a line-final colon are excluded. This never fails a run.',
|
|
847
852
|
)
|
package/src/commands/records.ts
CHANGED
|
@@ -110,8 +110,9 @@ export function register(program: Command): void {
|
|
|
110
110
|
'',
|
|
111
111
|
'Structure, under `structure` and never in the exit:',
|
|
112
112
|
" S1 to S5 from the plan standard, read on plans, a track's README and",
|
|
113
|
-
|
|
114
|
-
'
|
|
113
|
+
" numbered files, intake clusters, and an intake folder's",
|
|
114
|
+
' evidence/drive-log.md, plus intake-spill for content under a field.',
|
|
115
|
+
' A record written before the criteria misses S1, so it reports alone.',
|
|
115
116
|
'',
|
|
116
117
|
'Refusals, read off `reason`:',
|
|
117
118
|
" no-folder none of the kind's directories exist at either record root",
|