@halftone-ds/tokens 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -104,6 +104,74 @@ export const typeScale = ({ size, leading, flow, measure }) => ({
104
104
  });
105
105
 
106
106
 
107
+ /** The Text variants both kits draw (components/ui/text.tsx on mobile; the type-* utilities on the web). */
108
+ export const TEXT_STYLES = ['h1', 'h2', 'h3', 'h4', 'lead', 'p', 'large', 'small', 'muted', 'code', 'blockquote'];
109
+
110
+ /**
111
+ * The text styles as the kits draw them at the default typeset (16px, leading 1.75), from the mobile kit's
112
+ * components/ui/text.tsx and Tailwind's sizes: size px, leading × size, tracking em. `leading: 'prose'` is the typeset's own leading
113
+ * (Text p's leading-prose); every other leading scales with the typeset's (leading / 1.75), and sizes with
114
+ * size / 16. `small` is leading-none, which never scales.
115
+ */
116
+ export const TEXT_DEFAULTS = {
117
+ h1: { font: 'heading', weight: 800, size: 36, leading: 40 / 36, tracking: -0.025 },
118
+ h2: { font: 'heading', weight: 600, size: 30, leading: 36 / 30, tracking: -0.025 },
119
+ h3: { font: 'heading', weight: 600, size: 24, leading: 32 / 24, tracking: -0.025 },
120
+ h4: { font: 'heading', weight: 600, size: 20, leading: 28 / 20, tracking: -0.025 },
121
+ lead: { font: 'body', weight: 400, size: 20, leading: 28 / 20, tracking: 0 },
122
+ p: { font: 'body', weight: 400, size: 16, leading: 'prose', tracking: 0 },
123
+ large: { font: 'body', weight: 600, size: 18, leading: 28 / 18, tracking: 0 },
124
+ small: { font: 'body', weight: 500, size: 14, leading: 1, tracking: 0 },
125
+ muted: { font: 'body', weight: 400, size: 14, leading: 20 / 14, tracking: 0 },
126
+ code: { font: 'mono', weight: 600, size: 14, leading: 20 / 14, tracking: 0 },
127
+ blockquote: { font: 'body', weight: 400, size: 16, leading: 24 / 16, tracking: 0 },
128
+ };
129
+
130
+ const FONT_REF = /^\{primitive\.font\.family\.(heading|body|mono)\}$/;
131
+
132
+ /**
133
+ * A system's edited text styles from a semantic.type group: a theme's ({ h1: { size, … } }) or a token file's
134
+ * DTCG one ({ h1: { size: { $value } } }). The font is a reference to a role's family; a style's other keys
135
+ * are numbers. The typeset's own numbers (size, leading, flow, measure) are skipped.
136
+ * → { h1: { size, leading, weight, tracking, font: 'heading' } }
137
+ */
138
+ export function textEdits(group = {}) {
139
+ const out = {};
140
+ for (const name of TEXT_STYLES) {
141
+ const style = group?.[name];
142
+ if (!style || typeof style !== 'object' || '$value' in style) continue;
143
+ const value = (v) => (v && typeof v === 'object' && '$value' in v ? v.$value : v);
144
+ const edit = {};
145
+ for (const key of ['size', 'leading', 'weight', 'tracking']) if (typeof value(style[key]) === 'number') edit[key] = value(style[key]);
146
+ const font = FONT_REF.exec(String(value(style.font) ?? ''));
147
+ if (font) edit.font = font[1];
148
+ out[name] = edit;
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /**
154
+ * Every text style as drawn at a typeset (typeScale), edits on top, unrounded:
155
+ * { h1: { font, weight, size: px, leading: × size, tracking: em, lineHeight: px, edited: bool } }
156
+ * Sizes scale by the typeset's size / 16 and line heights by its leading / 1.75; Text p takes the typeset's
157
+ * leading itself and Text small (leading-none) never scales. With no edits this is exactly what the kits drew
158
+ * with Tailwind's text-* classes.
159
+ */
160
+ export function resolveTextStyles(type, edits = {}) {
161
+ return Object.fromEntries(
162
+ TEXT_STYLES.map((name) => {
163
+ const d = TEXT_DEFAULTS[name];
164
+ const leading = d.leading === 'prose' ? type.leading : d.leading === 1 ? 1 : d.leading * type.leadingFactor;
165
+ const edit = edits[name] ?? {};
166
+ const style = { font: d.font, weight: d.weight, size: d.size * type.scale, leading, tracking: d.tracking, ...edit, edited: Object.keys(edit).length > 0 };
167
+ // The line height in px, multiplied in the order the text-* classes always were (so it rounds the same).
168
+ const px = Math.round(d.size * (d.leading === 'prose' ? 1 : d.leading) * 1e6) / 1e6;
169
+ style.lineHeight = 'size' in edit || 'leading' in edit ? style.size * style.leading : d.leading === 'prose' ? px * type.scale * type.leading : d.leading === 1 ? style.size : px * type.scale * type.leadingFactor;
170
+ return [name, style];
171
+ })
172
+ );
173
+ }
174
+
107
175
  const s = (x, y, blur, spread, color) => ({ offsetX: x, offsetY: y, blur, spread, color });
108
176
  /**
109
177
  * Three elevation levels per depth (DTCG shadow values, light and dark): 1 for controls and cards,
@@ -13,8 +13,18 @@ const p = (ramp, step) => `{primitive.color.${ramp}.${step}}`;
13
13
  const DARK_STEP = { 600: '500', 700: '400', 800: '300', 900: '200', 950: '100' };
14
14
  export const darkStep = (anchor) => DARK_STEP[anchor] ?? anchor;
15
15
 
16
- /** { name: { light: ref, dark: ref } } in the order the kit's tokens.json lists them. */
17
- export function semanticColors(anchor = '600') {
16
+ /**
17
+ * The status tones' steps, as offsets from the step their seed sits on (600 for the curated ramps the kits
18
+ * ship), light and dark: a status seed that lands on another step moves its tone with it, as the brand does.
19
+ */
20
+ const STATUS_STEPS = { danger: [0, -1], success: [1, -1], warning: [1, -2], info: [1, -1] };
21
+ const status = (ramp, anchor = '600') => ({ light: p(ramp, shift(anchor, STATUS_STEPS[ramp][0])), dark: p(ramp, shift(anchor, STATUS_STEPS[ramp][1])) });
22
+
23
+ /**
24
+ * { name: { light: ref, dark: ref } } in the order the kit's tokens.json lists them. `anchors` holds the step
25
+ * each status seed sits on (`{ danger: '500' }`); a status without one uses its 600, the curated ramps' seed.
26
+ */
27
+ export function semanticColors(anchor = '600', anchors = {}) {
18
28
  const brand = { light: b(anchor), dark: b(darkStep(anchor)) };
19
29
  const onDark = { light: n(0), dark: n(950) };
20
30
  return {
@@ -32,13 +42,13 @@ export function semanticColors(anchor = '600') {
32
42
  'muted-foreground': { light: n(600), dark: n(400) },
33
43
  accent: { light: n(100), dark: n(800) },
34
44
  'accent-foreground': { light: n(900), dark: n(50) },
35
- destructive: { light: p('danger', 600), dark: p('danger', 500) },
45
+ destructive: status('danger', anchors.danger),
36
46
  'destructive-foreground': onDark,
37
- success: { light: p('success', 700), dark: p('success', 500) },
47
+ success: status('success', anchors.success),
38
48
  'success-foreground': onDark,
39
- warning: { light: p('warning', 700), dark: p('warning', 400) },
49
+ warning: status('warning', anchors.warning),
40
50
  'warning-foreground': onDark,
41
- info: { light: p('info', 700), dark: p('info', 500) },
51
+ info: status('info', anchors.info),
42
52
  'info-foreground': onDark,
43
53
  border: { light: n(200), dark: n(800) },
44
54
  input: { light: n(200), dark: n(800) },
@@ -59,7 +69,9 @@ export function semanticColors(anchor = '600') {
59
69
  };
60
70
  }
61
71
 
62
- const shift = (step, by) => STEPS[Math.min(STEPS.length - 1, Math.max(0, STEPS.indexOf(step) + by))];
72
+ function shift(step, by) {
73
+ return STEPS[Math.min(STEPS.length - 1, Math.max(0, STEPS.indexOf(step) + by))];
74
+ }
63
75
 
64
76
  /**
65
77
  * The colours a recipe's styles add (button, field), from the base colours after their contrast fix, so a moved
@@ -247,7 +247,7 @@ export function shadcnTheme(theme, { name, title: label, description, fontsUrl =
247
247
  $schema: 'https://ui.shadcn.com/schema/registry-item.json',
248
248
  name: name ?? `halftone-${theme.code}`,
249
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}`,
250
+ description: description ?? `A Halftone theme (${theme.code}): colours for light and dark, radius, shadows and fonts. halftonedesignsystem.com/studio/?t=${theme.code}`,
251
251
  type: 'registry:theme',
252
252
  ...(dependencies.length ? { registryDependencies: dependencies } : {}),
253
253
  cssVars: {
@@ -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,10 +22,40 @@ export const typeVars = (type) => ({
20
22
  '--typeset-measure': `${type.measure}ch`,
21
23
  });
22
24
 
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
+
23
54
  /**
24
55
  * 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).
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).
27
59
  */
28
60
  export function styleVars({ button, field }, format) {
29
61
  return {
@@ -34,9 +66,6 @@ export function styleVars({ button, field }, format) {
34
66
  '--field-alpha': String(field.alpha.light),
35
67
  '--field-border-w': `${field.border}px`,
36
68
  '--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
69
  // Web: a boxed field keeps shadcn's shadow-xs; a filled or underline field has none.
41
70
  ...(format === 'hsl' ? {} : { '--field-shadow': field.border ? 'var(--shadow-1)' : '0 0 #0000' }),
42
71
  },
@@ -44,7 +73,29 @@ export function styleVars({ button, field }, format) {
44
73
  };
45
74
  }
46
75
 
47
- /** { light: { '--primary': '…', '--radius': '0.625rem' }, dark: { … } } */
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) */
48
99
  export function themeVars(theme, format) {
49
100
  const convert = format === 'hsl' ? hexToHslTriplet : hexToOklch;
50
101
  const mode = (m) => {
@@ -56,10 +107,12 @@ export function themeVars(theme, format) {
56
107
  vars['--icon-stroke'] = String(theme.stroke);
57
108
  vars['--density'] = String(theme.density.scale);
58
109
  for (const size of ['sm', 'md', 'lg', 'x']) vars[`--control-${size}`] = `${theme.density[size]}px`;
59
- Object.assign(vars, typeVars(theme.type));
110
+ Object.assign(vars, typeVars(theme.type), textStyleVars(themeTextStyles(theme), format));
60
111
  }
61
112
  Object.assign(vars, styleVars(theme, format)[m]);
113
+ if (theme.components) Object.assign(vars, componentVars(theme.components, format, convert)[m]);
62
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]);
63
116
  return vars;
64
117
  };
65
118
  return { light: mode('light'), dark: mode('dark') };
package/lib/theme-cli.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  * `halftone-tokens theme …` — commit a theme from the docs picker into tokens.json.
3
3
  *
4
4
  * theme apply <theme> [--dry-run] [--force]
5
- * write the theme into tokens.json (only the paths a theme owns) and rebuild
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
@@ -12,19 +13,22 @@
12
13
  * theme shadcn <theme> [--out file] [--fonts-url url]
13
14
  * the theme as a shadcn registry:theme item (stdout, or a file), for `npx shadcn add`
14
15
  *
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.
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.
18
19
  */
19
20
  import { spawnSync } from 'node:child_process';
20
21
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
21
- import { join } from 'node:path';
22
+ import { join, resolve } from 'node:path';
22
23
 
23
24
  import { cvdWarnings, describeCvdWarnings, MIN_DELTA_E } from './cvd.mjs';
24
- import { FONTS } from './theme/fonts.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';
25
28
 
26
29
  import {
27
30
  applyRecipe,
31
+ applySystem,
28
32
  decodeRecipe,
29
33
  encodeRecipe,
30
34
  fontById,
@@ -37,6 +41,7 @@ import {
37
41
  recipeFromShadcn,
38
42
  SHADCN_FONTS_URL,
39
43
  shadcnTheme,
44
+ THEME_EXTENSION,
40
45
  themeStatus,
41
46
  } from './theme/index.mjs';
42
47
 
@@ -51,8 +56,55 @@ function printNotes(code, notes) {
51
56
  console.error(`From shadcn preset ${code}, approximated or not carried:\n${notes.map((n) => ` - ${n}`).join('\n')}`);
52
57
  }
53
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
+
54
106
  function readRecipe(arg, root) {
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);
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);
56
108
  if (isThemeCode(arg)) {
57
109
  try {
58
110
  return decodeRecipe(arg);
@@ -62,7 +114,7 @@ function readRecipe(arg, root) {
62
114
  }
63
115
  const preset = presetById(arg.toLowerCase());
64
116
  if (preset) return normalizeRecipe({ ...preset.recipe, preset: preset.id });
65
- const file = join(root, arg);
117
+ const file = resolve(root, arg);
66
118
  if (arg.endsWith('.json') && existsSync(file)) return normalizeRecipe(JSON.parse(readFileSync(file, 'utf8')));
67
119
  // Last, so a preset name that happens to look like one (bold, brutalist) stays Halftone's preset.
68
120
  const shadcn = isShadcnPresetCode(arg) ? decodeShadcnPreset(arg) : null;
@@ -98,7 +150,8 @@ function installFonts(root, config, theme, flags) {
98
150
  if (config.targets?.fonts?.platform !== 'expo') return;
99
151
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
100
152
  const have = new Set(Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((d) => d.startsWith('@expo-google-fonts/')));
101
- 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);
102
155
  const need = [...new Set(ids)].map((id) => `@expo-google-fonts/${id}`);
103
156
  const missing = need.filter((p) => !have.has(p));
104
157
  const unused = [...have].filter((p) => !need.includes(p) && FONTS.some((f) => `@expo-google-fonts/${f.id}` === p));
@@ -124,7 +177,9 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
124
177
  }
125
178
 
126
179
  if (sub === 'encode') {
127
- 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));
128
183
  return;
129
184
  }
130
185
 
@@ -166,6 +221,7 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
166
221
  if (sub === 'resolve') {
167
222
  const status = themeStatus(tokens());
168
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).`);
169
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.`);
170
226
  if (flags.json) console.log(JSON.stringify({ code: status.code, recipe: status.recipe }, null, 2));
171
227
  else console.log(status.code);
@@ -173,8 +229,8 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
173
229
  }
174
230
 
175
231
  if (sub === 'shadcn') {
176
- const recipe = readRecipe(arg, root);
177
- const { theme } = applyRecipe(tokens(), recipe);
232
+ const { recipe, system } = readTheme(arg, root);
233
+ const { theme } = system ? applySystem(tokens(), system) : applyRecipe(tokens(), recipe);
178
234
  const item = shadcnTheme(theme, {
179
235
  ...(recipe.preset ? { name: `halftone-${recipe.preset}`, title: `Halftone ${presetById(recipe.preset)?.name} theme` } : {}),
180
236
  fontsUrl: typeof flags['fonts-url'] === 'string' ? flags['fonts-url'] : SHADCN_FONTS_URL,
@@ -194,8 +250,16 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
194
250
  if (flags.check) process.exit(0);
195
251
  return;
196
252
  }
197
- const name = status.recipe.preset ? ` (${presetById(status.recipe.preset)?.name ?? status.recipe.preset})` : '';
198
- 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
+ }
199
263
  if (status.drift.length) {
200
264
  console.log(`\nEdited by hand since it was applied (${status.drift.length}):\n${status.drift.map((p) => ` ${p}`).join('\n')}`);
201
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.');
@@ -204,7 +268,7 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
204
268
  }
205
269
 
206
270
  if (sub === 'apply') {
207
- const recipe = readRecipe(arg, root);
271
+ const { recipe, system } = readTheme(arg, root);
208
272
  const current = tokens();
209
273
  const status = themeStatus(current);
210
274
  const blocked = !status.applied
@@ -214,12 +278,18 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
214
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.`
215
279
  : null;
216
280
  if (blocked && !flags.force && !flags['dry-run']) fail(blocked);
217
- const { tokens: next, theme } = applyRecipe(current, recipe);
218
- 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)}`);
219
288
  if (theme.adjustments.length) {
220
289
  console.log(`\nAdjusted for contrast (WCAG AA), hue kept:`);
221
290
  for (const a of theme.adjustments) console.log(` ${a.mode.padEnd(5)} ${a.token.padEnd(28)} ${a.from} → ${a.to}`);
222
291
  }
292
+ printWarnings(theme.warnings);
223
293
  const cvd = cvdWarnings(theme.resolved);
224
294
  if (cvd.length) {
225
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):`);
package/lib/usage.mjs ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Which components draw which tokens: the Studio's "Show usage" (apps/docs). Each kit's `registry:build` calls
3
+ * tokenUsage with its registry (component ids and their files) and writes the result to
4
+ * registry/generated/token-usage.json, which the docs site reads.
5
+ *
6
+ * tokenUsage({ root, components: [{ id, files }], tokens }) → { 'color.primary': ['button', …], 'radius.md': […], … }
7
+ *
8
+ * Keys:
9
+ * color.<name> a semantic colour (or an extra) in a class (bg-primary, text-muted-foreground/80, border-input),
10
+ * var(--name), or the mobile palette (palette.mutedForeground)
11
+ * radius.<step> rounded-sm…2xl, full, control (any side: rounded-t-md)
12
+ * shadow.<1|2|3> shadow-xs / shadow-sm / shadow (1), shadow-md (2), shadow-lg / xl / 2xl (3)
13
+ * type.<style> type-h2 (web), text-type-h2 (mobile), or a Text variant ("h2")
14
+ * font.<role> font-heading, font-sans (body), font-mono
15
+ * border.width border, border-x…, the theme's width
16
+ * component.<name> the component layer (button, chip, badge, input): its classes (bg-button-primary-bg,
17
+ * rounded-chip, type-badge) and variables (--field-*). A variant colour also counts as the role
18
+ * tokens.json points it at (bg-chip-selected-bg → color.foreground), so a role's usage still finds it
19
+ * A static read of class strings: a class built at runtime from parts is not seen.
20
+ */
21
+ import { readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { COMPONENT_NAMES, COMPONENTS, SLOT_VAR } from './theme/components.mjs';
24
+
25
+ const TEXT_STYLES = ['h1', 'h2', 'h3', 'h4', 'lead', 'p', 'large', 'small', 'muted', 'code', 'blockquote'];
26
+ const COLOR_UTILITY =
27
+ /^(?:bg|text|border(?:-[xytrblse])?|divide|outline|ring(?:-offset)?|fill|stroke|from|via|to|accent|caret|placeholder|decoration|shadow)-(.+?)(?:\/[\d.]+)?$/;
28
+ const RADIUS = /^rounded(?:-[trblse]{1,2})?-(sm|md|lg|xl|2xl|full|control)$/;
29
+ const SHADOW = { 'shadow-2xs': '1', 'shadow-xs': '1', 'shadow-sm': '1', shadow: '1', 'shadow-md': '2', 'shadow-lg': '3', 'shadow-xl': '3', 'shadow-2xl': '3' };
30
+ const FONT = { 'font-heading': 'heading', 'font-sans': 'body', 'font-mono': 'mono' };
31
+
32
+ const camel = (s) => s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
33
+
34
+ /** The class itself, without variants (`dark:hover:`, `[&_svg]:`), important (`!`) or a negative sign. */
35
+ function baseClass(token) {
36
+ let depth = 0;
37
+ let cut = -1;
38
+ for (let i = 0; i < token.length; i++) {
39
+ if (token[i] === '[') depth++;
40
+ else if (token[i] === ']') depth--;
41
+ else if (token[i] === ':' && depth === 0) cut = i;
42
+ }
43
+ return token.slice(cut + 1).replace(/^!|!$/g, '').replace(/^-/, '');
44
+ }
45
+
46
+ /** The colour names a token file defines: semantic colours and extras (a ramp's steps as name-step). */
47
+ export function colourNames(tokens) {
48
+ const names = new Set(Object.keys(tokens.semantic?.color ?? {}));
49
+ for (const [name, node] of Object.entries(tokens.extra?.color ?? {})) {
50
+ if (node && '$value' in node) names.add(name);
51
+ else for (const step of Object.keys(node ?? {})) if (/^\d+$/.test(step)) names.add(`${name}-${step}`);
52
+ }
53
+ return names;
54
+ }
55
+
56
+ /** The prefix each component's classes and variables use (Input's are field-*). */
57
+ const PREFIX = Object.fromEntries(COMPONENT_NAMES.map((name) => [COMPONENTS[name].prefix, name]));
58
+ const COMPONENT_CLASS = new RegExp(`(?:^|[-(])(${Object.keys(PREFIX).join('|')})-`);
59
+
60
+ /**
61
+ * The component layer's colour classes and the roles tokens.json points them at, both modes:
62
+ * { 'chip-selected-bg': ['foreground'], … }.
63
+ */
64
+ export function componentColourRoles(tokens) {
65
+ const out = {};
66
+ for (const name of COMPONENT_NAMES) {
67
+ const group = tokens.semantic?.component?.[name];
68
+ if (!group) continue;
69
+ for (const [variant, slots] of Object.entries(group)) {
70
+ if (!slots || typeof slots !== 'object' || '$value' in slots) continue;
71
+ for (const [slot, key] of Object.entries(SLOT_VAR)) {
72
+ const value = slots[slot]?.$value;
73
+ if (!value) continue;
74
+ const refs = typeof value === 'object' ? [value.light, value.dark] : [value];
75
+ const roles = [...new Set(refs.map((r) => /^\{semantic\.color\.([a-z0-9-]+)\}$/.exec(String(r))?.[1]).filter(Boolean))];
76
+ out[`${COMPONENTS[name].prefix}-${variant}-${key}`] = roles;
77
+ }
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** The token keys one source file uses. `roles`: componentColourRoles. */
84
+ export function usageIn(code, colours, roles = {}) {
85
+ const keys = new Set();
86
+ // Comments go first: an apostrophe in one ("the theme's") would pair with a quote in the code.
87
+ const source = code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1');
88
+ const byCamel = new Map([...colours].map((n) => [camel(n), n]));
89
+ for (const m of source.matchAll(/(["'`])((?:(?!\1)[^\\]|\\.)*)\1/g)) {
90
+ for (const token of m[2].split(/\s+/)) {
91
+ if (!token) continue;
92
+ const cls = baseClass(token);
93
+ const colour = COLOR_UTILITY.exec(cls);
94
+ if (colour && colours.has(colour[1])) keys.add(`color.${colour[1]}`);
95
+ if (colour && roles[colour[1]]) for (const role of roles[colour[1]]) keys.add(`color.${role}`);
96
+ const component = COMPONENT_CLASS.exec(cls);
97
+ if (component && (roles[colour?.[1]] || /^(rounded|h|w|size|px|type|text-type|leading-type|tracking-type|border(-b)?)-/.test(cls))) keys.add(`component.${PREFIX[component[1]]}`);
98
+ const radius = RADIUS.exec(cls);
99
+ if (radius) keys.add(`radius.${radius[1]}`);
100
+ if (SHADOW[cls]) keys.add(`shadow.${SHADOW[cls]}`);
101
+ const type = /^(?:text-)?type-([a-z0-9]+)$/.exec(cls);
102
+ if (type && TEXT_STYLES.includes(type[1])) keys.add(`type.${type[1]}`);
103
+ if (FONT[cls]) keys.add(`font.${FONT[cls]}`);
104
+ if (/^border(?:-[xytrblse])?$/.test(cls)) keys.add('border.width');
105
+ }
106
+ }
107
+ for (const m of source.matchAll(/var\(--([a-z0-9-]+)\)/g)) if (colours.has(m[1])) keys.add(`color.${m[1]}`);
108
+ for (const m of source.matchAll(/--(button|chip|badge|field)-[a-z0-9-]+/g)) if (!/^--(button|field)(-(foreground|edge))?$/.test(m[0])) keys.add(`component.${PREFIX[m[1]]}`);
109
+ for (const m of source.matchAll(/\b(?:palette|colors|colours)\.([a-z][A-Za-z0-9]*)\b/g)) if (byCamel.has(m[1])) keys.add(`color.${byCamel.get(m[1])}`);
110
+ for (const m of source.matchAll(/\bvariant(?:=|:\s*)\{?["']([a-z0-9]+)["']/g)) if (TEXT_STYLES.includes(m[1])) keys.add(`type.${m[1]}`);
111
+ return keys;
112
+ }
113
+
114
+ export function tokenUsage({ root, components, tokens }) {
115
+ const colours = colourNames(tokens);
116
+ const roles = componentColourRoles(tokens);
117
+ const usage = {};
118
+ for (const { id, files } of components) {
119
+ const keys = new Set();
120
+ for (const file of files ?? []) {
121
+ let source;
122
+ try {
123
+ source = readFileSync(join(root, file), 'utf8');
124
+ } catch {
125
+ continue;
126
+ }
127
+ for (const key of usageIn(source, colours, roles)) keys.add(key);
128
+ }
129
+ for (const key of keys) (usage[key] ??= []).push(id);
130
+ }
131
+ return Object.fromEntries(
132
+ Object.keys(usage)
133
+ .sort()
134
+ .map((key) => [key, [...new Set(usage[key])].sort()])
135
+ );
136
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halftone-ds/tokens",
3
- "version": "1.2.0",
3
+ "version": "1.3.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",
@@ -28,6 +28,10 @@
28
28
  "types": "./lib/theme/index.d.mts",
29
29
  "default": "./lib/theme/index.mjs"
30
30
  },
31
+ "./system": {
32
+ "types": "./lib/system/index.d.mts",
33
+ "default": "./lib/system/index.mjs"
34
+ },
31
35
  "./lib/*": "./lib/*.mjs",
32
36
  "./targets/*": "./targets/*.mjs",
33
37
  "./package.json": "./package.json"