@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.
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.1",
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"
@@ -3,9 +3,11 @@
3
3
  * short, platform-neutral prose an agent can load on its own. The spec has no light/dark modes, so
4
4
  * `colors` carries the light value under each name and the dark one under `<name>-dark`.
5
5
  * The detailed rules stay in DESIGN_SYSTEM.md; this file points there instead of repeating them.
6
- * options: { out: 'DESIGN.md', name?: string, description?: string }
6
+ * options: { out: 'DESIGN.md', name?: string, description?: string, platform?: 'mobile' | 'web' }
7
7
  */
8
8
  import { describeTintLimits, textOnTintLimits } from '../lib/contrast.mjs';
9
+ import { describeComponents } from '../lib/theme/components.mjs';
10
+ import { describeExtras } from '../lib/theme/extras.mjs';
9
11
  import { themeFonts } from './fonts.mjs';
10
12
  import { themeStatus } from '../lib/theme/index.mjs';
11
13
  import { group } from '../lib/resolve.mjs';
@@ -79,8 +81,9 @@ const STYLE_TEXT = {
79
81
 
80
82
  export default function designMd(ctx, options) {
81
83
  const name = options.name ?? 'Halftone';
82
- // Which radius buttons use: 'control' (the theme's pill setting, the mobile kit) or a scale step (web: 'md').
83
- const buttons = options.buttonRadius ?? 'control';
84
+ // The component layer: one theme for both kits (Button, Chip, Badge, Input).
85
+ const { button, input } = ctx.components;
86
+ const corner = (r) => (r >= 9999 ? '"{rounded.full}"' : px(r));
84
87
  const description =
85
88
  options.description ?? 'Brand-agnostic prototype kit: shadcn semantics, light and dark, WCAG AA enforced at build time.';
86
89
  const colors = ctx.colors
@@ -88,7 +91,7 @@ export default function designMd(ctx, options) {
88
91
  .join('\n');
89
92
  // The theme's radius: rounded-* are multiples of its base (rounded-lg); control is buttons and chips.
90
93
  const rounded = [
91
- ...ctx.radius.steps.map((s) => ` ${s.name}: ${px(Math.round(ctx.radius.base * s.scale * 10) / 10)}`),
94
+ ...ctx.radius.steps.map((s) => ` ${s.name}: ${px(s.px)}`),
92
95
  ` control: ${px(Math.min(ctx.shape.control, 9999))}`,
93
96
  ` full: ${px(9999)}`,
94
97
  ].join('\n');
@@ -103,6 +106,19 @@ export default function designMd(ctx, options) {
103
106
  const radius = Object.fromEntries(group(ctx.prim, ['radius']).map((t) => [t.key, t.value]));
104
107
  const duration = Object.fromEntries(group(ctx.prim, ['duration']).map((t) => [t.key, t.value]));
105
108
 
109
+ // A system's own tokens: listed, never in the front matter's shared vocabulary.
110
+ const extras = describeExtras(ctx.extras ?? { color: [], text: [], radius: [], shadow: [], border: [] }, { platform: options.platform ?? 'mobile' });
111
+ const extrasSection = extras.length
112
+ ? `
113
+ ## Extras (this brand's own)
114
+
115
+ Tokens this system added beyond the kit's slots. No kit component uses them; use them in screens, as classes like
116
+ any token (never their hex). They are not part of the shared vocabulary above.
117
+
118
+ ${extras.join('\n')}
119
+ `
120
+ : '';
121
+
106
122
  const contents = `---
107
123
  version: alpha
108
124
  name: ${q(name)}
@@ -119,13 +135,13 @@ components:
119
135
  button-primary:
120
136
  backgroundColor: "{colors.primary}"
121
137
  textColor: "{colors.primary-foreground}"
122
- rounded: "{rounded.${buttons}}"
123
- height: 48px
138
+ rounded: ${corner(button.radius)}
139
+ height: ${px(button.h.md)}
124
140
  button-secondary:
125
141
  backgroundColor: "{colors.secondary}"
126
142
  textColor: "{colors.secondary-foreground}"
127
- rounded: "{rounded.${buttons}}"
128
- height: 48px
143
+ rounded: ${corner(button.radius)}
144
+ height: ${px(button.h.md)}
129
145
  card:
130
146
  backgroundColor: "{colors.card}"
131
147
  textColor: "{colors.card-foreground}"
@@ -134,8 +150,8 @@ components:
134
150
  input:
135
151
  backgroundColor: "{colors.background}"
136
152
  textColor: "{colors.foreground}"
137
- rounded: "{rounded.md}"
138
- height: 48px
153
+ rounded: ${corner(input.radius)}
154
+ height: ${px(input.h.md)}
139
155
  ---
140
156
 
141
157
  <!-- ${ctx.header} -->
@@ -147,7 +163,7 @@ components:
147
163
  A neutral, brand-agnostic base for clickable prototypes on phone and web. Calm surfaces, one brand
148
164
  colour reserved for the primary action and focus, generous spacing, and both light and dark themes
149
165
  from day one. ${status.applied ? `The applied theme is \`${status.code}\`${status.drift.length ? ' (edited by hand since)' : ''}. ` : ''}Re-branding means applying a
150
- theme (\`npx halftone-tokens theme apply <code>\`, codes come from the docs theme picker) or editing
166
+ theme (\`npx halftone-tokens theme apply <code|file.halftone.json>\`, both from Studio on the docs site) or editing
151
167
  \`tokens/tokens.json\`; everything else follows. The full rules and the component registry are in
152
168
  \`DESIGN_SYSTEM.md\` — read it before building UI.
153
169
 
@@ -171,7 +187,7 @@ ${describeTintLimits(textOnTintLimits(ctx.colors))}.
171
187
 
172
188
  On a stronger tint, the text stays \`text-foreground\` and the tint, an icon or a dot carries the
173
189
  colour. \`halftone-tokens check\` flags class strings that break this.
174
-
190
+ ${extrasSection}
175
191
  ## Typography
176
192
 
177
193
  Headings (h1–h4, titles) use ${fontName(fonts.heading)}; everything else uses ${fontName(fonts.body)}; code and
@@ -195,13 +211,14 @@ a floating button). Content — inputs included — stays flat.
195
211
 
196
212
  ## Shapes
197
213
 
198
- Buttons are pills. Inputs ${radius.md}px, cards ${radius.lg}px, larger surfaces ${radius.xl}px, sheet tops
214
+ Buttons ${button.radius >= 9999 ? 'are pills' : `have ${button.radius}px corners`}. Inputs ${input.radius}px, cards ${radius.lg}px, larger surfaces ${radius.xl}px, sheet tops
199
215
  ${radius['2xl']}px; chips, avatars and dots are fully round.
200
216
 
201
217
  ## Components
202
218
 
203
- Only components listed in the registry in \`DESIGN_SYSTEM.md\` exist. Controls are 48px tall (small
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.
219
+ Only components listed in the registry in \`DESIGN_SYSTEM.md\` exist. ${STYLE_TEXT.button[ctx.styles.button]} ${STYLE_TEXT.field[ctx.styles.field]}
220
+ Button, Chip, Badge and Input draw the theme's component tokens, the same in both kits: ${describeComponents(ctx.components).join('; ')}.
221
+ Never restyle them by hand. Phones raise any tap target under 44px to 44.
205
222
  Motion: ${duration.fast}ms for micro-feedback, ${duration.base}ms for most transitions, ${duration.slow}ms for sheets, and
206
223
  nothing moves when the OS asks for reduced motion.
207
224
 
package/targets/figma.mjs CHANGED
@@ -16,7 +16,7 @@ export default function figma(ctx, options) {
16
16
  .map((t) => [t.key, { value: t.value, code: code(t.key) }])
17
17
  );
18
18
  // The theme's radius: rounded-* scale with its base (rounded-lg); rounded-control is buttons and chips.
19
- const radius = Object.fromEntries(ctx.radius.steps.map((s) => [s.name, { value: Math.round(ctx.radius.base * s.scale * 10) / 10, code: `rounded-${s.name}` }]));
19
+ const radius = Object.fromEntries(ctx.radius.steps.map((s) => [s.name, { value: s.px, code: `rounded-${s.name}` }]));
20
20
  radius.control = { value: Math.min(ctx.shape.control, 9999), code: 'rounded-control' };
21
21
  radius.full = { value: 9999, code: 'rounded-full' };
22
22
  const numbers = {
package/targets/fonts.mjs CHANGED
@@ -6,10 +6,11 @@
6
6
  * --kit-font-body and --kit-font-mono (app/tokens.css maps them to font-heading / font-sans / font-mono)
7
7
  * An empty family (heading, body) or Menlo (mono) is the platform's own font: nothing to load.
8
8
  *
9
- * Only fonts from the curated list (lib/theme/fonts.mjs) are supported; halftone-tokens theme apply installs
10
- * the @expo-google-fonts packages they need (see expoPackages).
9
+ * A curated font (lib/theme/fonts.mjs) loads the weights it lists; any other Google Fonts family (a system's
10
+ * fonts) loads 400–700 under the packages' own naming (fontByFamily). halftone-tokens theme apply installs the
11
+ * @expo-google-fonts packages they need (see expoPackages).
11
12
  */
12
- import { FONTS } from '../lib/theme/fonts.mjs';
13
+ import { fontByFamily } from '../lib/theme/fonts.mjs';
13
14
  import { group } from '../lib/resolve.mjs';
14
15
 
15
16
  const SUFFIX = { 400: 'Regular', 500: 'Medium', 600: 'SemiBold', 700: 'Bold' };
@@ -20,8 +21,7 @@ export function themeFonts(ctx) {
20
21
  const pick = (role) => {
21
22
  const family = families[role];
22
23
  if (!family) return null;
23
- const font = FONTS.find((f) => f.family === family);
24
- if (!font) throw new Error(`fonts: "${family}" (font.family.${role}) is not in the curated font list; pick one of ${FONTS.filter((f) => !f.system).map((f) => f.family).join(', ')}`);
24
+ const font = fontByFamily(family);
25
25
  return font.system ? null : font;
26
26
  };
27
27
  return { heading: pick('heading'), body: pick('body'), mono: pick('mono') };
package/targets/llms.mjs CHANGED
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * The tokens section of llms.txt: a compact Markdown fragment that registry-build stitches into the
3
3
  * app's llms.txt (and the docs site into /llms.txt).
4
- * options: { out: 'tokens/generated/llms-tokens.md' }
4
+ * options: { out: 'tokens/generated/llms-tokens.md', platform?: 'mobile' | 'web' }
5
5
  */
6
6
  import { group } from '../lib/resolve.mjs';
7
7
  import { AA, describeTintLimits, textOnTintLimits } from '../lib/contrast.mjs';
8
8
  import { themeStatus } from '../lib/theme/index.mjs';
9
9
  import { themeFonts } from './fonts.mjs';
10
+ import { describeComponents } from '../lib/theme/components.mjs';
11
+ import { describeExtras } from '../lib/theme/extras.mjs';
10
12
 
11
13
  export default function llms(ctx, options) {
12
14
  const rows = ctx.colors.map((c) => `| \`${c.name}\` | ${c.light} | ${c.dark} |`).join('\n');
@@ -16,6 +18,7 @@ export default function llms(ctx, options) {
16
18
  .join(' · ');
17
19
  const fonts = themeFonts(ctx);
18
20
  const theme = themeStatus(ctx.source);
21
+ const extras = describeExtras(ctx.extras ?? { color: [], text: [], radius: [], shadow: [], border: [] }, { platform: options.platform ?? 'mobile' });
19
22
  const contents = `<!-- ${ctx.header} -->
20
23
  ## Tokens
21
24
 
@@ -31,14 +34,15 @@ ${describeTintLimits(textOnTintLimits(ctx.colors))}.
31
34
  ${rows}
32
35
 
33
36
  - Spacing (4-pt grid, Tailwind numeric scale): ${steps(['space'])}
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`}`}
37
+ - Radius (\`rounded-*\`): ${ctx.radius.steps.map((s) => `${s.name} ${s.px}px`).join(' · ')} (multiples of \`--radius\` = ${ctx.radius.base}px); \`rounded-control\` ${ctx.shape.control >= 9999 ? 'pill' : `${ctx.shape.control}px`}
35
38
  - 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
39
  - 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
40
+ - Styles: ${ctx.styles.button} buttons, ${ctx.styles.field} fields
41
+ - Components (one theme for both kits; never restyle them by hand): ${describeComponents(ctx.components).join('; ')}. Phones raise any tap target under 44px to 44
38
42
  - 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)
39
43
  - 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\`)` : ''}
40
44
  - Font sizes (\`text-*\`): ${steps(['font', 'size'])}
41
45
  - Durations: ${steps(['duration'], 'ms')}
42
- `;
46
+ ${extras.length ? `\n### Extras (this brand's own)\n\nNo kit component uses these; screens may, as classes (never their hex):\n\n${extras.join('\n')}\n` : ''}`;
43
47
  return [{ path: options.out, contents }];
44
48
  }
@@ -4,9 +4,11 @@
4
4
  * options: { css: 'global.css', tailwind: 'tokens/generated/tailwind.theme.js' }
5
5
  */
6
6
  import { hexToHslTriplet } from '../lib/color.mjs';
7
- import { cssName, group, isColor, radiusCalc } from '../lib/resolve.mjs';
8
- import { TEXT_LINE_HEIGHT } from '../lib/theme/recipe.mjs';
9
- import { styleVars } from '../lib/theme/vars.mjs';
7
+ import { cssName, group, isColor, radiusCss } from '../lib/resolve.mjs';
8
+ import { shadowCss, TEXT_LINE_HEIGHT } from '../lib/theme/recipe.mjs';
9
+ import { COMPONENT_NAMES, COMPONENTS, componentColors, componentVars } from '../lib/theme/components.mjs';
10
+ import { extraTextStyles } from '../lib/theme/extras.mjs';
11
+ import { extraVars, styleVars, textStyleVars } from '../lib/theme/vars.mjs';
10
12
 
11
13
  const rem = (px) => (px >= 9999 ? '9999px' : `${Number((px / 16).toFixed(4))}rem`);
12
14
 
@@ -17,7 +19,8 @@ function cssBlock(selector, tokens, shape, mode, colors) {
17
19
  const lines = tokens
18
20
  // Shadows are literal in tailwind.theme.js: a var() box-shadow drops the view on native. The styles'
19
21
  // numbers come from shape (with their units), below.
20
- .filter((t) => t.$type !== 'shadow' && !SHAPE.has(cssName(t)) && !/^(density|type)-/.test(cssName(t)))
22
+ // A system's radius steps are in borderRadius below, with their units.
23
+ .filter((t) => t.$type !== 'shadow' && !SHAPE.has(cssName(t)) && !/^(density|type|radius)-/.test(cssName(t)))
21
24
  .filter((t) => isColor(t) || !/^(button|field)-/.test(cssName(t)))
22
25
  .map((t) => {
23
26
  const name = cssName(t);
@@ -29,10 +32,19 @@ function cssBlock(selector, tokens, shape, mode, colors) {
29
32
  lines.push(` --radius-control: ${rem(shape.control)};`, ` --border-width: ${shape.borderWidth}px;`, ` --icon-stroke: ${shape.stroke};`);
30
33
  for (const size of ['sm', 'md', 'lg', 'x']) lines.push(` --control-${size}: ${shape.density[size]}px;`);
31
34
  }
35
+ // The text styles (Text h1…blockquote): literal at the typeset, so an edited style is one variable.
36
+ if (shape.textStyles) for (const [name, value] of Object.entries(textStyleVars(shape.textStyles, 'hsl'))) lines.push(` ${name}: ${value};`);
32
37
  // Style colours a token file from before the styles doesn't have (resolve.mjs adds them), then the styles' numbers.
33
38
  const present = new Set(tokens.filter(isColor).map(cssName));
34
39
  for (const c of colors) if (!present.has(c.name)) lines.push(` --${c.name}: ${hexToHslTriplet(c[mode])};`);
35
40
  for (const [name, value] of Object.entries(styleVars(shape.styleShape, 'hsl')[mode])) lines.push(` ${name}: ${value};`);
41
+ // The component layer (Button, Chip, Badge, Input): corners, sizes, text and each variant's colours.
42
+ if (shape.components) for (const [name, value] of Object.entries(componentVars(shape.components, 'hsl', hexToHslTriplet)[mode])) lines.push(` ${name}: ${value};`);
43
+ // A system's extras: colours, text styles and border widths (corners and shadows are literal in tailwind.theme.js).
44
+ if (shape.extras) {
45
+ const extra = Object.entries(extraVars(shape.extras, 'hsl')[mode]).filter(([name]) => !/^--(radius|shadow)-/.test(name));
46
+ if (extra.length) lines.push(' /* Extras: this system\'s own tokens. No kit component uses them; screens may. */', ...extra.map(([name, value]) => ` ${name}: ${value};`));
47
+ }
36
48
  return ` ${selector} {\n${lines.join('\n')}\n }`;
37
49
  }
38
50
 
@@ -43,16 +55,16 @@ export default function nativewind3(ctx, options) {
43
55
  @tailwind utilities;
44
56
 
45
57
  @layer base {
46
- ${cssBlock(':root', ctx.semLight, { ...ctx.shape, styleShape: ctx.shape }, 'light', ctx.colors)}
58
+ ${cssBlock(':root', ctx.semLight, { ...ctx.shape, styleShape: ctx.shape, textStyles: ctx.textStyles, extras: ctx.extras, components: ctx.components }, 'light', ctx.colors)}
47
59
 
48
- ${cssBlock('.dark:root', ctx.semDark, { styleShape: ctx.shape }, 'dark', ctx.colors)}
60
+ ${cssBlock('.dark:root', ctx.semDark, { styleShape: ctx.shape, extras: ctx.extras, components: ctx.components }, 'dark', ctx.colors)}
49
61
  }
50
62
  `;
51
63
  const colors = Object.fromEntries(ctx.colors.map((c) => [c.name, `hsl(var(--${c.name}))`]));
52
64
  // The styles' fills carry their own opacity (an outline button, a transparent field). No /90 modifiers on these.
53
65
  colors.button = 'hsl(var(--button) / var(--button-alpha))';
54
66
  colors.field = 'hsl(var(--field) / var(--field-alpha))';
55
- const borderRadius = Object.fromEntries(ctx.radius.steps.map((s) => [s.name, radiusCalc(s.scale)]));
67
+ const borderRadius = Object.fromEntries(ctx.radius.steps.map((s) => [s.name, radiusCss(s)]));
56
68
  borderRadius.control = 'var(--radius-control)';
57
69
  // Field style: rounded-field (top) and rounded-b-field-b (bottom).
58
70
  borderRadius.field = 'var(--field-radius)';
@@ -77,7 +89,45 @@ ${cssBlock('.dark:root', ctx.semDark, { styleShape: ctx.shape }, 'dark', ctx.col
77
89
  const lineHeight = { prose: rem(size * leading) };
78
90
  const spacing = { 'control-x': 'var(--control-x)', 'field-x': 'var(--field-x)', flow: rem(size * flow) };
79
91
  const maxWidth = { measure: rem(measure * size * 0.5) };
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`;
92
+ // The text styles, one key per property: text-type-h1, leading-type-h1, tracking-type-h1, font-type-h1 (Text's
93
+ // variants). Variables (global.css), so a system's edited style changes one value.
94
+ for (const name of Object.keys(ctx.textStyles)) {
95
+ fontSize[`type-${name}`] = `var(--type-${name}-size)`;
96
+ lineHeight[`type-${name}`] = `var(--type-${name}-leading)`;
97
+ }
98
+ const letterSpacing = Object.fromEntries(Object.keys(ctx.textStyles).map((name) => [`type-${name}`, `var(--type-${name}-tracking)`]));
99
+ const fontWeight = Object.fromEntries(Object.keys(ctx.textStyles).map((name) => [`type-${name}`, `var(--type-${name}-weight)`]));
100
+ // The component layer: bg-button-primary-bg, rounded-button, h-button-md, px-button-x, text-type-button,
101
+ // border-button-primary-w… Each colour carries its opacity (--button-primary-bg-a).
102
+ Object.assign(colors, componentColors(ctx.components, (key, alpha) => (alpha ? `hsl(var(--${key}) / var(--${key}-a))` : `hsl(var(--${key}))`)));
103
+ for (const name of COMPONENT_NAMES) {
104
+ const c = ctx.components[name];
105
+ const p = COMPONENTS[name].prefix;
106
+ if (name !== 'input') borderRadius[p] = `var(--${p}-radius)`;
107
+ for (const size of Object.keys(c.h)) height[`${p}-${size}`] = `var(--${p}-h-${size})`;
108
+ if (name !== 'input') spacing[`${p}-x`] = `var(--${p}-x)`;
109
+ fontSize[`type-${p}`] = `var(--${p}-text-size)`;
110
+ lineHeight[`type-${p}`] = `var(--${p}-text-leading)`;
111
+ letterSpacing[`type-${p}`] = `var(--${p}-text-tracking)`;
112
+ }
113
+ Object.assign(spacing, { 'button-x-sm': 'var(--button-x-sm)', 'button-x-lg': 'var(--button-x-lg)' });
114
+ for (const variant of Object.keys(ctx.components.button.variants)) {
115
+ borderWidth[`button-${variant}-w`] = `var(--button-${variant}-border-w)`;
116
+ borderWidth[`button-${variant}-edge-w`] = `var(--button-${variant}-edge-w)`;
117
+ }
118
+ // A system's extras: bg-<name>, rounded-<name>, shadow-<name>, border-<name> (its width), text-type-<name>…
119
+ const extras = ctx.extras ?? { color: [], text: [], radius: [], shadow: [], border: [] };
120
+ for (const c of extras.color) colors[c.name] = `hsl(var(--${c.name}))`;
121
+ for (const r of extras.radius) borderRadius[r.name] = rem(r.px);
122
+ for (const sh of extras.shadow) boxShadow[sh.name] = shadowCss(sh.light);
123
+ for (const b of extras.border) borderWidth[b.name] = `var(--border-${b.name}-width)`;
124
+ for (const name of Object.keys(extraTextStyles(extras))) {
125
+ fontSize[`type-${name}`] = `var(--type-${name}-size)`;
126
+ lineHeight[`type-${name}`] = `var(--type-${name}-leading)`;
127
+ letterSpacing[`type-${name}`] = `var(--type-${name}-tracking)`;
128
+ fontWeight[`type-${name}`] = `var(--type-${name}-weight)`;
129
+ }
130
+ const tailwind = `// ${ctx.header}\nmodule.exports = ${JSON.stringify({ colors, borderRadius, borderWidth, boxShadow, height, minHeight: height, width: height, spacing, fontSize, lineHeight, letterSpacing, fontWeight, maxWidth }, null, 2)};\n`;
81
131
  return [
82
132
  { path: options.css, contents: css },
83
133
  { path: options.tailwind, contents: tailwind },
@@ -4,7 +4,7 @@
4
4
  * options: { out: 'tokens/generated/theme.registry.json' }
5
5
  */
6
6
  import { hexToHslTriplet, hexToOklch } from '../lib/color.mjs';
7
- import { radiusCalc } from '../lib/resolve.mjs';
7
+ import { radiusCss } from '../lib/resolve.mjs';
8
8
 
9
9
  export default function registryTheme(ctx, options) {
10
10
  const radius = `${ctx.radius.base / 16}rem`;
@@ -20,7 +20,7 @@ export default function registryTheme(ctx, options) {
20
20
  theme: {
21
21
  extend: {
22
22
  colors: Object.fromEntries(ctx.colors.map((c) => [c.name, `hsl(var(--${c.name}))`])),
23
- borderRadius: Object.fromEntries(ctx.radius.steps.map((s) => [s.name, radiusCalc(s.scale)])),
23
+ borderRadius: Object.fromEntries(ctx.radius.steps.map((s) => [s.name, radiusCss(s)])),
24
24
  },
25
25
  },
26
26
  },
@@ -28,7 +28,7 @@ export default function registryTheme(ctx, options) {
28
28
  },
29
29
  web: {
30
30
  cssVars: {
31
- theme: Object.fromEntries(ctx.radius.steps.map((s) => [`radius-${s.name}`, radiusCalc(s.scale)])),
31
+ theme: Object.fromEntries(ctx.radius.steps.map((s) => [`radius-${s.name}`, radiusCss(s)])),
32
32
  light: { radius, ...vars(hexToOklch, 'light') },
33
33
  dark: vars(hexToOklch, 'dark'),
34
34
  },
@@ -8,9 +8,11 @@
8
8
  * typeset: false leave the text sizes alone (the docs site keeps its reading sizes)
9
9
  */
10
10
  import { hexToOklch } from '../lib/color.mjs';
11
- import { group, radiusCalc } from '../lib/resolve.mjs';
11
+ import { group, radiusCss } from '../lib/resolve.mjs';
12
12
  import { TEXT_LINE_HEIGHT } from '../lib/theme/recipe.mjs';
13
- import { styleVars, typeVars } from '../lib/theme/vars.mjs';
13
+ import { COMPONENT_NAMES, COMPONENTS, componentColors, componentVars } from '../lib/theme/components.mjs';
14
+ import { hasExtras } from '../lib/theme/extras.mjs';
15
+ import { extraVars, styleVars, textStyleVars, typeVars } from '../lib/theme/vars.mjs';
14
16
 
15
17
  const rem = (px) => (px >= 9999 ? '9999px' : `${px / 16}rem`);
16
18
  const SYSTEM_SANS = "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif";
@@ -27,7 +29,91 @@ function textScale(ctx) {
27
29
  .join('\n');
28
30
  }
29
31
 
32
+ /** A font role's family, as @theme's --font-* resolve it (app/fonts.ts or a live theme sets --kit-font-*). */
33
+ const FONT_ROLE = {
34
+ heading: `var(--kit-font-heading, var(--kit-font-body, ${SYSTEM_SANS}))`,
35
+ body: `var(--kit-font-body, ${SYSTEM_SANS})`,
36
+ mono: `var(--kit-font-mono, ${SYSTEM_MONO})`,
37
+ };
38
+
39
+ /**
40
+ * The text styles as utilities: type-h1 … type-blockquote set the style's font, size, line height, tracking and
41
+ * weight from its variables. leading-*, tracking-* and font-* on the same element still win, as on text-*.
42
+ */
43
+ function textStyleUtilities(names) {
44
+ return names
45
+ .map(
46
+ (name) => `@utility type-${name} {
47
+ font-family: var(--type-${name}-font);
48
+ font-size: var(--type-${name}-size);
49
+ line-height: var(--tw-leading, var(--type-${name}-leading));
50
+ letter-spacing: var(--tw-tracking, var(--type-${name}-tracking));
51
+ font-weight: var(--tw-font-weight, var(--type-${name}-weight));
52
+ }`
53
+ )
54
+ .join('\n');
55
+ }
56
+
57
+ /**
58
+ * A system's extras (lib/theme/extras.mjs): their variables in :root and .dark, colours and corners in @theme (so
59
+ * bg-<name>, text-<name>, rounded-<name> exist), and shadow-, border- and type- utilities. No kit component uses them.
60
+ */
61
+ function extrasCss(ctx) {
62
+ const list = ctx.extras;
63
+ if (!list || !hasExtras(list)) return { light: '', dark: '', theme: '', utilities: '' };
64
+ const vars = extraVars(list, 'oklch', (role) => FONT_ROLE[role]);
65
+ const lines = (map) => Object.entries(map).map(([k, v]) => ` ${k}: ${v};`).join('\n');
66
+ // Corners are literal in @theme (rounded-<name>), not variables of their own.
67
+ const light = Object.fromEntries(Object.entries(vars.light).filter(([k]) => !k.startsWith('--radius-')));
68
+ const theme = [
69
+ ...list.color.map((c) => ` --color-${c.name}: var(--${c.name});`),
70
+ ...list.radius.map((r) => ` --radius-${r.name}: ${rem(r.px)};`),
71
+ ];
72
+ const utilities = [
73
+ ...list.shadow.map((s) => `@utility shadow-${s.name} {\n box-shadow: var(--shadow-${s.name});\n}`),
74
+ ...list.border.map((b) => `@utility border-${b.name} {\n border-width: var(--border-${b.name}-width);\n border-style: ${b.style ?? 'solid'};\n}`),
75
+ textStyleUtilities(list.text.map((t) => t.name)),
76
+ ].filter(Boolean);
77
+ return {
78
+ light: `\n /* Extras: this system's own tokens. No kit component uses them; screens may. */\n${lines(light)}`,
79
+ dark: Object.keys(vars.dark).length ? `\n /* Extras */\n${lines(vars.dark)}` : '',
80
+ theme: theme.length ? `\n /* Extras */\n${theme.join('\n')}` : '',
81
+ utilities: utilities.length ? `\n/* Extras */\n${utilities.join('\n')}\n` : '',
82
+ };
83
+ }
84
+
85
+ /**
86
+ * The component layer (Button, Chip, Badge, Input): the variables per mode, each variant's colours as Tailwind colours
87
+ * (bg-button-primary-bg; a colour with an opacity mixes it in), rounded-button / -chip / -badge, and type-button,
88
+ * type-chip, type-badge and type-field for the text. Heights and padding are read as h-(--button-h-md), px-(--button-x).
89
+ */
90
+ function componentsCss(ctx) {
91
+ const vars = componentVars(ctx.components, 'oklch', hexToOklch, (role) => FONT_ROLE[role]);
92
+ const lines = (map) => Object.entries(map).map(([k, v]) => ` ${k}: ${v};`).join('\n');
93
+ const colors = componentColors(ctx.components, (key, alpha) => (alpha ? `color-mix(in oklab, var(--${key}) calc(var(--${key}-a) * 100%), transparent)` : `var(--${key})`));
94
+ const theme = [
95
+ ...COMPONENT_NAMES.filter((n) => n !== 'input').map((n) => ` --radius-${n}: var(--${n}-radius);`),
96
+ ...Object.entries(colors).map(([key, css]) => ` --color-${key}: ${css};`),
97
+ ];
98
+ const utilities = COMPONENT_NAMES.map((n) => COMPONENTS[n].prefix).map(
99
+ (p) => `@utility type-${p} {
100
+ font-family: var(--${p}-text-font);
101
+ font-size: var(--${p}-text-size);
102
+ line-height: var(--tw-leading, var(--${p}-text-leading));
103
+ letter-spacing: var(--tw-tracking, var(--${p}-text-tracking));
104
+ }`
105
+ );
106
+ return {
107
+ light: `\n /* Components: Button, Chip, Badge, Input (corners, sizes, text, each variant's colours). */\n${lines(vars.light)}`,
108
+ dark: `\n /* Components */\n${lines(vars.dark)}`,
109
+ theme: `\n /* Components: rounded-button, bg-button-primary-bg, border-field-focus-border… */\n${theme.join('\n')}`,
110
+ utilities: `${utilities.join('\n')}\n`,
111
+ };
112
+ }
113
+
30
114
  export default function shadcnWeb(ctx, options) {
115
+ const extras = extrasCss(ctx);
116
+ const components = componentsCss(ctx);
31
117
  const vars = (mode) => ctx.colors.map((c) => ` --${c.name}: ${hexToOklch(c[mode])};`).join('\n');
32
118
  const styles = (mode) => Object.entries(styleVars(ctx.shape, 'oklch')[mode]).map(([k, v]) => ` ${k}: ${v};`).join('\n');
33
119
  // The styles' fills carry their own opacity (an outline button, a transparent field).
@@ -46,17 +132,19 @@ export default function shadcnWeb(ctx, options) {
46
132
  }
47
133
  ${styles('light')}
48
134
  ${shadows('light')}
49
- ${vars('light')}
135
+ /* The text styles (type-h1…type-blockquote), literal at the typeset: an edited style is one variable. */
136
+ ${Object.entries(textStyleVars(ctx.textStyles, 'oklch', (role) => FONT_ROLE[role])).map(([k, v]) => ` ${k}: ${v};`).join('\n')}
137
+ ${vars('light')}${components.light}${extras.light}
50
138
  }
51
139
 
52
140
  .dark {
53
141
  ${styles('dark')}
54
142
  ${shadows('dark')}
55
- ${vars('dark')}
143
+ ${vars('dark')}${components.dark}${extras.dark}
56
144
  }
57
145
 
58
146
  @theme inline {
59
- ${ctx.radius.steps.map((s) => ` --radius-${s.name}: ${radiusCalc(s.scale)};`).join('\n')}
147
+ ${ctx.radius.steps.map((s) => ` --radius-${s.name}: ${radiusCss(s)};`).join('\n')}
60
148
  --radius-control: var(--radius-control);
61
149
  /* The field style: rounded-field, rounded-b-field-b, px-field-x. Widths are border-(length:--field-border-w) etc. */
62
150
  --radius-field: var(--field-radius);
@@ -75,9 +163,11 @@ ${ctx.radius.steps.map((s) => ` --radius-${s.name}: ${radiusCalc(s.scale)};`).j
75
163
  --font-mono: var(--kit-font-mono, ${SYSTEM_MONO});${
76
164
  options.typeset === false ? '' : `\n /* The theme's typeset scales every text size and its line height (text-sm, text-base…). */\n${textScale(ctx)}`
77
165
  }
78
- ${ctx.colors.map((c) => ` --color-${c.name}: ${color(c.name)};`).join('\n')}
166
+ ${ctx.colors.map((c) => ` --color-${c.name}: ${color(c.name)};`).join('\n')}${components.theme}${extras.theme}
79
167
  }
80
168
 
169
+ ${textStyleUtilities(Object.keys(ctx.textStyles))}
170
+ ${components.utilities}${extras.utilities}
81
171
  @layer base {
82
172
  * {
83
173
  @apply border-border outline-ring/50;
@@ -4,6 +4,7 @@
4
4
  * options: { out: 'lib/theme.ts', navTheme?: 'expo-router/react-navigation' | false }
5
5
  */
6
6
  import { camel, group } from '../lib/resolve.mjs';
7
+ import { COMPONENT_NAMES, componentVarNames, TOUCH_TARGET } from '../lib/theme/components.mjs';
7
8
  import { presetById, sameRecipe, themeStatus } from '../lib/theme/index.mjs';
8
9
 
9
10
  /** The applied theme, for a read-only summary in the app (Foundations). */
@@ -11,10 +12,30 @@ function themeInfo(source) {
11
12
  const status = themeStatus(source);
12
13
  if (!status.applied) return 'null';
13
14
  const preset = presetById(status.recipe.preset ?? '');
14
- const name = preset && sameRecipe(preset.recipe, status.recipe) ? preset.name : 'Custom';
15
+ // A system is named after itself; its code is null when it has overrides a code can't carry.
16
+ const name = status.system?.name ?? (preset && sameRecipe(preset.recipe, status.recipe) ? preset.name : 'Custom');
15
17
  return JSON.stringify({ code: status.code, name, edited: status.drift.length > 0, recipe: status.recipe }, null, 2);
16
18
  }
17
19
 
20
+ const round = (n) => Number(n.toFixed(4));
21
+
22
+ /** The component layer's numbers: sizes (px, for the phone touch-target floor) and each text's font role and tracking. */
23
+ function componentsObject(components) {
24
+ return COMPONENT_NAMES.map((name) => {
25
+ const c = components[name];
26
+ const text = { font: c.text.font, size: round(c.text.size), lineHeight: round(c.text.leading), tracking: c.text.tracking };
27
+ // The font role is typed as a role, so code can branch on it (the object is `as const`).
28
+ const json = JSON.stringify({ height: c.h, x: c.x, radius: c.radius, text }).replace(/"font":("\w+")/, `"font":$1 as 'heading' | 'body' | 'mono'`);
29
+ return ` ${name}: ${json},`;
30
+ }).join('\n');
31
+ }
32
+
33
+ function textStylesObject(styles) {
34
+ return Object.entries(styles)
35
+ .map(([name, s]) => ` ${name}: ${JSON.stringify({ font: s.font, weight: s.weight, size: round(s.size), lineHeight: round(s.lineHeight), tracking: s.tracking })},`)
36
+ .join('\n');
37
+ }
38
+
18
39
  function colorObject(colors, mode) {
19
40
  return colors.map((c) => ` ${camel(c.name)}: '${c[mode]}',`).join('\n');
20
41
  }
@@ -77,6 +98,12 @@ ${primitiveGroup(ctx.prim, ['radius'])}
77
98
  styles: ${JSON.stringify(ctx.styles)},
78
99
  button: ${JSON.stringify(ctx.shape.button)},
79
100
  field: ${JSON.stringify(ctx.shape.field)},
101
+ /** The component layer (Button, Chip, Badge, Input) as numbers: heights and padding (px), corner, and the text's
102
+ * font role and tracking. The classes (h-button-md, rounded-button, text-type-button…) already follow them; this is
103
+ * for the touch-target floor (phones raise a height under ${TOUCH_TARGET} with hitSlop) and the font. */
104
+ components: {
105
+ ${componentsObject(ctx.components)}
106
+ },
80
107
  /** Control heights and padding (px) for the theme's density: h-control-sm, h-control, h-control-lg, px-control-x. */
81
108
  density: ${JSON.stringify({ sm: ctx.shape.density.sm, md: ctx.shape.density.md, lg: ctx.shape.density.lg, x: ctx.shape.density.x })},
82
109
  /** The type scale at 16px; the theme's typeset multiplies it (text-* classes are already scaled). */
@@ -86,6 +113,12 @@ ${primitiveGroup(ctx.prim, ['font', 'size'])}
86
113
  /** The typeset: body size (px) and its scale, running-text leading and its factor on the text sizes' line
87
114
  * heights, and Prose's flow (em between blocks) and measure (characters per line). */
88
115
  typeset: ${JSON.stringify(ctx.type)},
116
+ /** The Text variants at the typeset, a system's edited styles included: font role, weight, size and line height
117
+ * (px), tracking (em). The classes (text-type-h1, leading-type-h1…) already follow them; this is for code
118
+ * that picks something per style, such as a font file per weight. */
119
+ textStyles: {
120
+ ${textStylesObject(ctx.textStyles)}
121
+ },
89
122
  fontFamily: {
90
123
  ${primitiveGroup(ctx.prim, ['font', 'family'])}
91
124
  },
@@ -97,12 +130,17 @@ ${primitiveGroup(ctx.prim, ['easing'])}
97
130
  },
98
131
  } as const;
99
132
 
133
+ /** The component layer's CSS variables (what an embedded kit accepts from the docs site's live theme). */
134
+ export const COMPONENT_VARS = ${JSON.stringify(componentVarNames())} as const;
135
+
100
136
  /** The theme tokens.json was generated from (\`halftone-tokens theme apply\`), or null. \`edited\`: theme tokens
101
137
  * were changed by hand since. */
102
138
  export const THEME_INFO: ThemeInfo | null = ${themeInfo(ctx.source)};
103
139
 
104
140
  export type ThemeInfo = {
105
- code: string;
141
+ /** null for a system (\`.halftone.json\`) with overrides no theme code carries. */
142
+ code: string | null;
143
+ /** The preset's name, the system's name, or 'Custom'. */
106
144
  name: string;
107
145
  edited: boolean;
108
146
  recipe: {