@halftone-ds/tokens 1.2.0 → 1.3.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/README.md +43 -1
- package/cli.mjs +5 -2
- package/lib/contrast.mjs +33 -0
- package/lib/resolve.mjs +80 -20
- package/lib/system/generate.mjs +212 -0
- package/lib/system/index.d.mts +168 -0
- package/lib/system/index.mjs +33 -0
- package/lib/system/migrate.mjs +38 -0
- package/lib/system/schema.mjs +511 -0
- package/lib/theme/apply.mjs +187 -28
- package/lib/theme/components.mjs +471 -0
- package/lib/theme/extras.mjs +105 -0
- package/lib/theme/fix.mjs +63 -10
- package/lib/theme/fonts.mjs +13 -0
- package/lib/theme/generate.mjs +98 -20
- package/lib/theme/index.d.mts +91 -2
- package/lib/theme/index.mjs +21 -4
- package/lib/theme/presets.mjs +1 -1
- package/lib/theme/ramp.mjs +55 -15
- package/lib/theme/recipe.mjs +68 -0
- package/lib/theme/semantic.mjs +19 -7
- package/lib/theme/shadcn.mjs +1 -1
- package/lib/theme/vars.mjs +61 -8
- package/lib/theme-cli.mjs +87 -17
- package/lib/usage.mjs +136 -0
- package/package.json +5 -1
- package/targets/design-md.mjs +32 -15
- package/targets/figma.mjs +1 -1
- package/targets/fonts.mjs +5 -5
- package/targets/llms.mjs +8 -4
- package/targets/nativewind3.mjs +58 -8
- package/targets/registry-theme.mjs +3 -3
- package/targets/shadcn-web.mjs +96 -6
- package/targets/ts-theme.mjs +35 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The component layer: Button, Chip (FilterChip), Badge and Input (Textarea and Select draw it too), as tokens both
|
|
3
|
+
* kits read. One theme for both kits: corners, sizes, text and colours are shared, so a Button looks the same on
|
|
4
|
+
* the phone and on the web. Pure ESM.
|
|
5
|
+
*
|
|
6
|
+
* componentLayer(input) → { tokens, shape }
|
|
7
|
+
* tokens semantic.component.<name>.* (what tokens.json records; colours are references to semantic colours)
|
|
8
|
+
* shape the same, resolved (hex per mode, px), what componentVars and the contrast check read
|
|
9
|
+
*
|
|
10
|
+
* Each component has a style (Button and Input: the recipe's button and field styles), a corner (a Foundations
|
|
11
|
+
* corner, or px), a size (heights and horizontal padding, px), a text style (the component keeps its own weight)
|
|
12
|
+
* and fixed variants (Input: states). A variant's fill, label, border and pressed colours are roles (semantic
|
|
13
|
+
* colours); the defaults reproduce what the kits always drew. Pressed is derived from the fill unless it's set,
|
|
14
|
+
* hover follows pressed, and disabled is 50% opacity.
|
|
15
|
+
*
|
|
16
|
+
* Phones raise anything under 44px to a 44px touch target (hitSlop) in the mobile kit; the tokens hold the
|
|
17
|
+
* drawn sizes.
|
|
18
|
+
*/
|
|
19
|
+
import { OPTIONS, TEXT_DEFAULTS, TEXT_LINE_HEIGHT } from './recipe.mjs';
|
|
20
|
+
|
|
21
|
+
export const COMPONENT_NAMES = ['button', 'chip', 'badge', 'input'];
|
|
22
|
+
/** A Foundations corner a component can follow (rounded-sm…2xl, full); a number is an override in px. */
|
|
23
|
+
export const CORNERS = ['sm', 'md', 'lg', 'xl', '2xl', 'full'];
|
|
24
|
+
export const SLOTS = ['fill', 'label', 'border', 'pressed'];
|
|
25
|
+
/** The touch-target floor the mobile kit applies on phones (hitSlop). */
|
|
26
|
+
export const TOUCH_TARGET = 44;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Each component: its CSS prefix, its styles (the first is the default), the size keys it has, its variants with
|
|
30
|
+
* the slots each one takes, and its default text style.
|
|
31
|
+
*/
|
|
32
|
+
export const COMPONENTS = {
|
|
33
|
+
button: {
|
|
34
|
+
prefix: 'button',
|
|
35
|
+
label: 'Button',
|
|
36
|
+
styles: OPTIONS.button,
|
|
37
|
+
sizes: ['sm', 'md', 'lg', 'x'],
|
|
38
|
+
variants: { primary: SLOTS, secondary: SLOTS, outline: SLOTS, ghost: SLOTS, destructive: SLOTS },
|
|
39
|
+
text: 'p',
|
|
40
|
+
},
|
|
41
|
+
chip: {
|
|
42
|
+
prefix: 'chip',
|
|
43
|
+
label: 'Chip',
|
|
44
|
+
styles: ['outline', 'soft', 'solid'],
|
|
45
|
+
sizes: ['md', 'x'],
|
|
46
|
+
variants: { unselected: SLOTS, selected: SLOTS },
|
|
47
|
+
text: 'small',
|
|
48
|
+
},
|
|
49
|
+
badge: {
|
|
50
|
+
prefix: 'badge',
|
|
51
|
+
label: 'Badge',
|
|
52
|
+
styles: ['pill', 'square', 'soft'],
|
|
53
|
+
sizes: ['x'],
|
|
54
|
+
variants: { default: SLOTS, secondary: SLOTS, destructive: SLOTS, outline: SLOTS },
|
|
55
|
+
text: 'small',
|
|
56
|
+
},
|
|
57
|
+
input: {
|
|
58
|
+
prefix: 'field',
|
|
59
|
+
label: 'Input',
|
|
60
|
+
styles: OPTIONS.field,
|
|
61
|
+
sizes: ['sm', 'md', 'lg', 'x'],
|
|
62
|
+
variants: { default: ['fill', 'label', 'border'], focus: ['border'], error: ['border'], disabled: ['fill', 'label', 'border'] },
|
|
63
|
+
text: 'p',
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The slots as the CSS variables name them. `ring` is the focus ring Input's focus and error states derive. */
|
|
68
|
+
export const SLOT_VAR = { fill: 'bg', label: 'fg', border: 'border', pressed: 'pressed', ring: 'ring' };
|
|
69
|
+
/** The slots that carry an opacity (a variable of their own, -a). */
|
|
70
|
+
const ALPHA_SLOTS = new Set(['fill', 'border', 'pressed', 'ring']);
|
|
71
|
+
|
|
72
|
+
const round = (n, places = 4) => Number(n.toFixed(places));
|
|
73
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
74
|
+
|
|
75
|
+
/** Each text style's Tailwind size at 16px, whose line height a control's text takes (text-base: 24 on 16). */
|
|
76
|
+
const TW_SIZE = { 12: 'xs', 14: 'sm', 16: 'base', 18: 'lg', 20: 'xl', 24: '2xl', 30: '3xl', 36: '4xl' };
|
|
77
|
+
/** A control's line height as a multiple of its text size: the Tailwind size's own, so Body reads as text-base. */
|
|
78
|
+
export const fittedLeading = (style) => {
|
|
79
|
+
const px = TEXT_DEFAULTS[style]?.size ?? 16;
|
|
80
|
+
return TEXT_LINE_HEIGHT[TW_SIZE[px] ?? 'base'] / px;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The corner a component follows when the system doesn't pick one. */
|
|
84
|
+
export function defaultCorner(name, { controls = 'pill', style } = {}) {
|
|
85
|
+
if (name === 'button' || name === 'chip') return controls === 'pill' ? 'full' : 'lg';
|
|
86
|
+
if (name === 'badge') return style === 'square' ? 'sm' : 'full';
|
|
87
|
+
return 'md';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The default style of each component (Button and Input: the recipe's). */
|
|
91
|
+
export const defaultStyle = (name, recipe = {}) => (name === 'button' ? (recipe.button ?? 'solid') : name === 'input' ? (recipe.field ?? 'outline') : COMPONENTS[name].styles[0]);
|
|
92
|
+
|
|
93
|
+
// A colour spec: a role ('primary'), 'none', a ramp step ('brand.100'), { role, alpha }, or { light, dark } of those.
|
|
94
|
+
const both = (light, dark) => ({ light, dark });
|
|
95
|
+
const a = (role, alpha) => ({ role, alpha });
|
|
96
|
+
|
|
97
|
+
/** Every variant's default colours (specs), per component and style. `pressed: undefined` = derived from the fill. */
|
|
98
|
+
function variantDefaults(name, style, { button, field }) {
|
|
99
|
+
if (name === 'button') {
|
|
100
|
+
return {
|
|
101
|
+
primary: { fill: a('button', button.alpha), label: 'button-foreground', border: 'button-edge' },
|
|
102
|
+
secondary: { fill: 'secondary', label: 'secondary-foreground', border: 'none', pressed: a('secondary', 0.8) },
|
|
103
|
+
outline: { fill: both('background', a('input', 0.3)), label: 'foreground', border: both('border', 'input'), pressed: both('accent', a('input', 0.5)) },
|
|
104
|
+
ghost: { fill: 'none', label: 'foreground', border: 'none', pressed: both('accent', a('accent', 0.5)) },
|
|
105
|
+
destructive: { fill: 'destructive', label: 'destructive-foreground', border: 'none' },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (name === 'chip') {
|
|
109
|
+
const unselected =
|
|
110
|
+
style === 'outline' ? { fill: 'background', label: 'foreground', border: 'border', pressed: 'accent' } : { fill: 'secondary', label: 'secondary-foreground', border: 'none', pressed: a('secondary', 0.8) };
|
|
111
|
+
// Selected is inverted (foreground on background), whatever the style but solid, which fills it with the brand.
|
|
112
|
+
const selected = style === 'solid' ? { fill: 'primary', label: 'primary-foreground', border: 'primary' } : { fill: 'foreground', label: 'background', border: 'foreground' };
|
|
113
|
+
return { unselected, selected };
|
|
114
|
+
}
|
|
115
|
+
if (name === 'badge') {
|
|
116
|
+
const soft = style === 'soft';
|
|
117
|
+
return {
|
|
118
|
+
default: soft ? { fill: both('brand.100', 'brand.900'), label: both('brand.700', 'brand.200'), border: 'none' } : { fill: 'primary', label: 'primary-foreground', border: 'none' },
|
|
119
|
+
secondary: { fill: 'secondary', label: 'secondary-foreground', border: 'none' },
|
|
120
|
+
destructive: soft ? { fill: both('danger.100', 'danger.900'), label: both('danger.700', 'danger.200'), border: 'none' } : { fill: 'destructive', label: 'destructive-foreground', border: 'none' },
|
|
121
|
+
outline: { fill: 'none', label: 'foreground', border: 'border' },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
default: { fill: both(a('field', field.alpha.light), a('field', field.alpha.dark)), label: 'foreground', border: 'field-edge' },
|
|
126
|
+
focus: { border: 'ring' },
|
|
127
|
+
error: { border: 'destructive' },
|
|
128
|
+
disabled: {},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A spec in one mode → { ref, alpha } (ref: '{semantic.color.x}' or '{primitive.color.ramp.step}'). */
|
|
133
|
+
function specIn(spec, mode) {
|
|
134
|
+
if (isObject(spec) && 'light' in spec) return specIn(spec[mode], mode);
|
|
135
|
+
if (isObject(spec)) return { ...specIn(spec.role, mode), alpha: spec.alpha };
|
|
136
|
+
if (spec === 'none') return { ref: '{semantic.color.background}', alpha: 0 };
|
|
137
|
+
if (spec.includes('.')) return { ref: `{primitive.color.${spec}}`, alpha: 1 };
|
|
138
|
+
return { ref: `{semantic.color.${spec}}`, alpha: 1 };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The role a spec names in light mode (what the Studio shows), or null for a ramp step. */
|
|
142
|
+
function roleOf(spec) {
|
|
143
|
+
if (isObject(spec) && 'light' in spec) return roleOf(spec.light);
|
|
144
|
+
if (isObject(spec)) return roleOf(spec.role);
|
|
145
|
+
return spec === 'none' || !spec.includes('.') ? spec : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The component layer for a theme.
|
|
150
|
+
* styles { button, field } (the recipe's) and, from a system, chip and badge
|
|
151
|
+
* controls the recipe's 'pill' | 'match' (Button and Chip's default corner)
|
|
152
|
+
* corner (name) → px for sm…2xl ('full' is 9999)
|
|
153
|
+
* density { sm, md, lg, x } control heights and padding (px)
|
|
154
|
+
* borderWidth the theme's border width (px)
|
|
155
|
+
* button { alpha, border, edge }; field { alpha: { light, dark }, border, edge, inset, round, roundBottom }
|
|
156
|
+
* textStyles the text styles as drawn (resolveTextStyles); type: { leadingFactor }
|
|
157
|
+
* hex (ref, mode) → '#rrggbb'
|
|
158
|
+
* overrides a system's `components` (normalised; Button and Input styles already folded into the recipe)
|
|
159
|
+
*/
|
|
160
|
+
export function componentLayer({ styles, controls, corner, density, borderWidth, button, field, textStyles, type, hex, overrides = {} }) {
|
|
161
|
+
const tokens = {};
|
|
162
|
+
const shape = {};
|
|
163
|
+
const color = (spec, edited) => {
|
|
164
|
+
const light = specIn(spec, 'light');
|
|
165
|
+
const dark = specIn(spec, 'dark');
|
|
166
|
+
return {
|
|
167
|
+
role: roleOf(spec),
|
|
168
|
+
edited: !!edited,
|
|
169
|
+
ref: { light: light.ref, dark: dark.ref },
|
|
170
|
+
light: hex(light.ref, 'light'),
|
|
171
|
+
dark: hex(dark.ref, 'dark'),
|
|
172
|
+
alpha: { light: round(light.alpha), dark: round(dark.alpha) },
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
const faded = (c, by) => ({ ...c, role: c.role, edited: false, derived: true, alpha: { light: round(c.alpha.light * by), dark: round(c.alpha.dark * by) } });
|
|
176
|
+
|
|
177
|
+
for (const name of COMPONENT_NAMES) {
|
|
178
|
+
const def = COMPONENTS[name];
|
|
179
|
+
const o = overrides[name] ?? {};
|
|
180
|
+
const style = name === 'button' ? styles.button : name === 'input' ? styles.field : (o.style ?? def.styles[0]);
|
|
181
|
+
const cornerName = typeof o.radius === 'string' ? o.radius : defaultCorner(name, { controls, style });
|
|
182
|
+
const radius = typeof o.radius === 'number' ? o.radius : cornerName === 'full' ? 9999 : round(corner(cornerName), 2);
|
|
183
|
+
const size = o.size ?? {};
|
|
184
|
+
|
|
185
|
+
// Sizes: Button from density (small and large padding 16 and 24 until the padding is set, then ±4); the Chip a
|
|
186
|
+
// 36px pill with 14px padding; the Badge 8px padding; Input's heights from density, its padding the field
|
|
187
|
+
// style's inset (16px, or flush).
|
|
188
|
+
const h = {};
|
|
189
|
+
const x = {};
|
|
190
|
+
if (name === 'button') {
|
|
191
|
+
Object.assign(h, { sm: size.sm ?? density.sm, md: size.md ?? density.md, lg: size.lg ?? density.lg });
|
|
192
|
+
Object.assign(x, size.x === undefined ? { sm: 16, md: density.x, lg: 24 } : { sm: Math.max(0, size.x - 4), md: size.x, lg: size.x + 4 });
|
|
193
|
+
}
|
|
194
|
+
if (name === 'chip') {
|
|
195
|
+
h.md = size.md ?? 36;
|
|
196
|
+
x.md = size.x ?? 14;
|
|
197
|
+
}
|
|
198
|
+
if (name === 'badge') x.md = size.x ?? 8;
|
|
199
|
+
if (name === 'input') {
|
|
200
|
+
Object.assign(h, { sm: size.sm ?? 32, md: size.md ?? density.md, lg: size.lg ?? density.lg });
|
|
201
|
+
x.md = size.x ?? (field.inset ? 16 : 0);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Text: the style's font, size and tracking; the line height fits a control (Body → text-base's 24 on 16).
|
|
205
|
+
const textName = o.text ?? def.text;
|
|
206
|
+
const ts = textStyles[textName];
|
|
207
|
+
const text = { style: textName, font: ts.font, size: round(ts.size), leading: round(ts.size * fittedLeading(textName) * (type.leadingFactor ?? 1)), tracking: ts.tracking };
|
|
208
|
+
|
|
209
|
+
// Variants.
|
|
210
|
+
const defaults = variantDefaults(name, style, { button, field });
|
|
211
|
+
const variants = {};
|
|
212
|
+
for (const [variant, slots] of Object.entries(def.variants)) {
|
|
213
|
+
const edits = o.variants?.[variant] ?? {};
|
|
214
|
+
const d = defaults[variant];
|
|
215
|
+
const v = {};
|
|
216
|
+
if (name === 'input' && variant === 'disabled') {
|
|
217
|
+
// Disabled follows the default state (at 50% opacity) unless set.
|
|
218
|
+
for (const slot of slots) v[slot] = edits[slot] ? color(edits[slot], true) : { ...variants.default[slot], edited: false };
|
|
219
|
+
} else {
|
|
220
|
+
// A border that is the fill by default (a selected chip, a primary button) follows an edited fill.
|
|
221
|
+
const follows = edits.fill !== undefined && edits.border === undefined && JSON.stringify(roleOf(d.border ?? 'none')) === JSON.stringify(roleOf(d.fill ?? 'none')) && d.border !== 'none';
|
|
222
|
+
for (const slot of slots) {
|
|
223
|
+
if (slot === 'pressed') continue;
|
|
224
|
+
v[slot] = color(slot === 'border' && follows ? edits.fill : (edits[slot] ?? d[slot]), edits[slot] !== undefined);
|
|
225
|
+
// An edited colour is opaque; 'none' is transparent.
|
|
226
|
+
if (edits[slot] !== undefined && edits[slot] !== 'none') v[slot].alpha = { light: 1, dark: 1 };
|
|
227
|
+
}
|
|
228
|
+
// A border of 'none' is clear (the fill's colour at no opacity): the ring keeps its size, and a fill set in a
|
|
229
|
+
// className still shows under it.
|
|
230
|
+
if (v.border && roleOf(edits.border ?? d.border) === 'none' && v.fill) v.border = { ...v.fill, role: 'none', edited: edits.border !== undefined, alpha: { light: 0, dark: 0 } };
|
|
231
|
+
if (slots.includes('pressed')) {
|
|
232
|
+
if (edits.pressed !== undefined) v.pressed = { ...color(edits.pressed, true), alpha: { light: 1, dark: 1 } };
|
|
233
|
+
// Derived: the kit's own pressed colour until the fill changes, then the fill at 90% (a clear fill: accent).
|
|
234
|
+
else if (d.pressed && edits.fill === undefined) v.pressed = { ...color(d.pressed), derived: true };
|
|
235
|
+
else v.pressed = v.fill.alpha.light === 0 && v.fill.alpha.dark === 0 ? { ...color('accent'), derived: true } : faded(v.fill, 0.9);
|
|
236
|
+
}
|
|
237
|
+
// Input's focus and error rings: the border at 50% (focus) or 20% / 40% in dark (error).
|
|
238
|
+
if (name === 'input' && (variant === 'focus' || variant === 'error')) {
|
|
239
|
+
const ring = variant === 'focus' ? { light: 0.5, dark: 0.5 } : { light: 0.2, dark: 0.4 };
|
|
240
|
+
v.ring = { ...v.border, edited: false, derived: true, alpha: ring };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Button borders: a width per variant (0 without a border), and the primary's bottom edge (raised).
|
|
244
|
+
if (name === 'button') {
|
|
245
|
+
const none = v.border.role === 'none';
|
|
246
|
+
const width = variant === 'primary' ? button.border : variant === 'outline' ? borderWidth : 0;
|
|
247
|
+
const edge = variant === 'primary' ? button.edge : width;
|
|
248
|
+
v.borderWidth = none ? 0 : width || (edits.border !== undefined ? borderWidth : 0);
|
|
249
|
+
v.edgeWidth = none ? 0 : Math.max(edge, v.borderWidth);
|
|
250
|
+
}
|
|
251
|
+
variants[variant] = v;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const out = { style, corner: typeof o.radius === 'number' ? null : cornerName, radius, h, x, text, variants };
|
|
255
|
+
if (name === 'input') {
|
|
256
|
+
out.radiusBottom = round(radius * field.roundBottom, 4);
|
|
257
|
+
out.radius = round(radius * field.round, 4);
|
|
258
|
+
}
|
|
259
|
+
shape[name] = out;
|
|
260
|
+
tokens[name] = componentTokens(name, out, { button, field });
|
|
261
|
+
}
|
|
262
|
+
return { tokens, shape };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A value per mode, collapsed to one when both modes agree. */
|
|
266
|
+
const moded = (light, dark) => (light === dark ? light : { light, dark });
|
|
267
|
+
|
|
268
|
+
/** One component's tokens (semantic.component.<name>), plain values; apply.mjs gives them $type and $value. */
|
|
269
|
+
function componentTokens(name, c, { button, field }) {
|
|
270
|
+
const t = { radius: c.radius };
|
|
271
|
+
if (name === 'input') t['radius-bottom'] = c.radiusBottom;
|
|
272
|
+
if (Object.keys(c.h).length) t.height = { ...c.h };
|
|
273
|
+
t.x = { ...c.x };
|
|
274
|
+
t.text = { size: c.text.size, leading: c.text.leading, tracking: c.text.tracking, font: `{primitive.font.family.${c.text.font}}` };
|
|
275
|
+
// The style numbers the button and field tokens used to hold (semantic.button.*, semantic.field.*).
|
|
276
|
+
if (name === 'button') Object.assign(t, { alpha: button.alpha, 'border-width': button.border, 'edge-width': button.edge });
|
|
277
|
+
if (name === 'input') {
|
|
278
|
+
Object.assign(t, {
|
|
279
|
+
alpha: moded(field.alpha.light, field.alpha.dark),
|
|
280
|
+
'border-width': field.border,
|
|
281
|
+
'edge-width': field.edge,
|
|
282
|
+
round: field.round,
|
|
283
|
+
'round-bottom': field.roundBottom,
|
|
284
|
+
inset: field.inset,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
for (const [variant, v] of Object.entries(c.variants)) {
|
|
288
|
+
const group = {};
|
|
289
|
+
for (const slot of ['fill', 'label', 'border', 'pressed', 'ring']) {
|
|
290
|
+
if (!v[slot]) continue;
|
|
291
|
+
group[slot] = { light: v[slot].ref.light, dark: v[slot].ref.dark };
|
|
292
|
+
if (ALPHA_SLOTS.has(slot)) group[`${slot}-alpha`] = moded(v[slot].alpha.light, v[slot].alpha.dark);
|
|
293
|
+
}
|
|
294
|
+
if (v.borderWidth !== undefined) Object.assign(group, { 'border-width': v.borderWidth, 'edge-width': v.edgeWidth });
|
|
295
|
+
t[variant] = group;
|
|
296
|
+
}
|
|
297
|
+
return t;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The shape back from a token file's semantic.component group, as componentLayer returns it: `light` / `dark` are
|
|
302
|
+
* the flat resolved maps ('semantic.component.button.radius' → value), `raw` the unresolved group (for the font role
|
|
303
|
+
* and the roles the colours reference). Null when the file has no component tokens (a file from before them).
|
|
304
|
+
*/
|
|
305
|
+
export function readComponents(light, dark, raw) {
|
|
306
|
+
if (!raw || !Object.keys(raw).length) return null;
|
|
307
|
+
const shape = {};
|
|
308
|
+
const at = (map, path) => map[`semantic.component.${path}`];
|
|
309
|
+
const rawValue = (path) => {
|
|
310
|
+
let node = raw;
|
|
311
|
+
for (const key of path.split('.')) node = node?.[key];
|
|
312
|
+
return node?.$value;
|
|
313
|
+
};
|
|
314
|
+
for (const name of COMPONENT_NAMES) {
|
|
315
|
+
if (!raw[name]) return null;
|
|
316
|
+
const def = COMPONENTS[name];
|
|
317
|
+
const num = (path, fallback) => at(light, `${name}.${path}`) ?? fallback;
|
|
318
|
+
const sizes = (group) => Object.fromEntries(['sm', 'md', 'lg'].filter((s) => at(light, `${name}.${group}.${s}`) !== undefined).map((s) => [s, at(light, `${name}.${group}.${s}`)]));
|
|
319
|
+
const font = /\{primitive\.font\.family\.(heading|body|mono)\}/.exec(String(rawValue(`${name}.text.font`)))?.[1] ?? 'body';
|
|
320
|
+
const out = {
|
|
321
|
+
radius: num('radius', 0),
|
|
322
|
+
h: sizes('height'),
|
|
323
|
+
x: sizes('x'),
|
|
324
|
+
text: { font, size: num('text.size', 16), leading: num('text.leading', 24), tracking: num('text.tracking', 0) },
|
|
325
|
+
variants: {},
|
|
326
|
+
};
|
|
327
|
+
if (name === 'input') out.radiusBottom = num('radius-bottom', out.radius);
|
|
328
|
+
for (const variant of Object.keys(def.variants)) {
|
|
329
|
+
const v = {};
|
|
330
|
+
for (const slot of ['fill', 'label', 'border', 'pressed', 'ring']) {
|
|
331
|
+
const lightHex = at(light, `${name}.${variant}.${slot}`);
|
|
332
|
+
if (lightHex === undefined) continue;
|
|
333
|
+
const refs = rawValue(`${name}.${variant}.${slot}`);
|
|
334
|
+
const role = /\{semantic\.color\.([a-z0-9-]+)\}/.exec(String(isObject(refs) ? refs.light : refs))?.[1] ?? null;
|
|
335
|
+
v[slot] = {
|
|
336
|
+
role,
|
|
337
|
+
light: String(lightHex).toLowerCase(),
|
|
338
|
+
dark: String(at(dark, `${name}.${variant}.${slot}`)).toLowerCase(),
|
|
339
|
+
alpha: { light: at(light, `${name}.${variant}.${slot}-alpha`) ?? 1, dark: at(dark, `${name}.${variant}.${slot}-alpha`) ?? 1 },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if (at(light, `${name}.${variant}.border-width`) !== undefined) {
|
|
343
|
+
v.borderWidth = at(light, `${name}.${variant}.border-width`);
|
|
344
|
+
v.edgeWidth = at(light, `${name}.${variant}.edge-width`) ?? v.borderWidth;
|
|
345
|
+
}
|
|
346
|
+
out.variants[variant] = v;
|
|
347
|
+
}
|
|
348
|
+
shape[name] = out;
|
|
349
|
+
}
|
|
350
|
+
return shape;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Every component variable name a shape produces, light block (what an embedded kit may accept). */
|
|
354
|
+
export function componentVarNames() {
|
|
355
|
+
const names = [];
|
|
356
|
+
for (const name of COMPONENT_NAMES) {
|
|
357
|
+
const def = COMPONENTS[name];
|
|
358
|
+
const p = def.prefix;
|
|
359
|
+
names.push(`--${p}-radius`, `--${p}-text-size`, `--${p}-text-leading`, `--${p}-text-tracking`, `--${p}-text-font`);
|
|
360
|
+
if (name === 'input') names.push('--field-radius-b');
|
|
361
|
+
for (const s of def.sizes.filter((s) => s !== 'x')) names.push(`--${p}-h-${s}`);
|
|
362
|
+
names.push(`--${p}-x`);
|
|
363
|
+
if (name === 'button') names.push('--button-x-sm', '--button-x-lg');
|
|
364
|
+
for (const [variant, slots] of Object.entries(def.variants)) {
|
|
365
|
+
const all = [...slots, ...(name === 'input' && (variant === 'focus' || variant === 'error') ? ['ring'] : [])];
|
|
366
|
+
for (const slot of all) {
|
|
367
|
+
names.push(`--${p}-${variant}-${SLOT_VAR[slot]}`);
|
|
368
|
+
if (ALPHA_SLOTS.has(slot)) names.push(`--${p}-${variant}-${SLOT_VAR[slot]}-a`);
|
|
369
|
+
}
|
|
370
|
+
if (name === 'button') names.push(`--button-${variant}-border-w`, `--button-${variant}-edge-w`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return names;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* A shape as CSS variables, { light, dark }, in a kit's format (vars.mjs):
|
|
378
|
+
* 'hsl' mobile: colours as triplets, lengths in px or rem (4 places); no font variable (the kit picks it in JS)
|
|
379
|
+
* 'oklch' web: oklch colours; `font(role)` writes the text's font family
|
|
380
|
+
* Colours carry their opacity apart (--button-primary-bg and --button-primary-bg-a), as --button and --button-alpha do.
|
|
381
|
+
*/
|
|
382
|
+
export function componentVars(shape, format, convert, font = (role) => `var(--kit-font-${role})`) {
|
|
383
|
+
const light = {};
|
|
384
|
+
const dark = {};
|
|
385
|
+
const rem = (px) => (px >= 9999 ? '9999px' : `${round(px / 16)}rem`);
|
|
386
|
+
for (const name of COMPONENT_NAMES) {
|
|
387
|
+
const c = shape[name];
|
|
388
|
+
if (!c) continue;
|
|
389
|
+
const p = COMPONENTS[name].prefix;
|
|
390
|
+
light[`--${p}-radius`] = rem(c.radius);
|
|
391
|
+
if (name === 'input') light['--field-radius-b'] = rem(c.radiusBottom);
|
|
392
|
+
for (const [s, px] of Object.entries(c.h)) light[`--${p}-h-${s}`] = `${px}px`;
|
|
393
|
+
light[`--${p}-x`] = `${c.x.md}px`;
|
|
394
|
+
if (name === 'button') Object.assign(light, { '--button-x-sm': `${c.x.sm}px`, '--button-x-lg': `${c.x.lg}px` });
|
|
395
|
+
light[`--${p}-text-size`] = rem(c.text.size);
|
|
396
|
+
light[`--${p}-text-leading`] = rem(c.text.leading);
|
|
397
|
+
light[`--${p}-text-tracking`] = `${c.text.tracking}em`;
|
|
398
|
+
if (format !== 'hsl') light[`--${p}-text-font`] = font(c.text.font);
|
|
399
|
+
for (const [variant, v] of Object.entries(c.variants)) {
|
|
400
|
+
for (const [slot, col] of Object.entries(v)) {
|
|
401
|
+
if (!SLOT_VAR[slot] || !col) continue;
|
|
402
|
+
const key = `--${p}-${variant}-${SLOT_VAR[slot]}`;
|
|
403
|
+
light[key] = convert(col.light);
|
|
404
|
+
dark[key] = convert(col.dark);
|
|
405
|
+
if (ALPHA_SLOTS.has(slot)) {
|
|
406
|
+
light[`${key}-a`] = String(col.alpha.light);
|
|
407
|
+
dark[`${key}-a`] = String(col.alpha.dark);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (v.borderWidth !== undefined) Object.assign(light, { [`--button-${variant}-border-w`]: `${v.borderWidth}px`, [`--button-${variant}-edge-w`]: `${v.edgeWidth}px` });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return { light, dark };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** A shape's colour slots as Tailwind colours (name → CSS), for both kits' theme maps. */
|
|
417
|
+
export function componentColors(shape, css) {
|
|
418
|
+
const out = {};
|
|
419
|
+
for (const name of COMPONENT_NAMES) {
|
|
420
|
+
const c = shape[name];
|
|
421
|
+
if (!c) continue;
|
|
422
|
+
const p = COMPONENTS[name].prefix;
|
|
423
|
+
for (const [variant, v] of Object.entries(c.variants)) {
|
|
424
|
+
for (const slot of Object.keys(v)) {
|
|
425
|
+
if (!SLOT_VAR[slot]) continue;
|
|
426
|
+
const key = `${p}-${variant}-${SLOT_VAR[slot]}`;
|
|
427
|
+
out[key] = css(key, ALPHA_SLOTS.has(slot));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Label-on-fill pairings for every variant (the fill blended over the page at its opacity), in one mode: what the
|
|
436
|
+
* contrast gate and the Studio's Accessibility tab count. Input's focus and error borders aren't text.
|
|
437
|
+
* → [{ component, variant, label, fg, bg, fgRole, bgRole }]
|
|
438
|
+
*/
|
|
439
|
+
export function componentPairs(shape, mode, background, blend) {
|
|
440
|
+
const out = [];
|
|
441
|
+
for (const name of COMPONENT_NAMES) {
|
|
442
|
+
const c = shape?.[name];
|
|
443
|
+
if (!c) continue;
|
|
444
|
+
for (const [variant, v] of Object.entries(c.variants)) {
|
|
445
|
+
if (!v.label || !v.fill) continue;
|
|
446
|
+
const alpha = v.fill.alpha[mode];
|
|
447
|
+
const bg = alpha >= 1 ? v.fill[mode] : alpha <= 0 ? background : blend(v.fill[mode], background, alpha);
|
|
448
|
+
out.push({ component: name, variant, label: `${COMPONENTS[name].label} ${variant}: label on fill`, fg: v.label[mode], bg, fgRole: v.label.role, bgRole: alpha <= 0 ? 'background' : v.fill.role });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
/** A corner in words: 'pill', or px. */
|
|
456
|
+
const cornerText = (px) => (px >= 9999 ? 'pill' : `${round(px, 2)}px corners`);
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The component layer in a few lines for the agent docs (DESIGN.md, llms.txt): sizes, corners and the selected chip,
|
|
460
|
+
* the same in both kits.
|
|
461
|
+
*/
|
|
462
|
+
export function describeComponents(shape) {
|
|
463
|
+
const { button, chip, badge, input } = shape;
|
|
464
|
+
const role = (c) => (c?.role ?? 'its style').replace(/-/g, ' ');
|
|
465
|
+
return [
|
|
466
|
+
`Button ${button.h.md}px tall (small ${button.h.sm}, large ${button.h.lg}), ${button.x.md}px padding, ${cornerText(button.radius)}, ${button.text.size}px text`,
|
|
467
|
+
`Chip ${chip.h.md}px, ${cornerText(chip.radius)}; selected is ${role(chip.variants.selected.fill)} with ${role(chip.variants.selected.label)} text`,
|
|
468
|
+
`Badge ${cornerText(badge.radius)}, ${badge.text.size}px text`,
|
|
469
|
+
`Input, Textarea and Select ${input.h.md}px, ${cornerText(input.radius)}`,
|
|
470
|
+
];
|
|
471
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A system's extras (the `extra` token group, generateSystem → theme.extra, applySystem → tokens.json `extra.*`) as
|
|
3
|
+
* lists the targets write from. Extras are the brand's own tokens: every target emits them (CSS variables,
|
|
4
|
+
* Tailwind classes, the agent docs), and no kit component reads them. Pure ESM.
|
|
5
|
+
*
|
|
6
|
+
* extraList(group) → {
|
|
7
|
+
* color: [{ name, light, dark, ramp?, step? }] a single colour, or each step of a ramp (name 'sunset-500')
|
|
8
|
+
* text: [{ name, font: 'heading'|'body'|'mono', weight, size, leading, tracking }]
|
|
9
|
+
* radius: [{ name, px }]
|
|
10
|
+
* shadow: [{ name, light: Layer[], dark: Layer[] }] DTCG shadow layers
|
|
11
|
+
* border: [{ name, width, style? }]
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* What each becomes (both kits):
|
|
15
|
+
* colour --<name> (a ramp: --<name>-<step>), bg-<name> / text-<name> / border-<name>
|
|
16
|
+
* text --type-<name>-{size,leading,weight,tracking} (+ -font on the web), type-<name> (web), text-type-<name>… (mobile)
|
|
17
|
+
* radius rounded-<name>
|
|
18
|
+
* shadow --shadow-<name>, shadow-<name>
|
|
19
|
+
* border --border-<name>-width, border-<name> (web: width and style; mobile: the width)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const isModed = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && 'light' in v && 'dark' in v;
|
|
23
|
+
const modes = (v) => (isModed(v) ? { light: v.light, dark: v.dark } : { light: v, dark: v });
|
|
24
|
+
const lower = (hex) => (typeof hex === 'string' ? hex.toLowerCase() : hex);
|
|
25
|
+
const STEP = /^\d+$/;
|
|
26
|
+
|
|
27
|
+
/** The font role a text extra's family reference names ('{primitive.font.family.body}' → 'body'). */
|
|
28
|
+
const fontRole = (value) => /\{primitive\.font\.family\.(heading|body|mono)\}/.exec(String(value))?.[1] ?? 'body';
|
|
29
|
+
|
|
30
|
+
export function extraList(group) {
|
|
31
|
+
const out = { color: [], text: [], radius: [], shadow: [], border: [] };
|
|
32
|
+
if (!group || typeof group !== 'object') return out;
|
|
33
|
+
for (const [name, node] of Object.entries(group.color ?? {})) {
|
|
34
|
+
if (!node || typeof node !== 'object') continue;
|
|
35
|
+
if ('$value' in node) {
|
|
36
|
+
const v = modes(node.$value);
|
|
37
|
+
out.color.push({ name, light: lower(v.light), dark: lower(v.dark) });
|
|
38
|
+
} else {
|
|
39
|
+
for (const [step, token] of Object.entries(node)) {
|
|
40
|
+
if (!STEP.test(step) || !token || !('$value' in token)) continue;
|
|
41
|
+
const v = modes(token.$value);
|
|
42
|
+
out.color.push({ name: `${name}-${step}`, ramp: name, step, light: lower(v.light), dark: lower(v.dark) });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const [name, node] of Object.entries(group.text ?? {})) {
|
|
47
|
+
if (!node || typeof node !== 'object') continue;
|
|
48
|
+
const value = (key, fallback) => node[key]?.$value ?? fallback;
|
|
49
|
+
out.text.push({
|
|
50
|
+
name,
|
|
51
|
+
font: fontRole(value('font', '')),
|
|
52
|
+
weight: Number(value('weight', 400)),
|
|
53
|
+
size: Number(value('size', 16)),
|
|
54
|
+
leading: Number(value('leading', 1.5)),
|
|
55
|
+
tracking: Number(value('tracking', 0)),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
for (const [name, node] of Object.entries(group.radius ?? {})) if (node && '$value' in node) out.radius.push({ name, px: Number(node.$value) });
|
|
59
|
+
for (const [name, node] of Object.entries(group.shadow ?? {})) {
|
|
60
|
+
if (!node || !('$value' in node)) continue;
|
|
61
|
+
const v = modes(node.$value);
|
|
62
|
+
out.shadow.push({ name, light: v.light ?? [], dark: v.dark ?? [] });
|
|
63
|
+
}
|
|
64
|
+
for (const [name, node] of Object.entries(group.border ?? {})) {
|
|
65
|
+
if (!node || typeof node !== 'object') continue;
|
|
66
|
+
out.border.push({ name, width: Number(node.width?.$value ?? 1), ...(node.style?.$value ? { style: node.style.$value } : {}) });
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const hasExtras = (list) => Object.values(list).some((items) => items.length > 0);
|
|
72
|
+
|
|
73
|
+
/** A text extra as resolveTextStyles draws a style (line height in px), for textStyleVars. */
|
|
74
|
+
export const extraTextStyles = (list) => Object.fromEntries(list.text.map(({ name, ...s }) => [name, { ...s, lineHeight: s.size * s.leading }]));
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* How DESIGN.md and llms.txt describe the extras: one bullet each, with the classes to use. No kit component uses
|
|
78
|
+
* them; a screen may.
|
|
79
|
+
*/
|
|
80
|
+
export function describeExtras(list, { platform = 'web' } = {}) {
|
|
81
|
+
const lines = [];
|
|
82
|
+
const ramps = new Map();
|
|
83
|
+
for (const c of list.color) {
|
|
84
|
+
if (c.ramp) {
|
|
85
|
+
if (!ramps.has(c.ramp)) ramps.set(c.ramp, []);
|
|
86
|
+
ramps.get(c.ramp).push(c);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const dark = c.dark !== c.light ? ` (dark ${c.dark.toUpperCase()})` : '';
|
|
90
|
+
lines.push(`- \`${c.name}\` colour ${c.light.toUpperCase()}${dark}: \`bg-${c.name}\`, \`text-${c.name}\`, \`border-${c.name}\``);
|
|
91
|
+
}
|
|
92
|
+
for (const [name, steps] of ramps) {
|
|
93
|
+
const mid = steps.find((s) => s.step === '500') ?? steps[Math.floor(steps.length / 2)];
|
|
94
|
+
lines.push(`- \`${name}\` colour ramp ${steps[0].step}–${steps.at(-1).step} (${mid.step} ${mid.light.toUpperCase()}): \`bg-${name}-${mid.step}\`, \`text-${name}-${steps.at(-1).step}\`…`);
|
|
95
|
+
}
|
|
96
|
+
for (const t of list.text) {
|
|
97
|
+
const family = t.font === 'heading' ? ' font-heading' : t.font === 'mono' ? ' font-mono' : '';
|
|
98
|
+
const cls = platform === 'web' ? `type-${t.name}` : `text-type-${t.name} leading-type-${t.name} tracking-type-${t.name} font-type-${t.name}${family}`;
|
|
99
|
+
lines.push(`- \`${t.name}\` text style, ${t.font} font ${t.weight} · ${t.size}px / ×${t.leading}${t.tracking ? ` · ${t.tracking}em` : ''}: \`${cls}\``);
|
|
100
|
+
}
|
|
101
|
+
for (const r of list.radius) lines.push(`- \`${r.name}\` corner ${r.px >= 9999 ? 'full' : `${r.px}px`}: \`rounded-${r.name}\``);
|
|
102
|
+
for (const s of list.shadow) lines.push(`- \`${s.name}\` shadow, ${s.light.length} layer${s.light.length === 1 ? '' : 's'}: \`shadow-${s.name}\``);
|
|
103
|
+
for (const b of list.border) lines.push(`- \`${b.name}\` border ${b.width}px${b.style ? ` ${b.style}` : ''}: \`border-${b.name}\``);
|
|
104
|
+
return lines;
|
|
105
|
+
}
|