@erclx/canon 4.101.0 → 4.103.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 (44) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +5 -6
  3. package/claude/skills/canon-cli/SKILL.md +4 -1
  4. package/claude/skills/canon-rollout/SKILL.md +7 -6
  5. package/claude/skills/draft-readme/REQUIREMENT.md +2 -0
  6. package/claude/skills/draft-readme/SKILL.md +9 -1
  7. package/claude/skills/git-ship/SKILL.md +3 -7
  8. package/claude/skills/memory-capture/SKILL.md +0 -6
  9. package/claude/skills/memory-review/REQUIREMENT.md +1 -1
  10. package/claude/skills/memory-review/SKILL.md +2 -3
  11. package/claude/skills/plan-groundwork/SKILL.md +3 -2
  12. package/claude/skills/target-check/REQUIREMENT.md +42 -0
  13. package/claude/skills/target-check/SKILL.md +91 -0
  14. package/claude/skills/target-check/references/domains.md +88 -0
  15. package/claude/skills/ui-checklist/SKILL.md +17 -0
  16. package/docs/agents/commands.md +1 -1
  17. package/docs/agents/install-and-sync.md +7 -1
  18. package/docs/agents/scripting.md +1 -1
  19. package/docs/target-projects.md +3 -1
  20. package/docs/workflow/ai-workflow.md +16 -13
  21. package/governance/rules/ci/700-ci-workflow.md +11 -0
  22. package/package.json +1 -1
  23. package/scripts/core/regen-web-favicon.ts +9 -40
  24. package/scripts/core/regen-web-previews.ts +1 -0
  25. package/src/claude/cases/setup.ts +8 -0
  26. package/src/commands/design.ts +8 -2
  27. package/src/commands/tooling.ts +92 -19
  28. package/src/design/css.ts +5 -2
  29. package/src/design/favicon.ts +33 -0
  30. package/src/design/render.ts +46 -6
  31. package/src/design/tokens.ts +1 -1
  32. package/standards/groundwork.md +8 -2
  33. package/standards/readme.md +22 -3
  34. package/tooling/astro/manifest.toml +1 -0
  35. package/tooling/base/manifest.toml +2 -2
  36. package/tooling/base/reference.md +1 -1
  37. package/tooling/nextjs/manifest.toml +1 -0
  38. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +110 -0
  39. package/tooling/web/configs/e2e/screenshot.ts +69 -15
  40. package/tooling/web/configs/scripts/lib/preview-server.sh +31 -0
  41. package/tooling/web/configs/scripts/readme-screenshot.sh +26 -0
  42. package/tooling/web/configs/scripts/screenshot.sh +1 -24
  43. package/tooling/web/manifest.toml +1 -0
  44. package/tooling/web/reference.md +6 -1
@@ -65,7 +65,9 @@ The routing test is whether the repository can answer an item today. A session g
65
65
 
66
66
  A groundwork track may run experiments to settle a question, writing a fixture it reads itself under `.canon/tmp/runs/groundwork-fixtures/<slug>/` and spawning up to three billed headless runs before it asks. A fixture a headless run is pointed at sits outside the repository, since a session started under the project root inherits that project's `CLAUDE.md` and rules and would measure them instead of the arm.
67
67
 
68
- What a spike produces goes somewhere else again. An input the run reads is re-runnable and cited by nothing, so the scratch path above is the right lifetime for it, while a recording or a render the track cites as evidence for a finding is what a later reader opens to check the claim. Evidence therefore lives in `evidence/` inside the track beside the file citing it, since the scratch tree holds only what can be deleted without loss.
68
+ A spike starts against a sample, bounded on input size, duration, and spend, and scales up once the sample shows the method works. Nothing checks this, so it holds while a session reads the standard.
69
+
70
+ What a spike leaves behind splits on whether a later reader needs the file to check a claim. Bulk input, such as a large fixture, is re-runnable and cited by nothing, so the scratch path above is the right lifetime for it. Everything a reader opens to verify a result lives inside the track in one of three folders: `evidence/` for a recording or a render, `scripts/` for the arm scripts, and `clones/` for checkouts and copies of outside material a claim rests on. The scratch tree holds only what can be deleted without loss, which none of those is.
69
71
 
70
72
  #### Session 2
71
73
 
@@ -166,7 +168,7 @@ The list stays written in the skill body as the fallback for a target whose inst
166
168
 
167
169
  Capture leads rather than trails because a routed fact edits a tracked file, which has to reach the branch before the commit steps run.
168
170
 
169
- If capture wrote at least one memory file, `memory-review` then proposes a decision-ready fix scoped to those entries while context is fresh, otherwise it is skipped. It stops at Propose. Review the receipt and run Apply yourself, on its own commit separate from the feature.
171
+ The chain ends at capture, and review is run on its own.
170
172
 
