@halftone-ds/tokens 1.1.0 → 1.2.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 +42 -4
- package/cli.mjs +28 -12
- package/lib/apca.mjs +55 -0
- package/lib/color.mjs +27 -7
- package/lib/contrast.mjs +23 -1
- package/lib/cvd.mjs +97 -0
- package/lib/resolve.mjs +31 -3
- package/lib/theme/apply.mjs +17 -1
- package/lib/theme/codec.mjs +38 -23
- package/lib/theme/fonts.mjs +3 -1
- package/lib/theme/generate.mjs +27 -4
- package/lib/theme/index.d.mts +75 -0
- package/lib/theme/index.mjs +15 -2
- package/lib/theme/presets.mjs +18 -6
- package/lib/theme/recipe.mjs +39 -0
- package/lib/theme/semantic.mjs +25 -0
- package/lib/theme/shadcn.mjs +265 -0
- package/lib/theme/vars.mjs +25 -0
- package/lib/theme-cli.mjs +98 -6
- package/package.json +1 -1
- package/targets/design-md.mjs +33 -3
- package/targets/llms.mjs +2 -1
- package/targets/nativewind3.mjs +22 -7
- package/targets/shadcn-web.mjs +12 -2
- package/targets/ts-theme.mjs +7 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Halftone ⇄ shadcn. Pure ESM, no Node APIs: the docs site's studio runs it in the browser.
|
|
3
|
+
*
|
|
4
|
+
* isShadcnPresetCode(s) a shadcn/ui preset code (ui.shadcn.com/create): "b5owWMfJ8l"
|
|
5
|
+
* decodeShadcnPreset(code) → { version, style, baseColor, theme, chartColor, iconLibrary, font,
|
|
6
|
+
* fontHeading, radius, menuAccent, menuColor } | null
|
|
7
|
+
* encodeShadcnPreset(preset) the reverse (tests, round trips)
|
|
8
|
+
* recipeFromShadcn(preset) → { recipe, notes }: the nearest Halftone recipe, and one plain line per
|
|
9
|
+
* value it approximated or dropped (Halftone has no match for it)
|
|
10
|
+
* shadcnTheme(theme, options) a shadcn `registry:theme` item for a generated theme: colours (oklch, light
|
|
11
|
+
* and dark), radius, shadows, and its fonts as registryDependencies
|
|
12
|
+
* shadcnFont(id, role) the `registry:font` item those dependencies point at
|
|
13
|
+
*
|
|
14
|
+
* A Halftone theme carries more than a shadcn preset (density, depth, border, icon stroke, typeset, both kits),
|
|
15
|
+
* so codes go one way: a shadcn code becomes a Halftone recipe, never the reverse. The other way is the
|
|
16
|
+
* registry item, which any shadcn project installs with `npx shadcn add`.
|
|
17
|
+
*
|
|
18
|
+
* The preset codec below is ported from shadcn/ui, packages/registry/src/preset/preset.ts (@shadcn/registry
|
|
19
|
+
* 0.2.0, shadcn 4.22). Its value lists are append-only there; keep them in step.
|
|
20
|
+
*
|
|
21
|
+
* MIT License
|
|
22
|
+
*
|
|
23
|
+
* Copyright (c) 2023 shadcn
|
|
24
|
+
*
|
|
25
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
26
|
+
* associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
27
|
+
* without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
28
|
+
* copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
29
|
+
* following conditions:
|
|
30
|
+
*
|
|
31
|
+
* The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
32
|
+
* portions of the Software.
|
|
33
|
+
*
|
|
34
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
35
|
+
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
36
|
+
* EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
37
|
+
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
38
|
+
* USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
39
|
+
*/
|
|
40
|
+
import { fontById } from './fonts.mjs';
|
|
41
|
+
import { BRAND_RAMPS, NEUTRAL_RAMPS } from './palettes.mjs';
|
|
42
|
+
import { normalizeRecipe } from './recipe.mjs';
|
|
43
|
+
import { themeVars } from './vars.mjs';
|
|
44
|
+
|
|
45
|
+
// ---- the preset codec (ported) --------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
const MENU_COLORS = ['default', 'inverted', 'default-translucent', 'inverted-translucent'];
|
|
48
|
+
const MENU_ACCENTS = ['subtle', 'bold'];
|
|
49
|
+
const RADII = ['default', 'none', 'small', 'medium', 'large'];
|
|
50
|
+
const FONTS = [
|
|
51
|
+
'inter', 'noto-sans', 'nunito-sans', 'figtree', 'roboto', 'raleway', 'dm-sans', 'public-sans', 'outfit', 'jetbrains-mono',
|
|
52
|
+
'geist', 'geist-mono', 'lora', 'merriweather', 'playfair-display', 'noto-serif', 'roboto-slab', 'oxanium', 'manrope',
|
|
53
|
+
'space-grotesk', 'montserrat', 'ibm-plex-sans', 'source-sans-3', 'instrument-sans', 'eb-garamond', 'instrument-serif',
|
|
54
|
+
];
|
|
55
|
+
const ICON_LIBRARIES = ['lucide', 'hugeicons', 'tabler', 'phosphor', 'remixicon'];
|
|
56
|
+
const THEMES = [
|
|
57
|
+
'neutral', 'stone', 'zinc', 'gray', 'amber', 'blue', 'cyan', 'emerald', 'fuchsia', 'green', 'indigo', 'lime', 'orange',
|
|
58
|
+
'pink', 'purple', 'red', 'rose', 'sky', 'teal', 'violet', 'yellow', 'mauve', 'olive', 'mist', 'taupe',
|
|
59
|
+
];
|
|
60
|
+
const BASE_COLORS = ['neutral', 'stone', 'zinc', 'gray', 'mauve', 'olive', 'mist', 'taupe'];
|
|
61
|
+
const STYLES = ['nova', 'vega', 'maia', 'lyra', 'mira', 'luma', 'sera', 'rhea'];
|
|
62
|
+
const FONT_HEADINGS = ['inherit', ...FONTS];
|
|
63
|
+
|
|
64
|
+
const FIELDS_V1 = [
|
|
65
|
+
{ key: 'menuColor', values: MENU_COLORS, bits: 3 },
|
|
66
|
+
{ key: 'menuAccent', values: MENU_ACCENTS, bits: 3 },
|
|
67
|
+
{ key: 'radius', values: RADII, bits: 4 },
|
|
68
|
+
{ key: 'font', values: FONTS, bits: 6 },
|
|
69
|
+
{ key: 'iconLibrary', values: ICON_LIBRARIES, bits: 6 },
|
|
70
|
+
{ key: 'theme', values: THEMES, bits: 6 },
|
|
71
|
+
{ key: 'baseColor', values: BASE_COLORS, bits: 6 },
|
|
72
|
+
{ key: 'style', values: STYLES, bits: 6 },
|
|
73
|
+
];
|
|
74
|
+
const FIELDS_V2 = [...FIELDS_V1, { key: 'chartColor', values: THEMES, bits: 6 }, { key: 'fontHeading', values: FONT_HEADINGS, bits: 5 }];
|
|
75
|
+
/** v1 codes carry no chart colour; ui.shadcn.com picks one per theme (apps/v4 preset-query.ts). */
|
|
76
|
+
const V1_CHART_COLOR = { neutral: 'blue', stone: 'lime', zinc: 'amber', mauve: 'emerald', olive: 'violet', mist: 'rose', taupe: 'cyan' };
|
|
77
|
+
|
|
78
|
+
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
79
|
+
const fromBase62 = (text) => {
|
|
80
|
+
let n = 0;
|
|
81
|
+
for (const ch of text) {
|
|
82
|
+
const i = BASE62.indexOf(ch);
|
|
83
|
+
if (i === -1) return -1;
|
|
84
|
+
n = n * 62 + i;
|
|
85
|
+
}
|
|
86
|
+
return n;
|
|
87
|
+
};
|
|
88
|
+
const toBase62 = (n) => {
|
|
89
|
+
if (n === 0) return '0';
|
|
90
|
+
let s = '';
|
|
91
|
+
while (n > 0) {
|
|
92
|
+
s = BASE62[n % 62] + s;
|
|
93
|
+
n = Math.floor(n / 62);
|
|
94
|
+
}
|
|
95
|
+
return s;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const isShadcnPresetCode = (s) => typeof s === 'string' && /^[ab][0-9A-Za-z]{1,9}$/.test(s);
|
|
99
|
+
|
|
100
|
+
export function decodeShadcnPreset(code) {
|
|
101
|
+
if (!isShadcnPresetCode(code)) return null;
|
|
102
|
+
const version = code[0];
|
|
103
|
+
const fields = version === 'a' ? FIELDS_V1 : FIELDS_V2;
|
|
104
|
+
const bits = fromBase62(code.slice(1));
|
|
105
|
+
if (bits < 0) return null;
|
|
106
|
+
const preset = { version };
|
|
107
|
+
let offset = 0;
|
|
108
|
+
for (const field of fields) {
|
|
109
|
+
const i = Math.floor(bits / 2 ** offset) % 2 ** field.bits;
|
|
110
|
+
preset[field.key] = i < field.values.length ? field.values[i] : field.values[0];
|
|
111
|
+
offset += field.bits;
|
|
112
|
+
}
|
|
113
|
+
if (version === 'a') {
|
|
114
|
+
preset.fontHeading = 'inherit';
|
|
115
|
+
preset.chartColor = V1_CHART_COLOR[preset.theme] ?? preset.theme;
|
|
116
|
+
}
|
|
117
|
+
return preset;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function encodeShadcnPreset(preset) {
|
|
121
|
+
let bits = 0;
|
|
122
|
+
let offset = 0;
|
|
123
|
+
for (const field of FIELDS_V2) {
|
|
124
|
+
const i = field.values.indexOf(preset[field.key] ?? field.values[0]);
|
|
125
|
+
bits += (i === -1 ? 0 : i) * 2 ** offset;
|
|
126
|
+
offset += field.bits;
|
|
127
|
+
}
|
|
128
|
+
return `b${toBase62(bits)}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- shadcn → Halftone ----------------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/** shadcn's extra greys → the nearest Halftone neutral. */
|
|
134
|
+
const NEAREST_NEUTRAL = { mauve: 'zinc', olive: 'stone', mist: 'slate', taupe: 'stone' };
|
|
135
|
+
const RADIUS = { default: 'lg', medium: 'lg', none: 'none', small: 'sm', large: 'xl' };
|
|
136
|
+
const RADIUS_REM = { small: '0.45rem', large: '0.875rem' };
|
|
137
|
+
/** shadcn's fonts → Halftone's (the same family, or the nearest of the same kind). */
|
|
138
|
+
const FONT = {
|
|
139
|
+
inter: 'inter', 'dm-sans': 'dm-sans', outfit: 'outfit', 'jetbrains-mono': 'jetbrains-mono', geist: 'geist', 'geist-mono': 'geist-mono',
|
|
140
|
+
lora: 'lora', 'playfair-display': 'playfair-display', manrope: 'manrope', 'space-grotesk': 'space-grotesk', 'ibm-plex-sans': 'ibm-plex-sans',
|
|
141
|
+
'noto-sans': 'inter', 'nunito-sans': 'nunito', figtree: 'plus-jakarta-sans', roboto: 'inter', raleway: 'outfit', 'public-sans': 'inter',
|
|
142
|
+
montserrat: 'outfit', 'source-sans-3': 'inter', 'instrument-sans': 'inter', oxanium: 'space-grotesk',
|
|
143
|
+
merriweather: 'source-serif-4', 'noto-serif': 'source-serif-4', 'roboto-slab': 'source-serif-4', 'eb-garamond': 'fraunces',
|
|
144
|
+
'instrument-serif': 'playfair-display',
|
|
145
|
+
};
|
|
146
|
+
const title = (id) => id.split('-').map((w) => (/^(dm|ibm|eb)$/.test(w) ? w.toUpperCase() : w[0].toUpperCase() + w.slice(1))).join(' ');
|
|
147
|
+
|
|
148
|
+
/** The nearest Halftone recipe for a decoded shadcn preset, with a note for everything it couldn't carry. */
|
|
149
|
+
export function recipeFromShadcn(preset) {
|
|
150
|
+
const notes = [];
|
|
151
|
+
const base = preset.baseColor;
|
|
152
|
+
const neutral = NEAREST_NEUTRAL[base] ?? base;
|
|
153
|
+
if (NEAREST_NEUTRAL[base]) notes.push(`Base colour ${base}: Halftone has no ${base} grey, so it uses ${neutral}, the nearest.`);
|
|
154
|
+
|
|
155
|
+
let brand;
|
|
156
|
+
if (BRAND_RAMPS[preset.theme]) brand = BRAND_RAMPS[preset.theme]['600'];
|
|
157
|
+
else {
|
|
158
|
+
// A monochrome theme (the theme is a grey): the brand is that grey's 900, as in Halftone's Mono preset.
|
|
159
|
+
const grey = NEAREST_NEUTRAL[preset.theme] ?? preset.theme;
|
|
160
|
+
brand = NEUTRAL_RAMPS[grey]['900'];
|
|
161
|
+
if (NEAREST_NEUTRAL[preset.theme] && preset.theme !== base) notes.push(`Theme ${preset.theme}: a monochrome brand in ${grey}, the nearest grey.`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const radius = RADIUS[preset.radius] ?? 'lg';
|
|
165
|
+
if (RADIUS_REM[preset.radius]) notes.push(`Radius ${preset.radius} (${RADIUS_REM[preset.radius]}): Halftone's nearest step is ${radius}.`);
|
|
166
|
+
|
|
167
|
+
const font = (id, role) => {
|
|
168
|
+
const mapped = FONT[id] ?? 'inter';
|
|
169
|
+
if (mapped !== id) notes.push(`${role} font ${title(id)}: not in Halftone's list, so it uses ${fontById(mapped).family}.`);
|
|
170
|
+
return mapped;
|
|
171
|
+
};
|
|
172
|
+
const body = font(preset.font, 'Body');
|
|
173
|
+
const heading = preset.fontHeading && preset.fontHeading !== 'inherit' ? font(preset.fontHeading, 'Heading') : body;
|
|
174
|
+
// shadcn has no separate mono role; a mono body or heading font takes it too.
|
|
175
|
+
const mono = [body, heading].find((id) => fontById(id).category === 'mono') ?? 'system-mono';
|
|
176
|
+
|
|
177
|
+
if (preset.iconLibrary && preset.iconLibrary !== 'lucide') notes.push(`Icons ${title(preset.iconLibrary)}: Halftone's kits use Lucide.`);
|
|
178
|
+
if (preset.chartColor && preset.chartColor !== preset.theme) notes.push(`Chart colour ${preset.chartColor}: Halftone's charts follow the brand.`);
|
|
179
|
+
if (preset.menuAccent === 'bold') notes.push("Bold menu accents aren't a Halftone option: accents stay neutral.");
|
|
180
|
+
if (preset.menuColor && preset.menuColor !== 'default') notes.push(`Menu colour ${preset.menuColor} isn't a Halftone option.`);
|
|
181
|
+
if (preset.style && preset.style !== 'nova') {
|
|
182
|
+
notes.push(`Style ${title(preset.style)}: Halftone's own density, depth and border apply. Start from a Halftone preset to change them.`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const recipe = normalizeRecipe({ brand, neutral, radius, font: { heading, body, mono } });
|
|
186
|
+
return { recipe, notes };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- Halftone → shadcn ----------------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
export const SHADCN_FONTS_URL = 'https://halftonedesignsystem.com/r';
|
|
192
|
+
|
|
193
|
+
/** Tailwind's rounded-* steps as multiples of --radius (the kits' primitive radius steps over lg). */
|
|
194
|
+
const RADIUS_STEPS = { sm: 0.6, md: 0.8, lg: 1, xl: 1.6, '2xl': 2 };
|
|
195
|
+
const SHADOW_STEPS = { '2xs': 1, xs: 1, sm: 1, md: 2, lg: 3, xl: 3, '2xl': 3 };
|
|
196
|
+
const FONT_VARIABLE = { body: '--font-sans', heading: '--font-heading', mono: '--font-mono' };
|
|
197
|
+
const FALLBACK = { sans: 'sans-serif', display: 'sans-serif', serif: 'serif', mono: 'monospace' };
|
|
198
|
+
const SELECTOR = { heading: 'h1, h2, h3, h4, h5, h6', mono: 'code, kbd, pre, samp' };
|
|
199
|
+
|
|
200
|
+
/** The registry item path (under the fonts URL) for a font in a role. */
|
|
201
|
+
const fontPath = (id, role) => `font/${role === 'body' ? '' : `${role}-`}${id}.json`;
|
|
202
|
+
|
|
203
|
+
/** A shadcn `registry:font` item for one of Halftone's Google fonts, in a role (body, heading or mono). */
|
|
204
|
+
export function shadcnFont(id, role = 'body') {
|
|
205
|
+
const f = fontById(id);
|
|
206
|
+
if (!f || f.system) return null;
|
|
207
|
+
const variable = f.fontsource !== 'static';
|
|
208
|
+
return {
|
|
209
|
+
$schema: 'https://ui.shadcn.com/schema/registry-item.json',
|
|
210
|
+
name: role === 'body' ? `font-${id}` : `font-${role}-${id}`,
|
|
211
|
+
title: f.family,
|
|
212
|
+
type: 'registry:font',
|
|
213
|
+
font: {
|
|
214
|
+
family: `'${f.family}${variable ? ' Variable' : ''}', ${FALLBACK[f.category] ?? 'sans-serif'}`,
|
|
215
|
+
provider: 'google',
|
|
216
|
+
import: f.family.replace(/ /g, '_'),
|
|
217
|
+
variable: FONT_VARIABLE[role],
|
|
218
|
+
subsets: ['latin'],
|
|
219
|
+
...(variable ? {} : { weight: f.weights.map(String) }),
|
|
220
|
+
// shadcn applies a font's utility to `html` by default: the body font does go there, but the heading font
|
|
221
|
+
// belongs on headings (as in Halftone's kits) and the mono font on code.
|
|
222
|
+
...(role === 'body' ? {} : { selector: SELECTOR[role] }),
|
|
223
|
+
dependency: `@fontsource${variable ? '-variable' : ''}/${id}`,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* A shadcn `registry:theme` item for a generated theme. `npx shadcn add` writes the colours into :root and
|
|
230
|
+
* .dark, the radius and shadow steps into @theme, and installs the fonts it depends on (fontsUrl/font/*.json,
|
|
231
|
+
* hosted by the docs site). Web only: phone apps take the theme code.
|
|
232
|
+
*/
|
|
233
|
+
export function shadcnTheme(theme, { name, title: label, description, fontsUrl = SHADCN_FONTS_URL } = {}) {
|
|
234
|
+
const vars = themeVars(theme, 'oklch');
|
|
235
|
+
// The button and field styles are Halftone's own (shadcn's components don't read them): they stay behind.
|
|
236
|
+
const colours = Object.keys(theme.resolved.light).filter((c) => !/^(button|field)(-|$)/.test(c));
|
|
237
|
+
const mode = (m) => ({
|
|
238
|
+
...(m === 'light' ? { radius: vars.light['--radius'] } : {}),
|
|
239
|
+
...Object.fromEntries(colours.map((c) => [c, vars[m][`--${c}`]])),
|
|
240
|
+
...Object.fromEntries(['1', '2', '3'].map((level) => [`shadow-${level}`, vars[m][`--shadow-${level}`]])),
|
|
241
|
+
});
|
|
242
|
+
const roles = { body: theme.recipe.font.body, heading: theme.recipe.font.heading, mono: theme.recipe.font.mono };
|
|
243
|
+
const dependencies = Object.entries(roles)
|
|
244
|
+
.filter(([, id]) => !fontById(id)?.system)
|
|
245
|
+
.map(([role, id]) => `${fontsUrl.replace(/\/$/, '')}/${fontPath(id, role)}`);
|
|
246
|
+
return {
|
|
247
|
+
$schema: 'https://ui.shadcn.com/schema/registry-item.json',
|
|
248
|
+
name: name ?? `halftone-${theme.code}`,
|
|
249
|
+
title: label ?? `Halftone theme ${theme.code}`,
|
|
250
|
+
description: description ?? `A Halftone theme (${theme.code}): colours for light and dark, radius, shadows and fonts. halftonedesignsystem.com/themes/?t=${theme.code}`,
|
|
251
|
+
type: 'registry:theme',
|
|
252
|
+
...(dependencies.length ? { registryDependencies: dependencies } : {}),
|
|
253
|
+
cssVars: {
|
|
254
|
+
theme: {
|
|
255
|
+
...Object.fromEntries(Object.entries(RADIUS_STEPS).map(([step, k]) => [`radius-${step}`, k === 1 ? 'var(--radius)' : `calc(var(--radius) * ${k})`])),
|
|
256
|
+
...Object.fromEntries(Object.entries(SHADOW_STEPS).map(([step, level]) => [`shadow-${step}`, `var(--shadow-${level})`])),
|
|
257
|
+
},
|
|
258
|
+
light: mode('light'),
|
|
259
|
+
dark: mode('dark'),
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Where the docs site hosts a font item, for build scripts: `font/<id>.json`, `font/heading-<id>.json`, … */
|
|
265
|
+
export const shadcnFontPath = fontPath;
|
package/lib/theme/vars.mjs
CHANGED
|
@@ -20,6 +20,30 @@ export const typeVars = (type) => ({
|
|
|
20
20
|
'--typeset-measure': `${type.measure}ch`,
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The button and field styles' variables (both formats), from a theme's or a token file's { button, field }
|
|
25
|
+
* (generate.mjs, resolve.mjs). Widths and radii are numbers on the light block; the field's fill opacity is
|
|
26
|
+
* per mode. A field's inner padding is the kit's own: 1rem on mobile (hsl), px-3 on the web (oklch).
|
|
27
|
+
*/
|
|
28
|
+
export function styleVars({ button, field }, format) {
|
|
29
|
+
return {
|
|
30
|
+
light: {
|
|
31
|
+
'--button-alpha': String(button.alpha),
|
|
32
|
+
'--button-border-w': `${button.border}px`,
|
|
33
|
+
'--button-edge-w': `${button.edge}px`,
|
|
34
|
+
'--field-alpha': String(field.alpha.light),
|
|
35
|
+
'--field-border-w': `${field.border}px`,
|
|
36
|
+
'--field-edge-w': `${field.edge}px`,
|
|
37
|
+
'--field-radius': rem(field.radius),
|
|
38
|
+
'--field-radius-b': rem(field.radiusBottom),
|
|
39
|
+
'--field-x': field.inset ? (format === 'hsl' ? '1rem' : 'calc(var(--spacing) * 3)') : '0px',
|
|
40
|
+
// Web: a boxed field keeps shadcn's shadow-xs; a filled or underline field has none.
|
|
41
|
+
...(format === 'hsl' ? {} : { '--field-shadow': field.border ? 'var(--shadow-1)' : '0 0 #0000' }),
|
|
42
|
+
},
|
|
43
|
+
dark: { '--field-alpha': String(field.alpha.dark) },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
23
47
|
/** { light: { '--primary': '…', '--radius': '0.625rem' }, dark: { … } } */
|
|
24
48
|
export function themeVars(theme, format) {
|
|
25
49
|
const convert = format === 'hsl' ? hexToHslTriplet : hexToOklch;
|
|
@@ -34,6 +58,7 @@ export function themeVars(theme, format) {
|
|
|
34
58
|
for (const size of ['sm', 'md', 'lg', 'x']) vars[`--control-${size}`] = `${theme.density[size]}px`;
|
|
35
59
|
Object.assign(vars, typeVars(theme.type));
|
|
36
60
|
}
|
|
61
|
+
Object.assign(vars, styleVars(theme, format)[m]);
|
|
37
62
|
for (const [level, value] of Object.entries(theme.semantic.shadow)) vars[`--shadow-${level}`] = shadowCss(value[m]);
|
|
38
63
|
return vars;
|
|
39
64
|
};
|
package/lib/theme-cli.mjs
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `halftone-tokens theme …` — commit a theme from the docs picker into tokens.json.
|
|
3
3
|
*
|
|
4
|
-
* theme apply <
|
|
4
|
+
* theme apply <theme> [--dry-run] [--force]
|
|
5
5
|
* write the theme into tokens.json (only the paths a theme owns) and rebuild
|
|
6
6
|
* theme show [--check] the applied theme, its code and any hand edits since (drift)
|
|
7
7
|
* --check: exit 1 if the recorded theme no longer reproduces (CI)
|
|
8
8
|
* theme presets list the presets and their codes
|
|
9
|
-
* theme encode <
|
|
9
|
+
* theme encode <theme> print the Halftone code for a theme
|
|
10
|
+
* theme decode <code> [--json] what a code holds (a Halftone code, or a shadcn preset and what it maps to)
|
|
11
|
+
* theme resolve [--json] the code for tokens.json as it is; exit 1 if none is recorded or it was edited
|
|
12
|
+
* theme shadcn <theme> [--out file] [--fonts-url url]
|
|
13
|
+
* the theme as a shadcn registry:theme item (stdout, or a file), for `npx shadcn add`
|
|
14
|
+
*
|
|
15
|
+
* <theme> is a Halftone code (ht1-…, ht2-…, ht3-…), a preset name, a recipe .json, or a shadcn/ui preset code
|
|
16
|
+
* (ui.shadcn.com/create, e.g. b5owWMfJ8l), which becomes the nearest Halftone recipe: what it approximates
|
|
17
|
+
* or drops is listed on stderr.
|
|
10
18
|
*/
|
|
11
19
|
import { spawnSync } from 'node:child_process';
|
|
12
20
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
21
|
import { join } from 'node:path';
|
|
14
22
|
|
|
23
|
+
import { cvdWarnings, describeCvdWarnings, MIN_DELTA_E } from './cvd.mjs';
|
|
15
24
|
import { FONTS } from './theme/fonts.mjs';
|
|
16
25
|
|
|
17
26
|
import {
|
|
@@ -23,6 +32,11 @@ import {
|
|
|
23
32
|
normalizeRecipe,
|
|
24
33
|
presetById,
|
|
25
34
|
PRESETS,
|
|
35
|
+
decodeShadcnPreset,
|
|
36
|
+
isShadcnPresetCode,
|
|
37
|
+
recipeFromShadcn,
|
|
38
|
+
SHADCN_FONTS_URL,
|
|
39
|
+
shadcnTheme,
|
|
26
40
|
themeStatus,
|
|
27
41
|
} from './theme/index.mjs';
|
|
28
42
|
|
|
@@ -31,8 +45,14 @@ const fail = (message, code = 1) => {
|
|
|
31
45
|
process.exit(code);
|
|
32
46
|
};
|
|
33
47
|
|
|
48
|
+
/** A shadcn code's mapping notes, on stderr so stdout stays a code or JSON. */
|
|
49
|
+
function printNotes(code, notes) {
|
|
50
|
+
if (!notes.length) return;
|
|
51
|
+
console.error(`From shadcn preset ${code}, approximated or not carried:\n${notes.map((n) => ` - ${n}`).join('\n')}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
function readRecipe(arg, root) {
|
|
35
|
-
if (!arg) fail('say which theme: a code (ht1-… or
|
|
55
|
+
if (!arg) fail('say which theme: a code (ht1-…, ht2-… or ht3-…), a preset name, a recipe .json or a shadcn preset code', 2);
|
|
36
56
|
if (isThemeCode(arg)) {
|
|
37
57
|
try {
|
|
38
58
|
return decodeRecipe(arg);
|
|
@@ -44,11 +64,18 @@ function readRecipe(arg, root) {
|
|
|
44
64
|
if (preset) return normalizeRecipe({ ...preset.recipe, preset: preset.id });
|
|
45
65
|
const file = join(root, arg);
|
|
46
66
|
if (arg.endsWith('.json') && existsSync(file)) return normalizeRecipe(JSON.parse(readFileSync(file, 'utf8')));
|
|
47
|
-
|
|
67
|
+
// Last, so a preset name that happens to look like one (bold, brutalist) stays Halftone's preset.
|
|
68
|
+
const shadcn = isShadcnPresetCode(arg) ? decodeShadcnPreset(arg) : null;
|
|
69
|
+
if (shadcn) {
|
|
70
|
+
const { recipe, notes } = recipeFromShadcn(shadcn);
|
|
71
|
+
printNotes(arg, notes);
|
|
72
|
+
return recipe;
|
|
73
|
+
}
|
|
74
|
+
fail(`"${arg}" is not a theme code, a preset (${PRESETS.map((p) => p.id).join(', ')}), a recipe file or a shadcn preset code`, 2);
|
|
48
75
|
}
|
|
49
76
|
|
|
50
77
|
const describe = (input) => {
|
|
51
|
-
// A recipe recorded by an older halftone-tokens has no typeset: it gets the defaults.
|
|
78
|
+
// A recipe recorded by an older halftone-tokens has no typeset or styles: it gets the defaults.
|
|
52
79
|
const recipe = normalizeRecipe(input);
|
|
53
80
|
const font = (id) => {
|
|
54
81
|
const f = fontById(id);
|
|
@@ -58,6 +85,7 @@ const describe = (input) => {
|
|
|
58
85
|
`brand ${recipe.brand} · neutral ${recipe.neutral} · radius ${recipe.radius} · controls ${recipe.controls}`,
|
|
59
86
|
`type ${font(recipe.font.heading)} / ${font(recipe.font.body)} · stroke ${recipe.stroke} · depth ${recipe.depth} · density ${recipe.density} · border ${recipe.border}`,
|
|
60
87
|
`typeset ${recipe.size}px · leading ${recipe.leading} · flow ${recipe.flow} · measure ${recipe.measure}ch · mono ${font(recipe.font.mono)}`,
|
|
88
|
+
`styles: ${recipe.button} buttons · ${recipe.field} fields`,
|
|
61
89
|
].join('\n ');
|
|
62
90
|
};
|
|
63
91
|
|
|
@@ -100,6 +128,65 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
100
128
|
return;
|
|
101
129
|
}
|
|
102
130
|
|
|
131
|
+
if (sub === 'decode') {
|
|
132
|
+
if (!arg) fail('say which code to decode: ht1-…, ht2-…, ht3-… or a shadcn preset code', 2);
|
|
133
|
+
// A preset name first, as for apply: `bold` is Halftone's preset, not a shadcn code.
|
|
134
|
+
const preset = presetById(arg.toLowerCase());
|
|
135
|
+
if (preset) {
|
|
136
|
+
const recipe = normalizeRecipe({ ...preset.recipe, preset: preset.id });
|
|
137
|
+
if (flags.json) console.log(JSON.stringify({ kind: 'halftone', preset: preset.id, code: encodeRecipe(recipe), recipe }, null, 2));
|
|
138
|
+
else console.log(`Halftone preset ${preset.name}: ${encodeRecipe(recipe)}\n ${describe(recipe)}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (isThemeCode(arg)) {
|
|
142
|
+
let recipe;
|
|
143
|
+
try {
|
|
144
|
+
recipe = decodeRecipe(arg);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
fail(error.message);
|
|
147
|
+
}
|
|
148
|
+
if (flags.json) console.log(JSON.stringify({ kind: 'halftone', code: encodeRecipe(recipe), recipe }, null, 2));
|
|
149
|
+
else console.log(`Halftone theme ${encodeRecipe(recipe)}\n ${describe(recipe)}`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const shadcn = isShadcnPresetCode(arg) ? decodeShadcnPreset(arg) : null;
|
|
153
|
+
if (!shadcn) fail(`"${arg}" is not a Halftone theme code or a shadcn preset code`, 2);
|
|
154
|
+
const { recipe, notes } = recipeFromShadcn(shadcn);
|
|
155
|
+
const code = encodeRecipe(recipe);
|
|
156
|
+
if (flags.json) {
|
|
157
|
+
console.log(JSON.stringify({ kind: 'shadcn', shadcn, code, recipe, notes }, null, 2));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const fields = Object.entries(shadcn).map(([k, v]) => `${k} ${v}`).join(' · ');
|
|
161
|
+
console.log(`shadcn preset ${arg}\n ${fields}\n\nAs a Halftone theme: ${code}\n ${describe(recipe)}`);
|
|
162
|
+
if (notes.length) console.log(`\nApproximated or not carried:\n${notes.map((n) => ` - ${n}`).join('\n')}`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (sub === 'resolve') {
|
|
167
|
+
const status = themeStatus(tokens());
|
|
168
|
+
if (!status.applied) fail('no theme recorded in tokens.json, so there is no code for it. Apply one with `halftone-tokens theme apply <theme>`.');
|
|
169
|
+
if (status.drift.length) fail(`tokens.json was edited by hand since ${status.code} was applied (${status.drift.length} token(s)); no code reproduces it. \`theme show\` lists them.`);
|
|
170
|
+
if (flags.json) console.log(JSON.stringify({ code: status.code, recipe: status.recipe }, null, 2));
|
|
171
|
+
else console.log(status.code);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (sub === 'shadcn') {
|
|
176
|
+
const recipe = readRecipe(arg, root);
|
|
177
|
+
const { theme } = applyRecipe(tokens(), recipe);
|
|
178
|
+
const item = shadcnTheme(theme, {
|
|
179
|
+
...(recipe.preset ? { name: `halftone-${recipe.preset}`, title: `Halftone ${presetById(recipe.preset)?.name} theme` } : {}),
|
|
180
|
+
fontsUrl: typeof flags['fonts-url'] === 'string' ? flags['fonts-url'] : SHADCN_FONTS_URL,
|
|
181
|
+
});
|
|
182
|
+
const json = JSON.stringify(item, null, 2) + '\n';
|
|
183
|
+
if (typeof flags.out === 'string') {
|
|
184
|
+
writeFileSync(join(root, flags.out), json);
|
|
185
|
+
console.error(`Wrote ${flags.out}: install it in a shadcn project with \`npx shadcn@latest add ./${flags.out.split('/').pop()}\`.`);
|
|
186
|
+
} else process.stdout.write(json);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
103
190
|
if (sub === 'show') {
|
|
104
191
|
const status = themeStatus(tokens());
|
|
105
192
|
if (!status.applied) {
|
|
@@ -133,6 +220,11 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
133
220
|
console.log(`\nAdjusted for contrast (WCAG AA), hue kept:`);
|
|
134
221
|
for (const a of theme.adjustments) console.log(` ${a.mode.padEnd(5)} ${a.token.padEnd(28)} ${a.from} → ${a.to}`);
|
|
135
222
|
}
|
|
223
|
+
const cvd = cvdWarnings(theme.resolved);
|
|
224
|
+
if (cvd.length) {
|
|
225
|
+
console.log(`\nColour vision: ${cvd.length} pair(s) of tones closer than ΔE ${MIN_DELTA_E} (a warning; give them an icon or a label as well):`);
|
|
226
|
+
console.log(describeCvdWarnings(cvd).join('\n'));
|
|
227
|
+
}
|
|
136
228
|
if (flags['dry-run']) {
|
|
137
229
|
console.log(`\n--dry-run: nothing written.${blocked && !flags.force ? `\nNote: ${blocked}` : ''}`);
|
|
138
230
|
return;
|
|
@@ -144,5 +236,5 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
144
236
|
return;
|
|
145
237
|
}
|
|
146
238
|
|
|
147
|
-
fail(`unknown command "theme ${sub}". Use apply, show, presets or
|
|
239
|
+
fail(`unknown command "theme ${sub}". Use apply, show, presets, encode, decode, resolve or shadcn.`, 2);
|
|
148
240
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@halftone-ds/tokens",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Design tokens (W3C DTCG) to NativeWind, Tailwind 4 / shadcn, TypeScript, DESIGN.md and Figma, with a WCAG AA contrast gate. The token build behind Halftone.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/targets/design-md.mjs
CHANGED
|
@@ -46,6 +46,37 @@ function typography(prim, type) {
|
|
|
46
46
|
.join('\n');
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The contrast sentence. A passing palette says the build names any pairing that falls short (and --strict
|
|
51
|
+
* fails on it); a failing one, which only builds without --strict, names its pairings.
|
|
52
|
+
*/
|
|
53
|
+
function contrastLine(ctx) {
|
|
54
|
+
const failures = ctx.contrastFailures ?? [];
|
|
55
|
+
if (!failures.length) {
|
|
56
|
+
return `Every \`X\` / \`X-foreground\` pairing, muted text on every surface, and every status tone used as text
|
|
57
|
+
clears WCAG AA (4.5:1) in both themes; the build names any pairing that falls short, and \`--strict\` fails on it.`;
|
|
58
|
+
}
|
|
59
|
+
return `${failures.length} pairing(s) in this palette fall below WCAG AA (4.5:1). Fix them before handing a prototype
|
|
60
|
+
over (\`npm run tokens:build -- --strict\` fails on them):
|
|
61
|
+
|
|
62
|
+
${failures.map((f) => `- ${f.trim()}`).join('\n')}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** How the theme's styles read in DESIGN.md. */
|
|
66
|
+
const STYLE_TEXT = {
|
|
67
|
+
button: {
|
|
68
|
+
solid: 'The primary button is a solid brand fill.',
|
|
69
|
+
soft: 'The primary button is a soft brand tint with brand text.',
|
|
70
|
+
outline: 'The primary button is a brand outline with brand text, no fill.',
|
|
71
|
+
raised: 'The primary button is a solid brand fill with a darker bottom edge.',
|
|
72
|
+
},
|
|
73
|
+
field: {
|
|
74
|
+
outline: 'Text fields are outlined boxes.',
|
|
75
|
+
filled: 'Text fields are tinted, with a line along the bottom.',
|
|
76
|
+
underline: 'Text fields are a single line, no box.',
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
49
80
|
export default function designMd(ctx, options) {
|
|
50
81
|
const name = options.name ?? 'Halftone';
|
|
51
82
|
// Which radius buttons use: 'control' (the theme's pill setting, the mobile kit) or a scale step (web: 'md').
|
|
@@ -131,8 +162,7 @@ hex literal or a Tailwind palette colour in UI code. Keys ending in \`-dark\` ar
|
|
|
131
162
|
- **Destructive, Success, Warning, Info:** status only, each with its own \`-foreground\`.
|
|
132
163
|
- **Chart 1–5:** data series.
|
|
133
164
|
|
|
134
|
-
|
|
135
|
-
clears WCAG AA (4.5:1) in both themes; the build refuses a palette that does not.
|
|
165
|
+
${contrastLine(ctx)}
|
|
136
166
|
|
|
137
167
|
A tone's text on that tone's own tint (\`text-success\` on \`bg-success/15\`) is a weaker pairing. With
|
|
138
168
|
this palette it clears AA only for:
|
|
@@ -171,7 +201,7 @@ ${radius['2xl']}px; chips, avatars and dots are fully round.
|
|
|
171
201
|
## Components
|
|
172
202
|
|
|
173
203
|
Only components listed in the registry in \`DESIGN_SYSTEM.md\` exist. Controls are 48px tall (small
|
|
174
|
-
40, large 56). Selected chips and segments are neutral (inverted foreground), never the brand colour.
|
|
204
|
+
40, large 56). ${STYLE_TEXT.button[ctx.styles.button]} ${STYLE_TEXT.field[ctx.styles.field]} Selected chips and segments are neutral (inverted foreground), never the brand colour.
|
|
175
205
|
Motion: ${duration.fast}ms for micro-feedback, ${duration.base}ms for most transitions, ${duration.slow}ms for sheets, and
|
|
176
206
|
nothing moves when the OS asks for reduced motion.
|
|
177
207
|
|
package/targets/llms.mjs
CHANGED
|
@@ -21,7 +21,7 @@ export default function llms(ctx, options) {
|
|
|
21
21
|
|
|
22
22
|
Colours are semantic names only, used as Tailwind classes (\`bg-primary\`, \`text-muted-foreground\`,
|
|
23
23
|
\`border-border\`, \`bg-success/15\`). Never hex, never Tailwind palette colours. Both themes are
|
|
24
|
-
required; every fill/foreground pairing clears WCAG AA (${AA}:1)
|
|
24
|
+
required; ${ctx.contrastFailures?.length ? `${ctx.contrastFailures.length} pairing(s) in this palette fall below WCAG AA (${AA}:1): fix them (\`npm run tokens:build\` names them).` : `every fill/foreground pairing clears WCAG AA (${AA}:1); \`npm run tokens:build\` names any that falls short (\`--strict\` fails on it).`}
|
|
25
25
|
A tone's own text on its own tint (\`text-success\` on \`bg-success/15\`) clears AA only for these
|
|
26
26
|
tints; on a stronger one, put \`text-foreground\` on the tint:
|
|
27
27
|
${describeTintLimits(textOnTintLimits(ctx.colors))}.
|
|
@@ -34,6 +34,7 @@ ${rows}
|
|
|
34
34
|
- Radius (\`rounded-*\`): ${ctx.radius.steps.map((s) => `${s.name} ${Math.round(ctx.radius.base * s.scale * 10) / 10}px`).join(' · ')} (multiples of \`--radius\` = ${ctx.radius.base}px)${options.controlRadius === false ? '; buttons use \`rounded-md\`' : `; \`rounded-control\` (buttons, chips) ${ctx.shape.control >= 9999 ? 'pill' : `${ctx.shape.control}px`}`}
|
|
35
35
|
- Depth: \`shadow-sm\` (controls, cards), \`shadow-md\` (raised, popovers), \`shadow-lg\` (menus, dialogs) are the theme's three elevation levels; no shadow colour modifiers on surfaces (thumbs and floating elements may add \`shadow-black/20\`)
|
|
36
36
|
- Border \`border\` ${ctx.shape.borderWidth}px · icon stroke ${ctx.shape.stroke} (an Icon's \`strokeWidth\` is relative to 2)
|
|
37
|
+
- Styles: ${ctx.styles.button} buttons, ${ctx.styles.field} fields. Button and the text fields already draw them (\`bg-button\`, \`bg-field\`); never restyle a primary button or a field by hand
|
|
37
38
|
- Fonts: headings ${fonts.heading?.family ?? 'system'}, body ${fonts.body?.family ?? 'system'}, mono ${fonts.mono?.family ?? 'system'} (applied by Text; \`font-heading\` makes any Text a heading, \`font-mono\` code and figures)
|
|
38
39
|
- Typeset: body ${ctx.type.size}px, leading ${ctx.type.leading}; every \`text-*\` size below scales by ${ctx.type.scale}${theme.applied ? `\n- Theme: \`${theme.code}\` (\`npx halftone-tokens theme show\`)` : ''}
|
|
39
40
|
- Font sizes (\`text-*\`): ${steps(['font', 'size'])}
|
package/targets/nativewind3.mjs
CHANGED
|
@@ -6,26 +6,33 @@
|
|
|
6
6
|
import { hexToHslTriplet } from '../lib/color.mjs';
|
|
7
7
|
import { cssName, group, isColor, radiusCalc } from '../lib/resolve.mjs';
|
|
8
8
|
import { TEXT_LINE_HEIGHT } from '../lib/theme/recipe.mjs';
|
|
9
|
+
import { styleVars } from '../lib/theme/vars.mjs';
|
|
9
10
|
|
|
10
11
|
const rem = (px) => (px >= 9999 ? '9999px' : `${Number((px / 16).toFixed(4))}rem`);
|
|
11
12
|
|
|
12
13
|
/** Shape variables come from ctx.shape, which has defaults for a token file from before themes. */
|
|
13
14
|
const SHAPE = new Set(['radius-control', 'border-width', 'icon-stroke']);
|
|
14
15
|
|
|
15
|
-
function cssBlock(selector, tokens, shape) {
|
|
16
|
+
function cssBlock(selector, tokens, shape, mode, colors) {
|
|
16
17
|
const lines = tokens
|
|
17
|
-
// Shadows are literal in tailwind.theme.js: a var() box-shadow drops the view on native.
|
|
18
|
+
// Shadows are literal in tailwind.theme.js: a var() box-shadow drops the view on native. The styles'
|
|
19
|
+
// numbers come from shape (with their units), below.
|
|
18
20
|
.filter((t) => t.$type !== 'shadow' && !SHAPE.has(cssName(t)) && !/^(density|type)-/.test(cssName(t)))
|
|
21
|
+
.filter((t) => isColor(t) || !/^(button|field)-/.test(cssName(t)))
|
|
19
22
|
.map((t) => {
|
|
20
23
|
const name = cssName(t);
|
|
21
24
|
if (isColor(t)) return ` --${name}: ${hexToHslTriplet(t.$value)};`;
|
|
22
25
|
if (name === 'radius') return ` --${name}: ${rem(t.$value)};`;
|
|
23
26
|
return ` --${name}: ${t.$value};`;
|
|
24
27
|
});
|
|
25
|
-
if (shape) {
|
|
28
|
+
if (shape.control !== undefined) {
|
|
26
29
|
lines.push(` --radius-control: ${rem(shape.control)};`, ` --border-width: ${shape.borderWidth}px;`, ` --icon-stroke: ${shape.stroke};`);
|
|
27
30
|
for (const size of ['sm', 'md', 'lg', 'x']) lines.push(` --control-${size}: ${shape.density[size]}px;`);
|
|
28
31
|
}
|
|
32
|
+
// Style colours a token file from before the styles doesn't have (resolve.mjs adds them), then the styles' numbers.
|
|
33
|
+
const present = new Set(tokens.filter(isColor).map(cssName));
|
|
34
|
+
for (const c of colors) if (!present.has(c.name)) lines.push(` --${c.name}: ${hexToHslTriplet(c[mode])};`);
|
|
35
|
+
for (const [name, value] of Object.entries(styleVars(shape.styleShape, 'hsl')[mode])) lines.push(` ${name}: ${value};`);
|
|
29
36
|
return ` ${selector} {\n${lines.join('\n')}\n }`;
|
|
30
37
|
}
|
|
31
38
|
|
|
@@ -36,15 +43,23 @@ export default function nativewind3(ctx, options) {
|
|
|
36
43
|
@tailwind utilities;
|
|
37
44
|
|
|
38
45
|
@layer base {
|
|
39
|
-
${cssBlock(':root', ctx.semLight, ctx.shape)}
|
|
46
|
+
${cssBlock(':root', ctx.semLight, { ...ctx.shape, styleShape: ctx.shape }, 'light', ctx.colors)}
|
|
40
47
|
|
|
41
|
-
${cssBlock('.dark:root', ctx.semDark)}
|
|
48
|
+
${cssBlock('.dark:root', ctx.semDark, { styleShape: ctx.shape }, 'dark', ctx.colors)}
|
|
42
49
|
}
|
|
43
50
|
`;
|
|
44
51
|
const colors = Object.fromEntries(ctx.colors.map((c) => [c.name, `hsl(var(--${c.name}))`]));
|
|
52
|
+
// The styles' fills carry their own opacity (an outline button, a transparent field). No /90 modifiers on these.
|
|
53
|
+
colors.button = 'hsl(var(--button) / var(--button-alpha))';
|
|
54
|
+
colors.field = 'hsl(var(--field) / var(--field-alpha))';
|
|
45
55
|
const borderRadius = Object.fromEntries(ctx.radius.steps.map((s) => [s.name, radiusCalc(s.scale)]));
|
|
46
56
|
borderRadius.control = 'var(--radius-control)';
|
|
47
|
-
|
|
57
|
+
// Field style: rounded-field (top) and rounded-b-field-b (bottom).
|
|
58
|
+
borderRadius.field = 'var(--field-radius)';
|
|
59
|
+
borderRadius['field-b'] = 'var(--field-radius-b)';
|
|
60
|
+
// The styles' widths: border-button-w / border-b-button-edge-w, border-field-w / border-b-field-edge-w (named
|
|
61
|
+
// apart from the colours, or border-button would set both).
|
|
62
|
+
const borderWidth = { DEFAULT: 'var(--border-width)', 'button-w': 'var(--button-border-w)', 'button-edge-w': 'var(--button-edge-w)', 'field-w': 'var(--field-border-w)', 'field-edge-w': 'var(--field-edge-w)' };
|
|
48
63
|
// The theme's depth takes over Tailwind's shadow scale: 1 = xs/sm/shadow, 2 = md, 3 = lg/xl/2xl. Light
|
|
49
64
|
// values: native can't switch a box-shadow per scheme.
|
|
50
65
|
const s = ctx.shape.shadows.light;
|
|
@@ -60,7 +75,7 @@ ${cssBlock('.dark:root', ctx.semDark)}
|
|
|
60
75
|
.map(({ key, value }) => [key, [rem(value * scale), { lineHeight: rem(TEXT_LINE_HEIGHT[key] * scale * leadingFactor) }]])
|
|
61
76
|
);
|
|
62
77
|
const lineHeight = { prose: rem(size * leading) };
|
|
63
|
-
const spacing = { 'control-x': 'var(--control-x)', flow: rem(size * flow) };
|
|
78
|
+
const spacing = { 'control-x': 'var(--control-x)', 'field-x': 'var(--field-x)', flow: rem(size * flow) };
|
|
64
79
|
const maxWidth = { measure: rem(measure * size * 0.5) };
|
|
65
80
|
const tailwind = `// ${ctx.header}\nmodule.exports = ${JSON.stringify({ colors, borderRadius, borderWidth, boxShadow, height, minHeight: height, width: height, spacing, fontSize, lineHeight, maxWidth }, null, 2)};\n`;
|
|
66
81
|
return [
|