@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-teach/SKILL.md +11 -1
- package/docs/agents/commands.md +7 -2
- package/docs/agents/install-and-sync.md +23 -1
- package/docs/agents/teach.md +2 -0
- package/docs/visual-design-workflow.md +2 -0
- package/package.json +1 -1
- package/src/commands/design.ts +147 -5
- package/src/commands/sync.ts +3 -5
- package/src/commands/teach.ts +72 -0
- package/src/design/adapter.ts +59 -0
- package/src/design/base.css +123 -0
- package/src/design/components.ts +118 -0
- package/src/design/contrast.ts +81 -0
- package/src/design/css.ts +142 -0
- package/src/design/document.ts +143 -0
- package/src/design/regen.ts +56 -0
- package/src/design/render.ts +43 -9
- package/src/design/tokens.ts +315 -0
- package/src/gate/stages.ts +28 -0
- package/src/slides/styles.ts +42 -15
- package/src/sync/check.ts +41 -0
- package/src/sync/engine.ts +28 -7
- package/src/sync/stamp.ts +5 -4
- package/src/sync/target.ts +4 -1
- package/src/teach/workspace.ts +57 -0
|
@@ -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
|
+
}
|
package/src/gate/stages.ts
CHANGED
|
@@ -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
|
package/src/slides/styles.ts
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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])),
|
package/src/sync/engine.ts
CHANGED
|
@@ -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
|
-
*
|
|
162
|
-
*
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
10
|
-
* sync engine. Tooling runs its own inject and manifest
|
|
11
|
-
* records the stack chain it resolved instead and carries no
|
|
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
|
|
package/src/sync/target.ts
CHANGED
|
@@ -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
|
|
package/src/teach/workspace.ts
CHANGED
|
@@ -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
|
+
}
|