171
173
  Run `memory-review` standalone to curate the whole pen. An entry it retires moves to `.canon/memory/archive/` rather than being deleted, since a bulk pass has no undo.
172
174
 
@@ -260,7 +262,7 @@ This section is the corpus the coverage claim is measured against: every name `c
260
262
  | `canon:git-commit` | When the staged set is one concern, or was staged hunk by hand |
261
263
  | `canon:git-branch` | When a branch name needs generating or renaming to conventional form |
262
264
  | `canon:git-pr` | When a pull request needs a title and body written from the diff |
263
- | `canon:memory-review` | After capture writes an entry, to propose where each one belongs |
265
+ | `canon:memory-review` | When the pen has grown, to propose where each entry belongs |
264
266
 
265
267
  ### After the pull request opens
266
268
 
@@ -287,16 +289,17 @@ This section is the corpus the coverage claim is measured against: every name `c
287
289
 
288
290
  ### Keep the project current with the toolkit
289
291
 
290
- | Skill | When to use |
291
- | -------------------------------- | ---------------------------------------------------------------------------------- |
292
- | `canon:seed-sync` | After a toolkit update, to reconcile installed seeds without losing customizations |
293
- | `canon:migration-claude-md` | When `CLAUDE.md` grew past what always-load context should carry |
294
- | `canon:migration-context` | When `docs/` holds agent-flavored files belonging in `canon/context/` |
295
- | `canon:migration-superseded` | When a drift report names a `.claude/` file a folder has replaced |
296
- | `canon:migration-standards-drop` | When the project still holds an installed `.claude/standards/` tree |
297
- | `canon:canon-feedback-file` | When something in the toolkit is broken, missing, or off |
298
- | `canon:canon-feedback-triage` | In the toolkit repo, to work through the open feedback issues |
299
- | `canon:canon-rollout` | In the toolkit repo, to take one change out to every consuming project at once |
292
+ | Skill | When to use |
293
+ | -------------------------------- | -------------------------------------------------------------------------------------- |
294
+ | `canon:target-check` | In a target project, to report per domain what it holds against what the toolkit ships |
295
+ | `canon:seed-sync` | After a toolkit update, to reconcile installed seeds without losing customizations |
296
+ | `canon:migration-claude-md` | When `CLAUDE.md` grew past what always-load context should carry |
297
+ | `canon:migration-context` | When `docs/` holds agent-flavored files belonging in `canon/context/` |
298
+ | `canon:migration-superseded` | When a drift report names a `.claude/` file a folder has replaced |
299
+ | `canon:migration-standards-drop` | When the project still holds an installed `.claude/standards/` tree |
300
+ | `canon:canon-feedback-file` | When something in the toolkit is broken, missing, or off |
301
+ | `canon:canon-feedback-triage` | In the toolkit repo, to work through the open feedback issues |
302
+ | `canon:canon-rollout` | In the toolkit repo, to take one change out to every consuming project at once |
300
303
 
301
304
  ### Generate an artifact on demand
302
305
 
@@ -39,6 +39,17 @@ paths:
39
39
  - Install with `bun install --frozen-lockfile`.
40
40
  - Key the Playwright browser cache on the Playwright version string, never a static key.
41
41
 
42
+ ## Commit-back
43
+
44
+ - Commit a refreshed file back to the pull request branch only when the branch's own change moved it. Report the diff without committing when something outside the branch can move it, such as a count read at build time or a font that renders differently per machine, since that commit lands on a branch that never asked for it.
45
+ - Commit only what a deterministic step regenerates from source, and run the diff check before the commit step so an unchanged capture pushes nothing.
46
+ - Skip a pull request from a fork with `github.event.pull_request.head.repo.full_name == github.repository`, since the default token cannot push to a fork's branch.
47
+ - Set the concurrency group on the ref so a newer push cancels the run committing onto a stale head.
48
+ - Set `HUSKY: 0` on the commit step when the runner lacks a tool a hook shells out to.
49
+ - Never commit back to the trunk. A commit with no pull request and no reviewer needs an operator's decision, not a workflow's.
50
+ - Leave `workflow_dispatch` off a commit-back workflow. A manual run has no branch to commit onto, so the trigger would pass and do nothing.
51
+ - Fail a template's first step while a path list still holds its placeholder, so an unfinished install fails loudly rather than never firing.
52
+
42
53
  ## Authority
43
54
 
44
55
  - Load the `canon:ci-workflow` skill for the workflow template and the per-project adaptation. Report it rather than proceeding silently when the skill does not resolve.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.101.0",
4
+ "version": "4.103.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Writes web/public/favicon.svg from assets/brand/mark.svg and the design
3
- * source's two accent values.
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.
4
4
  *
