@halftone-ds/tokens 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.1.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"
@@ -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';
@@ -46,10 +48,42 @@ function typography(prim, type) {
46
48
  .join('\n');
47
49
  }
48
50
 
51
+ /**
52
+ * The contrast sentence. A passing palette says the build names any pairing that falls short (and --strict
53
+ * fails on it); a failing one, which only builds without --strict, names its pairings.
54
+ */
55
+ function contrastLine(ctx) {
56
+ const failures = ctx.contrastFailures ?? [];
57
+ if (!failures.length) {
58
+ return `Every \`X\` / \`X-foreground\` pairing, muted text on every surface, and every status tone used as text
59
+ clears WCAG AA (4.5:1) in both themes; the build names any pairing that falls short, and \`--strict\` fails on it.`;
60
+ }
61
+ return `${failures.length} pairing(s) in this palette fall below WCAG AA (4.5:1). Fix them before handing a prototype
62
+ over (\`npm run tokens:build -- --strict\` fails on them):
63
+
64
+ ${failures.map((f) => `- ${f.trim()}`).join('\n')}`;
65
+ }
66
+
67
+ /** How the theme's styles read in DESIGN.md. */
68
+ const STYLE_TEXT = {
69
+ button: {
70
+ solid: 'The primary button is a solid brand fill.',
71
+ soft: 'The primary button is a soft brand tint with brand text.',
72
+ outline: 'The primary button is a brand outline with brand text, no fill.',
73
+ raised: 'The primary button is a solid brand fill with a darker bottom edge.',
74
+ },
75
+ field: {
76
+ outline: 'Text fields are outlined boxes.',
77
+ filled: 'Text fields are tinted, with a line along the bottom.',
78
+ underline: 'Text fields are a single line, no box.',
79
+ },
80
+ };
81
+
49
82
  export default function designMd(ctx, options) {
50
83
  const name = options.name ?? 'Halftone';
51
- // Which radius buttons use: 'control' (the theme's pill setting, the mobile kit) or a scale step (web: 'md').
52
- 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));
53
87
  const description =
54
88
  options.description ?? 'Brand-agnostic prototype kit: shadcn semantics, light and dark, WCAG AA enforced at build time.';
55
89
  const colors = ctx.colors
@@ -57,7 +91,7 @@ export default function designMd(ctx, options) {
57
91
  .join('\n');
58
92
  // The theme's radius: rounded-* are multiples of its base (rounded-lg); control is buttons and chips.
59
93
  const rounded = [
60
- ...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)}`),
61
95
  ` control: ${px(Math.min(ctx.shape.control, 9999))}`,
62
96
  ` full: ${px(9999)}`,
63
97
  ].join('\n');
@@ -72,6 +106,19 @@ export default function designMd(ctx, options) {
72
106
  const radius = Object.fromEntries(group(ctx.prim, ['radius']).map((t) => [t.key, t.value]));
73
107
  const duration = Object.fromEntries(group(ctx.prim, ['duration']).map((t) => [t.key, t.value]));
74
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
+
75
122
  const contents = `---
76
123
  version: alpha
77
124
  name: ${q(name)}
@@ -88,13 +135,13 @@ components:
88
135
  button-primary:
89
136
  backgroundColor: "{colors.primary}"
90
137
  textColor: "{colors.primary-foreground}"
91
- rounded: "{rounded.${buttons}}"
92
- height: 48px
138
+ rounded: ${corner(button.radius)}
139
+ height: ${px(button.h.md)}
93
140
  button-secondary:
94
141
  backgroundColor: "{colors.secondary}"
95
142
  textColor: "{colors.secondary-foreground}"
96
- rounded: "{rounded.${buttons}}"
97
- height: 48px
143
+ rounded: ${corner(button.radius)}
144
+ height: ${px(button.h.md)}
98
145
  card:
99
146
  backgroundColor: "{colors.card}"
100
147
  textColor: "{colors.card-foreground}"
@@ -103,8 +150,8 @@ components:
103
150
  input:
104
151
  backgroundColor: "{colors.background}"
105
152
  textColor: "{colors.foreground}"
106
- rounded: "{rounded.md}"
107
- height: 48px
153
+ rounded: ${corner(input.radius)}
154
+ height: ${px(input.h.md)}
108
155
  ---
109
156
 
110
157
  <!-- ${ctx.header} -->
@@ -116,7 +163,7 @@ components:
116
163
  A neutral, brand-agnostic base for clickable prototypes on phone and web. Calm surfaces, one brand
117
164
  colour reserved for the primary action and focus, generous spacing, and both light and dark themes
