@erclx/canon 4.8.1 → 4.9.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.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * The design system's one source of values.
3
+ *
4
+ * `.claude/DESIGN.md` is rendered from this module rather than read by it, so
5
+ * the document a person opens is a view and this file is the fact. Every
6
+ * rendering surface reads from here in the form it can take: a CSS surface
7
+ * takes custom properties through `@/design/css`, and the slide renderer takes
8
+ * bare hex through `bareHex` below, because PowerPoint has no concept of a
9
+ * custom property.
10
+ *
11
+ * The two artifacts can disagree, which is what the `design` gate stage exists
12
+ * to catch. Nothing else compares them.
13
+ */
14
+
15
+ /** A color role, its purpose, and the value every surface renders it at. */
16
+ export interface ColorToken {
17
+ readonly role: string
18
+ readonly intent: string
19
+ readonly value: string
20
+ /**
21
+ * Roles this one is rendered on top of, named rather than spelled so a ground
22
+ * moving carries every reading measured against it. Empty on a ground itself
23
+ * and on a role with no hex value to measure.
24
+ */
25
+ readonly grounds?: readonly string[]
26
+ /**
27
+ * No rendering surface exercises this value yet, so it is a declaration the
28
+ * system has not tested rather than one it has. Rendered as `? verify`.
29
+ */
30
+ readonly verify?: boolean
31
+ }
32
+
33
+ export interface TypeToken {
34
+ readonly role: string
35
+ readonly family: string
36
+ readonly weight: string
37
+ readonly size: string
38
+ readonly lineHeight: string
39
+ readonly verify?: readonly ('family' | 'weight' | 'size' | 'lineHeight')[]
40
+ }
41
+
42
+ export interface SpaceToken {
43
+ readonly step: string
44
+ readonly multiplier: string
45
+ readonly value: string
46
+ }
47
+
48
+ export interface BorderToken {
49
+ readonly role: string
50
+ readonly radius: string
51
+ readonly width: string
52
+ readonly when: string
53
+ readonly verify?: readonly ('radius' | 'width')[]
54
+ }
55
+
56
+ export interface DesignTokens {
57
+ readonly personality: string
58
+ readonly color: readonly ColorToken[]
59
+ readonly colorNote: string
60
+ readonly typography: readonly TypeToken[]
61
+ readonly typographyNote: string
62
+ readonly spacing: readonly SpaceToken[]
63
+ readonly spacingNote: string
64
+ readonly borders: readonly BorderToken[]
65
+ readonly bordersNote: string
66
+ readonly motion: string
67
+ readonly iconography: string
68
+ readonly preamble: string
69
+ }
70
+
71
+ /** The one monospace stack every surface that renders text declares. */
72
+ const MONO = 'Noto Sans Mono, DejaVu Sans Mono, monospace'
73
+
74
+ const DARK_GROUNDS = ['background', 'surface'] as const
75
+ const LIGHT_GROUNDS = ['light-background', 'light-surface'] as const
76
+
77
+ export const TOKENS: DesignTokens = {
78
+ preamble: [
79
+ 'This document is rendered from `src/design/tokens.ts` by `canon design regen`, and the `design` stage of `bun run check` fails when the two disagree. Edit the module, never this file.',
80
+ '',
81
+ 'The values below are the system rather than a reading of one. Until 2026-09-01 this record transcribed two surfaces and agreed with nothing else, which is what made a change to it reach nobody. The slide theme, the token preview, and a teach workspace stylesheet now read the module this file is rendered from, so a value changed there changes what all three render.',
82
+ '',
83
+ 'Two surfaces still carry their own copies. The rendered hero at `assets/hero.html` is written by `scripts/core/regen-hero.sh` against a committed capture, and the terminal framing in `scripts/lib/ui.sh` and `src/ui.ts` writes ANSI rather than hex. The dark half below is the palette the hero carries, so the two agree today by value and not yet by construction.',
84
+ ].join('\n'),
85
+
86
+ personality: [
87
+ 'Warm neutrals carry the frame under a single rust accent, rendered in the same monospace the terminal uses. The subject picks the register rather than taste: a toolkit whose primary surface is a shell has no proportional voice available, so the rendered surfaces match the terminal instead of the reverse. One accent carries every count, link, and primary action. Promoting a second and third into structural roles is what reads as a generated interface, so the palette stays at one.',
88
+ ].join('\n'),
89
+
90
+ colorNote: [
91
+ 'Every role clears WCAG AA at 4.5:1 against each ground it declares, asserted in `src/design/contrast.test.ts`. Two corrections landed with this record becoming the source. The light `muted` step moved from `#7A736A`, which read 4.38 and 4.09 against the two light grounds, and the dark `accent` moved off the `#C8602E` the slide theme carried, which read 4.36 and 3.99 against the two dark ones. Both now sit on the values below.',
92
+ '',
93
+ 'Success, warning, and error hold ANSI codes because that is what `scripts/lib/ui.sh` writes, and no rendered surface implements an equivalent. Giving them a hex value would invent a mapping no file has, so they carry no contrast reading either.',
94
+ ].join('\n'),
95
+
96
+ color: [
97
+ {
98
+ role: 'background',
99
+ intent: 'page canvas',
100
+ value: '#191512',
101
+ },
102
+ {
103
+ role: 'surface',
104
+ intent: 'cards, panels, raised blocks',
105
+ value: '#211c19',
106
+ },
107
+ {
108
+ role: 'border',
109
+ intent: 'every rule and panel edge',
110
+ value: '#2f2823',
111
+ },
112
+ {
113
+ role: 'text',
114
+ intent: 'headings, counts, emphasized runs',
115
+ value: '#f4efe9',
116
+ grounds: DARK_GROUNDS,
117
+ },
118
+ {
119
+ role: 'text-body',
120
+ intent: 'default body copy',
121
+ value: '#c9c0b7',
122
+ grounds: DARK_GROUNDS,
123
+ },
124
+ {
125
+ role: 'text-secondary',
126
+ intent: 'labels, captions, supporting copy',
127
+ value: '#a79d94',
128
+ grounds: DARK_GROUNDS,
129
+ },
130
+ {
131
+ role: 'muted',
132
+ intent: 'the faintest step, trailing notes',
133
+ value: '#948a81',
134
+ grounds: DARK_GROUNDS,
135
+ },
136
+ {
137
+ role: 'accent',
138
+ intent: 'install command, mark, primary action',
139
+ value: '#e0724b',
140
+ grounds: DARK_GROUNDS,
141
+ },
142
+ { role: 'success', intent: 'terminal confirmations', value: 'ANSI 32' },
143
+ { role: 'warning', intent: 'terminal cautions', value: 'ANSI 33' },
144
+ { role: 'error', intent: 'terminal failures', value: 'ANSI 31' },
145
+ {
146
+ role: 'light-background',
147
+ intent: 'page canvas on a light ground',
148
+ value: '#faf7f2',
149
+ },
150
+ {
151
+ role: 'light-surface',
152
+ intent: 'cards and panels on a light ground',
153
+ value: '#f4efe6',
154
+ },
155
+ {
156
+ role: 'light-text',
157
+ intent: 'primary text on a light ground',
158
+ value: '#1a1815',
159
+ grounds: LIGHT_GROUNDS,
160
+ },
161
+ {
162
+ role: 'light-muted',
163
+ intent: 'secondary text on a light ground',
164
+ value: '#726b62',
165
+ grounds: LIGHT_GROUNDS,
166
+ },
167
+ {
168
+ role: 'light-accent',
169
+ intent: 'links and primary action on light',
170
+ value: '#a4471c',
171
+ grounds: LIGHT_GROUNDS,
172
+ },
173
+ {
174
+ role: 'light-border',
175
+ intent: 'rules and panel edges on light',
176
+ value: '#e4dcd0',
177
+ verify: true,
178
+ },
179
+ ],
180
+
181
+ typographyNote: [
182
+ 'One family covers every role. The size scale runs from 11.5 to 34 pixels across ten values, and five of them map onto a role. The other five are adjustments inside a single component and get no role here, since a scale with five invented steps reads as a system the surfaces do not implement. They are 11.5, 12.5, 13, 14, and 15 pixels.',
183
+ '',
184
+ 'A tagged cell is one no rendering surface exercises yet, which is a declaration the system has not tested rather than one it has.',
185
+ '',
186
+ 'Two rules set tracking and no others touch it. The label role carries `0.05em`, and the display role tightens to `-0.01em`.',
187
+ ].join('\n'),
188
+
189
+ typography: [
190
+ {
191
+ role: 'display',
192
+ family: MONO,
193
+ weight: '700',
194
+ size: '34px',
195
+ lineHeight: '1.3',
196
+ },
197
+ {
198
+ role: 'heading',
199
+ family: MONO,
200
+ weight: '700',
201
+ size: '19px',
202
+ lineHeight: '1.3',
203
+ verify: ['lineHeight'],
204
+ },
205
+ {
206
+ role: 'body',
207
+ family: MONO,
208
+ weight: '400',
209
+ size: '16px',
210
+ lineHeight: '1.65',
211
+ verify: ['weight'],
212
+ },
213
+ {
214
+ role: 'label',
215
+ family: MONO,
216
+ weight: '400',
217
+ size: '12px',
218
+ lineHeight: '1.45',
219
+ verify: ['weight', 'lineHeight'],
220
+ },
221
+ {
222
+ role: 'code',
223
+ family: MONO,
224
+ weight: '700',
225
+ size: '14.5px',
226
+ lineHeight: '1.3',
227
+ verify: ['lineHeight'],
228
+ },
229
+ ],
230
+
231
+ spacingNote: [
232
+ 'The base is six pixels, which is the largest unit dividing the values that recur: 6, 12, 18, 24, and 30. One-off paddings at 9, 10, 11, 13, 14, 16, 22, 26, 34, and 40 pixels sit off the scale entirely and get no step.',
233
+ '',
234
+ 'The outer window padding is a single declaration reading `44px 52px 38px`, and none of its three values divides by six. They carry no multiplier for that reason, and one declaration setting all three is the only thing grouping them, so they are a frame register rather than a scale.',
235
+ ].join('\n'),
236
+
237
+ spacing: [
238
+ { step: 'xs', multiplier: '1', value: '6px' },
239
+ { step: 'sm', multiplier: '2', value: '12px' },
240
+ { step: 'md', multiplier: '3', value: '18px' },
241
+ { step: 'lg', multiplier: '4', value: '24px' },
242
+ { step: 'xl', multiplier: '5', value: '30px' },
243
+ { step: 'frame-top', multiplier: 'none', value: '44px' },
244
+ { step: 'frame-inline', multiplier: 'none', value: '52px' },
245
+ { step: 'frame-bottom', multiplier: 'none', value: '38px' },
246
+ ],
247
+
248
+ bordersNote: [
249
+ 'Every border is one pixel solid at the `border` role, and that value appears in no text role. Three radii appear, and the two blocks carrying the largest and smallest have no border at all, so radius and width are independent here rather than paired. Nothing renders a pill, so both of its cells stay tagged.',
250
+ ].join('\n'),
251
+
252
+ borders: [
253
+ {
254
+ role: 'frame',
255
+ radius: '12px',
256
+ width: 'none',
257
+ when: 'the outer window, radius only',
258
+ },
259
+ {
260
+ role: 'panel',
261
+ radius: '10px',
262
+ width: '1px',
263
+ when: 'cards and columns',
264
+ },
265
+ {
266
+ role: 'action',
267
+ radius: '7px',
268
+ width: 'none',
269
+ when: 'the install command block',
270
+ },
271
+ {
272
+ role: 'rule',
273
+ radius: 'none',
274
+ width: '1px',
275
+ when: 'horizontal dividers between bands',
276
+ },
277
+ {
278
+ role: 'pill',
279
+ radius: '999px',
280
+ width: 'none',
281
+ when: 'tags and status chips, none built',
282
+ verify: ['radius', 'width'],
283
+ },
284
+ {
285
+ role: 'marker',
286
+ radius: '999px',
287
+ width: 'none',
288
+ when: 'the status dot, sized at 6px',
289
+ },
290
+ ],
291
+
292
+ motion:
293
+ 'Motion is not used. No transition, animation, or keyframe declaration appears on any rendered surface, and the capture pipeline screenshots a static frame.',
294
+
295
+ iconography:
296
+ 'No icon library is installed. The surfaces draw literal glyph characters, `✦` for the hero mark and `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing, and a custom icon has no place to load from.',
297
+ }
298
+
299
+ /** A role's value, or `undefined` where the record declares no such role. */
300
+ export function colorValue(role: string): string | undefined {
301
+ return TOKENS.color.find((token) => token.role === role)?.value
302
+ }
303
+
304
+ /**
305
+ * The adapter the slide renderer takes. `PptxGenJS` receives color as
306
+ * `{ color: theme.background }` and wants six hex digits with no leading `#`,
307
+ * so the shared thing is the value and this is the per-consumer form.
308
+ *
309
+ * The hex is raised to upper case because that is the spelling
310
+ * `src/slides/styles.ts` has always written, and the only one a diff of a
311
+ * rendered deck reads cleanly against.
312
+ */
313
+ export function bareHex(value: string): string {
314
+ return value.replace(/^#/, '').toUpperCase()
315
+ }
@@ -209,6 +209,34 @@ export const STAGES: readonly Stage[] = [
209
209
  ],
210
210
  success: 'Tooling paths clean',
211
211
  },
