@erclx/canon 4.8.0 → 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.
Files changed (41) hide show
  1. package/README.md +55 -41
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/claude-docs/REQUIREMENT.md +2 -2
  4. package/claude/skills/claude-docs/SKILL.md +7 -36
  5. package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +13 -8
  6. package/claude/skills/claude-tasks/SKILL.md +4 -5
  7. package/claude/skills/claude-teach/SKILL.md +11 -1
  8. package/claude/skills/claude-worker/SKILL.md +2 -2
  9. package/docs/agents/commands.md +8 -3
  10. package/docs/agents/install-and-sync.md +23 -1
  11. package/docs/agents/overview.md +3 -3
  12. package/docs/agents/tasks.md +10 -4
  13. package/docs/agents/teach.md +2 -0
  14. package/docs/ai-workflow.md +2 -4
  15. package/docs/visual-design-workflow.md +2 -0
  16. package/docs/zshrc-aliases.md +19 -7
  17. package/package.json +1 -1
  18. package/scripts/core/bootstrap.sh +4 -0
  19. package/src/commands/design.ts +147 -5
  20. package/src/commands/sync.ts +3 -5
  21. package/src/commands/tasks.ts +18 -1
  22. package/src/commands/teach.ts +72 -0
  23. package/src/design/adapter.ts +59 -0
  24. package/src/design/base.css +123 -0
  25. package/src/design/components.ts +118 -0
  26. package/src/design/contrast.ts +81 -0
  27. package/src/design/css.ts +142 -0
  28. package/src/design/document.ts +143 -0
  29. package/src/design/regen.ts +56 -0
  30. package/src/design/render.ts +43 -9
  31. package/src/design/tokens.ts +315 -0
  32. package/src/gate/stages.ts +28 -0
  33. package/src/slides/styles.ts +42 -15
  34. package/src/sync/check.ts +41 -0
  35. package/src/sync/engine.ts +28 -7
  36. package/src/sync/stamp.ts +5 -4
  37. package/src/sync/target.ts +4 -1
  38. package/src/tasks/archive.ts +91 -20
  39. package/src/tasks/validate.ts +76 -0
  40. package/src/teach/workspace.ts +57 -0
  41. package/standards/tasks.md +4 -4
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The component layer, beside the token layer in `@/design/tokens`.
3
+ *
4
+ * A token says what a value is and a component says what a repeated piece of
5
+ * interface is made of. The two repairs that produced this layer are both cases
6
+ * where the tokens were already right and the surface still read as unrelated
7
+ * to itself, so a token file alone would not have caught either.
8
+ *
9
+ * It has two members and it is provisional at two. Two repairs is thin evidence
10
+ * for an abstraction, and the honest position is a layer that says it has two
11
+ * rather than one padded out with speculative members to look settled. A third
12
+ * real instance is what decides whether the shape below holds.
13
+ *
14
+ * It does not live in `.claude/DESIGN.md`. `standards/design.md` keeps CSS class
15
+ * names out of that record and says they live in code, which is here.
16
+ */
17
+
18
+ export interface Component {
19
+ readonly name: string
20
+ /** Why the component exists, carried into the emitted stylesheet as a comment. */
21
+ readonly note: string
22
+ /** Custom properties this component reads, so a consumer can check it has them. */
23
+ readonly reads: readonly string[]
24
+ readonly rules: string
25
+ }
26
+
27
+ /**
28
+ * A dot and a word, never a bordered chip. The jump menus already said written
29
+ * and planned with a filled or hollow dot, so a status badge that spelled the
30
+ * same fact in uppercase inside a border was a second vocabulary for something
31
+ * the system already had one of.
32
+ */
33
+ const STATUS: Component = {
34
+ name: 'status',
35
+ note: [
36
+ 'A dot and a word, not a pill. A bordered uppercase chip is a second',
37
+ 'vocabulary for a fact the dot already carries, so the marker is the only',
38
+ 'status shape and `.is-done` is the only variant.',
39
+ ].join('\n '),
40
+ reads: [
41
+ '--color-border',
42
+ '--color-accent',
43
+ '--color-muted',
44
+ '--radius-marker',
45
+ ],
46
+ rules: `.status {
47
+ display: inline-flex;
48
+ align-items: center;
49
+ gap: 0.42rem;
50
+ font-size: 0.75rem;
51
+ font-weight: 500;
52
+ letter-spacing: 0;
53
+ text-transform: none;
54
+ color: var(--color-muted);
55
+ white-space: nowrap;
56
+ }
57
+
58
+ .status::before {
59
+ content: '';
60
+ width: 6px;
61
+ height: 6px;
62
+ flex: none;
63
+ border-radius: var(--radius-marker);
64
+ background: var(--color-border);
65
+ }
66
+
67
+ .status.is-done::before {
68
+ background: var(--color-accent);
69
+ }`,
70
+ }
71
+
72
+ /**
73
+ * Every scrolling region, the page included. Scoping this to one component is
74
+ * what left an outline rail, code blocks, and scrolling tables on the browser
75
+ * default beside a styled sibling, which reads as two designs on one page.
76
+ */
77
+ const SCROLLBAR: Component = {
78
+ name: 'scrollbar',
79
+ note: [
80
+ 'Every scrolling region takes the same bar, the page included. Scoping it',
81
+ 'to one component leaves its neighbors on the browser default, which is',
82
+ 'what reads as two designs on one page. `scrollbar-color` covers Firefox',
83
+ 'and the `::-webkit-` rules cover the rest, both from the same two tokens',
84
+ 'so the two engines cannot drift apart.',
85
+ ].join('\n '),
86
+ reads: ['--color-border', '--color-muted', '--color-background'],
87
+ rules: `* {
88
+ scrollbar-width: thin;
89
+ scrollbar-color: var(--color-border) transparent;
90
+ }
91
+
92
+ *::-webkit-scrollbar {
93
+ width: 10px;
94
+ height: 10px;
95
+ }
96
+
97
+ *::-webkit-scrollbar-track {
98
+ background: transparent;
99
+ }
100
+
101
+ *::-webkit-scrollbar-thumb {
102
+ border-radius: var(--radius-marker);
103
+ background: var(--color-border);
104
+ /* Inset by painting a border in the page color, which is what keeps the
105
+ thumb off the edges without a second element. */
106
+ border: 3px solid var(--color-background);
107
+ }
108
+
109
+ *::-webkit-scrollbar-thumb:hover {
110
+ background: var(--color-muted);
111
+ }
112
+
113
+ *::-webkit-scrollbar-corner {
114
+ background: transparent;
115
+ }`,
116
+ }
117
+
118
+ export const COMPONENTS: readonly Component[] = [STATUS, SCROLLBAR]
@@ -0,0 +1,81 @@
1
+ import type { ColorToken } from '@/design/tokens'
2
+ import { TOKENS } from '@/design/tokens'
3
+
4
+ /** WCAG 2.1 AA for body copy. Every role a surface renders as text clears it. */
5
+ export const AA_TEXT = 4.5
6
+
7
+ /** WCAG 2.1 AA for a large or non-text element, kept as the floor a reading is read against. */
8
+ export const AA_NON_TEXT = 3
9
+
10
+ const HEX = /^#[0-9a-fA-F]{6}$/
11
+
12
+ /**
13
+ * Relative luminance per WCAG 2.1. Only six-digit hex is accepted, since the
14
+ * record also carries ANSI codes that no ratio applies to and silently reading
15
+ * one as a color would report a passing number for a value nothing renders.
16
+ */
17
+ export function luminance(hex: string): number {
18
+ if (!HEX.test(hex)) throw new Error(`Not a six-digit hex color: ${hex}`)
19
+
20
+ const channels = [1, 3, 5].map(
21
+ (start) => parseInt(hex.slice(start, start + 2), 16) / 255,
22
+ )
23
+ const linear = channels.map((channel) =>
24
+ channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
25
+ )
26
+
27
+ return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
28
+ }
29
+
30
+ /** The WCAG ratio between two colors, lighter over darker, from 1 to 21. */
31
+ export function contrastRatio(a: string, b: string): number {
32
+ const [lighter, darker] = [luminance(a), luminance(b)].sort(
33
+ (first, second) => second - first,
34
+ )
35
+
36
+ return (lighter + 0.05) / (darker + 0.05)
37
+ }
38
+
39
+ export interface Reading {
40
+ readonly role: string
41
+ readonly ground: string
42
+ readonly ratio: number
43
+ readonly passes: boolean
44
+ }
45
+
46
+ /**
47
+ * Every reading the record declares, one per role and ground pair. A role
48
+ * naming no ground contributes none, which is what keeps a ground itself and
49
+ * the three ANSI roles out of the set rather than measured against something
50
+ * they never sit on.
51
+ */
52
+ export function readings(
53
+ tokens: readonly ColorToken[] = TOKENS.color,
54
+ ): Reading[] {
55
+ const values = new Map(tokens.map((token) => [token.role, token.value]))
56
+ const out: Reading[] = []
57
+
58
+ for (const token of tokens) {
59
+ for (const ground of token.grounds ?? []) {
60
+ const behind = values.get(ground)
61
+ if (behind === undefined) {
62
+ throw new Error(`${token.role} names an unknown ground: ${ground}`)
63
+ }
64
+
65
+ const ratio = contrastRatio(token.value, behind)
66
+ out.push({
67
+ role: token.role,
68
+ ground,
69
+ ratio,
70
+ passes: ratio >= AA_TEXT,
71
+ })
72
+ }
73
+ }
74
+
75
+ return out
76
+ }
77
+
78
+ /** Readings below AA, which is the list the test asserts is empty. */
79
+ export function failing(tokens?: readonly ColorToken[]): Reading[] {
80
+ return readings(tokens).filter((reading) => !reading.passes)
81
+ }
@@ -0,0 +1,142 @@
1
+ import { COMPONENTS } from '@/design/components'
2
+ import type { DesignTokens } from '@/design/tokens'
3
+ import { TOKENS } from '@/design/tokens'
4
+
5
+ /**
6
+ * The form a CSS surface takes the source in. Custom properties carry the token
7
+ * layer and plain rules carry the component layer, which is the whole of what a
8
+ * stylesheet needs from `@/design/tokens` and `@/design/components`.
9
+ *
10
+ * The property names match the ones `@/design/render` already emits from a
11
+ * parsed document, so the toolkit's own source and a target's hand-authored
12
+ * `.claude/DESIGN.md` produce one vocabulary rather than two.
13
+ */
14
+
15
+ /** A role name as it appears in a custom property. */
16
+ export function slug(name: string): string {
17
+ return name
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, '-')
20
+ .replace(/(^-|-$)/g, '')
21
+ }
22
+
23
+ /** Only a value a browser can render becomes a property. ANSI codes do not. */
24
+ function hasHexValue(value: string): boolean {
25
+ return value !== '' && !value.startsWith('ANSI')
26
+ }
27
+
28
+ /**
29
+ * Dark role each `light-` role stands in for. Read off the names rather than
30
+ * declared twice, so a light role added to the record joins the theme block
31
+ * with no edit here.
32
+ */
33
+ function lightCounterparts(tokens: DesignTokens): Map<string, string> {
34
+ const roles = new Set(tokens.color.map((token) => token.role))
35
+ const pairs = new Map<string, string>()
36
+
37
+ for (const token of tokens.color) {
38
+ if (!token.role.startsWith('light-')) continue
39
+ const dark = token.role.slice('light-'.length)
40
+ if (roles.has(dark)) pairs.set(dark, token.role)
41
+ }
42
+
43
+ return pairs
44
+ }
45
+
46
+ /**
47
+ * Text roles with no light counterpart, named in the emitted stylesheet rather
48
+ * than filled in. Inventing a value for one would put a color in the system
49
+ * that no surface ever read off anything, which is the failure this whole
50
+ * consolidation exists to end.
51
+ */
52
+ export function unmappedOnLight(tokens: DesignTokens = TOKENS): string[] {
53
+ const pairs = lightCounterparts(tokens)
54
+
55
+ return tokens.color
56
+ .filter(
57
+ (token) =>
58
+ !token.role.startsWith('light-') &&
59
+ hasHexValue(token.value) &&
60
+ !pairs.has(token.role),
61
+ )
62
+ .map((token) => token.role)
63
+ }
64
+
65
+ function tokenProperties(tokens: DesignTokens): string[] {
66
+ const lines: string[] = []
67
+
68
+ for (const token of tokens.color) {
69
+ if (!hasHexValue(token.value)) continue
70
+ lines.push(` --color-${slug(token.role)}: ${token.value};`)
71
+ }
72
+
73
+ for (const step of tokens.spacing) {
74
+ lines.push(` --space-${slug(step.step)}: ${step.value};`)
75
+ }
76
+
77
+ for (const role of tokens.typography) {
78
+ lines.push(` --type-${slug(role.role)}-size: ${role.size};`)
79
+ lines.push(` --type-${slug(role.role)}-lh: ${role.lineHeight};`)
80
+ }
81
+
82
+ for (const border of tokens.borders) {
83
+ if (border.radius === 'none') continue
84
+ lines.push(` --radius-${slug(border.role)}: ${border.radius};`)
85
+ }
86
+
87
+ return lines
88
+ }
89
+
90
+ /**
91
+ * The light theme, as a remap of the roles the record declares a `light-`
92
+ * counterpart for. A consumer that never opts in renders the dark set, which is
93
+ * what every surface here does today.
94
+ */
95
+ function lightBlock(tokens: DesignTokens): string {
96
+ const pairs = [...lightCounterparts(tokens)]
97
+ .map(
98
+ ([dark, light]) =>
99
+ ` --color-${slug(dark)}: var(--color-${slug(light)});`,
100
+ )
101
+ .join('\n')
102
+
103
+ const gap = unmappedOnLight(tokens)
104
+ const notice =
105
+ gap.length === 0
106
+ ? ''
107
+ : `/* The record declares no light counterpart for ${gap.join(', ')}, so\n a light-ground surface using one is reading a dark value. Declare the\n counterpart in src/design/tokens.ts rather than overriding it here. */\n`
108
+
109
+ return `${notice}[data-theme='light'] {
110
+ ${pairs}
111
+ }`
112
+ }
113
+
114
+ function componentBlock(): string {
115
+ return COMPONENTS.map(
116
+ (component) => `/* ${component.name}
117
+ ${component.note} */
118
+
119
+ ${component.rules}`,
120
+ ).join('\n\n')
121
+ }
122
+
123
+ export interface CssOptions {
124
+ /** Prepended as a comment, naming what wrote the file and from where. */
125
+ readonly banner?: string
126
+ /** Component rules ride along by default; a token-only consumer opts out. */
127
+ readonly components?: boolean
128
+ }
129
+
130
+ export function buildDesignCss(
131
+ tokens: DesignTokens = TOKENS,
132
+ options: CssOptions = {},
133
+ ): string {
134
+ const banner =
135
+ options.banner === undefined ? '' : `/* ${options.banner} */\n\n`
136
+ const root = [':root {', ...tokenProperties(tokens), '}'].join('\n')
137
+ const parts = [root, lightBlock(tokens)]
138
+
139
+ if (options.components !== false) parts.push(componentBlock())
140
+
141
+ return `${banner}${parts.join('\n\n')}\n`
142
+ }
@@ -0,0 +1,143 @@
1
+ import type {
2
+ BorderToken,
3
+ ColorToken,
4
+ DesignTokens,
5
+ SpaceToken,
6
+ TypeToken,
7
+ } from '@/design/tokens'
8
+ import { TOKENS } from '@/design/tokens'
9
+
10
+ /**
11
+ * Renders `.claude/DESIGN.md` from the token source.
12
+ *
13
+ * The document is the view and `@/design/tokens` is the fact, which is the one
14
+ * thing that changed when this record stopped being a transcript of two other
15
+ * surfaces. A reader opening the document reads what the surfaces render,
16
+ * because both come from the same module.
17
+ *
18
+ * Tables are emitted the way Prettier serializes them, padded to the widest
19
+ * cell in each column, so the formatting stage and this writer agree and the
20
+ * file has one shape rather than two writers fighting over it.
21
+ */
22
+
23
+ /** The tag a cell carries when no rendering surface exercises its value yet. */
24
+ const VERIFY = ' ? verify'
25
+
26
+ const MIN_COLUMN = 3
27
+
28
+ export function formatTable(
29
+ headers: readonly string[],
30
+ rows: readonly (readonly string[])[],
31
+ ): string {
32
+ const widths = headers.map((header, column) =>
33
+ Math.max(
34
+ MIN_COLUMN,
35
+ header.length,
36
+ ...rows.map((row) => (row[column] ?? '').length),
37
+ ),
38
+ )
39
+
40
+ const line = (cells: readonly string[]): string =>
41
+ `| ${cells.map((cell, column) => cell.padEnd(widths[column])).join(' | ')} |`
42
+
43
+ return [
44
+ line(headers),
45
+ `| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`,
46
+ ...rows.map(line),
47
+ ].join('\n')
48
+ }
49
+
50
+ function tag(value: string, tagged: boolean | undefined): string {
51
+ return tagged === true ? `${value}${VERIFY}` : value
52
+ }
53
+
54
+ function colorRows(tokens: readonly ColorToken[]): string[][] {
55
+ return tokens.map((token) => [
56
+ token.role,
57
+ token.intent,
58
+ tag(token.value, token.verify),
59
+ ])
60
+ }
61
+
62
+ function typeRows(tokens: readonly TypeToken[]): string[][] {
63
+ return tokens.map((token) => {
64
+ const tagged = new Set(token.verify ?? [])
65
+ return [
66
+ token.role,
67
+ tag(token.family, tagged.has('family')),
68
+ tag(token.weight, tagged.has('weight')),
69
+ tag(token.size, tagged.has('size')),
70
+ tag(token.lineHeight, tagged.has('lineHeight')),
71
+ ]
72
+ })
73
+ }
74
+
75
+ function spaceRows(tokens: readonly SpaceToken[]): string[][] {
76
+ return tokens.map((token) => [token.step, token.multiplier, token.value])
77
+ }
78
+
79
+ function borderRows(tokens: readonly BorderToken[]): string[][] {
80
+ return tokens.map((token) => {
81
+ const tagged = new Set(token.verify ?? [])
82
+ return [
83
+ token.role,
84
+ tag(token.radius, tagged.has('radius')),
85
+ tag(token.width, tagged.has('width')),
86
+ token.when,
87
+ ]
88
+ })
89
+ }
90
+
91
+ function section(heading: string, ...blocks: string[]): string {
92
+ return [`## ${heading}`, ...blocks.filter((block) => block !== '')].join(
93
+ '\n\n',
94
+ )
95
+ }
96
+
97
+ export function renderDesignDocument(tokens: DesignTokens = TOKENS): string {
98
+ const body = [
99
+ '# Design',
100
+ '',
101
+ 'Authoring guidance: `standards/design.md`.',
102
+ '',
103
+ tokens.preamble,
104
+ '',
105
+ section('Personality', tokens.personality),
106
+ '',
107
+ section(
108
+ 'Color',
109
+ tokens.colorNote,
110
+ formatTable(['Role', 'Intent', 'Value'], colorRows(tokens.color)),
111
+ ),
112
+ '',
113
+ section(
114
+ 'Typography',
115
+ tokens.typographyNote,
116
+ formatTable(
117
+ ['Role', 'Family', 'Weight', 'Size', 'Line height'],
118
+ typeRows(tokens.typography),
119
+ ),
120
+ ),
121
+ '',
122
+ section(
123
+ 'Spacing',
124
+ tokens.spacingNote,
125
+ formatTable(['Step', 'Multiplier', 'Value'], spaceRows(tokens.spacing)),
126
+ ),
127
+ '',
128
+ section(
129
+ 'Borders',
130
+ tokens.bordersNote,
131
+ formatTable(
132
+ ['Role', 'Radius', 'Width', 'When used'],
133
+ borderRows(tokens.borders),
134
+ ),
135
+ ),
136
+ '',
137
+ section('Motion', tokens.motion),
138
+ '',
139
+ section('Iconography', tokens.iconography),
140
+ ]
141
+
142
+ return `${body.join('\n')}\n`
143
+ }
@@ -0,0 +1,56 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { buildDesignCss } from '@/design/css'
4
+ import { renderDesignDocument } from '@/design/document'
5
+
6
+ /**
7
+ * The two artifacts the token source produces, written by `canon design regen`
8
+ * and asserted for drift by the `design` stage of `bun run check`.
9
+ *
10
+ * The document is what a person reads and the stylesheet is what a target
11
+ * installs, and neither is edited by hand. That is the cost the operator took
12
+ * on when the tokens moved into a module: two artifacts where the record used
13
+ * to be one, and a regen step that has to run or both go stale silently. The
14
+ * gate stage is what keeps silently out of it.
15
+ */
16
+
17
+ /** The rendered record, at the path `standards/design.md` fixes for it. */
18
+ export const DESIGN_DOCUMENT = join('.claude', 'DESIGN.md')
19
+
20
+ /**
21
+ * The base stylesheet a target installs. It sits inside `src/` because that is
22
+ * the folder the published package ships, and beside the module that writes it
23
+ * because a generated file kept away from its generator is one nobody thinks to
24
+ * regenerate.
25
+ */
26
+ export const DESIGN_BASE_CSS = join('src', 'design', 'base.css')
27
+
28
+ const BASE_BANNER = [
29
+ 'Generated by `canon design regen` from src/design/tokens.ts. Do not edit.',
30
+ 'This is the base half of a design install. A target overrides a value in',
31
+ '.claude/design/project/, which the three-way merge leaves alone, so this',
32
+ 'file changes freely and nothing a project wrote lives in it.',
33
+ ].join('\n ')
34
+
35
+ export interface RegenResult {
36
+ readonly documentPath: string
37
+ readonly cssPath: string
38
+ }
39
+
40
+ function write(root: string, rel: string, body: string): string {
41
+ const path = join(root, rel)
42
+ mkdirSync(dirname(path), { recursive: true })
43
+ writeFileSync(path, body)
44
+ return path
45
+ }
46
+
47
+ export function regenDesign(root: string): RegenResult {
48
+ return {
49
+ documentPath: write(root, DESIGN_DOCUMENT, renderDesignDocument()),
50
+ cssPath: write(
51
+ root,
52
+ DESIGN_BASE_CSS,
53
+ buildDesignCss(undefined, { banner: BASE_BANNER }),
54
+ ),
55
+ }
56
+ }
@@ -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, '&quot;')
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>