@erclx/canon 4.8.1 → 4.9.1

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.
@@ -2,6 +2,7 @@ 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 { colorValue } from '@/design/tokens'
5
6
 
6
7
  export interface RenderResult {
7
8
  htmlPath: string
@@ -135,6 +136,38 @@ function escape(s: string): string {
135
136
  .replace(/"/g, '"')
136
137
  }
137
138
 
139
+ /**
140
+ * The preview page's own chrome, read off the toolkit's design source rather
141
+ * than written as literals here.
142
+ *
143
+ * It takes a `--preview-` prefix rather than the `--color-` one the sheet beside
144
+ * it emits, because that sheet is built from whichever document is being
145
+ * previewed. A target's record is free to declare a role this page also uses,
146
+ * and sharing one name would let the page being previewed repaint the page
147
+ * doing the previewing.
148
+ *
149
+ * The light roles are the ones read, since the preview is a light document.
150
+ */
151
+ function previewChrome(): string {
152
+ const roles: ReadonlyArray<readonly [string, string]> = [
153
+ ['paper', 'light-background'],
154
+ ['panel', 'light-surface'],
155
+ ['ink', 'light-text'],
156
+ ['muted', 'light-muted'],
157
+ ['rule', 'light-border'],
158
+ ['accent', 'light-accent'],
159
+ ]
160
+
161
+ const lines = roles
162
+ .map(([name, role]) => {
163
+ const value = colorValue(role)
164
+ return value === undefined ? '' : ` --preview-${name}: ${value};`
165
+ })
166
+ .filter((line) => line !== '')
167
+
168
+ return [' :root {', ...lines, ' }'].join('\n')
169
+ }
170
+
138
171
  function buildHtml(doc: DesignDoc): string {
139
172
  const sections = [
140
173
  sectionPersonality(doc.personality),
@@ -147,7 +180,7 @@ function buildHtml(doc: DesignDoc): string {
147
180
  ]
148
181
  const { tagged, total } = confidence(doc)
149
182
  const verifyStyle = tagged
150
- ? '\n .verify { color: #a4471c; font-size: 12px; font-weight: 600; margin-left: 0.35rem; white-space: nowrap; }'
183
+ ? '\n .verify { color: var(--preview-accent); font-size: 12px; font-weight: 600; margin-left: 0.35rem; white-space: nowrap; }'
151
184
  : ''
152
185
  const verb = tagged === 1 ? 'carries' : 'carry'
153
186
  const summary = tagged
@@ -160,16 +193,17 @@ function buildHtml(doc: DesignDoc): string {
160
193
  <title>Design tokens</title>
161
194
  <link rel="stylesheet" href="design.css">
162
195
  <style>
163
- body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 960px; color: #222; }
196
+ ${previewChrome()}
197
+ body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 960px; color: var(--preview-ink); background: var(--preview-paper); }
164
198
  h1 { margin-top: 0; }
165
- h2 { margin-top: 2rem; border-bottom: 1px solid #ddd; padding-bottom: 0.25rem; }
199
+ h2 { margin-top: 2rem; border-bottom: 1px solid var(--preview-rule); padding-bottom: 0.25rem; }
166
200
  table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }
167
- th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #eee; font-size: 14px; }
168
- th { background: #f7f7f7; font-weight: 600; }
169
- .swatch { display: inline-block; width: 1.5rem; height: 1.5rem; border-radius: 4px; border: 1px solid #ddd; vertical-align: middle; margin-right: 0.5rem; }
170
- .bar { display: inline-block; height: 1rem; background: #888; border-radius: 2px; vertical-align: middle; }
171
- .note { color: #666; font-size: 13px; margin-top: 0.5rem; }
172
- .empty { color: #999; font-style: italic; }${verifyStyle}
201
+ th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--preview-rule); font-size: 14px; }
202
+ th { background: var(--preview-panel); font-weight: 600; }
203
+ .swatch { display: inline-block; width: 1.5rem; height: 1.5rem; border-radius: 4px; border: 1px solid var(--preview-rule); vertical-align: middle; margin-right: 0.5rem; }
204
+ .bar { display: inline-block; height: 1rem; background: var(--preview-muted); border-radius: 2px; vertical-align: middle; }
205
+ .note { color: var(--preview-muted); font-size: 13px; margin-top: 0.5rem; }
206
+ .empty { color: var(--preview-muted); font-style: italic; }${verifyStyle}
173
207
  </style>
174
208
  </head>
175
209
  <body>
@@ -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
@@ -10,7 +10,12 @@
10
10
 
11
11
  import { existsSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
- import { type RecordRoot, RECORD_ENTRIES, spell } from '@/record-root'
13
+ import {
14
+ type RecordRoot,
15
+ RECORD_ENTRIES,
16
+ RECORD_ROOTS,
17
+ spell,
18
+ } from '@/record-root'
14
19
 
15
20
  /** The root the entries below leave, exported so the writer can prune it. */
16
21
  export const FROM_ROOT: RecordRoot = '.claude'
@@ -104,6 +109,66 @@ export function isExcludedPath(path: string): boolean {
104
109
  return EXCLUDED_SUFFIXES.some((suffix) => path.endsWith(suffix))
105
110
  }
106
111
 
112
+ /**
113
+ * The roots holding nothing but records, so a path under one is a record
114
+ * whatever it is named.
115
+ *
116
+ * `.canon/` qualifies by construction. `.claude/ARCHITECTURE.md` fixes the rule
117
+ * that every gitignored session record moves there and nothing tracked ever
118
+ * lands there, which covers a record folder `RECORD_ENTRIES` has yet to learn
119
+ * about. The old root is the one that cannot take a whole-root reading, and it
120
+ * is derived by exclusion rather than named, so a third root added later reads
121
+ * as records-only unless someone says otherwise.
122
+ */
123
+ const RECORD_ONLY_ROOTS: readonly RecordRoot[] = RECORD_ROOTS.filter(
124
+ (root) => root !== FROM_ROOT,
125
+ )
126
+
127
+ /**
128
+ * Every prefix under which a path is a record rather than a file to sweep.
129
+ *
130
+ * The asymmetry is the point. A whole-root prefix is correct for the new root
131
+ * and wrong for the old one, which is mixed: this repository tracks 163 files
132
+ * under `.claude/`, and a target's installed `.claude/rules/core/035-tasks.md`
133
+ * is the file the sweep exists to repoint, so a bare `.claude/` prefix strands
134
+ * it silently. The old root is therefore entry-scoped, through `spell` so the
135
+ * one naming variant stays decided in `record-root.ts`.
136
+ *
137
+ * Joined with a literal separator rather than through `join`, the way
138
+ * `EXCLUDED_PREFIXES` already is. These are matched against what `git ls-files`
139
+ * returns, which is forward-slashed on every platform, where `join` would spell
140
+ * a backslash on Windows and match nothing.
141
+ */
142
+ const RECORD_PREFIXES: readonly string[] = [
143
+ ...RECORD_ONLY_ROOTS,
144
+ ...RECORD_ENTRIES.map((entry) => `${FROM_ROOT}/${spell(FROM_ROOT, entry)}`),
145
+ ]
146
+
147
+ /**
148
+ * Whether a path is a record artifact, which the sweep passes over entirely.
149
+ *
150
+ * Separate from `isExcludedPath`, which reports what it skips because a reader
151
+ * has to check those by hand. A record artifact is never something to check,
152
+ * and a target's record tree is large enough that reporting each one would bury
153
+ * the handful of exclusions that matter.
154
+ *
155
+ * A record folder becomes visible to the sweep at the moment `canon tooling
156
+ * sync claude` prunes the twelve old ignore entries down to one `.canon/` line,
157
+ * which is the step the documented first-run order puts immediately before this
158
+ * verb. Without this predicate the run that follows reads the memory pen and
159
+ * the groundwork trails as source and rewrites them.
160
+ *
161
+ * The three retired flat archives stay outside this, the way `CITATION` already
162
+ * leaves them alone: `.claude/plans-archive/x.md` does not start with
163
+ * `.claude/plans/`, and widening the entry list to catch it would change what
164
+ * `MOVED_ENTRIES` means for the folder half of the verb.
165
+ */
166
+ export function isRecordArtifact(path: string): boolean {
167
+ return RECORD_PREFIXES.some(
168
+ (prefix) => path === prefix || path.startsWith(`${prefix}/`),
169
+ )
170
+ }
171
+
107
172
  /**
108
173
  * Marks a line naming the old root on purpose.
109
174
  *
@@ -244,6 +309,11 @@ export function planRecordsMove(
244
309
  let kept = 0
245
310
 
246
311
  for (const source of sources) {
312
+ // Silently, and ahead of the exclusion test. The command boundary filters
313
+ // these out before it reads them, so this is what keeps the pure function
314
+ // correct under a direct call rather than what the verb relies on.
315
+ if (isRecordArtifact(source.path)) continue
316
+
247
317
  if (isExcludedPath(source.path)) {
248
318
  // Only an excluded file that actually carries a citation is reported. The
249
319
  // predicate covers every test file in the tree, so counting them all would
@@ -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])),