118
165
  from day one. ${status.applied ? `The applied theme is \`${status.code}\`${status.drift.length ? ' (edited by hand since)' : ''}. ` : ''}Re-branding means applying a
119
- 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
120
167
  \`tokens/tokens.json\`; everything else follows. The full rules and the component registry are in
121
168
  \`DESIGN_SYSTEM.md\` — read it before building UI.
122
169
 
@@ -131,8 +178,7 @@ hex literal or a Tailwind palette colour in UI code. Keys ending in \`-dark\` ar
131
178
  - **Destructive, Success, Warning, Info:** status only, each with its own \`-foreground\`.
132
179
  - **Chart 1–5:** data series.
133
180
 
134
- Every \`X\` / \`X-foreground\` pairing, muted text on every surface, and every status tone used as text
135
- clears WCAG AA (4.5:1) in both themes; the build refuses a palette that does not.
181
+ ${contrastLine(ctx)}
136
182
 
137
183
  A tone's text on that tone's own tint (\`text-success\` on \`bg-success/15\`) is a weaker pairing. With
138
184
  this palette it clears AA only for:
@@ -141,7 +187,7 @@ ${describeTintLimits(textOnTintLimits(ctx.colors))}.
141
187
 
142
188
  On a stronger tint, the text stays \`text-foreground\` and the tint, an icon or a dot carries the
143
189
  colour. \`halftone-tokens check\` flags class strings that break this.
144
-
190
+ ${extrasSection}
145
191
  ## Typography
146
192
 
147
193
  Headings (h1–h4, titles) use ${fontName(fonts.heading)}; everything else uses ${fontName(fonts.body)}; code and
@@ -165,13 +211,14 @@ a floating button). Content — inputs included — stays flat.
165
211
 
166
212
  ## Shapes
167
213
 
168
- 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
169
215
  ${radius['2xl']}px; chips, avatars and dots are fully round.
170
216
 
171
217
  ## Components
172
218
 
173
- Only components listed in the registry in \`DESIGN_SYSTEM.md\` exist. Controls are 48px tall (small
174
- 40, large 56). Selected chips and segments are neutral (inverted foreground), never the brand colour.
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.
175
222
  Motion: ${duration.fast}ms for micro-feedback, ${duration.base}ms for most transitions, ${duration.slow}ms for sheets, and
176
223
  nothing moves when the OS asks for reduced motion.
177
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,12 +18,13 @@ 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
 
22
25
  Colours are semantic names only, used as Tailwind classes (\`bg-primary\`, \`text-muted-foreground\`,
23
26
  \`border-border\`, \`bg-success/15\`). Never hex, never Tailwind palette colours. Both themes are
24
- required; every fill/foreground pairing clears WCAG AA (${AA}:1), enforced by \`npm run tokens:build\`.
27
+ required; ${ctx.contrastFailures?.length ? `${ctx.contrastFailures.length} pairing(s) in this palette fall below WCAG AA (${AA}:1): fix them (\`npm run tokens:build\` names them).` : `every fill/foreground pairing clears WCAG AA (${AA}:1); \`npm run tokens:build\` names any that falls short (\`--strict\` fails on it).`}
25
28
  A tone's own text on its own tint (\`text-success\` on \`bg-success/15\`) clears AA only for these
26
29
  tints; on a stronger one, put \`text-foreground\` on the tint:
27
30
  ${describeTintLimits(textOnTintLimits(ctx.colors))}.
@@ -31,13 +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)
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
37
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)
38
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\`)` : ''}
39
44
  - Font sizes (\`text-*\`): ${steps(['font', 'size'])}
40
45
  - Durations: ${steps(['duration'], 'ms')}
41
- `;
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` : ''}`;
42
47
  return [{ path: options.out, contents }];
43
48
  }
@@ -4,28 +4,47 @@
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';
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';
9
12
 
10
13
  const rem = (px) => (px >= 9999 ? '9999px' : `${Number((px / 16).toFixed(4))}rem`);
11
14
 
12
15
  /** Shape variables come from ctx.shape, which has defaults for a token file from before themes. */
13
16
  const SHAPE = new Set(['radius-control', 'border-width', 'icon-stroke']);
14
17
 
15
- function cssBlock(selector, tokens, shape) {
18
+ function cssBlock(selector, tokens, shape, mode, colors) {
16
19
  const lines = tokens
17
- // Shadows are literal in tailwind.theme.js: a var() box-shadow drops the view on native.
18
- .filter((t) => t.$type !== 'shadow' && !SHAPE.has(cssName(t)) && !/^(density|type)-/.test(cssName(t)))
20
+ // Shadows are literal in tailwind.theme.js: a var() box-shadow drops the view on native. The styles'
21
+ // numbers come from shape (with their units), below.
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)))
24
+ .filter((t) => isColor(t) || !/^(button|field)-/.test(cssName(t)))
19
25
  .map((t) => {
20
26
  const name = cssName(t);
21
27
  if (isColor(t)) return ` --${name}: ${hexToHslTriplet(t.$value)};`;
22
28
  if (name === 'radius') return ` --${name}: ${rem(t.$value)};`;
23
29
  return ` --${name}: ${t.$value};`;
24
30
  });
