@halftone-ds/tokens 1.1.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.
@@ -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/studio/?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;
@@ -5,7 +5,9 @@
5
5
  * 'oklch' Tailwind 4 / shadcn web (`oklch(0.546 0.2152 262.88)`) — the web kit and the docs site
6
6
  */
7
7
  import { hexToHslTriplet, hexToOklch } from '../color.mjs';
8
- import { shadowCss } from './recipe.mjs';
8
+ import { componentVars } from './components.mjs';
9
+ import { extraList, extraTextStyles } from './extras.mjs';
10
+ import { resolveTextStyles, shadowCss, textEdits } from './recipe.mjs';
9
11
 
10
12
  export const rem = (px) => (px >= 9999 ? '9999px' : `${px / 16}rem`);
11
13
 
@@ -20,7 +22,80 @@ export const typeVars = (type) => ({
20
22
  '--typeset-measure': `${type.measure}ch`,
21
23
  });
22
24
 
23
- /** { light: { '--primary': '…', '--radius': '0.625rem' }, dark: { … } } */
25
+ const fixed = (n, places = 4) => Number(n.toFixed(places));
26
+
27
+ /** A theme's text styles as drawn (recipe.mjs resolveTextStyles): its typeset, its edited styles on top. */
28
+ export const themeTextStyles = (theme) => resolveTextStyles(theme.type, textEdits(theme.semantic?.type));
29
+
30
+ /**
31
+ * The text styles' variables, --type-<style>-{size,leading,weight,tracking,font}, from resolveTextStyles. Literal
32
+ * values at the theme's typeset, in each kit's units:
33
+ * 'hsl' mobile (NativeWind): size and line height in rem (native line heights are lengths); the font is
34
+ * the role, which components/ui/text.tsx picks in JS, so no variable. Tracking is em on native too:
35
+ * NativeWind resolves it against the style's own size (its preset's tracking-* are fixed px instead)
36
+ * 'oklch' web: rem and a unitless line height; the font is the role's family variable. `font(role)` writes
37
+ * it (the build adds fallbacks; a live theme always sets --kit-font-*)
38
+ */
39
+ export function textStyleVars(styles, format, font = (role) => `var(--kit-font-${role})`) {
40
+ const vars = {};
41
+ // Mobile rounds rem to 4 places, as nativewind3's text-* sizes always were.
42
+ const length = (px) => (format === 'hsl' ? `${fixed(px / 16)}rem` : rem(fixed(px)));
43
+ for (const [name, s] of Object.entries(styles)) {
44
+ const v = `--type-${name}`;
45
+ vars[`${v}-size`] = length(s.size);
46
+ vars[`${v}-leading`] = format === 'hsl' ? length(s.lineHeight ?? s.size * s.leading) : String(fixed(s.leading, 6));
47
+ vars[`${v}-weight`] = String(s.weight);
48
+ vars[`${v}-tracking`] = `${s.tracking}em`;
49
+ if (format !== 'hsl') vars[`${v}-font`] = font(s.font);
50
+ }
51
+ return vars;
52
+ }
53
+
54
+ /**
55
+ * The button and field styles' variables (both formats), from a theme's or a token file's { button, field }
56
+ * (generate.mjs, resolve.mjs). Widths are numbers on the light block; the field's fill opacity is per mode. A
57
+ * field's corners and padding are the component layer's (componentVars: --field-radius, --field-radius-b,
58
+ * --field-x).
59
+ */
60
+ export function styleVars({ button, field }, format) {
61
+ return {
62
+ light: {
63
+ '--button-alpha': String(button.alpha),
64
+ '--button-border-w': `${button.border}px`,
65
+ '--button-edge-w': `${button.edge}px`,
66
+ '--field-alpha': String(field.alpha.light),
67
+ '--field-border-w': `${field.border}px`,
68
+ '--field-edge-w': `${field.edge}px`,
69
+ // Web: a boxed field keeps shadcn's shadow-xs; a filled or underline field has none.
70
+ ...(format === 'hsl' ? {} : { '--field-shadow': field.border ? 'var(--shadow-1)' : '0 0 #0000' }),
71
+ },
72
+ dark: { '--field-alpha': String(field.alpha.dark) },
73
+ };
74
+ }
75
+
76
+ /**
77
+ * A system's extras as variables (lib/theme/extras.mjs): --<colour> (a ramp's --<name>-<step>), --radius-<name>,
78
+ * --shadow-<name> per mode, --border-<name>-width and --type-<name>-*. `font` as textStyleVars takes it.
79
+ */
80
+ export function extraVars(list, format, font) {
81
+ const convert = format === 'hsl' ? hexToHslTriplet : hexToOklch;
82
+ const light = {};
83
+ const dark = {};
84
+ for (const c of list.color) {
85
+ light[`--${c.name}`] = convert(c.light);
86
+ dark[`--${c.name}`] = convert(c.dark);
87
+ }
88
+ for (const r of list.radius) light[`--radius-${r.name}`] = rem(r.px);
89
+ for (const s of list.shadow) {
90
+ light[`--shadow-${s.name}`] = shadowCss(s.light);
91
+ dark[`--shadow-${s.name}`] = shadowCss(s.dark);
92
+ }
93
+ for (const b of list.border) light[`--border-${b.name}-width`] = `${b.width}px`;
94
+ Object.assign(light, textStyleVars(extraTextStyles(list), format, font));
95
+ return { light, dark };
96
+ }
97
+
98
+ /** { light: { '--primary': '…', '--radius': '0.625rem' }, dark: { … } } (a system's extras too) */
24
99
  export function themeVars(theme, format) {
25
100
  const convert = format === 'hsl' ? hexToHslTriplet : hexToOklch;
26
101
  const mode = (m) => {
@@ -32,9 +107,12 @@ export function themeVars(theme, format) {
32
107
  vars['--icon-stroke'] = String(theme.stroke);
33
108
  vars['--density'] = String(theme.density.scale);
34
109
  for (const size of ['sm', 'md', 'lg', 'x']) vars[`--control-${size}`] = `${theme.density[size]}px`;
35
- Object.assign(vars, typeVars(theme.type));
110
+ Object.assign(vars, typeVars(theme.type), textStyleVars(themeTextStyles(theme), format));
36
111
  }
112
+ Object.assign(vars, styleVars(theme, format)[m]);
113
+ if (theme.components) Object.assign(vars, componentVars(theme.components, format, convert)[m]);
37
114
  for (const [level, value] of Object.entries(theme.semantic.shadow)) vars[`--shadow-${level}`] = shadowCss(value[m]);
115
+ if (theme.extra) Object.assign(vars, extraVars(extraList(theme.extra), format)[m]);
38
116
  return vars;
39
117
  };
40
118
  return { light: mode('light'), dark: mode('dark') };
package/lib/theme-cli.mjs CHANGED
@@ -1,21 +1,34 @@
1
1
  /**
2
2
  * `halftone-tokens theme …` — commit a theme from the docs picker into tokens.json.
3
3
  *
4
- * theme apply <code|preset|recipe.json> [--dry-run] [--force]
5
- * write the theme into tokens.json (only the paths a theme owns) and rebuild
4
+ * theme apply <theme> [--dry-run] [--force]
5
+ * write the theme into tokens.json (only the paths a theme owns) and rebuild; a system document is
6
+ * recorded in tokens.json's $extensions (without its brand images)
6
7
  * theme show [--check] the applied theme, its code and any hand edits since (drift)
7
8
  * --check: exit 1 if the recorded theme no longer reproduces (CI)
8
9
  * theme presets list the presets and their codes
9
- * theme encode <recipe.json> print the code for a recipe file
10
+ * theme encode <theme> print the Halftone code for a theme
11
+ * theme decode <code> [--json] what a code holds (a Halftone code, or a shadcn preset and what it maps to)
12
+ * theme resolve [--json] the code for tokens.json as it is; exit 1 if none is recorded or it was edited
13
+ * theme shadcn <theme> [--out file] [--fonts-url url]
14
+ * the theme as a shadcn registry:theme item (stdout, or a file), for `npx shadcn add`
15
+ *
16
+ * <theme> is a Halftone code (ht1-…, ht2-…, ht3-…), a preset name, a recipe .json, a system document
17
+ * (name.halftone.json, from the Studio), or a shadcn/ui preset code (ui.shadcn.com/create, e.g. b5owWMfJ8l),
18
+ * which becomes the nearest Halftone recipe: what it approximates or drops is listed on stderr.
10
19
  */
11
20
  import { spawnSync } from 'node:child_process';
12
21
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
13
- import { join } from 'node:path';
22
+ import { join, resolve } from 'node:path';
14
23
 
15
- import { FONTS } from './theme/fonts.mjs';
24
+ import { cvdWarnings, describeCvdWarnings, MIN_DELTA_E } from './cvd.mjs';
25
+ import { FONTS, fontByFamily } from './theme/fonts.mjs';
26
+ import { isSystem, normalizeSystem, SystemError, systemOverrides } from './system/schema.mjs';
27
+ import { systemCode } from './system/generate.mjs';
16
28
 
17
29
  import {
18
30
  applyRecipe,
31
+ applySystem,
19
32
  decodeRecipe,
20
33
  encodeRecipe,
21
34
  fontById,
@@ -23,6 +36,12 @@ import {
23
36
  normalizeRecipe,
24
37
  presetById,
25
38
  PRESETS,
39
+ decodeShadcnPreset,
40
+ isShadcnPresetCode,
41
+ recipeFromShadcn,
42
+ SHADCN_FONTS_URL,
43
+ shadcnTheme,
44
+ THEME_EXTENSION,
26
45
  themeStatus,
27
46
  } from './theme/index.mjs';
28
47
 
@@ -31,8 +50,61 @@ const fail = (message, code = 1) => {
31
50
  process.exit(code);
32
51
  };
33
52
 
53
+ /** A shadcn code's mapping notes, on stderr so stdout stays a code or JSON. */
54
+ function printNotes(code, notes) {
55
+ if (!notes.length) return;
56
+ console.error(`From shadcn preset ${code}, approximated or not carried:\n${notes.map((n) => ` - ${n}`).join('\n')}`);
57
+ }
58
+
59
+ /** { recipe, system? }: a system document when <theme> is one (name.halftone.json), else a recipe. */
60
+ function readTheme(arg, root) {
61
+ // resolve: an absolute path stays itself (join would put it under root).
62
+ const file = arg ? resolve(root, arg) : '';
63
+ if (arg?.endsWith('.json') && existsSync(file)) {
64
+ let json;
65
+ try {
66
+ json = JSON.parse(readFileSync(file, 'utf8'));
67
+ } catch (error) {
68
+ fail(`${arg} is not valid JSON: ${error.message}`);
69
+ }
70
+ if (isSystem(json)) {
71
+ try {
72
+ const system = normalizeSystem(json);
73
+ return { recipe: system.recipe, system };
74
+ } catch (error) {
75
+ if (error instanceof SystemError) fail(`${arg}: ${error.message}`);
76
+ throw error;
77
+ }
78
+ }
79
+ }
80
+ return { recipe: readRecipe(arg, root) };
81
+ }
82
+
83
+ /** Why a system has no code: its overrides, briefly. */
84
+ function noCode(system) {
85
+ const overrides = systemOverrides(system);
86
+ const shown = overrides.slice(0, 6).join(', ') + (overrides.length > 6 ? `, and ${overrides.length - 6} more` : '');
87
+ return `"${system.name}" has ${overrides.length} override(s) a theme code can't carry (${shown}). Share its .halftone.json instead.`;
88
+ }
89
+
90
+ /** A system's overrides, grouped by section, for `show` and `apply`. */
91
+ function describeOverrides(system) {
92
+ const bySection = {};
93
+ for (const path of systemOverrides(system)) {
94
+ const [section, ...rest] = path.split('.');
95
+ (bySection[section] ??= []).push(rest.join('.'));
96
+ }
97
+ return Object.entries(bySection).map(([section, list]) => `${section}: ${list.join(', ')}`);
98
+ }
99
+
100
+ function printWarnings(warnings) {
101
+ if (!warnings?.length) return;
102
+ console.log(`\nBelow WCAG AA because of colours chosen by hand (warnings; the fixer leaves hand-picked colours alone):`);
103
+ for (const w of warnings) console.log(` ${w.mode.padEnd(5)} ${w.label.padEnd(40)} ${w.ratio}:1 (needs ${w.threshold})${w.locked.length ? ` · chosen: ${w.locked.join(', ')}` : ''}`);
104
+ }
105
+
34
106
  function readRecipe(arg, root) {
35
- if (!arg) fail('say which theme: a code (ht1-… or ht2-…), a preset name or a recipe .json', 2);
107
+ if (!arg) fail('say which theme: a code (ht1-…, ht2-… or ht3-…), a preset name, a recipe .json, a system .halftone.json or a shadcn preset code', 2);
36
108
  if (isThemeCode(arg)) {
37
109
  try {
38
110
  return decodeRecipe(arg);
@@ -42,13 +114,20 @@ function readRecipe(arg, root) {
42
114
  }
43
115
  const preset = presetById(arg.toLowerCase());
44
116
  if (preset) return normalizeRecipe({ ...preset.recipe, preset: preset.id });
45
- const file = join(root, arg);
117
+ const file = resolve(root, arg);
46
118
  if (arg.endsWith('.json') && existsSync(file)) return normalizeRecipe(JSON.parse(readFileSync(file, 'utf8')));
47
- fail(`"${arg}" is not a theme code, a preset (${PRESETS.map((p) => p.id).join(', ')}) or a recipe file`, 2);
119
+ // Last, so a preset name that happens to look like one (bold, brutalist) stays Halftone's preset.
120
+ const shadcn = isShadcnPresetCode(arg) ? decodeShadcnPreset(arg) : null;
121
+ if (shadcn) {
122
+ const { recipe, notes } = recipeFromShadcn(shadcn);
123
+ printNotes(arg, notes);
124
+ return recipe;
125
+ }
126
+ 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
127
  }
49
128
 
50
129
  const describe = (input) => {
51
- // A recipe recorded by an older halftone-tokens has no typeset: it gets the defaults.
130
+ // A recipe recorded by an older halftone-tokens has no typeset or styles: it gets the defaults.
52
131
  const recipe = normalizeRecipe(input);
53
132
  const font = (id) => {
54
133
  const f = fontById(id);
@@ -58,6 +137,7 @@ const describe = (input) => {
58
137
  `brand ${recipe.brand} · neutral ${recipe.neutral} · radius ${recipe.radius} · controls ${recipe.controls}`,
59
138
  `type ${font(recipe.font.heading)} / ${font(recipe.font.body)} · stroke ${recipe.stroke} · depth ${recipe.depth} · density ${recipe.density} · border ${recipe.border}`,
60
139
  `typeset ${recipe.size}px · leading ${recipe.leading} · flow ${recipe.flow} · measure ${recipe.measure}ch · mono ${font(recipe.font.mono)}`,
140
+ `styles: ${recipe.button} buttons · ${recipe.field} fields`,
61
141
  ].join('\n ');
62
142
  };
63
143
 
@@ -70,7 +150,8 @@ function installFonts(root, config, theme, flags) {
70
150
  if (config.targets?.fonts?.platform !== 'expo') return;
71
151
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
72
152
  const have = new Set(Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((d) => d.startsWith('@expo-google-fonts/')));
73
- const ids = Object.values(theme.recipe.font).filter((id) => !fontById(id)?.system);
153
+ // The theme's families: curated ones, or any Google Fonts family a system names.
154
+ const ids = Object.values(theme.fonts).filter(Boolean).map(fontByFamily).filter((f) => !f.system).map((f) => f.id);
74
155
  const need = [...new Set(ids)].map((id) => `@expo-google-fonts/${id}`);
75
156
  const missing = need.filter((p) => !have.has(p));
76
157
  const unused = [...have].filter((p) => !need.includes(p) && FONTS.some((f) => `@expo-google-fonts/${f.id}` === p));
@@ -96,7 +177,69 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
96
177
  }
97
178
 
98
179
  if (sub === 'encode') {
99
- console.log(encodeRecipe(readRecipe(arg, root)));
180
+ const { recipe, system } = readTheme(arg, root);
181
+ if (system && !systemCode(system)) fail(noCode(system));
182
+ console.log(encodeRecipe(recipe));
183
+ return;
184
+ }
185
+
186
+ if (sub === 'decode') {
187
+ if (!arg) fail('say which code to decode: ht1-…, ht2-…, ht3-… or a shadcn preset code', 2);
188
+ // A preset name first, as for apply: `bold` is Halftone's preset, not a shadcn code.
189
+ const preset = presetById(arg.toLowerCase());
190
+ if (preset) {
191
+ const recipe = normalizeRecipe({ ...preset.recipe, preset: preset.id });
192
+ if (flags.json) console.log(JSON.stringify({ kind: 'halftone', preset: preset.id, code: encodeRecipe(recipe), recipe }, null, 2));
193
+ else console.log(`Halftone preset ${preset.name}: ${encodeRecipe(recipe)}\n ${describe(recipe)}`);
194
+ return;
195
+ }
196
+ if (isThemeCode(arg)) {
197
+ let recipe;
198
+ try {
199
+ recipe = decodeRecipe(arg);
200
+ } catch (error) {
201
+ fail(error.message);
202
+ }
203
+ if (flags.json) console.log(JSON.stringify({ kind: 'halftone', code: encodeRecipe(recipe), recipe }, null, 2));
204
+ else console.log(`Halftone theme ${encodeRecipe(recipe)}\n ${describe(recipe)}`);
205
+ return;
206
+ }
207
+ const shadcn = isShadcnPresetCode(arg) ? decodeShadcnPreset(arg) : null;
208
+ if (!shadcn) fail(`"${arg}" is not a Halftone theme code or a shadcn preset code`, 2);
209
+ const { recipe, notes } = recipeFromShadcn(shadcn);
210
+ const code = encodeRecipe(recipe);
211
+ if (flags.json) {
212
+ console.log(JSON.stringify({ kind: 'shadcn', shadcn, code, recipe, notes }, null, 2));
213
+ return;
214
+ }
215
+ const fields = Object.entries(shadcn).map(([k, v]) => `${k} ${v}`).join(' · ');
216
+ console.log(`shadcn preset ${arg}\n ${fields}\n\nAs a Halftone theme: ${code}\n ${describe(recipe)}`);
217
+ if (notes.length) console.log(`\nApproximated or not carried:\n${notes.map((n) => ` - ${n}`).join('\n')}`);
218
+ return;
219
+ }
220
+
221
+ if (sub === 'resolve') {
222
+ const status = themeStatus(tokens());
223
+ 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>`.');
224
+ if (!status.code) fail(`the applied system ${noCode(status.system)} Its document is recorded in tokens.json ($extensions).`);
225
+ 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.`);
226
+ if (flags.json) console.log(JSON.stringify({ code: status.code, recipe: status.recipe }, null, 2));
227
+ else console.log(status.code);
228
+ return;
229
+ }
230
+
231
+ if (sub === 'shadcn') {
232
+ const { recipe, system } = readTheme(arg, root);
233
+ const { theme } = system ? applySystem(tokens(), system) : applyRecipe(tokens(), recipe);
234
+ const item = shadcnTheme(theme, {
235
+ ...(recipe.preset ? { name: `halftone-${recipe.preset}`, title: `Halftone ${presetById(recipe.preset)?.name} theme` } : {}),
236
+ fontsUrl: typeof flags['fonts-url'] === 'string' ? flags['fonts-url'] : SHADCN_FONTS_URL,
237
+ });
238
+ const json = JSON.stringify(item, null, 2) + '\n';
239
+ if (typeof flags.out === 'string') {
240
+ writeFileSync(join(root, flags.out), json);
241
+ console.error(`Wrote ${flags.out}: install it in a shadcn project with \`npx shadcn@latest add ./${flags.out.split('/').pop()}\`.`);
242
+ } else process.stdout.write(json);
100
243
  return;
101
244
  }
102
245
 
@@ -107,8 +250,16 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
107
250
  if (flags.check) process.exit(0);
108
251
  return;
109
252
  }
110
- const name = status.recipe.preset ? ` (${presetById(status.recipe.preset)?.name ?? status.recipe.preset})` : '';
111
- console.log(`Theme ${status.code}${name}\n ${describe(status.recipe)}`);
253
+ if (status.system) {
254
+ const { system } = status;
255
+ const overrides = describeOverrides(system);
256
+ console.log(`System "${system.name}"${status.code ? ` ${status.code}` : ' (no theme code: it has overrides)'}\n ${describe(status.recipe)}`);
257
+ if (overrides.length) console.log(`\nOverrides:\n${overrides.map((o) => ` ${o}`).join('\n')}`);
258
+ printWarnings(tokens().$extensions?.[THEME_EXTENSION]?.warnings);
259
+ } else {
260
+ const name = status.recipe.preset ? ` (${presetById(status.recipe.preset)?.name ?? status.recipe.preset})` : '';
261
+ console.log(`Theme ${status.code}${name}\n ${describe(status.recipe)}`);
262
+ }
112
263
  if (status.drift.length) {
113
264
  console.log(`\nEdited by hand since it was applied (${status.drift.length}):\n${status.drift.map((p) => ` ${p}`).join('\n')}`);
114
265
  if (flags.check) fail('tokens.json no longer matches its recorded theme. Re-apply it, or apply with --force to record the edits away.');
@@ -117,7 +268,7 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
117
268
  }
118
269
 
119
270
  if (sub === 'apply') {
120
- const recipe = readRecipe(arg, root);
271
+ const { recipe, system } = readTheme(arg, root);
121
272
  const current = tokens();
122
273
  const status = themeStatus(current);
123
274
  const blocked = !status.applied
@@ -127,12 +278,23 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
127
278
  ? `these theme tokens were edited by hand since ${status.code} was applied:\n${status.drift.map((p) => ` ${p}`).join('\n')}\nRe-run with --force to overwrite them.`
128
279
  : null;
129
280
  if (blocked && !flags.force && !flags['dry-run']) fail(blocked);
130
- const { tokens: next, theme } = applyRecipe(current, recipe);
131
- console.log(`Theme ${theme.code}${recipe.preset ? ` (${presetById(recipe.preset)?.name})` : ''}\n ${describe(theme.recipe)}`);
281
+ const { tokens: next, theme } = system ? applySystem(current, system) : applyRecipe(current, recipe);
282
+ if (system) {
283
+ const code = systemCode(system);
284
+ const overrides = describeOverrides(system);
285
+ console.log(`System "${system.name}"${code ? ` ${code}` : ''}\n ${describe(theme.recipe)}`);
286
+ if (overrides.length) console.log(`\nOverrides (no theme code carries these; tokens.json records the document):\n${overrides.map((o) => ` ${o}`).join('\n')}`);
287
+ } else console.log(`Theme ${theme.code}${recipe.preset ? ` (${presetById(recipe.preset)?.name})` : ''}\n ${describe(theme.recipe)}`);
132
288
  if (theme.adjustments.length) {
133
289
  console.log(`\nAdjusted for contrast (WCAG AA), hue kept:`);
134
290
  for (const a of theme.adjustments) console.log(` ${a.mode.padEnd(5)} ${a.token.padEnd(28)} ${a.from} → ${a.to}`);
135
291
  }
292
+ printWarnings(theme.warnings);
293
+ const cvd = cvdWarnings(theme.resolved);
294
+ if (cvd.length) {
295
+ 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):`);
296
+ console.log(describeCvdWarnings(cvd).join('\n'));
297
+ }
136
298
  if (flags['dry-run']) {
137
299
  console.log(`\n--dry-run: nothing written.${blocked && !flags.force ? `\nNote: ${blocked}` : ''}`);
138
300
  return;
@@ -144,5 +306,5 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
144
306
  return;
145
307
  }
146
308
 
147
- fail(`unknown command "theme ${sub}". Use apply, show, presets or encode.`, 2);
309
+ fail(`unknown command "theme ${sub}". Use apply, show, presets, encode, decode, resolve or shadcn.`, 2);
148
310
  }