@halftone-ds/tokens 1.2.0 → 1.3.1

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;
@@ -74,17 +126,20 @@ function readRecipe(arg, root) {
74
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);
75
127
  }
76
128
 
77
- const describe = (input) => {
129
+ /** A recipe in a few lines. `families`: a system's own fonts (any Google Fonts family), which win over the recipe's. */
130
+ const describe = (input, families = {}) => {
78
131
  // A recipe recorded by an older halftone-tokens has no typeset or styles: it gets the defaults.
79
132
  const recipe = normalizeRecipe(input);
80
- const font = (id) => {
133
+ const font = (role) => {
134
+ if (families[role]) return families[role];
135
+ const id = recipe.font[role];
81
136
  const f = fontById(id);
82
137
  return f?.system ? (f.category === 'mono' ? 'system mono' : 'system') : (f?.family ?? id);
83
138
  };
84
139
  return [
85
140
  `brand ${recipe.brand} · neutral ${recipe.neutral} · radius ${recipe.radius} · controls ${recipe.controls}`,
86
- `type ${font(recipe.font.heading)} / ${font(recipe.font.body)} · stroke ${recipe.stroke} · depth ${recipe.depth} · density ${recipe.density} · border ${recipe.border}`,
87
- `typeset ${recipe.size}px · leading ${recipe.leading} · flow ${recipe.flow} · measure ${recipe.measure}ch · mono ${font(recipe.font.mono)}`,
141
+ `type ${font('heading')} / ${font('body')} · stroke ${recipe.stroke} · depth ${recipe.depth} · density ${recipe.density} · border ${recipe.border}`,
142
+ `typeset ${recipe.size}px · leading ${recipe.leading} · flow ${recipe.flow} · measure ${recipe.measure}ch · mono ${font('mono')}`,
88
143
  `styles: ${recipe.button} buttons · ${recipe.field} fields`,
89
144
  ].join('\n ');
90
145
  };
@@ -98,7 +153,8 @@ function installFonts(root, config, theme, flags) {
98
153
  if (config.targets?.fonts?.platform !== 'expo') return;
99
154
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
100
155
  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);
156
+ // The theme's families: curated ones, or any Google Fonts family a system names.
157
+ const ids = Object.values(theme.fonts).filter(Boolean).map(fontByFamily).filter((f) => !f.system).map((f) => f.id);
102
158
  const need = [...new Set(ids)].map((id) => `@expo-google-fonts/${id}`);
103
159
  const missing = need.filter((p) => !have.has(p));
104
160
  const unused = [...have].filter((p) => !need.includes(p) && FONTS.some((f) => `@expo-google-fonts/${f.id}` === p));
@@ -114,6 +170,18 @@ function installFonts(root, config, theme, flags) {
114
170
  if (unused.length) console.log(`\nNo longer used by the theme (remove when you like): npm uninstall ${unused.join(' ')}`);
115
171
  }
116
172
 
173
+ /**
174
+ * A kit's registry:build writes which components draw which token (registry/generated/token-usage.json and the docs
175
+ * from it), and that follows the colour roles a system maps, so applying one rebuilds it: its --check stays green.
176
+ */
177
+ function rebuildRegistry(root) {
178
+ const file = join(root, 'package.json');
179
+ if (!existsSync(file) || !JSON.parse(readFileSync(file, 'utf8')).scripts?.['registry:build']) return;
180
+ console.log('\nregistry:build (token usage follows the colour roles)…');
181
+ const r = spawnSync('npm', ['run', '--silent', 'registry:build'], { cwd: root, stdio: 'inherit' });
182
+ if (r.status !== 0) console.log('registry:build failed: run `npm run registry:build` yourself.');
183
+ }
184
+
117
185
  export async function themeCommand({ positional, flags, root, sourcePath, config, build }) {
118
186
  const [sub = 'show', arg] = positional;
119
187
  const tokens = () => JSON.parse(readFileSync(sourcePath, 'utf8'));
@@ -124,7 +192,9 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
124
192
  }
125
193
 
126
194
  if (sub === 'encode') {
127
- console.log(encodeRecipe(readRecipe(arg, root)));
195
+ const { recipe, system } = readTheme(arg, root);
196
+ if (system && !systemCode(system)) fail(noCode(system));
197
+ console.log(encodeRecipe(recipe));
128
198
  return;
129
199
  }
130
200
 
@@ -166,6 +236,7 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
166
236
  if (sub === 'resolve') {
167
237
  const status = themeStatus(tokens());
168
238
  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>`.');
239
+ if (!status.code) fail(`the applied system ${noCode(status.system)} Its document is recorded in tokens.json ($extensions).`);
169
240
  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
241
  if (flags.json) console.log(JSON.stringify({ code: status.code, recipe: status.recipe }, null, 2));
171
242
  else console.log(status.code);
@@ -173,8 +244,8 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
173
244
  }
174
245
 
175
246
  if (sub === 'shadcn') {
176
- const recipe = readRecipe(arg, root);
177
- const { theme } = applyRecipe(tokens(), recipe);
247
+ const { recipe, system } = readTheme(arg, root);
248
+ const { theme } = system ? applySystem(tokens(), system) : applyRecipe(tokens(), recipe);
178
249
  const item = shadcnTheme(theme, {
179
250
  ...(recipe.preset ? { name: `halftone-${recipe.preset}`, title: `Halftone ${presetById(recipe.preset)?.name} theme` } : {}),
180
251
  fontsUrl: typeof flags['fonts-url'] === 'string' ? flags['fonts-url'] : SHADCN_FONTS_URL,
@@ -194,8 +265,16 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
194
265
  if (flags.check) process.exit(0);
195
266
  return;
196
267
  }
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)}`);
268
+ if (status.system) {
269
+ const { system } = status;
270
+ const overrides = describeOverrides(system);
271
+ console.log(`System "${system.name}"${status.code ? ` ${status.code}` : ' (no theme code: it has overrides)'}\n ${describe(status.recipe, system.fonts)}`);
272
+ if (overrides.length) console.log(`\nOverrides:\n${overrides.map((o) => ` ${o}`).join('\n')}`);
273
+ printWarnings(tokens().$extensions?.[THEME_EXTENSION]?.warnings);
274
+ } else {
275
+ const name = status.recipe.preset ? ` (${presetById(status.recipe.preset)?.name ?? status.recipe.preset})` : '';
276
+ console.log(`Theme ${status.code}${name}\n ${describe(status.recipe)}`);
277
+ }
199
278
  if (status.drift.length) {
200
279
  console.log(`\nEdited by hand since it was applied (${status.drift.length}):\n${status.drift.map((p) => ` ${p}`).join('\n')}`);
201
280
  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 +283,7 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
204
283
  }
205
284
 
206
285
  if (sub === 'apply') {
207
- const recipe = readRecipe(arg, root);
286
+ const { recipe, system } = readTheme(arg, root);
208
287
  const current = tokens();
209
288
  const status = themeStatus(current);
210
289
  const blocked = !status.applied
@@ -214,15 +293,23 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
214
293
  ? `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
294
  : null;
216
295
  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)}`);
296
+ const { tokens: next, theme } = system ? applySystem(current, system) : applyRecipe(current, recipe);
297
+ if (system) {
298
+ const code = systemCode(system);
299
+ const overrides = describeOverrides(system);
300
+ console.log(`System "${system.name}"${code ? ` ${code}` : ''}\n ${describe(theme.recipe, system.fonts)}`);
301
+ if (overrides.length) console.log(`\nOverrides (no theme code carries these; tokens.json records the document):\n${overrides.map((o) => ` ${o}`).join('\n')}`);
302
+ } else console.log(`Theme ${theme.code}${recipe.preset ? ` (${presetById(recipe.preset)?.name})` : ''}\n ${describe(theme.recipe)}`);
219
303
  if (theme.adjustments.length) {
220
304
  console.log(`\nAdjusted for contrast (WCAG AA), hue kept:`);
221
305
  for (const a of theme.adjustments) console.log(` ${a.mode.padEnd(5)} ${a.token.padEnd(28)} ${a.from} → ${a.to}`);
222
306
  }
307
+ printWarnings(theme.warnings);
223
308
  const cvd = cvdWarnings(theme.resolved);
224
309
  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):`);
310
+ // Counted as the Studio's Accessibility tab counts them: pairs of tones, each listed in every view it's close in.
311
+ const pairs = new Set(cvd.map((w) => `${w.a}/${w.b}`)).size;
312
+ console.log(`\nColour vision: ${pairs} pair(s) of tones close together, in ${cvd.length} view(s) below ΔE ${MIN_DELTA_E} (a warning; give them an icon or a label as well):`);
226
313
  console.log(describeCvdWarnings(cvd).join('\n'));
227
314
  }
228
315
  if (flags['dry-run']) {
@@ -233,6 +320,7 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
233
320
  console.log(`\nWrote ${sourcePath.slice(root.length + 1)}.`);
234
321
  installFonts(root, config, theme, flags);
235
322
  await build();
323
+ rebuildRegistry(root);
236
324
  return;
237
325
  }
238
326
 
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
+ }