@erclx/canon 5.69.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.
- package/CHANGELOG.md +17 -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/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/records.ts +3 -2
- package/src/commands/sandbox.ts +9 -2
- package/src/commands/take.ts +159 -11
- 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/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/docs/target/migrations.md +0 -205
package/src/commands/take.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
|
|
5
|
+
import { cameraTrack, readRate } from '@/take/camera'
|
|
6
|
+
import { readTakeLog } from '@/take/log'
|
|
5
7
|
import { readTakePlan } from '@/take/plan'
|
|
6
8
|
import type { TakePlanRefusal } from '@/take/plan'
|
|
7
9
|
import type { TakeRefusal } from '@/take/film'
|
|
@@ -24,11 +26,17 @@ import {
|
|
|
24
26
|
type Take = typeof import('@/take/film')
|
|
25
27
|
|
|
26
28
|
interface RunOptions {
|
|
27
|
-
readonly out
|
|
29
|
+
readonly out?: string
|
|
28
30
|
readonly reel?: string
|
|
29
31
|
readonly json?: boolean
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
interface CameraOptions {
|
|
35
|
+
readonly out?: string
|
|
36
|
+
readonly rate?: string
|
|
37
|
+
readonly json?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
const INSTALL_FFMPEG =
|
|
33
41
|
'install FFmpeg from your package manager, such as apt install ffmpeg or brew install ffmpeg'
|
|
34
42
|
|
|
@@ -40,7 +48,8 @@ const PLAN_REFUSALS: Record<TakePlanRefusal, string> = {
|
|
|
40
48
|
'bad-beat':
|
|
41
49
|
'A beat is missing its name or carries a setup that is not a list.',
|
|
42
50
|
'no-steps': 'A beat names no step, so its take would film nothing.',
|
|
43
|
-
'unknown-route':
|
|
51
|
+
'unknown-route':
|
|
52
|
+
'The plan or a beat names a route this build does not record on.',
|
|
44
53
|
'bad-step': 'A step is missing something the take needs to perform it.',
|
|
45
54
|
}
|
|
46
55
|
|
|
@@ -53,14 +62,19 @@ const TAKE_REFUSALS: Record<TakeRefusal, string> = {
|
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
export function register(program: Command): void {
|
|
56
|
-
program
|
|
65
|
+
const take = program
|
|
57
66
|
.command('take')
|
|
58
67
|
.description(
|
|
59
68
|
'Film a web app beat by beat with a click log and camera track',
|
|
60
69
|
)
|
|
61
70
|
.argument('<url>', 'Address to film')
|
|
62
71
|
.argument('<plan>', 'JSON file naming the beats and their steps')
|
|
63
|
-
|
|
72
|
+
// Checked in the action rather than required here, since commander checks
|
|
73
|
+
// a parent's required options on every subcommand, `camera` included.
|
|
74
|
+
.option('--out <dir>', 'Folder the clips, logs, and tracks land in')
|
|
75
|
+
// Keeps this command's options ahead of `camera`, which reuses --out and
|
|
76
|
+
// --json for its own.
|
|
77
|
+
.enablePositionalOptions()
|
|
64
78
|
.helpOption('-h, --help', 'Show this help message')
|
|
65
79
|
.option(
|
|
66
80
|
'--reel <file>',
|
|
@@ -106,12 +120,33 @@ export function register(program: Command): void {
|
|
|
106
120
|
'Step kinds:',
|
|
107
121
|
' move <target>|<x,y> glide the pointer to a target or a point',
|
|
108
122
|
' click <target> glide to the target and press it',
|
|
123
|
+
' dblclick <target> glide to the target and press it twice',
|
|
124
|
+
' drag <from> <to> press the from target, glide held, release on to',
|
|
125
|
+
' key <keys> press a combination such as Control+K',
|
|
109
126
|
' fill <target> <text> press the target and type the text',
|
|
110
127
|
' scroll <target> <dy> glide to the target and wheel dy pixels',
|
|
111
128
|
' wait <ms> hold, for a state the page reaches on its own',
|
|
112
129
|
'',
|
|
113
|
-
'
|
|
114
|
-
'
|
|
130
|
+
'click, dblclick, and drag take modifiers, a list of Alt, Control, Meta,',
|
|
131
|
+
'and Shift held through the press, as in "modifiers": ["Shift"]. keys',
|
|
132
|
+
'names those modifiers joined by + ahead of one character or a named key:',
|
|
133
|
+
'Enter, Tab, Escape, Backspace, Delete, Insert, Space, Home, End, PageUp,',
|
|
134
|
+
'PageDown, the four arrows as ArrowUp and the like, and F1 to F12. A key',
|
|
135
|
+
'moves no pointer, so the camera neither aims at it nor ends a zoom.',
|
|
136
|
+
'',
|
|
137
|
+
'drag drives pointer events, the kind a canvas board listens for. It',
|
|
138
|
+
'starts no native HTML5 drag of a draggable element on the stepped route,',
|
|
139
|
+
'so a beat needing one films the same only on the screencast route.',
|
|
140
|
+
'',
|
|
141
|
+
'A top-level route sets the route of every beat that names none of its',
|
|
142
|
+
'own. A beat carrying push: false keeps its camera on the whole page,',
|
|
143
|
+
'with the cursor and click ring still in the track, and its log.json',
|
|
144
|
+
'records push: false. Each log.json carries clip, the clip size in',
|
|
145
|
+
'pixels, which caps the zoom depth so a 1920-wide screencast stays sharp.',
|
|
146
|
+
'',
|
|
147
|
+
'A target is a CSS selector, or text=<words> for the first innermost',
|
|
148
|
+
'element with a size whose whole text is those words, so a hidden copy',
|
|
149
|
+
'earlier in the page is skipped. Setup steps run before the first kept',
|
|
115
150
|
'frame. Every beat opens and closes on a 0.6 s hold.',
|
|
116
151
|
'',
|
|
117
152
|
'Needs a reachable page, FFmpeg, and the headless shell. Install with:',
|
|
@@ -123,7 +158,8 @@ export function register(program: Command): void {
|
|
|
123
158
|
' no-beats the plan names no beat',
|
|
124
159
|
' bad-beat a beat has no name or a setup that is not a list',
|
|
125
160
|
' no-steps a beat names no step',
|
|
126
|
-
' unknown-route a beat names a route other than step or
|
|
161
|
+
' unknown-route the plan or a beat names a route other than step or',
|
|
162
|
+
' screencast',
|
|
127
163
|
' bad-step a step is missing what the take needs to perform it',
|
|
128
164
|
' engine-missing the browser engine is not installed in this project',
|
|
129
165
|
' ffmpeg-missing FFmpeg is not on the PATH',
|
|
@@ -133,6 +169,13 @@ export function register(program: Command): void {
|
|
|
133
169
|
' take-failed the take failed against a reachable page',
|
|
134
170
|
' reel-failed every beat was filmed and the reel did not encode',
|
|
135
171
|
'',
|
|
172
|
+
'A refusal in a beat carries beat. target-missing carries still when the',
|
|
173
|
+
"page answered a capture, the beat folder's still.jpg as the step met it. A",
|
|
174
|
+
'beat that fell back to the screencast before refusing carries fallback,',
|
|
175
|
+
'the reason it fell back. The screencast opens the address a second time,',
|
|
176
|
+
'so a single-use link is spent by then: check the access before the',
|
|
177
|
+
'selector.',
|
|
178
|
+
'',
|
|
136
179
|
'Exit codes:',
|
|
137
180
|
' 0 every beat was filmed, and the reel written when asked for',
|
|
138
181
|
' 1 refused, with the reason on stderr or in the JSON record',
|
|
@@ -144,15 +187,113 @@ export function register(program: Command): void {
|
|
|
144
187
|
'',
|
|
145
188
|
].join('\n'),
|
|
146
189
|
)
|
|
147
|
-
.action(
|
|
148
|
-
|
|
190
|
+
.action(
|
|
191
|
+
async (url: string, plan: string, opts: RunOptions, command: Command) => {
|
|
192
|
+
if (!opts.out)
|
|
193
|
+
command.error("error: required option '--out <dir>' not specified")
|
|
194
|
+
process.exitCode = await runTake(url, plan, { ...opts, out: opts.out })
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
take
|
|
199
|
+
.command('camera')
|
|
200
|
+
.description("Build a camera track from a take's log.json")
|
|
201
|
+
.argument('<log>', "A beat's log.json, as a take wrote it")
|
|
202
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
203
|
+
.option('--out <file>', 'Write the track to a file rather than stdout')
|
|
204
|
+
.option(
|
|
205
|
+
'--rate <n>',
|
|
206
|
+
'How many times faster the edit plays the clip, 1 by default',
|
|
207
|
+
)
|
|
208
|
+
.option('--json', 'Add a machine-readable record on stdout with --out')
|
|
209
|
+
.addHelpText(
|
|
210
|
+
'after',
|
|
211
|
+
[
|
|
212
|
+
'',
|
|
213
|
+
"Builds the same camera track a take writes beside each beat's clip, from",
|
|
214
|
+
'the log alone, so a track can be rebuilt for a clip the edit plays at',
|
|
215
|
+
'another speed. With no --out the track is the data on stdout, one line,',
|
|
216
|
+
'and a refusal under --json is the record there instead.',
|
|
217
|
+
'',
|
|
218
|
+
'--rate <n> builds the track on the playback clock of a clip sped up n',
|
|
219
|
+
'times, dividing every time in the log by n, so a zoom settles at the',
|
|
220
|
+
'speed a viewer sees rather than pumping several times a beat.',
|
|
221
|
+
'',
|
|
222
|
+
"The zoom depth is capped by the clip's resolution, read from the log's",
|
|
223
|
+
'clip field, or from its route on a log written before that field. A log',
|
|
224
|
+
'carrying push: false holds the whole page with the cursor and ring.',
|
|
225
|
+
'',
|
|
226
|
+
'Reasons:',
|
|
227
|
+
' no-log-file nothing readable at the log path',
|
|
228
|
+
" unreadable-log the file is not a take's log.json",
|
|
229
|
+
' bad-rate --rate is not a positive number',
|
|
230
|
+
'',
|
|
231
|
+
'Exit codes:',
|
|
232
|
+
' 0 the track was written',
|
|
233
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
234
|
+
'',
|
|
235
|
+
'Examples:',
|
|
236
|
+
" canon take camera takes/01-open/log.json | jq '.frames | length'",
|
|
237
|
+
' canon take camera takes/01-open/log.json --rate 2 --out camera-2x.json',
|
|
238
|
+
'',
|
|
239
|
+
].join('\n'),
|
|
240
|
+
)
|
|
241
|
+
.action((log: string, opts: CameraOptions) => {
|
|
242
|
+
process.exitCode = runCamera(log, opts)
|
|
149
243
|
})
|
|
150
244
|
}
|
|
151
245
|
|
|
246
|
+
function runCamera(logPath: string, opts: CameraOptions): number {
|
|
247
|
+
const emitJson = opts.json ?? false
|
|
248
|
+
const path = resolve(logPath)
|
|
249
|
+
const refuseCamera = (reason: string, message: string): number => {
|
|
250
|
+
logWarn(message)
|
|
251
|
+
emit(emitJson, { log: path, reason, message })
|
|
252
|
+
return 1
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const rate = opts.rate === undefined ? 1 : readRate(opts.rate)
|
|
256
|
+
if (rate === undefined) {
|
|
257
|
+
return refuseCamera(
|
|
258
|
+
'bad-rate',
|
|
259
|
+
`--rate ${opts.rate} is not a positive number, such as 1.5 or 2.`,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let source: string
|
|
264
|
+
try {
|
|
265
|
+
source = readFileSync(path, 'utf8')
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return refuseCamera(
|
|
268
|
+
'no-log-file',
|
|
269
|
+
`No log file at ${path}. ${error instanceof Error ? error.message : String(error)}`,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
const read = readTakeLog(source)
|
|
273
|
+
if (read.kind === 'refused') {
|
|
274
|
+
return refuseCamera(
|
|
275
|
+
'unreadable-log',
|
|
276
|
+
`${path} is not a take's log.json, being the file beside a beat's clip.mp4. ${read.detail}`,
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const track = cameraTrack(read.log, { rate })
|
|
281
|
+
const line = `${JSON.stringify(track)}\n`
|
|
282
|
+
if (!opts.out) {
|
|
283
|
+
process.stdout.write(line)
|
|
284
|
+
return 0
|
|
285
|
+
}
|
|
286
|
+
const out = resolve(opts.out)
|
|
287
|
+
writeFileSync(out, line)
|
|
288
|
+
logInfo(`${out} ${plural(track.frames.length, 'frame')} rate ${rate}`)
|
|
289
|
+
emit(emitJson, { log: path, out, frames: track.frames.length, rate })
|
|
290
|
+
return 0
|
|
291
|
+
}
|
|
292
|
+
|
|
152
293
|
async function runTake(
|
|
153
294
|
url: string,
|
|
154
295
|
planPath: string,
|
|
155
|
-
opts: RunOptions,
|
|
296
|
+
opts: RunOptions & { readonly out: string },
|
|
156
297
|
): Promise<number> {
|
|
157
298
|
const emitJson = opts.json ?? false
|
|
158
299
|
const path = resolve(planPath)
|
|
@@ -209,6 +350,11 @@ async function runTake(
|
|
|
209
350
|
logStep('Refused')
|
|
210
351
|
logWarn(TAKE_REFUSALS[result.reason])
|
|
211
352
|
if (result.beat) logWarn(`in beat ${result.beat}`)
|
|
353
|
+
if (result.fallback) {
|
|
354
|
+
logWarn(
|
|
355
|
+
`after falling back to a screencast, which opened the address again: ${result.fallback}`,
|
|
356
|
+
)
|
|
357
|
+
}
|
|
212
358
|
if (result.reason === 'server-unreachable') {
|
|
213
359
|
logWarn(`Start whatever serves ${url}, then run this again.`)
|
|
214
360
|
}
|
|
@@ -224,6 +370,8 @@ async function runTake(
|
|
|
224
370
|
reason: result.reason,
|
|
225
371
|
message: result.message,
|
|
226
372
|
...(result.beat ? { beat: result.beat } : {}),
|
|
373
|
+
...(result.fallback ? { fallback: result.fallback } : {}),
|
|
374
|
+
...(result.still ? { still: result.still } : {}),
|
|
227
375
|
...(result.reason === 'browser-missing'
|
|
228
376
|
? { install: INSTALL_BROWSER }
|
|
229
377
|
: {}),
|
package/src/design/favicon.ts
CHANGED
|
@@ -1,37 +1,109 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface ThemePair {
|
|
2
2
|
readonly light: string
|
|
3
3
|
readonly dark: string
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
export interface FaviconColors {
|
|
7
|
+
/** The tile the mark sits on, dark wherever no theme applies. */
|
|
8
|
+
readonly tile: ThemePair
|
|
9
|
+
/** The tile's edge, drawn on light alone. */
|
|
10
|
+
readonly edge: string
|
|
11
|
+
/** The c, in the text ink of each theme. */
|
|
12
|
+
readonly c: ThemePair
|
|
13
|
+
/** The pip, in the brand pair rather than the page accent. */
|
|
14
|
+
readonly pip: ThemePair
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The mark's own colors, picked on the board and kept apart from the page accent tokens. */
|
|
7
18
|
export const FAVICON_COLORS: FaviconColors = {
|
|
8
|
-
light: '#
|
|
9
|
-
|
|
19
|
+
tile: { light: '#fbfaf8', dark: '#1b1a18' },
|
|
20
|
+
edge: '#dcdad6',
|
|
21
|
+
c: { light: '#2c2c29', dark: '#d9d7d4' },
|
|
22
|
+
pip: { light: '#c2380e', dark: '#ff6b42' },
|
|
10
23
|
}
|
|
11
24
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
|
|
25
|
+
/** The mark spans 96 of a 132 tile, with the tile's corner at 28% of its side. */
|
|
26
|
+
const MARK_IN_TILE = 96 / 132
|
|
27
|
+
const TILE_RADIUS = 0.28
|
|
28
|
+
/** About one pixel at a 32 pixel tab icon, drawn inside the tile's bounds. */
|
|
29
|
+
const EDGE_WIDTH = 4
|
|
30
|
+
|
|
31
|
+
/** The source's shapes, with its authoring comment and `currentColor` fills dropped. */
|
|
32
|
+
function markShapes(mark: string): string {
|
|
33
|
+
return mark
|
|
18
34
|
.replace(/<!--[\s\S]*?-->/, '')
|
|
19
35
|
.trim()
|
|
20
36
|
.replace(/^<svg[^>]*>/, '')
|
|
21
37
|
.replace(/<\/svg>$/, '')
|
|
22
38
|
.replaceAll(' fill="currentColor"', '')
|
|
23
39
|
.trim()
|
|
40
|
+
}
|
|
24
41
|
|
|
25
|
-
|
|
42
|
+
function markView(mark: string): [number, number, number, number] {
|
|
43
|
+
const view = /viewBox="([^"]+)"/
|
|
44
|
+
.exec(mark)?.[1]
|
|
45
|
+
.trim()
|
|
46
|
+
.split(/[\s,]+/)
|
|
47
|
+
const numbers = view?.map(Number) ?? []
|
|
48
|
+
if (numbers.length !== 4 || numbers.some((n) => !Number.isFinite(n))) {
|
|
49
|
+
throw new Error('the mark carries no viewBox of four numbers')
|
|
50
|
+
}
|
|
51
|
+
return numbers as [number, number, number, number]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const round = (n: number): number => Math.round(n * 100) / 100
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The mark on its tile. The base rules draw the dark set, which a renderer
|
|
58
|
+
* ignoring the media query shows, and a light preference swaps in the light
|
|
59
|
+
* set. A browser reports light when no scheme is set, so a tab there shows the
|
|
60
|
+
* light tile. Every fill is a rule rather than an attribute so the light branch
|
|
61
|
+
* can flip them all.
|
|
62
|
+
*/
|
|
63
|
+
export function renderFavicon(mark: string, colors: FaviconColors): string {
|
|
64
|
+
const [x, y, width, height] = markView(mark)
|
|
65
|
+
const side = Math.max(width, height) / MARK_IN_TILE
|
|
66
|
+
const left = round(x + width / 2 - side / 2)
|
|
67
|
+
const top = round(y + height / 2 - side / 2)
|
|
68
|
+
const inset = EDGE_WIDTH / 2
|
|
69
|
+
const shapes = markShapes(mark)
|
|
70
|
+
|
|
71
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${left} ${top} ${round(side)} ${round(side)}">
|
|
26
72
|
<style>
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
}
|
|
29
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">
|
|
30
84
|
${shapes.replace(/\n\s*/g, '\n ')}
|
|
85
|
+
</g>
|
|
31
86
|
</svg>
|
|
32
87
|
`
|
|
33
88
|
}
|
|
34
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The bare mark in fixed fills, for a surface that loads it as an image and
|
|
92
|
+
* so has neither a CSS context for `currentColor` nor a theme of its own.
|
|
93
|
+
*/
|
|
94
|
+
export function renderMark(
|
|
95
|
+
mark: string,
|
|
96
|
+
fills: { readonly c: string; readonly pip: string },
|
|
97
|
+
): string {
|
|
98
|
+
const svg = mark.replace(/<!--[\s\S]*?-->/, '').trim()
|
|
99
|
+
const pipFill = /(<[a-z]+ [^>]*class="pip"[^>]*?) fill="[^"]*"/
|
|
100
|
+
if (!pipFill.test(svg)) {
|
|
101
|
+
throw new Error('the mark carries no filled .pip shape')
|
|
102
|
+
}
|
|
103
|
+
const pip = svg.replace(pipFill, `$1 fill="${fills.pip}"`)
|
|
104
|
+
return `${pip.replaceAll('currentColor', fills.c)}\n`
|
|
105
|
+
}
|
|
106
|
+
|
|
35
107
|
/**
|
|
36
108
|
* The `<link rel="icon">` a page carries the rendered favicon in, inlined so a
|
|
37
109
|
* generated page needs no sibling file and no href that varies with its depth.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of `scripts/core/design-sync.ts`, ported from Frameboard's
|
|
3
|
+
* own sync. Frameboard frames load a shared kit, while this board's frames
|
|
4
|
+
* load their project's `../assets/`, so each project's assets land under
|
|
5
|
+
* `kit/<project>/assets/` in `canon/design/`, mirroring the board, and no two
|
|
6
|
+
* projects can overwrite one another's files of the same name.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A `frames.json` entry the sync cannot capture, named so the file can be fixed. */
|
|
10
|
+
export class MappingError extends Error {}
|
|
11
|
+
|
|
12
|
+
const REFERENCE = /^[a-z0-9]+(-[a-z0-9]+)*\/[a-z0-9]+(-[a-z0-9]+)*$/
|
|
13
|
+
const BOARD_REF = /^[^/\s]+\/[^/\s]+\/[^/\s]+$/
|
|
14
|
+
// A link starts at a quote or `url(`, so `../../assets/` is left alone, and a
|
|
15
|
+
// file name stops at `#` or `?`, which address a part of the file.
|
|
16
|
+
const BOARD_LINK = /(["'(]\s*)\.\.\/assets\//g
|
|
17
|
+
const BOARD_ASSET = /["'(]\s*\.\.\/assets\/([^"'()\s#?]+)/g
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reads `frames.json`, mapping each reference, `<group>/<name>`, to the
|
|
21
|
+
* board frame `<project>/<page>/<frame>` it is captured from. An entry in
|
|
22
|
+
* any other shape throws rather than filing into the wrong folder.
|
|
23
|
+
*/
|
|
24
|
+
export function parseFrameMap(json: string): Map<string, string> {
|
|
25
|
+
const parsed: unknown = JSON.parse(json)
|
|
26
|
+
const frames =
|
|
27
|
+
typeof parsed === 'object' && parsed !== null && 'frames' in parsed
|
|
28
|
+
? parsed.frames
|
|
29
|
+
: undefined
|
|
30
|
+
if (typeof frames !== 'object' || frames === null) {
|
|
31
|
+
throw new MappingError('frames.json needs a frames object.')
|
|
32
|
+
}
|
|
33
|
+
const map = new Map<string, string>()
|
|
34
|
+
for (const [reference, ref] of Object.entries(frames)) {
|
|
35
|
+
if (!REFERENCE.test(reference)) {
|
|
36
|
+
throw new MappingError(
|
|
37
|
+
`frames.json entry "${reference}" needs a kebab-case <group>/<name>.`,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
if (typeof ref !== 'string' || !BOARD_REF.test(ref)) {
|
|
41
|
+
throw new MappingError(
|
|
42
|
+
`frames.json entry "${reference}" needs a board frame as <project>/<page>/<frame>.`,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
map.set(reference, ref)
|
|
46
|
+
}
|
|
47
|
+
return map
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The mapped board frames the board does not hold. */
|
|
51
|
+
export function missingFrames(
|
|
52
|
+
map: ReadonlyMap<string, string>,
|
|
53
|
+
board: ReadonlySet<string>,
|
|
54
|
+
): string[] {
|
|
55
|
+
return [...map.values()].filter((ref) => !board.has(ref))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What the folder holds of a board frame: its project asset links moved to
|
|
60
|
+
* the project's kit, every other link left as the frame wrote it. The HTML
|
|
61
|
+
* is not reformatted, since the formatter ignores the folder and the sync is
|
|
62
|
+
* its one writer.
|
|
63
|
+
*/
|
|
64
|
+
export function toReference(html: string, project: string): string {
|
|
65
|
+
return html.replaceAll(BOARD_LINK, `$1../kit/${project}/assets/`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The project assets a frame loads, by their path under `assets/`, each once. */
|
|
69
|
+
export function boardAssets(html: string): string[] {
|
|
70
|
+
return [...new Set([...html.matchAll(BOARD_ASSET)].map((match) => match[1]))]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Drift {
|
|
74
|
+
readonly changed: readonly string[]
|
|
75
|
+
/** PNGs whose bytes differ, a warning since a PNG varies with the browser build. */
|
|
76
|
+
readonly pngDiffers: readonly string[]
|
|
77
|
+
readonly missing: readonly string[]
|
|
78
|
+
readonly stray: readonly string[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Compares what a sync would write against the files the folder holds, the HTML as the source and a PNG as derived. */
|
|
82
|
+
export function classifyDrift(
|
|
83
|
+
expected: ReadonlyMap<string, string | Uint8Array>,
|
|
84
|
+
tracked: ReadonlyMap<string, string | Uint8Array>,
|
|
85
|
+
): Drift {
|
|
86
|
+
const changed: string[] = []
|
|
87
|
+
const pngDiffers: string[] = []
|
|
88
|
+
const missing: string[] = []
|
|
89
|
+
for (const [path, data] of expected) {
|
|
90
|
+
const copy = tracked.get(path)
|
|
91
|
+
if (copy === undefined) missing.push(path)
|
|
92
|
+
else if (!Buffer.from(copy).equals(Buffer.from(data))) {
|
|
93
|
+
;(path.endsWith('.png') ? pngDiffers : changed).push(path)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const stray = [...tracked.keys()].filter((path) => !expected.has(path))
|
|
97
|
+
return { changed, pngDiffers, missing, stray }
|
|
98
|
+
}
|
package/src/design/render.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import type { Cell, DesignDoc, Row } from '@/design/parse'
|
|
4
4
|
import { parseDesignDoc } from '@/design/parse'
|
|
5
5
|
import { fontFaceBlock } from '@/design/css'
|
|
6
|
+
import { faviconLink } from '@/design/favicon'
|
|
6
7
|
import type { FontFace } from '@/design/fonts'
|
|
7
8
|
import { FONT_FACES } from '@/design/fonts'
|
|
8
9
|
import { colorValue } from '@/design/tokens'
|
|
@@ -12,16 +13,6 @@ export interface RenderResult {
|
|
|
12
13
|
cssPath: string
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
-
* The same path data as `assets/brand/mark.svg`, colored via `colorValue`
|
|
17
|
-
* since a data URI has no CSS context to resolve `currentColor` against and
|
|
18
|
-
* this page renders on the light chrome `previewChrome()` sets up.
|
|
19
|
-
*/
|
|
20
|
-
const FAVICON_COLOR = colorValue('light-accent') ?? '#a4471c'
|
|
21
|
-
const FAVICON_HREF = `data:image/svg+xml,${encodeURIComponent(
|
|
22
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 10 80 80"><path d="M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z" fill="${FAVICON_COLOR}" /><rect x="44" y="15" width="12" height="70" rx="2" fill="${FAVICON_COLOR}" /></svg>`,
|
|
23
|
-
)}`
|
|
24
|
-
|
|
25
16
|
export interface RenderOptions {
|
|
26
17
|
/**
|
|
27
18
|
* Declare each vendored face the typography table names, and let the page
|
|
@@ -29,6 +20,11 @@ export interface RenderOptions {
|
|
|
29
20
|
* base64 block a target's render would otherwise grow by unasked.
|
|
30
21
|
*/
|
|
31
22
|
readonly embedFonts?: boolean
|
|
23
|
+
/**
|
|
24
|
+
* A rendered favicon SVG to inline in the head. The caller reads and renders
|
|
25
|
+
* the mark, so this module reads no brand file a target does not carry.
|
|
26
|
+
*/
|
|
27
|
+
readonly favicon?: string
|
|
32
28
|
}
|
|
33
29
|
|
|
34
30
|
export function renderDesignDoc(
|
|
@@ -42,7 +38,10 @@ export function renderDesignDoc(
|
|
|
42
38
|
const htmlPath = join(outDir, 'index.html')
|
|
43
39
|
const faces = options.embedFonts ? namedFaces(doc) : []
|
|
44
40
|
writeFileSync(cssPath, buildCss(doc, faces))
|
|
45
|
-
writeFileSync(
|
|
41
|
+
writeFileSync(
|
|
42
|
+
htmlPath,
|
|
43
|
+
buildHtml(doc, bodyFamily(doc, faces), options.favicon),
|
|
44
|
+
)
|
|
46
45
|
return { htmlPath, cssPath }
|
|
47
46
|
}
|
|
48
47
|
|
|
@@ -218,7 +217,11 @@ function previewChrome(): string {
|
|
|
218
217
|
return [' :root {', ...lines, ' }'].join('\n')
|
|
219
218
|
}
|
|
220
219
|
|
|
221
|
-
function buildHtml(
|
|
220
|
+
function buildHtml(
|
|
221
|
+
doc: DesignDoc,
|
|
222
|
+
bodyStack: string,
|
|
223
|
+
favicon: string | undefined,
|
|
224
|
+
): string {
|
|
222
225
|
const sections = [
|
|
223
226
|
sectionPersonality(doc.personality),
|
|
224
227
|
sectionColor(doc.color),
|
|
@@ -239,8 +242,7 @@ function buildHtml(doc: DesignDoc, bodyStack: string): string {
|
|
|
239
242
|
return `<!doctype html>
|
|
240
243
|
<html lang="en">
|
|
241
244
|
<head>
|
|
242
|
-
<meta charset="utf-8"
|
|
243
|
-
<link rel="icon" href="${FAVICON_HREF}">
|
|
245
|
+
<meta charset="utf-8">${favicon === undefined ? '' : `\n${faviconLink(favicon)}`}
|
|
244
246
|
<title>Design tokens</title>
|
|
245
247
|
<link rel="stylesheet" href="design.css">
|
|
246
248
|
<style>
|
package/src/design/tokens.ts
CHANGED
|
@@ -100,7 +100,7 @@ export const REVIEWED = '2026-10-24'
|
|
|
100
100
|
|
|
101
101
|
export const TOKENS: DesignTokens = {
|
|
102
102
|
personality:
|
|
103
|
-
'Warm neutrals
|
|
103
|
+
'Warm neutrals with actions in ink, set in Geist. The page reads as prose, and monospace appears only where a surface shows a path, a command, or what a shell printed. The rust accent marks a status the surface it shows would color, and the landing page carries none, leaving the vermilion pip of the mark as its one hue.',
|
|
104
104
|
|
|
105
105
|
colorRules: [
|
|
106
106
|
'One accent. Never promote a second color into a structural role.',
|
|
@@ -155,7 +155,7 @@ export const TOKENS: DesignTokens = {
|
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
157
|
role: 'accent',
|
|
158
|
-
intent: '
|
|
158
|
+
intent: 'a status the surface would color, such as an open pull request',
|
|
159
159
|
value: '#c76b5f',
|
|
160
160
|
grounds: DARK_GROUNDS,
|
|
161
161
|
},
|
|
@@ -208,7 +208,7 @@ export const TOKENS: DesignTokens = {
|
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
role: 'light-accent',
|
|
211
|
-
intent: '
|
|
211
|
+
intent: 'a status the surface would color, on light',
|
|
212
212
|
value: '#ad4a4b',
|
|
213
213
|
grounds: LIGHT_GROUNDS,
|
|
214
214
|
},
|
|
@@ -361,6 +361,7 @@ export const TOKENS: DesignTokens = {
|
|
|
361
361
|
|
|
362
362
|
iconography: [
|
|
363
363
|
'No icon library. `assets/brand/mark.svg` is the one authored icon.',
|
|
364
|
+
'The mark takes its own colors from `src/design/favicon.ts`, never the accent: the c in the text ink and the pip in a vermilion pair.',
|
|
364
365
|
'The terminal draws glyphs: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯`.',
|
|
365
366
|
],
|
|
366
367
|
}
|
package/src/gate/stages.ts
CHANGED
|
@@ -248,6 +248,12 @@ export const STAGES: readonly Stage[] = [
|
|
|
248
248
|
failure:
|
|
249
249
|
'The tab icon drifted from the token source. Run bun run check and commit web/public/favicon.svg.',
|
|
250
250
|
},
|
|
251
|
+
{
|
|
252
|
+
kind: 'drift',
|
|
253
|
+
pathspec: 'assets/brand/mark-color*.svg',
|
|
254
|
+
failure:
|
|
255
|
+
'The README mark pair drifted from the mark source. Run bun run check and commit assets/brand/mark-color.svg and assets/brand/mark-color-light.svg.',
|
|
256
|
+
},
|
|
251
257
|
{ kind: 'measure', measure: designProse },
|
|
252
258
|
],
|
|
253
259
|
success: 'Design source clean',
|
package/src/init/steps.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { GATED_CONFIG_REL, gateScopeRoots } from '@/markdown/gated'
|
|
|
7
7
|
export type RunFactory = (args: readonly string[]) => () => Promise<boolean>
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* States the same fact as `docs/
|
|
11
|
-
* own words. Keep the two in step by hand; nothing compares them.
|
|
10
|
+
* States the same fact as `docs/migrations/by-hand.md`'s backup section
|
|
11
|
+
* in its own words. Keep the two in step by hand; nothing compares them.
|
|
12
12
|
*/
|
|
13
13
|
const RECORDS_SETUP_NOTICE =
|
|
14
14
|
"No private repository to push to yet. Run 'canon records push' once one exists to print the one-time setup command, or --skip records to silence this."
|
package/src/migrate/status.ts
CHANGED
|
@@ -8,9 +8,9 @@ interface Migration {
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Every `canon migrate` subcommand in the order a project runs them, records
|
|
11
|
-
* first. This is the source for that order, and
|
|
12
|
-
* cites the verb rather than keeping a
|
|
13
|
-
* one line here.
|
|
11
|
+
* first. This is the source for that order, and
|
|
12
|
+
* `docs/migrations/by-verb.md` cites the verb rather than keeping a
|
|
13
|
+
* second list. A new migration joins by one line here.
|
|
14
14
|
*/
|
|
15
15
|
export const MIGRATIONS: readonly Migration[] = [
|
|
16
16
|
{ name: 'records', flags: [] },
|
package/src/records/validate.ts
CHANGED
|
@@ -807,6 +807,13 @@ const STRUCTURE_FILES: Readonly<
|
|
|
807
807
|
intake: (file) => file !== INTAKE_INDEX && file !== INTAKE_HANDOFF,
|
|
808
808
|
}
|
|
809
809
|
|
|
810
|
+
/**
|
|
811
|
+
* An app drive's log, which sits under an intake folder's `evidence/` and
|
|
812
|
+
* holds findings as headings and fields rather than items, so it reads on the
|
|
813
|
+
* plain criteria rather than the cluster exemptions.
|
|
814
|
+
*/
|
|
815
|
+
const DRIVE_LOG = 'evidence/drive-log.md'
|
|
816
|
+
|
|
810
817
|
async function folderStructure(
|
|
811
818
|
dir: string,
|
|
812
819
|
kind: RecordKind,
|
|
@@ -817,9 +824,14 @@ async function folderStructure(
|
|
|
817
824
|
|
|
818
825
|
const report = kind === 'intake' ? intakeStructure : structureFindings
|
|
819
826
|
const files = (await listMarkdown(dir)).filter(reads)
|
|
827
|
+
const reports = files.map((file) => ({ file, read: report }))
|
|
828
|
+
if (kind === 'intake' && existsSync(join(dir, DRIVE_LOG))) {
|
|
829
|
+
reports.push({ file: DRIVE_LOG, read: structureFindings })
|
|
830
|
+
}
|
|
831
|
+
|
|
820
832
|
const perFile = await Promise.all(
|
|
821
|
-
|
|
822
|
-
|
|
833
|
+
reports.map(async ({ file, read }) =>
|
|
834
|
+
read(await readFile(join(dir, file), 'utf8')).map((entry) => ({
|
|
823
835
|
...entry,
|
|
824
836
|
record: slug,
|
|
825
837
|
subject: file,
|