5
5
  * The page is the fourth surface to carry the mark as a favicon and the only
6
6
  * one that can answer for itself. `regen-hero.sh` and `src/design/render.ts`
@@ -17,6 +17,8 @@
17
17
  */
18
18
  import { writeFileSync } from 'node:fs'
19
19
 
20
+ import { FAVICON_COLORS, renderFavicon } from '../../src/design/favicon'
21
+
20
22
  const root = new URL('../..', import.meta.url).pathname
21
23
 
22
24
  const mark = (await Bun.file(`${root}assets/brand/mark.svg`).text()).trim()
@@ -25,43 +27,10 @@ if (!mark) {
25
27
  process.exit(1)
26
28
  }
27
29
 
28
- const tokenCss = await Bun.file(`${root}web/src/styles/tokens.css`).text()
29
- const read = (name: string): string => {
30
- const match = tokenCss.match(
31
- new RegExp(`--color-${name}:\\s*(#[0-9a-fA-F]{3,8})`),
32
- )
33
- if (!match) {
34
- console.error(
35
- `regen-web-favicon: tokens.css carries no --color-${name}, refusing to write a colorless favicon`,
36
- )
37
- process.exit(1)
38
- }
39
- return match[1] as string
40
- }
41
-
42
- const dark = read('accent')
43
- const light = read('light-accent')
44
-
45
- // The shapes only, with the source's authoring comment dropped. Both fills are
46
- // replaced by a rule rather than an attribute so one branch can flip both.
47
- const shapes = mark
48
- .replace(/<!--[\s\S]*?-->/, '')
49
- .trim()
50
- .replace(/^<svg[^>]*>/, '')
51
- .replace(/<\/svg>$/, '')
52
- .replaceAll(' fill="currentColor"', '')
53
- .trim()
54
-
55
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 10 80 80">
56
- <style>
57
- path, rect { fill: ${light}; }
58
- @media (prefers-color-scheme: dark) { path, rect { fill: ${dark}; } }
59
- </style>
60
- ${shapes.replace(/\n\s*/g, '\n ')}
61
- </svg>
62
- `
63
-
64
- writeFileSync(`${root}web/public/favicon.svg`, svg)
30
+ writeFileSync(
31
+ `${root}web/public/favicon.svg`,
32
+ renderFavicon(mark, FAVICON_COLORS),
33
+ )
65
34
  console.log(
66
- `regen-web-favicon: wrote web/public/favicon.svg (${light} / ${dark})`,
35
+ `regen-web-favicon: wrote web/public/favicon.svg (${FAVICON_COLORS.light} / ${FAVICON_COLORS.dark})`,
67
36
  )
@@ -21,6 +21,7 @@ const root = new URL('../..', import.meta.url).pathname
21
21
  const { cssPath } = renderDesignDoc(
22
22
  `${root}${DESIGN_DOCUMENT}`,
23
23
  `${root}web/public/previews/design-tokens`,
24
+ { embedFonts: true },
24
25
  )
25
26
 
26
27
  const css = readFileSync(cssPath, 'utf8')
@@ -91,6 +91,14 @@ export const SETUP_CASES: readonly SkillCase[] = [
91
91
  'Take this change out to every project that installed the toolkit and get each one to a pull request.',
92
92
  expect: 'canon-rollout',
93
93
  },
94
+ // Reporting per domain is what separates this from canon-operator, which
95
+ // diagnoses on the way to running the operation an intent named. This answers
96
+ // one fixed question across a fixed domain list and repairs nothing.
97
+ {
98
+ prompt:
99
+ 'Report what this project has fallen behind the toolkit on, domain by domain, and fix nothing.',
100
+ expect: 'target-check',
101
+ },
94
102
  {
95
103
  prompt:
96
104
  "This project needs its own coding rule that the toolkit doesn't ship.",
@@ -119,7 +119,11 @@ export function register(program: Command): void {
119
119
  'Output directory',
120
120
  creationRel(process.cwd(), SCRATCH, 'render', 'design'),
121
121
  )
122
- .action((opts: { source: string; out: string }) => {
122
+ .option(
123
+ '--embed-fonts',
124
+ 'Declare each vendored face the typography table names, and set the page body in the body stack',
125
+ )
126
+ .action((opts: { source: string; out: string; embedFonts?: boolean }) => {
123
127
  const sourcePath = resolve(process.cwd(), opts.source)
124
128
  const outDir = resolve(process.cwd(), opts.out)
125
129
  const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
@@ -133,7 +137,9 @@ export function register(program: Command): void {
133
137
  process.stderr.write(
134
138
  `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}Render design tokens${NC}\n`,
135
139
  )
136
- const result = renderDesignDoc(sourcePath, outDir)
140
+ const result = renderDesignDoc(sourcePath, outDir, {
141
+ embedFonts: opts.embedFonts,
142
+ })
137
143
  process.stderr.write(
138
144
  `${GREY}│${NC} ${GREEN}✓${NC} ${result.htmlPath}\n${GREY}│${NC} ${GREEN}✓${NC} ${result.cssPath}\n${GREY}└${NC}\n`,
139
145
  )
@@ -39,6 +39,12 @@ interface SyncOptions {
39
39
  readonly skip?: string
40
40
  readonly check?: boolean
41
41
  readonly write?: boolean
42
+ readonly diff?: { readonly json: boolean }
43
+ }
44
+
45
+ interface DiffOptions {
46
+ readonly skip?: string
47
+ readonly json?: boolean
42
48
  }
43
49
 
44
50
  interface InjectOptions {
@@ -59,7 +65,7 @@ interface ListOptions {
59
65
 
60
66
  type Prepared =
61
67
  | { readonly ok: true; readonly chain: Manifest[]; readonly target: string }
62
- | { readonly ok: false; readonly error: string }
68
+ | { readonly ok: false; readonly reason: string; readonly error: string }
63
69
 
64
70
  export function register(program: Command): void {
65
71
  const tooling = program
@@ -74,7 +80,10 @@ export function register(program: Command): void {
74
80
  .argument('[target]', 'Target directory', '.')
75
81
  .helpOption('-h, --help', 'Show this help message')
76
82
  .option('--skip <stack>', 'Drop a layer from the extends chain')
77
- .option('--check', 'Report what would change and write nothing')
83
+ .option(
84
+ '--check',
85
+ 'Report what would change and write nothing, always exiting 0. Prefer `canon tooling diff`',
86
+ )
78
87
  .option('--write', 'Apply every change without prompting')
79
88
  .addHelpText(
80
89
  'after',
@@ -98,6 +107,42 @@ export function register(program: Command): void {
98
107
  },
99
108
  )
100
109
 
110
+ tooling
111
+ .command('diff')
112
+ .description('Report how a target differs from a stack and write nothing')
113
+ .argument('[stack]', 'Tooling stack name (e.g. base, vite-react)')
114
+ .argument('[target]', 'Target directory', '.')
115
+ .helpOption('-h, --help', 'Show this help message')
116
+ .option('--skip <stack>', 'Drop a layer from the extends chain')
117
+ .option('--json', 'Add a machine-readable record on stdout')
118
+ .addHelpText(
119
+ 'after',
120
+ [
121
+ '',
122
+ 'The comparison `canon tooling sync --check` reports, under a name that',
123
+ 'says it only asks. Exit codes:',
124
+ ' 0 the target matches the stack',
125
+ ' 1 a file, script, dependency, or gitignore entry differs, or a refusal',
126
+ '',
127
+ '`sync --check` reports the same list and always exits 0. Use `diff` to',
128
+ 'gate CI, and keep `--check` for scripts that already call it.',
129
+ '',
130
+ 'Examples:',
131
+ ' canon tooling diff base',
132
+ ' canon tooling diff base --json',
133
+ '',
134
+ ].join('\n'),
135
+ )
136
+ .action(
137
+ async (stack: string | undefined, target: string, opts: DiffOptions) => {
138
+ process.exitCode = await runSync(stack, target, {
139
+ skip: opts.skip,
140
+ check: true,
141
+ diff: { json: opts.json === true },
142
+ })
143
+ },
144
+ )
145
+
101
146
  tooling
102
147
  .command('inject')
103
148
  .description('Apply one stack to a target without scanning or prompting')
@@ -239,18 +284,27 @@ function prepare(stack: string, target: string, skip?: string): Prepared {
239
284
  if (mismatch !== undefined) logWarn(mismatch)
240
285
 
241
286
  if (!stackExists(PROJECT_ROOT, stack)) {
242
- return { ok: false, error: `Stack not found: ${stack}` }
287
+ return {
288
+ ok: false,
289
+ reason: 'unknown-stack',
290
+ error: `Stack not found: ${stack}`,
291
+ }
243
292
  }
244
293
 
245
294
  if (skip !== undefined) {
246
295
  if (skip === stack) {
247
296
  return {
248
297
  ok: false,
298
+ reason: 'bad-skip',
249
299
  error: `Cannot --skip the stack being synced: ${skip}`,
250
300
  }
251
301
  }
252
302
  if (!stackExists(PROJECT_ROOT, skip)) {
253
- return { ok: false, error: `Stack to skip not found: ${skip}` }
303
+ return {
304
+ ok: false,
305
+ reason: 'unknown-skip',
306
+ error: `Stack to skip not found: ${skip}`,
307
+ }
254
308
  }
255
309
  }
256
310
 
@@ -258,6 +312,7 @@ function prepare(stack: string, target: string, skip?: string): Prepared {
258
312
  if (resolved === PROJECT_ROOT) {
259
313
  return {
260
314
  ok: false,
315
+ reason: 'toolkit-root',
261
316
  error:
262
317
  'Cannot run against toolkit root. Files here are the source of truth.',
263
318
  }
@@ -275,40 +330,44 @@ async function runSync(
275
330
  target: string,
276
331
  opts: SyncOptions,
277
332
  ): Promise<number> {
278
- intro('canon tooling sync')
333
+ intro(opts.diff === undefined ? 'canon tooling sync' : 'canon tooling diff')
279
334
 
280
335
  if (opts.check === true && opts.write === true) {
281
- logWarn('Pass --check or --write, not both.')
282
- outro()
283
- return 1
336
+ return refuse(
337
+ opts,
338
+ 'conflicting-flags',
339
+ 'Pass --check or --write, not both.',
340
+ )
284
341
  }
285
342
 
286
343
  const selected = stack ?? (await promptForStack())
287
344
  if (selected === undefined) {
288
- logWarn('No tooling stacks found')
289
- outro()
290
- return 1
345
+ return refuse(opts, 'no-stacks', 'No tooling stacks found')
291
346
  }
292
347
 
293
348
  if (isStackExcluded(selected)) {
294
- logWarn(
349
+ return refuse(
350
+ opts,
351
+ 'excluded-stack',
295
352
  'Claude is managed by `canon claude`, not `canon tooling`. Run `canon claude sync` instead.',
296
353
  )
297
- outro()
298
- return 1
299
354
  }
300
355
 
301
356
  const prepared = prepare(selected, target, opts.skip)
302
- if (!prepared.ok) {
303
- logWarn(prepared.error)
304
- outro()
305
- return 1
306
- }
357
+ if (!prepared.ok) return refuse(opts, prepared.reason, prepared.error)
307
358
 
308
359
  const result = scan(prepared.chain, prepared.target)
309
360
 
310
361
  report(result)
311
362
 
363
+ if (opts.diff !== undefined) {
364
+ if (opts.diff.json) {
365
+ process.stdout.write(`${JSON.stringify({ ok: true, ...result })}\n`)
366
+ }
367
+ outro()
368
+ return result.totalChanges === 0 ? 0 : 1
369
+ }
370
+
312
371
  const mode = resolveWriteMode(opts)
313
372
  const { GREEN, NC } = palette(process.stderr)
314
373
 
@@ -353,6 +412,20 @@ async function runSync(
353
412
  return 0
354
413
  }
355
414
 
415
+ /**
416
+ * A diff caller branches on the record rather than the exit, since a shell
417
+ * wrapper can flatten the status. Every refusal therefore answers with a reason
418
+ * on stdout under `--json`, beside the exit 1 that keeps a gate failing.
419
+ */
420
+ function refuse(opts: SyncOptions, reason: string, message: string): number {
421
+ logWarn(message)
422
+ if (opts.diff?.json) {
423
+ process.stdout.write(`${JSON.stringify({ ok: false, reason, message })}\n`)
424
+ }
425
+ outro()
426
+ return 1
427
+ }
428
+
356
429
  type WriteMode = 'apply' | 'prompt' | 'report' | 'unauthorized'
357
430
 
358
431
  type ApplyDecision = 'apply' | 'cancelled' | 'reported' | 'unauthorized'
package/src/design/css.ts CHANGED
@@ -136,14 +136,17 @@ ${component.rules}`,
136
136
  * installed. Defaults to the mono stack's primary family; teach passes its own