25
- if (shape) {
31
+ if (shape.control !== undefined) {
26
32
  lines.push(` --radius-control: ${rem(shape.control)};`, ` --border-width: ${shape.borderWidth}px;`, ` --icon-stroke: ${shape.stroke};`);
27
33
  for (const size of ['sm', 'md', 'lg', 'x']) lines.push(` --control-${size}: ${shape.density[size]}px;`);
28
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};`);
37
+ // Style colours a token file from before the styles doesn't have (resolve.mjs adds them), then the styles' numbers.
38
+ const present = new Set(tokens.filter(isColor).map(cssName));
39
+ for (const c of colors) if (!present.has(c.name)) lines.push(` --${c.name}: ${hexToHslTriplet(c[mode])};`);
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
+ }
29
48
  return ` ${selector} {\n${lines.join('\n')}\n }`;
30
49
  }
31
50
 
@@ -36,15 +55,23 @@ export default function nativewind3(ctx, options) {
36
55
  @tailwind utilities;
37
56
 
38
57
  @layer base {
39
- ${cssBlock(':root', ctx.semLight, ctx.shape)}
58
+ ${cssBlock(':root', ctx.semLight, { ...ctx.shape, styleShape: ctx.shape, textStyles: ctx.textStyles, extras: ctx.extras, components: ctx.components }, 'light', ctx.colors)}
40
59
 
41
- ${cssBlock('.dark:root', ctx.semDark)}
60
+ ${cssBlock('.dark:root', ctx.semDark, { styleShape: ctx.shape, extras: ctx.extras, components: ctx.components }, 'dark', ctx.colors)}
42
61
  }
43
62
  `;
44
63
  const colors = Object.fromEntries(ctx.colors.map((c) => [c.name, `hsl(var(--${c.name}))`]));
45
- const borderRadius = Object.fromEntries(ctx.radius.steps.map((s) => [s.name, radiusCalc(s.scale)]));
64
+ // The styles' fills carry their own opacity (an outline button, a transparent field). No /90 modifiers on these.
65
+ colors.button = 'hsl(var(--button) / var(--button-alpha))';
66
+ colors.field = 'hsl(var(--field) / var(--field-alpha))';
67
+ const borderRadius = Object.fromEntries(ctx.radius.steps.map((s) => [s.name, radiusCss(s)]));
46
68
  borderRadius.control = 'var(--radius-control)';
47
- const borderWidth = { DEFAULT: 'var(--border-width)' };
69
+ // Field style: rounded-field (top) and rounded-b-field-b (bottom).
70
+ borderRadius.field = 'var(--field-radius)';
71
+ borderRadius['field-b'] = 'var(--field-radius-b)';
72
+ // The styles' widths: border-button-w / border-b-button-edge-w, border-field-w / border-b-field-edge-w (named
73
+ // apart from the colours, or border-button would set both).
74
+ const borderWidth = { DEFAULT: 'var(--border-width)', 'button-w': 'var(--button-border-w)', 'button-edge-w': 'var(--button-edge-w)', 'field-w': 'var(--field-border-w)', 'field-edge-w': 'var(--field-edge-w)' };
48
75
  // The theme's depth takes over Tailwind's shadow scale: 1 = xs/sm/shadow, 2 = md, 3 = lg/xl/2xl. Light
49
76
  // values: native can't switch a box-shadow per scheme.
50
77
  const s = ctx.shape.shadows.light;
@@ -60,9 +87,47 @@ ${cssBlock('.dark:root', ctx.semDark)}
60
87
  .map(({ key, value }) => [key, [rem(value * scale), { lineHeight: rem(TEXT_LINE_HEIGHT[key] * scale * leadingFactor) }]])
61
88
  );
62
89
  const lineHeight = { prose: rem(size * leading) };
63
- const spacing = { 'control-x': 'var(--control-x)', flow: rem(size * flow) };
90
+ const spacing = { 'control-x': 'var(--control-x)', 'field-x': 'var(--field-x)', flow: rem(size * flow) };
64
91
  const maxWidth = { measure: rem(measure * size * 0.5) };
65
- 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`;
66
131
  return [
67
132
  { path: options.css, contents: css },
68
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
  },