canon-ds 0.1.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/LICENSE +21 -0
- package/README.md +153 -0
- package/THIRD_PARTY_NOTICES.md +55 -0
- package/bin/canon.js +6 -0
- package/lib/build-manifest.mjs +54 -0
- package/lib/build.mjs +69 -0
- package/lib/cli.mjs +273 -0
- package/lib/color.js +160 -0
- package/lib/components/_shared.mjs +93 -0
- package/lib/components/accordion.mjs +136 -0
- package/lib/components/account-card.mjs +166 -0
- package/lib/components/activity-feed.mjs +176 -0
- package/lib/components/activity-gauge.mjs +98 -0
- package/lib/components/agent-presence.mjs +156 -0
- package/lib/components/alert.mjs +154 -0
- package/lib/components/app-store-button.mjs +108 -0
- package/lib/components/avatar-group.mjs +128 -0
- package/lib/components/avatar.mjs +225 -0
- package/lib/components/badge-group.mjs +55 -0
- package/lib/components/badge.mjs +174 -0
- package/lib/components/banner.mjs +116 -0
- package/lib/components/breadcrumb.mjs +170 -0
- package/lib/components/button-group.mjs +114 -0
- package/lib/components/button.mjs +197 -0
- package/lib/components/card-header.mjs +115 -0
- package/lib/components/card.mjs +106 -0
- package/lib/components/carousel.mjs +182 -0
- package/lib/components/chart-frame.mjs +101 -0
- package/lib/components/checkbox.mjs +163 -0
- package/lib/components/close-button.mjs +49 -0
- package/lib/components/code.mjs +123 -0
- package/lib/components/color-picker.mjs +122 -0
- package/lib/components/combobox.mjs +210 -0
- package/lib/components/command-palette.mjs +226 -0
- package/lib/components/content-divider.mjs +104 -0
- package/lib/components/counter.mjs +86 -0
- package/lib/components/credit-card.mjs +126 -0
- package/lib/components/date-picker.mjs +202 -0
- package/lib/components/description-list.mjs +88 -0
- package/lib/components/dialog.mjs +140 -0
- package/lib/components/divider.mjs +115 -0
- package/lib/components/drawer.mjs +150 -0
- package/lib/components/empty-state.mjs +112 -0
- package/lib/components/featured-card.mjs +124 -0
- package/lib/components/featured-icon.mjs +67 -0
- package/lib/components/field.mjs +122 -0
- package/lib/components/file-dropzone.mjs +166 -0
- package/lib/components/filter-bar.mjs +179 -0
- package/lib/components/header-navigation.mjs +94 -0
- package/lib/components/icon-button.mjs +179 -0
- package/lib/components/index.mjs +52 -0
- package/lib/components/inline-cta.mjs +139 -0
- package/lib/components/input-group.mjs +164 -0
- package/lib/components/input.mjs +197 -0
- package/lib/components/kanban.mjs +179 -0
- package/lib/components/kbd.mjs +70 -0
- package/lib/components/kicker.mjs +79 -0
- package/lib/components/link.mjs +108 -0
- package/lib/components/list.mjs +117 -0
- package/lib/components/media-frame.mjs +119 -0
- package/lib/components/menu.mjs +190 -0
- package/lib/components/message.mjs +249 -0
- package/lib/components/metric-group.mjs +210 -0
- package/lib/components/mobile-header.mjs +173 -0
- package/lib/components/multi-select.mjs +218 -0
- package/lib/components/notification.mjs +175 -0
- package/lib/components/number-input.mjs +149 -0
- package/lib/components/page-header.mjs +68 -0
- package/lib/components/pagination.mjs +236 -0
- package/lib/components/pin-input.mjs +120 -0
- package/lib/components/popover.mjs +126 -0
- package/lib/components/progress-circle.mjs +115 -0
- package/lib/components/progress.mjs +93 -0
- package/lib/components/prose.mjs +96 -0
- package/lib/components/radio.mjs +160 -0
- package/lib/components/rating.mjs +93 -0
- package/lib/components/rich-text-editor.mjs +178 -0
- package/lib/components/section-header.mjs +62 -0
- package/lib/components/segmented-control.mjs +121 -0
- package/lib/components/select.mjs +163 -0
- package/lib/components/sidebar-nav.mjs +172 -0
- package/lib/components/sidebar.mjs +124 -0
- package/lib/components/skeleton.mjs +66 -0
- package/lib/components/slider.mjs +122 -0
- package/lib/components/social-button.mjs +119 -0
- package/lib/components/spinner.mjs +69 -0
- package/lib/components/stat.mjs +120 -0
- package/lib/components/stepper.mjs +228 -0
- package/lib/components/switch.mjs +159 -0
- package/lib/components/table.mjs +212 -0
- package/lib/components/tabs.mjs +207 -0
- package/lib/components/tag.mjs +196 -0
- package/lib/components/tags-input.mjs +146 -0
- package/lib/components/textarea.mjs +117 -0
- package/lib/components/timeline.mjs +135 -0
- package/lib/components/toast.mjs +115 -0
- package/lib/components/tooltip.mjs +86 -0
- package/lib/components/topbar.mjs +207 -0
- package/lib/components/tree-view.mjs +156 -0
- package/lib/components/video-player.mjs +189 -0
- package/lib/connect.mjs +110 -0
- package/lib/design-files.mjs +175 -0
- package/lib/distribution.mjs +109 -0
- package/lib/editor.js +940 -0
- package/lib/engine.js +339 -0
- package/lib/generators/agents.mjs +68 -0
- package/lib/generators/connection.md +58 -0
- package/lib/generators/css.mjs +2 -0
- package/lib/generators/designmd.mjs +324 -0
- package/lib/generators/documentation.css +82 -0
- package/lib/generators/documentation.html +81 -0
- package/lib/generators/documentation.js +96 -0
- package/lib/generators/documentation.mjs +35 -0
- package/lib/generators/dtcg.mjs +60 -0
- package/lib/generators/preview.css +279 -0
- package/lib/generators/preview.mjs +225 -0
- package/lib/generators/react.mjs +162 -0
- package/lib/generators/tailwind.mjs +114 -0
- package/lib/install.mjs +108 -0
- package/lib/lint.mjs +538 -0
- package/lib/mcp.mjs +268 -0
- package/lib/open.mjs +7 -0
- package/lib/patterns/_app.mjs +56 -0
- package/lib/patterns/app-shell.mjs +25 -0
- package/lib/patterns/audit-log.mjs +37 -0
- package/lib/patterns/auth-pages.mjs +44 -0
- package/lib/patterns/billing-page.mjs +39 -0
- package/lib/patterns/calendar-page.mjs +55 -0
- package/lib/patterns/dashboard-page.mjs +28 -0
- package/lib/patterns/files-page.mjs +30 -0
- package/lib/patterns/first-run.mjs +31 -0
- package/lib/patterns/form-layout.mjs +24 -0
- package/lib/patterns/inbox-page.mjs +58 -0
- package/lib/patterns/index.mjs +29 -0
- package/lib/patterns/list-detail-page.mjs +24 -0
- package/lib/patterns/mobile-app.mjs +46 -0
- package/lib/patterns/modal-flows.mjs +32 -0
- package/lib/patterns/onboarding-page.mjs +42 -0
- package/lib/patterns/permissions-page.mjs +38 -0
- package/lib/patterns/profile-page.mjs +32 -0
- package/lib/patterns/settings-page.mjs +24 -0
- package/lib/project.mjs +28 -0
- package/lib/serve.mjs +279 -0
- package/lib/system.mjs +210 -0
- package/lib/tokens/base.js +251 -0
- package/lib/tokens/canon-preset.mjs +67 -0
- package/lib/tokens/index.mjs +1 -0
- package/lib/tokens/legacy-canon.json +559 -0
- package/lib/tokens/presets.mjs +161 -0
- package/lib/tokens/resolve.mjs +1 -0
- package/lib/types.mjs +4 -0
- package/lib/version.mjs +2 -0
- package/package.json +54 -0
package/lib/system.mjs
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { buildTokens, applySeedChanges } from "./tokens/index.mjs";
|
|
4
|
+
import { PRESETS } from "./tokens/presets.mjs";
|
|
5
|
+
import { indexTokens } from "./tokens/resolve.mjs";
|
|
6
|
+
import { loadCatalog, compareSpecs } from "./components/index.mjs";
|
|
7
|
+
import { loadPatterns, comparePatterns } from "./patterns/index.mjs";
|
|
8
|
+
import { VERSION } from "./version.mjs";
|
|
9
|
+
/** Build an in-memory System from a preset + overrides. */
|
|
10
|
+
export async function createSystem(opts) {
|
|
11
|
+
const CATALOG = await loadCatalog();
|
|
12
|
+
const PATTERNS = await loadPatterns();
|
|
13
|
+
const preset = PRESETS[opts.preset ?? 'canon'];
|
|
14
|
+
if (!preset)
|
|
15
|
+
throw new Error(`Unknown preset "${opts.preset}". Available: ${Object.keys(PRESETS).join(', ')}`);
|
|
16
|
+
const prefix = (opts.prefix ?? slugify(opts.name).slice(0, 8) ?? 'cn').replace(/[^a-z0-9]/g, '') || 'cn';
|
|
17
|
+
const { overrides: presetOverrides = {}, ...presetSeeds } = preset.seeds;
|
|
18
|
+
const seeds = applySeedChanges({ name: opts.name, prefix, ...presetSeeds, presetOverrides }, stripUndefined(opts.seeds ?? {}));
|
|
19
|
+
const tokens = buildTokens(seeds);
|
|
20
|
+
const meta = {
|
|
21
|
+
name: opts.name,
|
|
22
|
+
prefix,
|
|
23
|
+
version: '1.0.0',
|
|
24
|
+
description: opts.description ?? `${opts.name} design system, compiled by canon ${VERSION}.`,
|
|
25
|
+
direction: preset.direction,
|
|
26
|
+
seeds,
|
|
27
|
+
defaultTheme: seeds.defaultTheme ?? 'light',
|
|
28
|
+
out: 'dist',
|
|
29
|
+
lint: {
|
|
30
|
+
include: ['src', 'app', 'components', 'pages', 'lib', 'styles'],
|
|
31
|
+
exclude: ['node_modules', 'dist', 'build', '.next', 'design', 'public', 'coverage'],
|
|
32
|
+
allow: ['**/tokens.css', '**/canon.css', '**/*.stories.*'],
|
|
33
|
+
tailwind: true,
|
|
34
|
+
},
|
|
35
|
+
icons: { set: 'Lucide', strokeWidth: '1.5', note: 'Outline icons, 1.5px stroke, sized by the icon token scale (16px default in text, 20px in nav). Never emoji, never filled icon sets mixed with outline.' },
|
|
36
|
+
};
|
|
37
|
+
return { meta, tokens, components: structuredClone(CATALOG), patterns: structuredClone(PATTERNS) };
|
|
38
|
+
}
|
|
39
|
+
export function writeDesignDir(system, dir) {
|
|
40
|
+
const written = [];
|
|
41
|
+
const w = (rel, data) => {
|
|
42
|
+
const p = join(dir, rel);
|
|
43
|
+
mkdirSync(join(p, '..'), { recursive: true });
|
|
44
|
+
writeFileSync(p, JSON.stringify(data, null, 2) + '\n');
|
|
45
|
+
written.push(p);
|
|
46
|
+
};
|
|
47
|
+
w('system.json', system.meta);
|
|
48
|
+
w('tokens.json', system.tokens);
|
|
49
|
+
for (const c of system.components)
|
|
50
|
+
w(`components/${c.slug}.json`, c);
|
|
51
|
+
for (const p of system.patterns)
|
|
52
|
+
w(`patterns/${p.slug}.json`, p);
|
|
53
|
+
const readme = join(dir, 'README.md');
|
|
54
|
+
if (!existsSync(readme)) {
|
|
55
|
+
writeFileSync(readme, `# ${system.meta.name} — design source
|
|
56
|
+
|
|
57
|
+
This folder is the single source of truth for the ${system.meta.name} design system.
|
|
58
|
+
|
|
59
|
+
- \`system.json\` — name, prefix, art direction, seeds, lint config.
|
|
60
|
+
- \`tokens.json\` — every token (colors, type, space, radius, shadow, motion…). Values may reference other tokens with \`{group.path}\`.
|
|
61
|
+
- \`components/*.json\` — one file per component: anatomy, props, states, styles per part/variant/state, examples, rules.
|
|
62
|
+
- \`patterns/*.json\` — page-level compositions.
|
|
63
|
+
|
|
64
|
+
Edit these files, then run \`canon build\` (or \`canon sync\` to also refresh the agent files in the project root).
|
|
65
|
+
Never edit \`dist/\` by hand — it is regenerated.
|
|
66
|
+
`);
|
|
67
|
+
written.push(readme);
|
|
68
|
+
}
|
|
69
|
+
return written;
|
|
70
|
+
}
|
|
71
|
+
export function loadDesignDir(dir) {
|
|
72
|
+
const read = (rel) => JSON.parse(readFileSync(join(dir, rel), 'utf8'));
|
|
73
|
+
if (!existsSync(join(dir, 'system.json')))
|
|
74
|
+
throw new Error(`No design system found at ${dir} (missing system.json). Run \`canon init\` first.`);
|
|
75
|
+
const meta = read('system.json');
|
|
76
|
+
const tokens = read('tokens.json');
|
|
77
|
+
const compDir = join(dir, 'components');
|
|
78
|
+
const components = existsSync(compDir)
|
|
79
|
+
? readdirSync(compDir).filter((f) => f.endsWith('.json')).sort().map((f) => read(`components/${f}`))
|
|
80
|
+
: [];
|
|
81
|
+
components.sort(compareSpecs);
|
|
82
|
+
const patDir = join(dir, 'patterns');
|
|
83
|
+
const patterns = existsSync(patDir)
|
|
84
|
+
? readdirSync(patDir).filter((f) => f.endsWith('.json')).sort().map((f) => read(`patterns/${f}`))
|
|
85
|
+
: [];
|
|
86
|
+
patterns.sort(comparePatterns);
|
|
87
|
+
return { meta, tokens, components, patterns };
|
|
88
|
+
}
|
|
89
|
+
/** Structural validation. Throws with every problem listed. */
|
|
90
|
+
export function validateSystem(system) {
|
|
91
|
+
const problems = [];
|
|
92
|
+
const warnings = [];
|
|
93
|
+
let idx;
|
|
94
|
+
try {
|
|
95
|
+
idx = indexTokens(system.tokens, system.meta.prefix);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
problems.push(`tokens: ${e.message}`);
|
|
99
|
+
return { errors: problems, warnings };
|
|
100
|
+
}
|
|
101
|
+
const refRe = /\{([a-zA-Z0-9_.-]+)\}/g;
|
|
102
|
+
const checkRefs = (where, value) => {
|
|
103
|
+
for (const m of value.matchAll(refRe))
|
|
104
|
+
if (!idx.has(m[1]))
|
|
105
|
+
problems.push(`${where}: unknown token {${m[1]}}`);
|
|
106
|
+
};
|
|
107
|
+
const checkBlock = (c, where, block) => {
|
|
108
|
+
if (!block)
|
|
109
|
+
return;
|
|
110
|
+
for (const [part, decls] of Object.entries(block)) {
|
|
111
|
+
if (part === '@states') {
|
|
112
|
+
for (const [state, parts] of Object.entries(decls)) {
|
|
113
|
+
if (!c.states[state])
|
|
114
|
+
problems.push(`${c.slug} ${where}: unknown state "${state}"`);
|
|
115
|
+
for (const [p2, d2] of Object.entries(parts)) {
|
|
116
|
+
if (!c.anatomy.some((a) => a.part === p2))
|
|
117
|
+
problems.push(`${c.slug} ${where} @states.${state}: unknown part "${p2}"`);
|
|
118
|
+
for (const [prop, val] of Object.entries(d2))
|
|
119
|
+
checkRefs(`${c.slug} ${where} ${state} ${p2} ${prop}`, val);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!c.anatomy.some((a) => a.part === part))
|
|
125
|
+
problems.push(`${c.slug} ${where}: unknown part "${part}"`);
|
|
126
|
+
for (const [prop, val] of Object.entries(decls))
|
|
127
|
+
checkRefs(`${c.slug} ${where} ${part} ${prop}`, val);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const slugs = new Set();
|
|
131
|
+
for (const c of system.components) {
|
|
132
|
+
if (slugs.has(c.slug))
|
|
133
|
+
problems.push(`duplicate component slug "${c.slug}"`);
|
|
134
|
+
slugs.add(c.slug);
|
|
135
|
+
if (!c.anatomy.some((a) => a.part === 'root'))
|
|
136
|
+
problems.push(`${c.slug}: anatomy must include a "root" part`);
|
|
137
|
+
checkBlock(c, 'base', c.base);
|
|
138
|
+
for (const [axis, values] of Object.entries(c.variants ?? {})) {
|
|
139
|
+
const prop = c.props[axis];
|
|
140
|
+
if (!prop)
|
|
141
|
+
problems.push(`${c.slug}: variants axis "${axis}" has no matching prop`);
|
|
142
|
+
for (const [value, block] of Object.entries(values)) {
|
|
143
|
+
if (prop && !prop.values.includes(value))
|
|
144
|
+
problems.push(`${c.slug}: variant ${axis}="${value}" not listed in props.${axis}.values`);
|
|
145
|
+
checkBlock(c, `variants.${axis}.${value}`, block);
|
|
146
|
+
}
|
|
147
|
+
if (prop)
|
|
148
|
+
for (const v of prop.values)
|
|
149
|
+
if (!(v in values))
|
|
150
|
+
problems.push(`${c.slug}: props.${axis} value "${v}" has no variants block (add an empty one)`);
|
|
151
|
+
}
|
|
152
|
+
for (const [axis, prop] of Object.entries(c.props)) {
|
|
153
|
+
if (!prop.values.includes(prop.default))
|
|
154
|
+
problems.push(`${c.slug}: props.${axis}.default "${prop.default}" not in values`);
|
|
155
|
+
}
|
|
156
|
+
for (const comp of c.compound ?? []) {
|
|
157
|
+
for (const [axis, val] of Object.entries(comp.when))
|
|
158
|
+
if (!c.props[axis]?.values.includes(val))
|
|
159
|
+
problems.push(`${c.slug}: compound when ${axis}="${val}" invalid`);
|
|
160
|
+
checkBlock(c, 'compound', comp.block);
|
|
161
|
+
}
|
|
162
|
+
if (c.extraCss)
|
|
163
|
+
checkRefs(`${c.slug} extraCss`, c.extraCss);
|
|
164
|
+
if (!c.examples?.length)
|
|
165
|
+
problems.push(`${c.slug}: needs at least one example`);
|
|
166
|
+
const allSlugs = new Set(system.components.map((o) => o.slug));
|
|
167
|
+
for (const ex of [...(c.examples ?? []), ...(c.recipes ?? [])]) {
|
|
168
|
+
for (const m of ex.html.matchAll(/\bcn-([a-z0-9]+(?:-[a-z0-9]+)*)__([a-z0-9]+(?:-[a-z0-9]+)*)/g)) {
|
|
169
|
+
const owner = system.components.find((o) => o.slug === m[1]);
|
|
170
|
+
if (!owner) {
|
|
171
|
+
if (!system.components.some((o) => (o.extraCss ?? '').includes(`.cn-${m[1]}`)))
|
|
172
|
+
warnings.push(`${c.slug} example "${ex.title}": references unknown component cn-${m[1]}`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!owner.anatomy.some((a) => a.part === m[2]) && !(owner.extraCss ?? '').includes(`cn-${m[1]}__${m[2]}`))
|
|
176
|
+
problems.push(`${c.slug} example "${ex.title}": cn-${m[1]}__${m[2]} is not a part of ${m[1]} (parts: ${owner.anatomy.map((a) => a.part).join(', ')})`);
|
|
177
|
+
}
|
|
178
|
+
for (const tagm of ex.html.matchAll(/<[a-z][a-z0-9-]*\b[^>]*\bclass="([^"]*)"[^>]*>/g)) {
|
|
179
|
+
const owner = tagm[1].split(/\s+/).map((cls) => system.components.find((o) => `cn-${o.slug}` === cls)).find(Boolean);
|
|
180
|
+
if (!owner)
|
|
181
|
+
continue;
|
|
182
|
+
for (const d of tagm[0].matchAll(/\bdata-([a-z-]+)="([^"]*)"/g)) {
|
|
183
|
+
const pr = owner.props[d[1]];
|
|
184
|
+
if (pr && !pr.values.includes(d[2]))
|
|
185
|
+
warnings.push(`${c.slug} example "${ex.title}": cn-${owner.slug} data-${d[1]}="${d[2]}" is not an allowed value (${pr.values.join('|')})`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const m of ex.html.matchAll(/class="([^"]*)"/g)) {
|
|
189
|
+
for (const cls of m[1].split(/\s+/)) {
|
|
190
|
+
const mm = cls.match(/^cn-([a-z0-9]+(?:-[a-z0-9]+)*)$/);
|
|
191
|
+
if (mm && !allSlugs.has(mm[1]) && !['icon', 'sr-only', 'tabular', 'truncate', 'container', 'prose', 'section', 'stack', 'row', 'grid', 'split', 'center', 'measure', 'mx-auto', 'flex-1', 'hide-mobile', 'show-mobile', 'placeholder'].includes(mm[1]) && !mm[1].startsWith('text-') && !system.components.some((o) => (o.extraCss ?? '').includes(`.cn-${mm[1]}`)) && !system.patterns.some((pt) => (pt.css ?? '').includes(`.cn-${mm[1]}`)))
|
|
192
|
+
warnings.push(`${c.slug} example "${ex.title}": class cn-${mm[1]} is not a known component`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const r of c.related)
|
|
197
|
+
if (!system.components.some((o) => o.slug === r))
|
|
198
|
+
warnings.push(`${c.slug}: related "${r}" is not in this design system`);
|
|
199
|
+
}
|
|
200
|
+
for (const p of system.patterns)
|
|
201
|
+
if (p.css)
|
|
202
|
+
checkRefs(`pattern ${p.slug}`, p.css);
|
|
203
|
+
return { errors: problems, warnings };
|
|
204
|
+
}
|
|
205
|
+
export function slugify(s) {
|
|
206
|
+
return s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
207
|
+
}
|
|
208
|
+
function stripUndefined(o) {
|
|
209
|
+
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
210
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { buildScale, buildNeutralScale, hexToOklch, alpha } from '../color.js';
|
|
2
|
+
|
|
3
|
+
export function deepMerge(base, over) {
|
|
4
|
+
if (over === undefined || over === null) return base;
|
|
5
|
+
if (typeof base !== 'object' || base === null || Array.isArray(base) || typeof over !== 'object') return over;
|
|
6
|
+
const out = { ...base };
|
|
7
|
+
for (const [k, v] of Object.entries(over)) out[k] = deepMerge(base[k], v);
|
|
8
|
+
return out;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Apply seed choices over preset defaults while retaining deliberate token overrides. */
|
|
12
|
+
export function applySeedChanges(seeds, changes = {}, legacyDefaults = {}) {
|
|
13
|
+
const next = structuredClone(seeds);
|
|
14
|
+
// Older designs stored preset values in overrides. Migrate when first edited.
|
|
15
|
+
if (!Object.hasOwn(next, 'presetOverrides')) {
|
|
16
|
+
next.presetOverrides = next.overrides ?? {};
|
|
17
|
+
// Only values matching known defaults may be replaced by a seed change.
|
|
18
|
+
// Older designs stored deliberate edits in this same layer as well.
|
|
19
|
+
next.overrides = tokenDifferences(next.overrides ?? {}, legacyDefaults) ?? {};
|
|
20
|
+
}
|
|
21
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
22
|
+
if (value === undefined || (key === 'action' && value === '')) delete next[key];
|
|
23
|
+
else if (key === 'overrides') next.overrides = deepMerge(next.overrides ?? {}, value);
|
|
24
|
+
else next[key] = structuredClone(value);
|
|
25
|
+
}
|
|
26
|
+
const clear = (...paths) => {
|
|
27
|
+
for (const path of paths) {
|
|
28
|
+
const parts = path.split('.');
|
|
29
|
+
let parent = next.presetOverrides;
|
|
30
|
+
for (const key of parts.slice(0, -1)) parent = parent?.[key];
|
|
31
|
+
if (parent) delete parent[parts.at(-1)];
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const has = (key) => Object.hasOwn(changes, key);
|
|
35
|
+
if (has('brand')) clear('color.primitive.brand', 'color.semantic.ring-focus');
|
|
36
|
+
if (has('action') || (has('brand') && !next.action)) {
|
|
37
|
+
clear(...['bg-action', 'bg-action-hover', 'bg-action-active', 'bg-action-subtle', 'fg-on-action', 'fg-action', 'border-action', 'ring-focus'].map(k => `color.semantic.${k}`));
|
|
38
|
+
}
|
|
39
|
+
if (['canvasLight', 'inkDark', 'neutralHue', 'neutralChroma'].some(has)) clear('color.primitive.neutral');
|
|
40
|
+
if (has('canvasLight')) clear('color.semantic.bg-canvas.light');
|
|
41
|
+
if (has('fontSans')) clear('font.family.sans', 'font.family.display');
|
|
42
|
+
if (has('fontDisplay')) clear('font.family.display');
|
|
43
|
+
if (has('fontMono')) clear('font.family.mono');
|
|
44
|
+
if (has('baseFontSize')) clear('font.size', 'font.control', 'type');
|
|
45
|
+
if (has('radiusScale')) clear('radius');
|
|
46
|
+
if (has('controlHeight')) clear('size.control');
|
|
47
|
+
if (has('shadowTint')) clear('shadow');
|
|
48
|
+
return next;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Preserve hand-edited tokens when an existing design is regenerated from seeds. */
|
|
52
|
+
export function tokenDifferences(actual, derived) {
|
|
53
|
+
if (JSON.stringify(actual) === JSON.stringify(derived)) return undefined;
|
|
54
|
+
if (!actual || typeof actual !== 'object' || Array.isArray(actual)) return actual;
|
|
55
|
+
const difference = {};
|
|
56
|
+
for (const [key, value] of Object.entries(actual)) {
|
|
57
|
+
const child = tokenDifferences(value, derived?.[key]);
|
|
58
|
+
if (child !== undefined) difference[key] = child;
|
|
59
|
+
}
|
|
60
|
+
return Object.keys(difference).length ? difference : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const px = (n) => `${Math.round(n * 100) / 100}px`;
|
|
64
|
+
|
|
65
|
+
/** Full token set derived from a handful of seeds. */
|
|
66
|
+
/** Full token set derived from a handful of seeds. @param {import('../types.ts').Seeds} seeds */
|
|
67
|
+
export function buildTokens(seeds) {
|
|
68
|
+
const brandHue = hexToOklch(seeds.brand).H;
|
|
69
|
+
const neutral = buildNeutralScale({
|
|
70
|
+
hue: seeds.neutralHue ?? brandHue,
|
|
71
|
+
chroma: seeds.neutralChroma ?? 0.004,
|
|
72
|
+
light: seeds.canvasLight ?? '#FAFAFA',
|
|
73
|
+
dark: seeds.inkDark ?? '#171717',
|
|
74
|
+
});
|
|
75
|
+
const brand = buildScale(seeds.brand);
|
|
76
|
+
const action = seeds.action ? buildScale(seeds.action, { maxChroma: 0.3 }) : brand;
|
|
77
|
+
const actionIsDark = hexToOklch(seeds.action ?? seeds.brand).L < 0.4;
|
|
78
|
+
const A = seeds.action ? 'action' : 'brand';
|
|
79
|
+
const red = buildScale('#DC2626');
|
|
80
|
+
const amber = buildScale('#D97706');
|
|
81
|
+
const green = buildScale('#16A34A');
|
|
82
|
+
const blue = buildScale('#2563EB');
|
|
83
|
+
|
|
84
|
+
const rs = seeds.radiusScale ?? 1;
|
|
85
|
+
const r = (n) => (rs === 0 ? '0px' : px(n * rs));
|
|
86
|
+
const base = seeds.baseFontSize ?? 14;
|
|
87
|
+
const ctrl = seeds.controlHeight ?? 36;
|
|
88
|
+
const tint = seeds.shadowTint ?? '#000000';
|
|
89
|
+
const sh = (a) => alpha(tint, a);
|
|
90
|
+
const focus = seeds.action && !actionIsDark ? action : brand;
|
|
91
|
+
|
|
92
|
+
// Action-color pair. When the action color is a dark neutral (charcoal
|
|
93
|
+
// buttons), hover goes darker and the dark theme inverts it to light.
|
|
94
|
+
const act = actionIsDark
|
|
95
|
+
? { light: seeds.action ?? seeds.brand, lightHover: neutral['950'], lightActive: '#000000', dark: neutral['50'], darkHover: neutral['100'], darkActive: neutral['200'], onLight: neutral['50'], onDark: neutral['950'], subtleL: neutral['100'], subtleD: neutral['800'], fgL: neutral['900'], fgD: neutral['100'] }
|
|
96
|
+
: { light: `{${A}.600}`, lightHover: `{${A}.700}`, lightActive: `{${A}.800}`, dark: `{${A}.500}`, darkHover: `{${A}.400}`, darkActive: `{${A}.300}`, onLight: '#FFFFFF', onDark: '{neutral.950}', subtleL: `{${A}.50}`, subtleD: `{${A}.950}`, fgL: `{${A}.700}`, fgD: `{${A}.400}` };
|
|
97
|
+
|
|
98
|
+
const tokens = {
|
|
99
|
+
color: {
|
|
100
|
+
primitive: {
|
|
101
|
+
white: '#FFFFFF',
|
|
102
|
+
black: '#000000',
|
|
103
|
+
neutral,
|
|
104
|
+
brand,
|
|
105
|
+
...(seeds.action ? { action } : {}),
|
|
106
|
+
red, amber, green, blue,
|
|
107
|
+
},
|
|
108
|
+
semantic: {
|
|
109
|
+
// ---- backgrounds
|
|
110
|
+
'bg-canvas': { light: '{neutral.50}', dark: '{neutral.950}', description: 'Page background. The outermost surface.' },
|
|
111
|
+
'bg-surface': { light: '{white}', dark: '{neutral.900}', description: 'Cards, panels, inputs, table rows. Sits on canvas.' },
|
|
112
|
+
'bg-surface-raised': { light: '{white}', dark: '{neutral.800}', description: 'Popovers, dropdowns, dialogs. Floating above surface.' },
|
|
113
|
+
'bg-subtle': { light: '{neutral.100}', dark: '{neutral.800}', description: 'Quiet fills: table headers, code, secondary chips, hover rows.' },
|
|
114
|
+
'bg-muted': { light: '{neutral.200}', dark: '{neutral.700}', description: 'Stronger quiet fill: pressed states, skeletons, dividers-as-fill.' },
|
|
115
|
+
'bg-inverse': { light: '{neutral.900}', dark: '{neutral.50}', description: 'Tooltips, inverted chips.' },
|
|
116
|
+
'bg-overlay': { light: alpha(neutral['950'], 0.45), dark: 'rgba(0, 0, 0, 0.6)', description: 'Scrim behind dialogs and drawers.' },
|
|
117
|
+
'bg-action': { light: act.light, dark: act.dark, description: 'Primary action fill: primary buttons, active toggles, selected checkboxes.' },
|
|
118
|
+
'bg-action-hover': { light: act.lightHover, dark: act.darkHover, description: 'Primary action fill on hover.' },
|
|
119
|
+
'bg-action-active': { light: act.lightActive, dark: act.darkActive, description: 'Primary action fill while pressed.' },
|
|
120
|
+
'bg-action-subtle': { light: act.subtleL, dark: act.subtleD, description: 'Tinted fill for selected items, soft badges of the action color.' },
|
|
121
|
+
'bg-accent': { light: '{brand.500}', dark: '{brand.400}', description: 'Brand identity fill (decorative highlights, "AI working" markers). Never text.' },
|
|
122
|
+
'bg-accent-subtle': { light: '{brand.100}', dark: '{brand.950}', description: 'Soft brand wash: chips, highlighted rows, callouts.' },
|
|
123
|
+
'bg-success': { light: '{green.600}', dark: '{green.500}', description: 'Solid success fill.' },
|
|
124
|
+
'bg-success-subtle': { light: '{green.50}', dark: alpha(green['900'], 0.4), description: 'Soft success fill (alerts, badges).' },
|
|
125
|
+
'bg-warning': { light: '{amber.500}', dark: '{amber.400}', description: 'Solid warning fill.' },
|
|
126
|
+
'bg-warning-subtle': { light: '{amber.50}', dark: alpha(amber['900'], 0.4), description: 'Soft warning fill.' },
|
|
127
|
+
'bg-danger': { light: '{red.600}', dark: '{red.500}', description: 'Solid danger fill: destructive buttons.' },
|
|
128
|
+
'bg-danger-hover': { light: '{red.700}', dark: '{red.400}', description: 'Destructive button hover.' },
|
|
129
|
+
'bg-danger-subtle': { light: '{red.50}', dark: alpha(red['900'], 0.4), description: 'Soft danger fill.' },
|
|
130
|
+
'bg-info': { light: '{blue.600}', dark: '{blue.500}', description: 'Solid info fill.' },
|
|
131
|
+
'bg-info-subtle': { light: '{blue.50}', dark: alpha(blue['900'], 0.4), description: 'Soft info fill.' },
|
|
132
|
+
'bg-disabled': { light: '{neutral.100}', dark: '{neutral.800}', description: 'Fill of disabled controls.' },
|
|
133
|
+
// ---- foregrounds
|
|
134
|
+
'fg-default': { light: '{neutral.950}', dark: '{neutral.50}', description: 'Primary text and icons.' },
|
|
135
|
+
'fg-muted': { light: '{neutral.600}', dark: '{neutral.400}', description: 'Secondary text: descriptions, helper text, table meta.' },
|
|
136
|
+
'fg-subtle': { light: '{neutral.500}', dark: '{neutral.500}', description: 'Tertiary text: timestamps, captions, kickers.' },
|
|
137
|
+
'fg-placeholder': { light: '{neutral.400}', dark: '{neutral.600}', description: 'Input placeholders.' },
|
|
138
|
+
'fg-inverse': { light: '{neutral.50}', dark: '{neutral.950}', description: 'Text on bg-inverse.' },
|
|
139
|
+
'fg-on-action': { light: act.onLight, dark: act.onDark, description: 'Text/icon on bg-action.' },
|
|
140
|
+
'fg-on-accent': { light: '{white}', dark: '{neutral.950}', description: 'Text/icon on bg-accent (rare).' },
|
|
141
|
+
'fg-action': { light: act.fgL, dark: act.fgD, description: 'Action-colored text: links in lists, selected tab labels, icons.' },
|
|
142
|
+
'fg-accent': { light: '{brand.700}', dark: '{brand.300}', description: 'Brand-colored text. AA on canvas; use sparingly.' },
|
|
143
|
+
'fg-success': { light: '{green.700}', dark: '{green.400}', description: 'Success text/icon.' },
|
|
144
|
+
'fg-warning': { light: '{amber.700}', dark: '{amber.400}', description: 'Warning text/icon.' },
|
|
145
|
+
'fg-danger': { light: '{red.700}', dark: '{red.400}', description: 'Danger text/icon, error messages.' },
|
|
146
|
+
'fg-info': { light: '{blue.700}', dark: '{blue.400}', description: 'Info text/icon.' },
|
|
147
|
+
'fg-disabled': { light: '{neutral.400}', dark: '{neutral.600}', description: 'Text of disabled controls.' },
|
|
148
|
+
'fg-link': { light: '{neutral.950}', dark: '{neutral.50}', description: 'Inline links (underlined, ink-colored — never blue by default).' },
|
|
149
|
+
'fg-link-hover': { light: act.fgL, dark: act.fgD, description: 'Inline link on hover.' },
|
|
150
|
+
// ---- borders
|
|
151
|
+
'border-default': { light: '{neutral.200}', dark: '{neutral.800}', description: 'Default 1px hairline: cards, inputs, dividers, table rules.' },
|
|
152
|
+
'border-strong': { light: '{neutral.300}', dark: '{neutral.700}', description: 'Hovered inputs, emphasized separators.' },
|
|
153
|
+
'border-subtle': { light: '{neutral.100}', dark: alpha(neutral['800'], 0.6), description: 'Very quiet separators inside a surface.' },
|
|
154
|
+
'border-action': { light: act.light, dark: act.dark, description: 'Focused inputs, selected cards.' },
|
|
155
|
+
'border-success': { light: '{green.300}', dark: '{green.800}', description: '' },
|
|
156
|
+
'border-warning': { light: '{amber.300}', dark: '{amber.800}', description: '' },
|
|
157
|
+
'border-danger': { light: '{red.300}', dark: '{red.800}', description: 'Invalid inputs.' },
|
|
158
|
+
'border-info': { light: '{blue.300}', dark: '{blue.800}', description: '' },
|
|
159
|
+
'border-disabled': { light: '{neutral.200}', dark: '{neutral.800}', description: '' },
|
|
160
|
+
'border-control': { light: '{neutral.200}', dark: '{neutral.700}', description: 'Border of inputs, selects and outline/secondary buttons at rest.' },
|
|
161
|
+
'border-control-hover': { light: '{neutral.300}', dark: '{neutral.600}', description: 'Border of inputs and outline buttons on hover.' },
|
|
162
|
+
// ---- focus ring
|
|
163
|
+
'ring-focus': { light: alpha(focus['500'], 0.4), dark: alpha(focus['400'], 0.5), description: 'The 3px focus ring color. Only ever shown on :focus-visible.' },
|
|
164
|
+
'ring-danger': { light: alpha(red['500'], 0.35), dark: alpha(red['400'], 0.4), description: 'Focus ring on invalid controls.' },
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
font: {
|
|
168
|
+
family: {
|
|
169
|
+
sans: seeds.fontSans ?? '"Geist", "Inter", system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
170
|
+
mono: seeds.fontMono ?? '"Geist Mono", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace',
|
|
171
|
+
display: seeds.fontDisplay ?? seeds.fontSans ?? '"Geist", "Inter", system-ui, sans-serif',
|
|
172
|
+
},
|
|
173
|
+
size: {
|
|
174
|
+
'2xs': px(base * 0.7857), // 11
|
|
175
|
+
xs: px(base * 0.8571), // 12
|
|
176
|
+
sm: px(base * 0.9286), // 13
|
|
177
|
+
md: px(base), // 14
|
|
178
|
+
lg: px(base * 1.1429), // 16
|
|
179
|
+
xl: px(base * 1.2857), // 18
|
|
180
|
+
'2xl': px(base * 1.4286), // 20
|
|
181
|
+
'3xl': px(base * 1.7143), // 24
|
|
182
|
+
'4xl': px(base * 2.1429), // 30
|
|
183
|
+
'5xl': px(base * 2.5714), // 36
|
|
184
|
+
'6xl': px(base * 3.4286), // 48
|
|
185
|
+
'7xl': px(base * 4.2857), // 60
|
|
186
|
+
'8xl': px(base * 5.1429), // 72
|
|
187
|
+
},
|
|
188
|
+
weight: { regular: '400', medium: '500', semibold: '600', bold: '700' },
|
|
189
|
+
control: { xs: px(base * 0.8571), sm: px(base), md: px(base), lg: px(base * 1.1429), xl: px(base * 1.1429) },
|
|
190
|
+
lineHeight: { none: '1', tight: '1.1', snug: '1.25', normal: '1.5', relaxed: '1.6', loose: '1.75' },
|
|
191
|
+
letterSpacing: { tighter: '-0.04em', tight: '-0.02em', snug: '-0.01em', normal: '0', wide: '0.04em', wider: '0.08em' },
|
|
192
|
+
},
|
|
193
|
+
type: {
|
|
194
|
+
'display-xl': { family: '{font.family.display}', size: '{font.size.7xl}', weight: '{font.weight.semibold}', lineHeight: '{font.lineHeight.none}', letterSpacing: '{font.letterSpacing.tighter}', description: 'Hero headline. One per page, marketing only.' },
|
|
195
|
+
'display-lg': { family: '{font.family.display}', size: '{font.size.6xl}', weight: '{font.weight.semibold}', lineHeight: '{font.lineHeight.tight}', letterSpacing: '{font.letterSpacing.tighter}', description: 'Section hero on marketing pages.' },
|
|
196
|
+
'display-md': { family: '{font.family.display}', size: '{font.size.5xl}', weight: '{font.weight.semibold}', lineHeight: '{font.lineHeight.tight}', letterSpacing: '{font.letterSpacing.tight}', description: 'Big numbers (KPI hero), page-level titles on marketing.' },
|
|
197
|
+
'heading-xl': { family: '{font.family.sans}', size: '{font.size.4xl}', weight: '{font.weight.semibold}', lineHeight: '{font.lineHeight.tight}', letterSpacing: '{font.letterSpacing.tight}', description: 'h1 in the app: page title.' },
|
|
198
|
+
'heading-lg': { family: '{font.family.sans}', size: '{font.size.3xl}', weight: '{font.weight.semibold}', lineHeight: '{font.lineHeight.snug}', letterSpacing: '{font.letterSpacing.tight}', description: 'h2: section title inside a page.' },
|
|
199
|
+
'heading-md': { family: '{font.family.sans}', size: '{font.size.2xl}', weight: '{font.weight.semibold}', lineHeight: '{font.lineHeight.snug}', letterSpacing: '{font.letterSpacing.tight}', description: 'h3: card title, dialog title.' },
|
|
200
|
+
'heading-sm': { family: '{font.family.sans}', size: '{font.size.lg}', weight: '{font.weight.semibold}', lineHeight: '{font.lineHeight.snug}', letterSpacing: '{font.letterSpacing.snug}', description: 'h4: subsection title, list group title.' },
|
|
201
|
+
'heading-xs': { family: '{font.family.sans}', size: '{font.size.md}', weight: '{font.weight.semibold}', lineHeight: '{font.lineHeight.snug}', letterSpacing: '{font.letterSpacing.normal}', description: 'h5: small title inside a compact card or row.' },
|
|
202
|
+
'body-xl': { family: '{font.family.sans}', size: '{font.size.xl}', weight: '{font.weight.regular}', lineHeight: '{font.lineHeight.normal}', letterSpacing: '{font.letterSpacing.snug}', description: 'Lead paragraphs under hero and section titles on marketing pages (20px).' },
|
|
203
|
+
'body-lg': { family: '{font.family.sans}', size: '{font.size.lg}', weight: '{font.weight.regular}', lineHeight: '{font.lineHeight.relaxed}', letterSpacing: '{font.letterSpacing.snug}', description: 'Lede paragraphs, long-form reading.' },
|
|
204
|
+
'body-md': { family: '{font.family.sans}', size: '{font.size.md}', weight: '{font.weight.regular}', lineHeight: '{font.lineHeight.normal}', letterSpacing: '{font.letterSpacing.normal}', description: 'Default UI text. Inputs, table cells, descriptions.' },
|
|
205
|
+
'body-sm': { family: '{font.family.sans}', size: '{font.size.sm}', weight: '{font.weight.regular}', lineHeight: '{font.lineHeight.normal}', letterSpacing: '{font.letterSpacing.normal}', description: 'Secondary text, helper text, dense tables.' },
|
|
206
|
+
'body-xs': { family: '{font.family.sans}', size: '{font.size.xs}', weight: '{font.weight.regular}', lineHeight: '{font.lineHeight.normal}', letterSpacing: '{font.letterSpacing.normal}', description: 'Captions, timestamps, footnotes.' },
|
|
207
|
+
'label-lg': { family: '{font.family.sans}', size: '{font.size.lg}', weight: '{font.weight.medium}', lineHeight: '{font.lineHeight.none}', letterSpacing: '{font.letterSpacing.snug}', description: 'Large button labels, tabs on marketing.' },
|
|
208
|
+
'label-md': { family: '{font.family.sans}', size: '{font.size.md}', weight: '{font.weight.medium}', lineHeight: '{font.lineHeight.none}', letterSpacing: '{font.letterSpacing.normal}', description: 'Buttons, tabs, form labels, menu items.' },
|
|
209
|
+
'label-sm': { family: '{font.family.sans}', size: '{font.size.sm}', weight: '{font.weight.medium}', lineHeight: '{font.lineHeight.none}', letterSpacing: '{font.letterSpacing.normal}', description: 'Small buttons, badges, table headers.' },
|
|
210
|
+
'label-xs': { family: '{font.family.sans}', size: '{font.size.xs}', weight: '{font.weight.medium}', lineHeight: '{font.lineHeight.none}', letterSpacing: '{font.letterSpacing.normal}', description: 'Tiny badges, counters.' },
|
|
211
|
+
'kicker': { family: '{font.family.mono}', size: '{font.size.2xs}', weight: '{font.weight.medium}', lineHeight: '{font.lineHeight.none}', letterSpacing: '{font.letterSpacing.wider}', transform: 'uppercase', description: 'Mono uppercase eyebrow above titles and section labels. The most recognizable text style; use it instead of colored labels.' },
|
|
212
|
+
'code-md': { family: '{font.family.mono}', size: '{font.size.sm}', weight: '{font.weight.regular}', lineHeight: '{font.lineHeight.relaxed}', letterSpacing: '{font.letterSpacing.normal}', description: 'Code blocks.' },
|
|
213
|
+
'code-sm': { family: '{font.family.mono}', size: '{font.size.xs}', weight: '{font.weight.regular}', lineHeight: '{font.lineHeight.normal}', letterSpacing: '{font.letterSpacing.normal}', description: 'Inline code, kbd, IDs, technical values in tables.' },
|
|
214
|
+
'numeric-md': { family: '{font.family.sans}', size: '{font.size.md}', weight: '{font.weight.medium}', lineHeight: '{font.lineHeight.none}', letterSpacing: '{font.letterSpacing.normal}', description: 'Tabular numbers in tables and stats (font-variant-numeric: tabular-nums).' },
|
|
215
|
+
},
|
|
216
|
+
space: {
|
|
217
|
+
'0': '0px', px: '1px', '0.5': '2px', '1': '4px', '1.5': '6px', '2': '8px', '2.5': '10px', '3': '12px', '3.5': '14px',
|
|
218
|
+
'4': '16px', '5': '20px', '6': '24px', '7': '28px', '8': '32px', '9': '36px', '10': '40px', '11': '44px', '12': '48px',
|
|
219
|
+
'14': '56px', '16': '64px', '20': '80px', '24': '96px', '28': '112px', '32': '128px', '40': '160px', '48': '192px', '56': '224px', '64': '256px',
|
|
220
|
+
},
|
|
221
|
+
radius: { none: '0px', xs: r(2), sm: r(4), md: r(6), lg: r(8), xl: r(12), '2xl': r(16), '3xl': r(24), full: '9999px', control: r(8), card: r(12), panel: r(8), overlay: r(16) },
|
|
222
|
+
border: { width: { none: '0px', thin: '1px', medium: '2px', thick: '3px' } },
|
|
223
|
+
shadow: {
|
|
224
|
+
xs: { light: `0 1px 2px ${sh(0.05)}`, dark: '0 1px 2px rgba(0, 0, 0, 0.4)', description: 'Barely-there lift for inputs and small chips.' },
|
|
225
|
+
sm: { light: `0 1px 3px ${sh(0.08)}, 0 1px 2px -1px ${sh(0.06)}`, dark: '0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px -1px rgba(0, 0, 0, 0.4)', description: 'Resting cards that need separation from a same-colored canvas.' },
|
|
226
|
+
md: { light: `0 4px 6px -1px ${sh(0.08)}, 0 2px 4px -2px ${sh(0.06)}`, dark: '0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -2px rgba(0, 0, 0, 0.4)', description: 'Dropdowns, popovers, hovered interactive cards.' },
|
|
227
|
+
lg: { light: `0 10px 15px -3px ${sh(0.1)}, 0 4px 6px -4px ${sh(0.06)}`, dark: '0 10px 15px -3px rgba(0, 0, 0, 0.6), 0 4px 6px -4px rgba(0, 0, 0, 0.4)', description: 'Dialogs, drawers, command palette.' },
|
|
228
|
+
xl: { light: `0 20px 24px -4px ${sh(0.08)}, 0 8px 8px -4px ${sh(0.03)}`, dark: '0 20px 24px -4px rgba(0, 0, 0, 0.6), 0 8px 8px -4px rgba(0, 0, 0, 0.4)', description: 'Large floating layers: command palette, big popovers.' },
|
|
229
|
+
'2xl': { light: `0 24px 48px -12px ${sh(0.18)}, 0 4px 4px -2px ${sh(0.04)}`, dark: '0 24px 48px -12px rgba(0, 0, 0, 0.7)', description: 'Hero cards and app screenshots on marketing pages. Never inside the app shell.' },
|
|
230
|
+
'3xl': { light: `0 32px 64px -12px ${sh(0.14)}, 0 5px 5px -2.5px ${sh(0.04)}`, dark: '0 32px 64px -12px rgba(0, 0, 0, 0.75)', description: 'Device mockups on marketing pages.' },
|
|
231
|
+
control: { light: '0 0 0 0 transparent', dark: '0 0 0 0 transparent', description: 'Optional depth for filled buttons. Composed with xs; both remain flat in the Canon preset.' },
|
|
232
|
+
focus: { light: '0 0 0 3px {color.ring-focus}', dark: '0 0 0 3px {color.ring-focus}', description: 'Focus ring. Always paired with border-action on inputs.' },
|
|
233
|
+
'focus-danger': { light: '0 0 0 3px {color.ring-danger}', dark: '0 0 0 3px {color.ring-danger}', description: 'Focus ring on invalid controls.' },
|
|
234
|
+
},
|
|
235
|
+
size: {
|
|
236
|
+
control: { xs: px(ctrl - 8), sm: px(ctrl - 4), md: px(ctrl), lg: px(ctrl + 4), xl: px(ctrl + 8) },
|
|
237
|
+
controlPadding: { xs: '10px', sm: '12px', md: '14px', lg: '16px', xl: '18px' },
|
|
238
|
+
controlIcon: { xs: '16px', sm: '20px', md: '20px', lg: '20px', xl: '20px' },
|
|
239
|
+
icon: { xs: '12px', sm: '14px', md: '16px', lg: '20px', xl: '24px', '2xl': '32px' },
|
|
240
|
+
container: { xs: '480px', sm: '640px', md: '768px', lg: '1024px', xl: '1152px', '2xl': '1280px', prose: '65ch' },
|
|
241
|
+
},
|
|
242
|
+
z: { base: '0', raised: '1', sticky: '100', dropdown: '200', overlay: '300', modal: '400', popover: '500', toast: '600', tooltip: '700' },
|
|
243
|
+
motion: {
|
|
244
|
+
duration: { instant: '0ms', fast: '120ms', normal: '180ms', slow: '260ms', slower: '400ms' },
|
|
245
|
+
easing: { standard: 'cubic-bezier(0.2, 0, 0, 1)', out: 'cubic-bezier(0.16, 1, 0.3, 1)', in: 'cubic-bezier(0.4, 0, 1, 1)', linear: 'linear' },
|
|
246
|
+
},
|
|
247
|
+
breakpoint: { sm: '640px', md: '768px', lg: '1024px', xl: '1280px', '2xl': '1536px' },
|
|
248
|
+
opacity: { disabled: '0.5', muted: '0.7', overlay: '0.45' },
|
|
249
|
+
};
|
|
250
|
+
return deepMerge(deepMerge(tokens, seeds.presetOverrides), seeds.overrides);
|
|
251
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { buildScale } from '../color.js';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
export const CANON_BRAND = '#B4309F';
|
|
4
|
+
const NEUTRAL = { '50': '#FAFAFA', '100': '#F3F4F5', '200': '#E5E7E9', '300': '#CED2D6', '400': '#9EA5AD', '500': '#68727D', '600': '#515C67', '700': '#3F4A55', '800': '#2F3942', '900': '#232B33', '950': '#151B21' };
|
|
5
|
+
const flat = { light: '0 0 0 0 transparent', dark: '0 0 0 0 transparent' };
|
|
6
|
+
const scale = (seed) => buildScale(seed);
|
|
7
|
+
export const CANON_OVERRIDES = {
|
|
8
|
+
color: {
|
|
9
|
+
primitive: {
|
|
10
|
+
neutral: NEUTRAL, brand: scale(CANON_BRAND),
|
|
11
|
+
red: scale('#B54737'), amber: scale('#A1641B'),
|
|
12
|
+
green: scale('#397852'), blue: scale('#32679B'),
|
|
13
|
+
},
|
|
14
|
+
semantic: {
|
|
15
|
+
'bg-canvas': { light: '{white}', dark: '{neutral.950}' },
|
|
16
|
+
'bg-surface': { light: '{white}', dark: '{neutral.900}' },
|
|
17
|
+
'bg-surface-raised': { light: '{white}', dark: '{neutral.800}' },
|
|
18
|
+
'bg-subtle': { light: '{neutral.50}', dark: '{neutral.800}' },
|
|
19
|
+
'bg-muted': { light: '{neutral.100}', dark: '{neutral.700}' },
|
|
20
|
+
'bg-overlay': { light: 'rgba(21, 27, 33, 0.48)', dark: 'rgba(8, 12, 16, 0.72)' },
|
|
21
|
+
'bg-action': { light: '{brand.600}', dark: '{brand.300}' },
|
|
22
|
+
'bg-action-hover': { light: '{brand.700}', dark: '{brand.200}' },
|
|
23
|
+
'bg-action-active': { light: '{brand.800}', dark: '{brand.100}' },
|
|
24
|
+
'bg-action-subtle': { light: '{neutral.100}', dark: '{neutral.800}' },
|
|
25
|
+
'bg-accent': { light: '{brand.600}', dark: '{brand.400}' },
|
|
26
|
+
'bg-accent-subtle': { light: '{neutral.100}', dark: '{neutral.800}' },
|
|
27
|
+
'fg-default': { light: '{neutral.900}', dark: '{neutral.50}' },
|
|
28
|
+
'fg-muted': { light: '{neutral.600}', dark: '{neutral.300}' },
|
|
29
|
+
'fg-subtle': { light: '{neutral.500}', dark: '{neutral.400}' },
|
|
30
|
+
'fg-placeholder': { light: '{neutral.500}', dark: '{neutral.400}' },
|
|
31
|
+
'fg-on-action': { light: '{white}', dark: '{brand.950}' },
|
|
32
|
+
'fg-on-accent': { light: '{white}', dark: '{brand.950}' },
|
|
33
|
+
'fg-action': { light: '{brand.700}', dark: '{brand.300}' },
|
|
34
|
+
'fg-accent': { light: '{brand.700}', dark: '{brand.300}' },
|
|
35
|
+
'fg-link': { light: '{brand.700}', dark: '{brand.300}' },
|
|
36
|
+
'fg-link-hover': { light: '{brand.800}', dark: '{brand.200}' },
|
|
37
|
+
'border-control': { light: '{neutral.300}', dark: '{neutral.600}' },
|
|
38
|
+
'border-control-hover': { light: '{neutral.500}', dark: '{neutral.400}' },
|
|
39
|
+
'border-action': { light: '{brand.600}', dark: '{brand.300}' },
|
|
40
|
+
'ring-focus': { light: 'rgba(180, 48, 159, 0.28)', dark: 'rgba(229, 169, 218, 0.4)' },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
font: {
|
|
44
|
+
size: { '2xs': '11px', xs: '12px', sm: '13px', md: '14px', lg: '16px', xl: '18px', '2xl': '22px', '3xl': '28px', '4xl': '34px', '5xl': '42px', '6xl': '56px', '7xl': '68px', '8xl': '80px' },
|
|
45
|
+
},
|
|
46
|
+
type: {
|
|
47
|
+
'kicker': { family: '{font.family.mono}', size: '{font.size.2xs}', weight: '{font.weight.medium}', lineHeight: '1.5', letterSpacing: '0.08em', transform: 'uppercase', description: 'A small, tracked mono label for orientation. Use ink, with a short rule to introduce major sections.' },
|
|
48
|
+
},
|
|
49
|
+
radius: { control: '6px', card: '10px', panel: '8px', overlay: '12px' },
|
|
50
|
+
shadow: {
|
|
51
|
+
xs: { ...flat, description: 'Resting controls are flat. Borders and surface color separate them.' },
|
|
52
|
+
sm: { ...flat, description: 'Resting cards and selected segments do not float.' },
|
|
53
|
+
control: { ...flat, description: 'Flat button fill, without an inset bevel.' },
|
|
54
|
+
md: { light: '0 8px 24px -8px rgba(21, 30, 25, 0.14)', dark: '0 8px 24px -8px rgba(0, 0, 0, 0.45)' },
|
|
55
|
+
lg: { light: '0 16px 48px -16px rgba(21, 30, 25, 0.22)', dark: '0 16px 48px -16px rgba(0, 0, 0, 0.6)' },
|
|
56
|
+
focus: { light: '0 0 0 3px {color.ring-focus}', dark: '0 0 0 3px {color.ring-focus}' },
|
|
57
|
+
'focus-danger': { light: '0 0 0 3px {color.ring-danger}', dark: '0 0 0 3px {color.ring-danger}' },
|
|
58
|
+
},
|
|
59
|
+
size: { icon: { xs: '12px', sm: '16px', md: '20px', lg: '24px', xl: '28px', '2xl': '32px' } },
|
|
60
|
+
motion: { duration: { fast: '140ms', normal: '200ms', slow: '280ms' } },
|
|
61
|
+
};
|
|
62
|
+
/** Migration must compare old designs with their original defaults. */
|
|
63
|
+
export function legacyPresetDefaults(seeds) {
|
|
64
|
+
return seeds.overrides?.color?.primitive?.brand?.['600'] === '#7F56D9'
|
|
65
|
+
? JSON.parse(readFileSync(new URL('./legacy-canon.json', import.meta.url), 'utf8'))
|
|
66
|
+
: CANON_OVERRIDES;
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { buildTokens, deepMerge, applySeedChanges, tokenDifferences } from './base.js';
|