137
137
  * three faces instead of widening this default for every consumer.
138
138
  */
139
- function fontFaceBlock(faces: readonly FontFace[]): string {
139
+ export function fontFaceBlock(
140
+ faces: readonly FontFace[],
141
+ display: 'swap' | 'block' = 'swap',
142
+ ): string {
140
143
  return faces
141
144
  .map(
142
145
  (face) => `@font-face {
143
146
  font-family: '${face.family}';
144
147
  font-weight: ${face.weight};
145
148
  font-style: normal;
146
- font-display: swap;
149
+ font-display: ${display};
147
150
  src: url(data:font/woff2;base64,${face.base64}) format('woff2');
148
151
  }`,
149
152
  )
@@ -0,0 +1,33 @@
1
+ export interface FaviconColors {
2
+ readonly light: string
3
+ readonly dark: string
4
+ }
5
+
6
+ /** The favicon's own fills, chosen for tab size and apart from the page accent tokens. */
7
+ export const FAVICON_COLORS: FaviconColors = {
8
+ light: '#c42938',
9
+ dark: '#e54e40',
10
+ }
11
+
12
+ /**
13
+ * The shapes only, with the source's authoring comment dropped. Both fills are
14
+ * replaced by a rule rather than an attribute so one branch can flip both.
15
+ */
16
+ export function renderFavicon(mark: string, colors: FaviconColors): string {
17
+ const shapes = mark
18
+ .replace(/<!--[\s\S]*?-->/, '')
19
+ .trim()
20
+ .replace(/^<svg[^>]*>/, '')
21
+ .replace(/<\/svg>$/, '')
22
+ .replaceAll(' fill="currentColor"', '')
23
+ .trim()
24
+
25
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 10 80 80">
26
+ <style>
27
+ path, rect { fill: ${colors.light}; }
28
+ @media (prefers-color-scheme: dark) { path, rect { fill: ${colors.dark}; } }
29
+ </style>
30
+ ${shapes.replace(/\n\s*/g, '\n ')}
31
+ </svg>
32
+ `
33
+ }
@@ -2,6 +2,9 @@ import { mkdirSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Cell, DesignDoc, Row } from '@/design/parse'
4
4
  import { parseDesignDoc } from '@/design/parse'
5
+ import { fontFaceBlock } from '@/design/css'
6
+ import type { FontFace } from '@/design/fonts'
7
+ import { FONT_FACES } from '@/design/fonts'
5
8
  import { colorValue } from '@/design/tokens'
6
9
 
7
10
  export interface RenderResult {
@@ -19,19 +22,55 @@ const FAVICON_HREF = `data:image/svg+xml,${encodeURIComponent(
19
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>`,
20
23
  )}`
21
24
 
25
+ export interface RenderOptions {
26
+ /**
27
+ * Declare each vendored face the typography table names, and let the page
28
+ * body take the body role's stack. Off by default, since a face is a large
29
+ * base64 block a target's render would otherwise grow by unasked.
30
+ */
31
+ readonly embedFonts?: boolean
32
+ }
33
+
22
34
  export function renderDesignDoc(
23
35
  sourcePath: string,
24
36
  outDir: string,
37
+ options: RenderOptions = {},
25
38
  ): RenderResult {
26
39
  const doc = parseDesignDoc(sourcePath)
27
40
  mkdirSync(outDir, { recursive: true })
28
41
  const cssPath = join(outDir, 'design.css')
29
42
  const htmlPath = join(outDir, 'index.html')
30
- writeFileSync(cssPath, buildCss(doc))
31
- writeFileSync(htmlPath, buildHtml(doc))
43
+ const faces = options.embedFonts ? namedFaces(doc) : []
44
+ writeFileSync(cssPath, buildCss(doc, faces))
45
+ writeFileSync(htmlPath, buildHtml(doc, bodyFamily(doc, faces)))
32
46
  return { htmlPath, cssPath }
33
47
  }
34
48
 
49
+ /** The vendored faces whose family appears in a typography `Family` cell. */
50
+ function namedFaces(doc: DesignDoc): FontFace[] {
51
+ const named = new Set(
52
+ doc.typography.flatMap((row) =>
53
+ val(row, 'Family')
54
+ .split(',')
55
+ .map((family) => family.trim().replace(/^['"]|['"]$/g, '')),
56
+ ),
57
+ )
58
+ return FONT_FACES.filter((face) => named.has(face.family))
59
+ }
60
+
61
+ /** A stack lands raw in a `<style>` block, where HTML escaping does nothing. */
62
+ const SAFE_STACK = /^[\w\s,'"-]+$/
63
+
64
+ /** The stack the body role names, once a face for it is embedded. */
65
+ function bodyFamily(doc: DesignDoc, faces: readonly FontFace[]): string {
66
+ if (!faces.length) return 'system-ui, sans-serif'
67
+ const row =
68
+ doc.typography.find((r) => slug(val(r, 'Role')) === 'body') ??
69
+ doc.typography[0]
70
+ const family = val(row, 'Family')
71
+ return SAFE_STACK.test(family) ? family : 'system-ui, sans-serif'
72
+ }
73
+
35
74
  function slug(s: string): string {
36
75
  return s
37
76
  .toLowerCase()
@@ -105,7 +144,7 @@ function confidence(doc: DesignDoc): Confidence {
105
144
  return { tagged, total }
106
145
  }
107
146
 
108
- function buildCss(doc: DesignDoc): string {
147
+ function buildCss(doc: DesignDoc, faces: readonly FontFace[]): string {
109
148
  const lines: string[] = [':root {']
110
149
  for (const row of doc.color) {
111
150
  if (val(row, 'Value')) {
@@ -135,7 +174,8 @@ function buildCss(doc: DesignDoc): string {
135
174
  }
136
175
  }
137
176
  lines.push('}')
138
- return lines.join('\n') + '\n'
177
+ const root = lines.join('\n') + '\n'
178
+ return faces.length ? `${fontFaceBlock(faces, 'block')}\n\n${root}` : root
139
179
  }
140
180
 
141
181
  function escape(s: string): string {
@@ -178,7 +218,7 @@ function previewChrome(): string {
178
218
  return [' :root {', ...lines, ' }'].join('\n')
179
219
  }
180
220
 
181
- function buildHtml(doc: DesignDoc): string {
221
+ function buildHtml(doc: DesignDoc, bodyStack: string): string {
182
222
  const sections = [
183
223
  sectionPersonality(doc.personality),
184
224
  sectionColor(doc.color),
@@ -205,7 +245,7 @@ function buildHtml(doc: DesignDoc): string {
205
245
  <link rel="stylesheet" href="design.css">
206
246
  <style>
207
247
  ${previewChrome()}
208
- body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 960px; color: var(--preview-ink); background: var(--preview-paper); }
248
+ body { font-family: ${bodyStack}; margin: 2rem; max-width: 960px; color: var(--preview-ink); background: var(--preview-paper); }
209
249
  h1 { margin-top: 0; }
210
250
  h2 { margin-top: 2rem; border-bottom: 1px solid var(--preview-rule); padding-bottom: 0.25rem; }
211
251
  table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }
@@ -377,7 +377,7 @@ export const TOKENS: DesignTokens = {
377
377
  iconography: [
378
378
  'No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing.',
379
379
  '',
380
- 'The same mark ships as a favicon on every rendered surface, as three independently-maintained copies that track different accents by design rather than by drift, colored to fit the chrome each renders on: the dark accent (`#e0724b`) for a dark-chrome surface and the light accent (`#a4471c`) for a light-chrome one. Unifying the three or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design.md` carries which file holds each copy.',
380
+ 'The same mark ships in six independently-maintained copies that carry different colors by design rather than by drift, each fitted to the chrome it renders on: the dark accent for a dark-chrome surface, the light accent for a light-chrome one, and the pair the web favicon takes from `src/design/favicon.ts`. Unifying them or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design.md` carries which file holds each copy.',
381
381
  ].join('\n'),
382
382
  }
383
383
 
@@ -130,9 +130,15 @@ Each spike carries four things:
130
130
 
131
131
  Cost is a report rather than a limit, and it is what makes the next spike estimable before anyone commits to it. Record it even when it comes to a single read.
132
132
 
133
- A spike also leaves files behind, and they split on whether the track cites them rather than on whether they are markdown. An input the run reads, being a fixture page, an arm script, or a copied asset, is re-runnable and cited by nothing, so it stays outside the track wherever the surface driving the spike puts it. Evidence the record cites, being a recording, a render, or a frame pulled from one, is what a later reader opens to check a claim, so it lives in `evidence/` inside the track beside the file citing it. Name the split rather than the file types, which is what keeps this from going stale on the next kind of artifact a spike produces.
133
+ Start a spike against a sample and scale up only once the sample shows the method works. Bound the sample on all three of input size, duration, and spend, since fifty minutes of audio is a runtime and a bill before it is a file size, and three minutes of it usually shows whether the pipeline runs at all. The same holds for a dataset, an image set, or a batch of queries. Record the sample's bounds and the figure for the full input in the spike, so a reader can tell what the result was measured on and what the scale-up costs. The rule reaches only a track, since that is the surface that reads this standard, and nothing checks that a spike ran small first, so it holds only while a session reads it.
134
134
 
135
- `evidence/` takes no number, since numbering is the read order over the files a reader opens in sequence and an artifact is reached from the claim that cites it instead.
135
+ A spike also leaves files behind, and they split on whether a later reader needs the file to check a claim rather than on whether it is markdown or re-runnable. A file a reader opens to verify how a result was produced or what it showed lives inside the track. A file that is only bulk input, such as a large fixture or a copied asset, stays outside it wherever the surface driving the spike puts it. Three subfolders inside the track carry the first kind, and none takes a number, since numbering is the read order over the files a reader opens in sequence and these are reached from the claim that cites them instead:
136
+
137
+ - `evidence/`: what a run produced and the record cites, being a recording, a render, or a frame pulled from one, beside the file citing it.
138
+ - `scripts/`: the arm scripts and harnesses a spike ran. An arm script is re-runnable, and it is also what a reader opens to check the method, so this refines the earlier split, which sorted on re-runnable alone, and does not contradict it.
139
+ - `clones/`: checkouts and copies of outside material a claim rests on, kept small enough to hold in the folder. A checkout too large to keep is cited by its address and commit instead.
140
+
141
+ `spikes/`, `web/`, and `research/` were considered and folded into these, so do not reopen them. A spike's files sit under the three above, and a fetched page belongs in `clones/`. The `web/` case is the arguable one, since a screenshot of a reference site could sit apart, and it folds into `evidence/` because that is where a cited artifact already lives.
136
142
 
137
143
  Reach for a test harness the project already carries before building one. A track needing an experiment no existing harness can express has found a finding, and it belongs in the folder rather than in a new abstraction.
138
144
 
@@ -11,7 +11,7 @@ The reader is what changes. Reference prose serves someone who already committed
11
11
 
12
12
  ## Scope
13
13
 
14
- Governs every `README.md`: voice, heading structure, required and optional sections, badge selection, and what the page links out to instead of carrying.
14
+ Governs every `README.md`: voice, heading structure, required and optional sections, the header block, badge selection, and what the page links out to instead of carrying.
15
15
 
16
16
  Does not govern:
17
17
 
@@ -34,14 +34,14 @@ Scoped to the README at a repository root. A nested README documenting a folder,
34
34
 
35
35
  - H1 title, H2 major sections, H3 subsections. Maintain proper hierarchy for GitHub's auto-generated table of contents.
36
36
  - Use sentence case for all headings (proper nouns and product names retain their casing)
37
- - Project description in plain text directly under H1. Keep it to 2-3 sentences.
37
+ - Project description in plain text below the header block, or directly under the H1 when the block holds nothing beneath the title. Keep it to 2-3 sentences.
38
38
  - Do not create deeply nested heading structures that harm scannability
39
39
  - Do not use horizontal rules or dividers (`---`)
40
40
 
41
41
  ## Sections
42
42
 
43
43
  - Required: project description, installation/setup, usage examples, support/help resources
44
- - Optional: badges (at top, before description), features, contributing (link to `CONTRIBUTING.md`), license (link to `LICENSE`)
44
+ - Optional: the header block below, features, contributing (link to `CONTRIBUTING.md`), license (link to `LICENSE`)
45
45
  - Do not include full API documentation. Link to separate docs instead.
46
46
  - Do not include license text. Reference the `LICENSE` file.
47
47
  - Do not include detailed contribution guidelines. Reference `CONTRIBUTING.md`.
@@ -49,6 +49,25 @@ Scoped to the README at a repository root. A nested README documenting a folder,
49
49
  - A README that points to the project's own context entries instead of restating them satisfies the required list by routing rather than carrying. A repository documented in `docs/` or `canon/context/` names the entry point and the listing command, then stops. The reader is one hop away, the same way `## Badges` treats zero as complete.
50
50
  - A README missing an H1, or whose headings restate the tool that scaffolded it rather than the project, is unedited generator output rather than a page anyone wrote. Draft over it. Nothing here asks a session to preserve a section a scaffold wrote and nobody replaced.
51
51
 
52
+ ### Header block
53
+
54
+ The opening of a root README is a block of up to six elements in a fixed order. Every element is read off the repository rather than written from scratch, and each one is a slot: a project that holds nothing for it leaves it out.
55
+
56
+ 1. Mark: the project's logo or icon, from an image the repository already commits. Omit it when the project has none.
57
+ 2. Title: the H1, naming the project.
58
+ 3. Badges: the block `### Badges` below governs, placed directly under the title.
59
+ 4. Claim: one line saying what the project does, taken from the manifest description or the repository description.
60
+ 5. Live link: the URL of the running surface, from a `homepage` field or a deploy config. Omit it when the project has no page or no known URL.
61
+ 6. Product screenshot: an image the project already commits and references, shown with alt text naming what it depicts. Present only when the project has a page. Omit it when no such image exists rather than writing a placeholder path.
62
+
63
+ The description of 2-3 sentences follows the block and expands the claim rather than repeating it.
64
+
65
+ - A project with no page omits the link and the screenshot. A library or a CLI reads as a complete block with a title, badges, and a claim.
66
+ - The order is the block's rather than the page's. The screenshot sits last inside the block, which puts it in the first row of content a reader meets below the title, badges, and claim.
67
+ - A themed light and dark pair counts as one element in the slot rather than two. The block does not require a pair and leaves the handling of themes to the project.
68
+ - Centering the block in an HTML wrapper is optional. Plain markdown reaches every host a README renders on, and a wrapper is styling a project can add.
69
+ - Never invent an element. A mark or a screenshot the project does not hold stays out of the page.
70
+
52
71
  ### Badges
53
72
 
54
73
  A badge earns its place by carrying a fact the reader cannot get from the page they are already on. Three classes pass that test.