@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-teach/SKILL.md +11 -1
- package/docs/agents/commands.md +10 -3
- package/docs/agents/install-and-sync.md +23 -1
- package/docs/agents/teach.md +2 -0
- package/docs/target-projects.md +6 -4
- package/docs/visual-design-workflow.md +2 -0
- package/package.json +1 -1
- package/src/commands/design.ts +147 -5
- package/src/commands/migrate.ts +27 -4
- 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/migrate/records.ts +71 -1
- 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,123 @@
|
|
|
1
|
+
/* Generated by `canon design regen` from src/design/tokens.ts. Do not edit.
|
|
2
|
+
This is the base half of a design install. A target overrides a value in
|
|
3
|
+
.claude/design/project/, which the three-way merge leaves alone, so this
|
|
4
|
+
file changes freely and nothing a project wrote lives in it. */
|
|
5
|
+
|
|
6
|
+
:root {
|
|
7
|
+
--color-background: #191512;
|
|
8
|
+
--color-surface: #211c19;
|
|
9
|
+
--color-border: #2f2823;
|
|
10
|
+
--color-text: #f4efe9;
|
|
11
|
+
--color-text-body: #c9c0b7;
|
|
12
|
+
--color-text-secondary: #a79d94;
|
|
13
|
+
--color-muted: #948a81;
|
|
14
|
+
--color-accent: #e0724b;
|
|
15
|
+
--color-light-background: #faf7f2;
|
|
16
|
+
--color-light-surface: #f4efe6;
|
|
17
|
+
--color-light-text: #1a1815;
|
|
18
|
+
--color-light-muted: #726b62;
|
|
19
|
+
--color-light-accent: #a4471c;
|
|
20
|
+
--color-light-border: #e4dcd0;
|
|
21
|
+
--space-xs: 6px;
|
|
22
|
+
--space-sm: 12px;
|
|
23
|
+
--space-md: 18px;
|
|
24
|
+
--space-lg: 24px;
|
|
25
|
+
--space-xl: 30px;
|
|
26
|
+
--space-frame-top: 44px;
|
|
27
|
+
--space-frame-inline: 52px;
|
|
28
|
+
--space-frame-bottom: 38px;
|
|
29
|
+
--type-display-size: 34px;
|
|
30
|
+
--type-display-lh: 1.3;
|
|
31
|
+
--type-heading-size: 19px;
|
|
32
|
+
--type-heading-lh: 1.3;
|
|
33
|
+
--type-body-size: 16px;
|
|
34
|
+
--type-body-lh: 1.65;
|
|
35
|
+
--type-label-size: 12px;
|
|
36
|
+
--type-label-lh: 1.45;
|
|
37
|
+
--type-code-size: 14.5px;
|
|
38
|
+
--type-code-lh: 1.3;
|
|
39
|
+
--radius-frame: 12px;
|
|
40
|
+
--radius-panel: 10px;
|
|
41
|
+
--radius-action: 7px;
|
|
42
|
+
--radius-pill: 999px;
|
|
43
|
+
--radius-marker: 999px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* The record declares no light counterpart for text-body, text-secondary, so
|
|
47
|
+
a light-ground surface using one is reading a dark value. Declare the
|
|
48
|
+
counterpart in src/design/tokens.ts rather than overriding it here. */
|
|
49
|
+
[data-theme='light'] {
|
|
50
|
+
--color-background: var(--color-light-background);
|
|
51
|
+
--color-surface: var(--color-light-surface);
|
|
52
|
+
--color-text: var(--color-light-text);
|
|
53
|
+
--color-muted: var(--color-light-muted);
|
|
54
|
+
--color-accent: var(--color-light-accent);
|
|
55
|
+
--color-border: var(--color-light-border);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* status
|
|
59
|
+
A dot and a word, not a pill. A bordered uppercase chip is a second
|
|
60
|
+
vocabulary for a fact the dot already carries, so the marker is the only
|
|
61
|
+
status shape and `.is-done` is the only variant. */
|
|
62
|
+
|
|
63
|
+
.status {
|
|
64
|
+
display: inline-flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 0.42rem;
|
|
67
|
+
font-size: 0.75rem;
|
|
68
|
+
font-weight: 500;
|
|
69
|
+
letter-spacing: 0;
|
|
70
|
+
text-transform: none;
|
|
71
|
+
color: var(--color-muted);
|
|
72
|
+
white-space: nowrap;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.status::before {
|
|
76
|
+
content: '';
|
|
77
|
+
width: 6px;
|
|
78
|
+
height: 6px;
|
|
79
|
+
flex: none;
|
|
80
|
+
border-radius: var(--radius-marker);
|
|
81
|
+
background: var(--color-border);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.status.is-done::before {
|
|
85
|
+
background: var(--color-accent);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* scrollbar
|
|
89
|
+
Every scrolling region takes the same bar, the page included. Scoping it
|
|
90
|
+
to one component leaves its neighbors on the browser default, which is
|
|
91
|
+
what reads as two designs on one page. `scrollbar-color` covers Firefox
|
|
92
|
+
and the `::-webkit-` rules cover the rest, both from the same two tokens
|
|
93
|
+
so the two engines cannot drift apart. */
|
|
94
|
+
|
|
95
|
+
* {
|
|
96
|
+
scrollbar-width: thin;
|
|
97
|
+
scrollbar-color: var(--color-border) transparent;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
*::-webkit-scrollbar {
|
|
101
|
+
width: 10px;
|
|
102
|
+
height: 10px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
*::-webkit-scrollbar-track {
|
|
106
|
+
background: transparent;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
*::-webkit-scrollbar-thumb {
|
|
110
|
+
border-radius: var(--radius-marker);
|
|
111
|
+
background: var(--color-border);
|
|
112
|
+
/* Inset by painting a border in the page color, which is what keeps the
|
|
113
|
+
thumb off the edges without a second element. */
|
|
114
|
+
border: 3px solid var(--color-background);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
*::-webkit-scrollbar-thumb:hover {
|
|
118
|
+
background: var(--color-muted);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
*::-webkit-scrollbar-corner {
|
|
122
|
+
background: transparent;
|
|
123
|
+
}
|
|
@@ -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
|
+
}
|