@halftone-ds/tokens 1.0.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.
@@ -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;
@@ -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 <code|preset|recipe.json> [--dry-run] [--force]
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 <recipe.json> print the code for a recipe file
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 ht2-…), a preset name or a recipe .json', 2);
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
- fail(`"${arg}" is not a theme code, a preset (${PRESETS.map((p) => p.id).join(', ')}) or a recipe file`, 2);
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 encode.`, 2);
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.0.0",
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",
@@ -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
- Every \`X\` / \`X-foreground\` pairing, muted text on every surface, and every status tone used as text
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), enforced by \`npm run tokens:build\`.
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'])}
@@ -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
- const borderWidth = { DEFAULT: 'var(--border-width)' };
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 [