212
+ {
213
+ // `.claude/DESIGN.md` and the base stylesheet are both written from
214
+ // `src/design/tokens.ts` and neither is edited by hand. Two artifacts from
215
+ // one source is the cost of the token move, and a render step that has to
216
+ // run is only safe while something fails when it did not, which is this.
217
+ id: 'design',
218
+ label: 'Design',
219
+ checks: [
220
+ {
221
+ kind: 'cli',
222
+ argv: ['design', 'regen'],
223
+ failure: 'Design regen failed',
224
+ },
225
+ {
226
+ kind: 'drift',
227
+ pathspec: '.claude/DESIGN.md',
228
+ failure:
229
+ 'The design record drifted from the token source. Run bun run check and commit .claude/DESIGN.md.',
230
+ },
231
+ {
232
+ kind: 'drift',
233
+ pathspec: 'src/design/base.css',
234
+ failure:
235
+ 'The base stylesheet drifted from the token source. Run bun run check and commit src/design/base.css.',
236
+ },
237
+ ],
238
+ success: 'Design source clean',
239
+ },
212
240
  {
213
241
  // The claude manifest is the only route a target's ignore set travels, and
214
242
  // it is hand-maintained beside this repository's own `.gitignore` with
@@ -1,3 +1,5 @@
1
+ import { bareHex, colorValue } from '@/design/tokens'
2
+
1
3
  export type Variant = 'light' | 'dark'
2
4
 
3
5
  export interface Theme {
@@ -8,20 +10,17 @@ export interface Theme {
8
10
  accent: string
9
11
  }
10
12
 
11
- const LIGHT: Theme = {
12
- background: 'FAF7F2',
13
- surface: 'F4EFE6',
14
- ink: '1A1815',
15
- muted: '7A736A',
16
- accent: 'A4471C',
17
- }
18
-
19
- const DARK: Theme = {
20
- background: '1A1815',
21
- surface: '23201C',
22
- ink: 'F4EFE6',
23
- muted: 'A39C92',
24
- accent: 'C8602E',
13
+ /**
14
+ * Which role in `@/design/tokens` fills each slot of a slide theme. The names
15
+ * differ because a deck names a slot by what sits in it and the record names a
16
+ * role by what it is for, and mapping the two here is the whole of the adapter.
17
+ */
18
+ const ROLES: Record<keyof Theme, readonly [dark: string, light: string]> = {
19
+ background: ['background', 'light-background'],
20
+ surface: ['surface', 'light-surface'],
21
+ ink: ['text', 'light-text'],
22
+ muted: ['text-secondary', 'light-muted'],
23
+ accent: ['accent', 'light-accent'],
25
24
  }
26
25
 
27
26
  export const FONTS = {
@@ -39,6 +38,34 @@ export const TYPE = {
39
38
  caption: 11,
40
39
  } as const
41
40
 
41
+ /**
42
+ * Builds a theme from the design source in the form this renderer can take.
43
+ *
44
+ * `src/slides/render.ts` hands color to `PptxGenJS` as `{ color: theme.background }`
45
+ * and PowerPoint has no concept of a custom property, so the shared thing is the
46
+ * value and bare hex is the per-consumer form. Every CSS surface takes the same
47
+ * values through `@/design/css` instead.
48
+ *
49
+ * A role the record does not declare throws rather than rendering a slide in a
50
+ * default nobody chose, since a missing color reaches a reader as a deck that
51
+ * looks wrong with nothing saying why.
52
+ */
42
53
  export function buildTheme(variant: Variant): Theme {
43
- return variant === 'dark' ? DARK : LIGHT
54
+ const pick = (slot: keyof Theme): string => {
55
+ const role = ROLES[slot][variant === 'dark' ? 0 : 1]
56
+ const value = colorValue(role)
57
+ if (value === undefined) {
58
+ throw new Error(`The design record declares no ${role} role`)
59
+ }
60
+
61
+ return bareHex(value)
62
+ }
63
+
64
+ return {
65
+ background: pick('background'),
66
+ surface: pick('surface'),
67
+ ink: pick('ink'),
68
+ muted: pick('muted'),
69
+ accent: pick('accent'),
70
+ }
44
71
  }
package/src/sync/check.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { basename, join, sep } from 'node:path'
3
3
  import { execa } from 'execa'
4
4
  import { gitEnv } from '@/git-env'
5
+ import { createDesignAdapter, DESIGN_INSTALL_DIR } from '@/design/adapter'
5
6
  import { createGovAdapter, rulesSourceDir } from '@/gov/adapter'
6
7
  import { loadGovStack, resolveMissingRules, resolveRules } from '@/gov/stacks'
7
8
  import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
@@ -36,9 +37,16 @@ import { readSkew, type SkewReport } from '@/version/skew'
36
37
  * three lookups below have no entry to offer it. Standards and snippets left
37
38
  * the list with their install channels: nothing writes either corpus into a
38
39
  * target, so there is no installed copy to attribute.
40
+ *
41
+ * Design joins as a scanned domain rather than a stamp-only one because its
42
+ * base file is attributed the same way a rule is, and its install marker is
43
+ * what keeps it off a target that never asked for it: a project with no
44
+ * `.claude/design/` is never scanned, so design values arrive on an install
45
+ * rather than on the next sync.
39
46
  */
40
47
  export const SCANNED_DOMAINS = [
41
48
  'governance',
49
+ 'design',
42
50
  ] as const satisfies readonly StampDomain[]
43
51
 
44
52
  export type ScannedDomain = (typeof SCANNED_DOMAINS)[number]
@@ -50,14 +58,17 @@ export type ScannedDomain = (typeof SCANNED_DOMAINS)[number]
50
58
  */
51
59
  const SYNCED_SOURCES: Record<ScannedDomain, string> = {
52
60
  governance: 'governance/rules/',
61
+ design: 'src/design/',
53
62
  }
54
63
 
55
64
  const ADAPTERS: Record<ScannedDomain, (root: string) => SyncAdapter> = {
56
65
  governance: createGovAdapter,
66
+ design: createDesignAdapter,
57
67
  }
58
68
 
59
69
  const INSTALL_MARKERS: Record<ScannedDomain, readonly string[]> = {
60
70
  governance: ['.claude', 'rules'],
71
+ design: DESIGN_INSTALL_DIR.split(sep),
61
72
  }
62
73
 
63
74
  export interface StateCounts {
@@ -187,6 +198,36 @@ export interface CheckReport {
187
198
  readonly skew: SkewReport
188
199
  }
189
200
 
201
+ /**
202
+ * Scanned domains a target takes deliberately rather than by being managed.
203
+ *
204
+ * An absent one is a choice, so it is never reported as unstamped. Governance
205
+ * wants the opposite reading, since a managed target without it has yet to
206
+ * install what every project is expected to carry, and naming it is the only
207
+ * place that shows up.
208
+ */
209
+ export const OPT_IN_DOMAINS: readonly ScannedDomain[] = ['design']
210
+
211
+ /**
212
+ * Scanned domains this target should have stamped and has not, which is the
213
+ * one line a domain nobody installed ever appears on.
214
+ *
215
+ * An unmigrated domain is excluded because the relocation is its remedy rather
216
+ * than a sync, and an opt-in domain the target does not hold is excluded
217
+ * because there is nothing there to stamp and the sync it would name refuses.
218
+ */
219
+ export function uncoveredDomains(report: CheckReport): ScannedDomain[] {
220
+ const unmigrated = new Set(report.unmigrated.map((entry) => entry.domain))
221
+ const installed = new Set(report.domains.map((entry) => entry.domain))
222
+
223
+ return SCANNED_DOMAINS.filter(
224
+ (domain) =>
225
+ !report.covers.includes(domain) &&
226
+ !unmigrated.has(domain) &&
227
+ (!OPT_IN_DOMAINS.includes(domain) || installed.has(domain)),
228
+ )
229
+ }
230
+
190
231
  export function installedStampDomains(target: string): ScannedDomain[] {
191
232
  return SCANNED_DOMAINS.filter((domain) =>
192
233
  isDirectory(join(target, ...INSTALL_MARKERS[domain])),
@@ -139,6 +139,8 @@ export interface SyncAdapter {
139
139
  collectMissing?(target: string): RetiredSurface[]
140
140
  /** Dropped from the walk, so neither matching nor orphaned. */
141
141
  isExcluded?(file: InstalledFile): boolean
142
+ /** Glob the walk lists. Defaults to `DEFAULT_INSTALL_PATTERN`. */
143
+ readonly installPattern?: string
142
144
  /**
143
145
  * Top-level folder under `installedRoot` that is project-authored by
144
146
  * location rather than by the name inference `locateSource` runs.
@@ -158,15 +160,26 @@ export interface SyncAdapter {
158
160
  }
159
161
 
160
162
  /**
161
- * Lists installed markdown, dotfiles included. `Bun.Glob` skips entries
162
- * beginning with a dot unless `dot` is set, and every domain installs under
163
- * `.claude/`, so a nested dot-directory would silently drop out of the walk.
163
+ * What a domain installs, when it installs something other than markdown.
164
+ * Design ships a stylesheet, and every other domain ships prose.
164
165
  */
165
- export function listInstalled(root: string, target: string): InstalledFile[] {
166
+ export const DEFAULT_INSTALL_PATTERN = '**/*.md'
167
+
168
+ /**
169
+ * Lists installed files matching the domain's pattern, dotfiles included.
170
+ * `Bun.Glob` skips entries beginning with a dot unless `dot` is set, and every
171
+ * domain installs under `.claude/`, so a nested dot-directory would silently
172
+ * drop out of the walk.
173
+ */
174
+ export function listInstalled(
175
+ root: string,
176
+ target: string,
177
+ pattern: string = DEFAULT_INSTALL_PATTERN,
178
+ ): InstalledFile[] {
166
179
  if (!existsSync(root)) return []
167
180
 
168
181
  return [
169
- ...new Bun.Glob('**/*.md').scanSync({
182
+ ...new Bun.Glob(pattern).scanSync({
170
183
  cwd: root,
171
184
  onlyFiles: true,
172
185
  dot: true,
@@ -192,7 +205,11 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
192
205
  const hashes = stampedHashes(readStamp(target), adapter.stamp?.domain)
193
206
  const walked = new Set<string>()
194
207
 
195
- for (const file of listInstalled(adapter.installedRoot(target), target)) {
208
+ for (const file of listInstalled(
209
+ adapter.installedRoot(target),
210
+ target,
211
+ adapter.installPattern,
212
+ )) {
196
213
  if (adapter.isExcluded?.(file) === true) continue
197
214
  walked.add(toStampKey(file.rel))
198
215
 
@@ -562,7 +579,11 @@ export async function recordStamp(
562
579
 
563
580
  const hashes: Record<string, string> = {}
564
581
 
565
- for (const file of listInstalled(adapter.installedRoot(target), target)) {
582
+ for (const file of listInstalled(
583
+ adapter.installedRoot(target),
584
+ target,
585
+ adapter.installPattern,
586
+ )) {
566
587
  if (adapter.isExcluded?.(file) === true) continue
567
588
  if (isProjectAuthored(adapter, file)) continue
568
589
 
package/src/sync/stamp.ts CHANGED
@@ -6,16 +6,17 @@ import { execa } from 'execa'
6
6
  import { recordTarget } from '@/targets/registry'
7
7
 
8
8
  /**
9
- * Domains the stamp can record. Governance attributes file by file through the
10
- * sync engine. Tooling runs its own inject and manifest machinery, so it
11
- * records the stack chain it resolved instead and carries no file hashes.
9
+ * Domains the stamp can record. Governance and design both attribute file by
10
+ * file through the sync engine. Tooling runs its own inject and manifest
11
+ * machinery, so it records the stack chain it resolved instead and carries no
12
+ * file hashes.
12
13
  *
13
14
  * A stamp written before the standards or snippets install channel closed
14
15
  * still carries a `standards` or `snippets` record. `isStamp` ignores the key
15
16
  * and `sortDomains` drops it on the next write, so the target loses a domain
16
17
  * nothing can refresh rather than losing the whole file.
17
18
  */
18
- export const STAMP_DOMAINS = ['governance', 'tooling'] as const
19
+ export const STAMP_DOMAINS = ['governance', 'design', 'tooling'] as const
19
20
 
20
21
  export type StampDomain = (typeof STAMP_DOMAINS)[number]
21
22
 
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import { DESIGN_INSTALL_DIR } from '@/design/adapter'
3
4
  import { isDirectory } from '@/target'
4
5
 
5
- export const SYNC_DOMAINS = ['governance', 'claude'] as const
6
+ export const SYNC_DOMAINS = ['governance', 'design', 'claude'] as const
6
7
 
7
8
  export type SyncDomain = (typeof SYNC_DOMAINS)[number]
8
9
 
@@ -13,6 +14,7 @@ export interface DomainState {
13
14
 
14
15
  const DOMAIN_MARKERS: Record<SyncDomain, string> = {
15
16
  governance: join('.claude', 'rules'),
17
+ design: DESIGN_INSTALL_DIR,
16
18
  claude: '.claude',
17
19
  }
18
20
 
@@ -24,6 +26,7 @@ const DOMAIN_MARKERS: Record<SyncDomain, string> = {
24
26
  */
25
27
  const DOMAIN_PATHS: Record<SyncDomain, readonly string[]> = {
26
28
  governance: ['.claude/rules/', '.claude/GOV.md'],
29
+ design: ['.claude/design/'],
27
30
  claude: ['.gitignore'],
28
31
  }
29
32
 
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { join, relative } from 'node:path'
4
+ import { buildDesignCss } from '@/design/css'
4
5
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
6
  import { type BodyLine, bodyLines } from '@/markdown/scan'
6
7
  import { recordDir } from '@/record-root'
@@ -841,3 +842,59 @@ export async function defineTerms(
841
842
 
842
843
  return { ok: true, slug: found.slug, path, defined: terms }
843
844
  }
845
+
846
+ export interface StylesheetWritten {
847
+ readonly ok: true
848
+ readonly slug: string
849
+ /** Relative to the root, so a caller prints a path a reader can open. */
850
+ readonly path: string
851
+ /** False when the workspace already held one and this call left it alone. */
852
+ readonly written: boolean
853
+ }
854
+
855
+ export type StylesheetOutcome = StylesheetWritten | TeachRefused
856
+
857
+ const STYLESHEET_BANNER = [
858
+ 'Seeded by `canon teach stylesheet` from the design source in',
859
+ 'src/design/tokens.ts. The tokens and the two components below are the',
860
+ 'system this workspace renders in. Add lesson rules under them and read a',
861
+ 'value through its custom property rather than restating the hex, which is',
862
+ 'what let one workspace fork the palette from every other.',
863
+ ].join('\n ')
864
+
865
+ /**
866
+ * Writes a workspace's one stylesheet from the design source.
867
+ *
868
+ * Every workspace used to carry a hand-authored copy, which is how the course
869
+ * palette forked once per workspace. The name is fixed at `TEACH_STYLESHEET`
870
+ * and the folder at `TEACH_ASSETS` for the same reason a second lesson has to
871
+ * reach the file the first one wrote, and this is what puts the values in it.
872
+ *
873
+ * An existing stylesheet is left alone rather than replaced. A workspace adds
874
+ * lesson rules to this file as it goes, so overwriting would discard them, and
875
+ * `--force` is the caller saying it wants the seed back.
876
+ */
877
+ export async function writeStylesheet(
878
+ root: string,
879
+ selector: string,
880
+ force = false,
881
+ ): Promise<StylesheetOutcome> {
882
+ const found = await readWorkspace(root, selector)
883
+ if (!found.ok) return found
884
+
885
+ const workspace = found.workspace
886
+ const rel = join(workspace.path, TEACH_ASSETS, TEACH_STYLESHEET)
887
+ const path = join(root, rel)
888
+
889
+ if (existsSync(path) && !force) {
890
+ return { ok: true, slug: workspace.slug, path: rel, written: false }
891
+ }
892
+
893
+ await mkdir(join(root, workspace.path, TEACH_ASSETS), { recursive: true })
894
+ await writeFile(
895
+ path,
896
+ buildDesignCss(undefined, { banner: STYLESHEET_BANNER }),
897
+ )
898
+
899
+ return { ok: true, slug: workspace.slug, path: rel, written: true }
900
+ }