@halftone-ds/tokens 0.0.0-stage → 1.1.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/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@halftone-ds/tokens",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/itsHendri/halftone.git",
10
+ "directory": "packages/tokens"
11
+ },
12
+ "homepage": "https://github.com/itsHendri/halftone/tree/main/packages/tokens#readme",
13
+ "keywords": [
14
+ "design-tokens",
15
+ "dtcg",
16
+ "nativewind",
17
+ "tailwindcss",
18
+ "shadcn",
19
+ "expo",
20
+ "wcag"
21
+ ],
22
+ "bin": {
23
+ "halftone-tokens": "cli.mjs"
24
+ },
25
+ "exports": {
26
+ ".": "./cli.mjs",
27
+ "./theme": {
28
+ "types": "./lib/theme/index.d.mts",
29
+ "default": "./lib/theme/index.mjs"
30
+ },
31
+ "./lib/*": "./lib/*.mjs",
32
+ "./targets/*": "./targets/*.mjs",
33
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "cli.mjs",
37
+ "push-figma.mjs",
38
+ "lib/",
39
+ "targets/",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "scripts": {
47
+ "test": "node --test test/*.test.mjs"
48
+ },
49
+ "dependencies": {
50
+ "style-dictionary": "^5.5.5"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ }
55
+ }
package/push-figma.mjs ADDED
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tokens/push-figma.mjs — mirror tokens/tokens.json into a Figma Variables collection.
4
+ *
5
+ * npm run tokens:figma # writes tokens/generated/figma-variables.json
6
+ * FIGMA_TOKEN=… FIGMA_FILE_KEY=… npm run tokens:figma # …and POSTs it to the Variables REST API
7
+ *
8
+ * One-way, code → Figma. The payload creates (or updates, when FIGMA_COLLECTION_ID is set) a
9
+ * collection "Kit tokens" with two modes (Light, Dark): one COLOR variable per semantic colour, STRING
10
+ * variables for the theme's fonts and
11
+ * FLOAT variables for radius, spacing and font sizes. Every variable gets WEB code syntax
12
+ * (`var(--primary)` / `p-4`) so Dev Mode and the Figma MCP hand back the class names, not hex.
13
+ *
14
+ * The write endpoint (POST /v1/files/:key/variables) needs a full Enterprise seat. Without it, the
15
+ * generated JSON is still useful: paste it into a `use_figma` (Figma MCP) call, or import it with a
16
+ * variables plugin.
17
+ */
18
+ import { mkdirSync, writeFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { pathToFileURL } from 'node:url';
21
+
22
+ // The app root: `halftone-tokens figma --root` sets KIT_TOKENS_ROOT; `npm run tokens:figma` runs from it.
23
+ const root = process.env.KIT_TOKENS_ROOT ?? process.cwd();
24
+ const theme = await import(pathToFileURL(join(root, 'tokens/generated/figma-theme.mjs')).href).catch(() => null);
25
+ if (!theme) {
26
+ console.error('Run `npm run tokens:build` first (it emits tokens/generated/figma-theme.mjs).');
27
+ process.exit(1);
28
+ }
29
+ const { COLORS, NUMBERS, STRINGS } = theme;
30
+
31
+ const hexToRgba = (hex) => {
32
+ const n = parseInt(hex.slice(1), 16);
33
+ return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255, a: 1 };
34
+ };
35
+
36
+ const COLLECTION = process.env.FIGMA_COLLECTION_ID ?? 'tmp-collection';
37
+ const LIGHT = 'tmp-mode-light';
38
+ const DARK = 'tmp-mode-dark';
39
+ const creatingCollection = !process.env.FIGMA_COLLECTION_ID;
40
+
41
+ const payload = {
42
+ variableCollections: creatingCollection ? [{ action: 'CREATE', id: COLLECTION, name: 'Kit tokens', initialModeId: LIGHT }] : [],
43
+ variableModes: creatingCollection
44
+ ? [
45
+ { action: 'UPDATE', id: LIGHT, name: 'Light', variableCollectionId: COLLECTION },
46
+ { action: 'CREATE', id: DARK, name: 'Dark', variableCollectionId: COLLECTION },
47
+ ]
48
+ : [],
49
+ variables: [],
50
+ variableModeValues: [],
51
+ };
52
+
53
+ for (const [name, { light, dark }] of Object.entries(COLORS)) {
54
+ const id = `tmp-color-${name}`;
55
+ payload.variables.push({
56
+ action: 'CREATE',
57
+ id,
58
+ name: `color/${name}`,
59
+ variableCollectionId: COLLECTION,
60
+ resolvedType: 'COLOR',
61
+ scopes: ['ALL_SCOPES'],
62
+ codeSyntax: { WEB: `var(--${name})` },
63
+ });
64
+ payload.variableModeValues.push({ variableId: id, modeId: LIGHT, value: hexToRgba(light) });
65
+ payload.variableModeValues.push({ variableId: id, modeId: DARK, value: hexToRgba(dark) });
66
+ }
67
+
68
+ for (const [group, entries] of Object.entries(NUMBERS)) {
69
+ for (const [key, { value, code }] of Object.entries(entries)) {
70
+ const id = `tmp-${group}-${key}`;
71
+ payload.variables.push({
72
+ action: 'CREATE',
73
+ id,
74
+ name: `${group}/${key}`,
75
+ variableCollectionId: COLLECTION,
76
+ resolvedType: 'FLOAT',
77
+ scopes:
78
+ group === 'radius' ? ['CORNER_RADIUS'] : group === 'font-size' ? ['FONT_SIZE'] : group === 'border' || group === 'icon' ? ['STROKE_FLOAT'] : ['GAP', 'WIDTH_HEIGHT'],
79
+ codeSyntax: { WEB: code },
80
+ });
81
+ payload.variableModeValues.push({ variableId: id, modeId: LIGHT, value });
82
+ payload.variableModeValues.push({ variableId: id, modeId: DARK, value });
83
+ }
84
+ }
85
+
86
+ for (const [group, entries] of Object.entries(STRINGS ?? {})) {
87
+ for (const [key, { value, code }] of Object.entries(entries)) {
88
+ const id = `tmp-${group}-${key}`;
89
+ payload.variables.push({
90
+ action: 'CREATE',
91
+ id,
92
+ name: `${group}/${key}`,
93
+ variableCollectionId: COLLECTION,
94
+ resolvedType: 'STRING',
95
+ scopes: ['FONT_FAMILY'],
96
+ codeSyntax: { WEB: code },
97
+ });
98
+ payload.variableModeValues.push({ variableId: id, modeId: LIGHT, value });
99
+ payload.variableModeValues.push({ variableId: id, modeId: DARK, value });
100
+ }
101
+ }
102
+
103
+ mkdirSync(join(root, 'tokens/generated'), { recursive: true });
104
+ const out = join(root, 'tokens/generated/figma-variables.json');
105
+ writeFileSync(out, JSON.stringify(payload, null, 2) + '\n');
106
+ console.log(`Wrote ${out}: ${payload.variables.length} variables × 2 modes`);
107
+
108
+ const token = process.env.FIGMA_TOKEN;
109
+ const fileKey = process.env.FIGMA_FILE_KEY;
110
+ if (!token || !fileKey) {
111
+ console.log('Set FIGMA_TOKEN and FIGMA_FILE_KEY to push to Figma (Enterprise seat required for the write API).');
112
+ process.exit(0);
113
+ }
114
+ const res = await fetch(`https://api.figma.com/v1/files/${fileKey}/variables`, {
115
+ method: 'POST',
116
+ headers: { 'X-Figma-Token': token, 'Content-Type': 'application/json' },
117
+ body: JSON.stringify(payload),
118
+ });
119
+ const body = await res.text();
120
+ if (!res.ok) {
121
+ console.error(`Figma API ${res.status}: ${body}`);
122
+ process.exit(1);
123
+ }
124
+ console.log('Pushed to Figma:', body.slice(0, 400));
@@ -0,0 +1,187 @@
1
+ /**
2
+ * DESIGN.md (google-labs-code/design.md, spec version "alpha"): the tokens as YAML front matter plus
3
+ * short, platform-neutral prose an agent can load on its own. The spec has no light/dark modes, so
4
+ * `colors` carries the light value under each name and the dark one under `<name>-dark`.
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 }
7
+ */
8
+ import { describeTintLimits, textOnTintLimits } from '../lib/contrast.mjs';
9
+ import { themeFonts } from './fonts.mjs';
10
+ import { themeStatus } from '../lib/theme/index.mjs';
11
+ import { group } from '../lib/resolve.mjs';
12
+
13
+ const q = (s) => JSON.stringify(String(s));
14
+ const px = (n) => `${n}px`;
15
+
16
+ /** The Text variants of components/ui/text.tsx, as typography tokens, at the theme's typeset. */
17
+ function typography(prim, type) {
18
+ const size = Object.fromEntries(group(prim, ['font', 'size']).map((t) => [t.key, Math.round(t.value * type.scale * 10) / 10]));
19
+ const families = Object.fromEntries(group(prim, ['font', 'family']).map((t) => [t.key, t.value]));
20
+ const heading = families.heading || families.sans || 'system-ui';
21
+ const sans = families.body || families.sans || 'system-ui';
22
+ const mono = families.mono || 'monospace';
23
+ const levels = [
24
+ ['h1', heading, size['4xl'], 800, 1.1, '-0.025em'],
25
+ ['h2', heading, size['3xl'], 600, 1.2, '-0.025em'],
26
+ ['h3', heading, size['2xl'], 600, 1.25, '-0.025em'],
27
+ ['h4', heading, size.xl, 600, 1.3, '-0.025em'],
28
+ ['lead', sans, size.xl, 400, 1.4],
29
+ ['large', sans, size.lg, 600, 1.4],
30
+ ['body', sans, size.base, 400, type.leading],
31
+ ['small', sans, size.sm, 500, 1],
32
+ ['muted', sans, size.sm, 400, 1.4],
33
+ ['code', mono, size.sm, 600, 1.4],
34
+ ];
35
+ return levels
36
+ .map(([name, family, fontSize, weight, lineHeight, tracking]) =>
37
+ [
38
+ ` ${name}:`,
39
+ ` fontFamily: ${q(family)}`,
40
+ ` fontSize: ${px(fontSize)}`,
41
+ ` fontWeight: ${weight}`,
42
+ ` lineHeight: ${lineHeight}`,
43
+ ...(tracking ? [` letterSpacing: ${tracking}`] : []),
44
+ ].join('\n')
45
+ )
46
+ .join('\n');
47
+ }
48
+
49
+ export default function designMd(ctx, options) {
50
+ 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';
53
+ const description =
54
+ options.description ?? 'Brand-agnostic prototype kit: shadcn semantics, light and dark, WCAG AA enforced at build time.';
55
+ const colors = ctx.colors
56
+ .flatMap((c) => [` ${c.name}: ${q(c.light)}`, ` ${c.name}-dark: ${q(c.dark)}`])
57
+ .join('\n');
58
+ // The theme's radius: rounded-* are multiples of its base (rounded-lg); control is buttons and chips.
59
+ const rounded = [
60
+ ...ctx.radius.steps.map((s) => ` ${s.name}: ${px(Math.round(ctx.radius.base * s.scale * 10) / 10)}`),
61
+ ` control: ${px(Math.min(ctx.shape.control, 9999))}`,
62
+ ` full: ${px(9999)}`,
63
+ ].join('\n');
64
+ const fonts = themeFonts(ctx);
65
+ const status = themeStatus(ctx.source);
66
+ const fontName = (f) => (f ? f.family : 'the system font (SF / Roboto)');
67
+ const spacing = group(ctx.prim, ['space'])
68
+ .map((t) => ` ${q(t.key)}: ${px(t.value)}`)
69
+ .join('\n');
70
+ const hex = Object.fromEntries(ctx.colors.map((c) => [c.name, c.light]));
71
+ const darkHex = Object.fromEntries(ctx.colors.map((c) => [c.name, c.dark]));
72
+ const radius = Object.fromEntries(group(ctx.prim, ['radius']).map((t) => [t.key, t.value]));
73
+ const duration = Object.fromEntries(group(ctx.prim, ['duration']).map((t) => [t.key, t.value]));
74
+
75
+ const contents = `---
76
+ version: alpha
77
+ name: ${q(name)}
78
+ description: ${q(description)}
79
+ colors:
80
+ ${colors}
81
+ typography:
82
+ ${typography(ctx.prim, ctx.type)}
83
+ rounded:
84
+ ${rounded}
85
+ spacing:
86
+ ${spacing}
87
+ components:
88
+ button-primary:
89
+ backgroundColor: "{colors.primary}"
90
+ textColor: "{colors.primary-foreground}"
91
+ rounded: "{rounded.${buttons}}"
92
+ height: 48px
93
+ button-secondary:
94
+ backgroundColor: "{colors.secondary}"
95
+ textColor: "{colors.secondary-foreground}"
96
+ rounded: "{rounded.${buttons}}"
97
+ height: 48px
98
+ card:
99
+ backgroundColor: "{colors.card}"
100
+ textColor: "{colors.card-foreground}"
101
+ rounded: "{rounded.lg}"
102
+ padding: 16px
103
+ input:
104
+ backgroundColor: "{colors.background}"
105
+ textColor: "{colors.foreground}"
106
+ rounded: "{rounded.md}"
107
+ height: 48px
108
+ ---
109
+
110
+ <!-- ${ctx.header} -->
111
+
112
+ # ${name}
113
+
114
+ ## Overview
115
+
116
+ A neutral, brand-agnostic base for clickable prototypes on phone and web. Calm surfaces, one brand
117
+ colour reserved for the primary action and focus, generous spacing, and both light and dark themes
118
+ 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
120
+ \`tokens/tokens.json\`; everything else follows. The full rules and the component registry are in
121
+ \`DESIGN_SYSTEM.md\` — read it before building UI.
122
+
123
+ ## Colors
124
+
125
+ Semantic names only (the shadcn vocabulary), each usable as \`bg-\`, \`text-\` and \`border-\`. Never a
126
+ hex literal or a Tailwind palette colour in UI code. Keys ending in \`-dark\` are the dark theme.
127
+
128
+ - **Primary (${hex.primary} / dark ${darkHex.primary}):** the single most important action, links and focus.
129
+ - **Background (${hex.background}) and Card (${hex.card}):** the page canvas and raised surfaces.
130
+ - **Muted foreground (${hex['muted-foreground']}):** helper text, captions, placeholders.
131
+ - **Destructive, Success, Warning, Info:** status only, each with its own \`-foreground\`.
132
+ - **Chart 1–5:** data series.
133
+
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.
136
+
137
+ A tone's text on that tone's own tint (\`text-success\` on \`bg-success/15\`) is a weaker pairing. With
138
+ this palette it clears AA only for:
139
+
140
+ ${describeTintLimits(textOnTintLimits(ctx.colors))}.
141
+
142
+ On a stronger tint, the text stays \`text-foreground\` and the tint, an icon or a dot carries the
143
+ colour. \`halftone-tokens check\` flags class strings that break this.
144
+
145
+ ## Typography
146
+
147
+ Headings (h1–h4, titles) use ${fontName(fonts.heading)}; everything else uses ${fontName(fonts.body)}; code and
148
+ figures (\`font-mono\`) use ${fonts.mono ? fonts.mono.family : 'the system monospace font'}. Typeset: body text ${ctx.type.size}px (every
149
+ \`text-*\` size scales with it), running text leading ${ctx.type.leading}. Long-form text (articles, help, rendered
150
+ markdown) goes in \`Prose\`: ${ctx.type.flow}em between blocks, lines up to ${ctx.type.measure} characters.
151
+ Use the Text variants (h1–h4, lead, large, body, small, muted, code) rather than raw sizes. Medium weight for row titles, semibold for headings and
152
+ values, bold only for hero amounts.
153
+
154
+ ## Layout
155
+
156
+ A 4-pt grid, restricted to the steps in \`spacing\` (0–64px). Screen edge padding 20px, 20px between
157
+ sections, 16px inside cards, 12px vertical in list rows. Siblings are spaced with gaps, not margins,
158
+ and spacing varies to create hierarchy. Interactive targets are at least 44×44.
159
+
160
+ ## Elevation & Depth
161
+
162
+ Mostly flat: hierarchy comes from surface colour (background → card) and borders. A shadow is only
163
+ for something you can press or something floating above the page (dialogs, sheets, menus, toasts,
164
+ a floating button). Content — inputs included — stays flat.
165
+
166
+ ## Shapes
167
+
168
+ Buttons are pills. Inputs ${radius.md}px, cards ${radius.lg}px, larger surfaces ${radius.xl}px, sheet tops
169
+ ${radius['2xl']}px; chips, avatars and dots are fully round.
170
+
171
+ ## Components
172
+
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.
175
+ Motion: ${duration.fast}ms for micro-feedback, ${duration.base}ms for most transitions, ${duration.slow}ms for sheets, and
176
+ nothing moves when the OS asks for reduced motion.
177
+
178
+ ## Do's and Don'ts
179
+
180
+ - Do keep the brand colour for the primary action and focus; one primary action per screen.
181
+ - Do check every screen in light and dark.
182
+ - Don't nest cards inside cards or put a section title inside its card.
183
+ - Don't use grey-on-grey text below 4.5:1, tap targets under 44×44, or icon-only buttons without a label.
184
+ - Don't invent a component: if it is not in the registry, stop and add it properly.
185
+ `;
186
+ return [{ path: options.out, contents }];
187
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Input for push-figma.mjs: semantic colours per mode, the numeric scales with their class names (so
3
+ * Figma Variables carry `var(--primary)` / `p-4` code syntax), the theme's fonts as strings, and its
4
+ * shadows for reference (Figma effect styles can't be written through the Variables API).
5
+ * options: { out: 'tokens/generated/figma-theme.mjs' }
6
+ */
7
+ import { group } from '../lib/resolve.mjs';
8
+ import { themeFonts } from './fonts.mjs';
9
+
10
+ export default function figma(ctx, options) {
11
+ const colors = Object.fromEntries(ctx.colors.map((c) => [c.name, { light: c.light, dark: c.dark }]));
12
+ const num = (prefix, code) =>
13
+ Object.fromEntries(
14
+ group(ctx.prim, prefix)
15
+ .filter((t) => typeof t.value === 'number')
16
+ .map((t) => [t.key, { value: t.value, code: code(t.key) }])
17
+ );
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}` }]));
20
+ radius.control = { value: Math.min(ctx.shape.control, 9999), code: 'rounded-control' };
21
+ radius.full = { value: 9999, code: 'rounded-full' };
22
+ const numbers = {
23
+ radius,
24
+ space: num(['space'], (k) => `p-${k}`),
25
+ 'font-size': num(['font', 'size'], (k) => `text-${k}`),
26
+ border: { width: { value: ctx.shape.borderWidth, code: 'border' } },
27
+ icon: { stroke: { value: ctx.shape.stroke, code: 'strokeWidth' } },
28
+ };
29
+ const fonts = themeFonts(ctx);
30
+ const strings = {
31
+ font: {
32
+ heading: { value: fonts.heading?.family ?? 'System', code: 'font-heading' },
33
+ body: { value: fonts.body?.family ?? 'System', code: 'font-sans' },
34
+ },
35
+ };
36
+ const contents =
37
+ `// ${ctx.header}\nexport const COLORS = ${JSON.stringify(colors, null, 2)};\nexport const NUMBERS = ${JSON.stringify(numbers, null, 2)};\n` +
38
+ `export const STRINGS = ${JSON.stringify(strings, null, 2)};\n` +
39
+ `/** shadow-sm / -md / -lg as CSS, per mode: make them effect styles by hand (not in the Variables API). */\nexport const SHADOWS = ${JSON.stringify(ctx.shape.shadows, null, 2)};\n`;
40
+ return [{ path: options.out, contents }];
41
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The theme's fonts (primitive.font.family.heading / body / mono), as code that loads them:
3
+ * options: { platform: 'expo', out: 'lib/fonts.ts' } @expo-google-fonts imports, one per weight, and
4
+ * the family name expo-font registers for each role and weight (FONT_FAMILY)
5
+ * options: { platform: 'next', out: 'app/fonts.ts' } next/font/google, exposing --kit-font-heading,
6
+ * --kit-font-body and --kit-font-mono (app/tokens.css maps them to font-heading / font-sans / font-mono)
7
+ * An empty family (heading, body) or Menlo (mono) is the platform's own font: nothing to load.
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).
11
+ */
12
+ import { FONTS } from '../lib/theme/fonts.mjs';
13
+ import { group } from '../lib/resolve.mjs';
14
+
15
+ const SUFFIX = { 400: 'Regular', 500: 'Medium', 600: 'SemiBold', 700: 'Bold' };
16
+
17
+ /** The theme's fonts: { heading, body, mono }, each a FONTS entry or null (the platform's font). */
18
+ export function themeFonts(ctx) {
19
+ const families = Object.fromEntries(group(ctx.prim, ['font', 'family']).map(({ key, value }) => [key, value]));
20
+ const pick = (role) => {
21
+ const family = families[role];
22
+ 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(', ')}`);
25
+ return font.system ? null : font;
26
+ };
27
+ return { heading: pick('heading'), body: pick('body'), mono: pick('mono') };
28
+ }
29
+
30
+ /** The npm packages an Expo app needs for these fonts. */
31
+ export const expoPackages = (fonts) => [...new Set(Object.values(fonts).filter(Boolean).map((f) => `@expo-google-fonts/${f.id}`))];
32
+
33
+ function expo(ctx, fonts) {
34
+ const used = [...new Map(Object.values(fonts).filter(Boolean).map((f) => [f.id, f])).values()];
35
+ const name = (f, w) => `${f.expoPrefix}_${w}${SUFFIX[w]}`;
36
+ const imports = used.flatMap((f) => f.weights.map((w) => `import { ${name(f, w)} } from '@expo-google-fonts/${f.id}/${w}${SUFFIX[w]}';`));
37
+ const assets = used.flatMap((f) => f.weights.map((w) => name(f, w)));
38
+ const family = (f) => (f ? `{ ${f.weights.map((w) => `${w}: '${name(f, w)}'`).join(', ')} }` : 'null');
39
+ return `// ${ctx.header}
40
+ ${imports.join('\n')}${imports.length ? '\n' : ''}
41
+ /** Font files for useFonts from expo-font (app/_layout.tsx). Empty when the theme uses the system font. */
42
+ export const FONT_ASSETS = ${assets.length ? `{ ${assets.join(', ')} }` : '{}'};
43
+
44
+ /**
45
+ * The family expo-font registers for each role and weight (null: the system font). Android does not pick a
46
+ * weight of a custom font from fontWeight, so components/ui/text.tsx sets the family per weight.
47
+ */
48
+ export const FONT_FAMILY: Record<'heading' | 'body' | 'mono', Record<number, string> | null> = {
49
+ heading: ${family(fonts.heading)},
50
+ body: ${family(fonts.body)},
51
+ mono: ${family(fonts.mono)},
52
+ };
53
+ `;
54
+ }
55
+
56
+ const nextName = (f) => f.family.replace(/ /g, '_');
57
+
58
+ function next(ctx, fonts) {
59
+ const { heading, body, mono } = fonts;
60
+ if (!heading && !body && !mono) {
61
+ return `// ${ctx.header}
62
+ /** The theme uses the system fonts: no font variables (app/tokens.css falls back to the system stacks). */
63
+ export const fontVariables = '';
64
+ `;
65
+ }
66
+ const roles = [];
67
+ if (body) roles.push(['body', body]);
68
+ // The same family for both roles loads once; --kit-font-heading falls back to the body font.
69
+ if (heading && heading.id !== body?.id) roles.push(['heading', heading]);
70
+ if (mono) roles.push(['mono', mono]);
71
+ const loaders = [...new Set(roles.map(([, f]) => nextName(f)))];
72
+ return `// ${ctx.header}
73
+ import { ${loaders.join(', ')} } from 'next/font/google';
74
+
75
+ ${roles
76
+ .map(
77
+ ([role, f]) =>
78
+ `const ${role} = ${nextName(f)}({ subsets: ['latin'], weight: [${f.weights.map((w) => `'${w}'`).join(', ')}], variable: '--kit-font-${role}', display: 'swap' });`
79
+ )
80
+ .join('\n')}
81
+
82
+ /** Put on <html>: defines --kit-font-${roles.map(([r]) => r).join(' and --kit-font-')}, which app/tokens.css maps to font-sans, font-heading and font-mono. */
83
+ export const fontVariables = [${roles.map(([role]) => `${role}.variable`).join(', ')}].join(' ');
84
+ `;
85
+ }
86
+
87
+ export default function fontsTarget(ctx, options) {
88
+ const fonts = themeFonts(ctx);
89
+ const contents = options.platform === 'next' ? next(ctx, fonts) : expo(ctx, fonts);
90
+ return [{ path: options.out, contents }];
91
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The tokens section of llms.txt: a compact Markdown fragment that registry-build stitches into the
3
+ * app's llms.txt (and the docs site into /llms.txt).
4
+ * options: { out: 'tokens/generated/llms-tokens.md' }
5
+ */
6
+ import { group } from '../lib/resolve.mjs';
7
+ import { AA, describeTintLimits, textOnTintLimits } from '../lib/contrast.mjs';
8
+ import { themeStatus } from '../lib/theme/index.mjs';
9
+ import { themeFonts } from './fonts.mjs';
10
+
11
+ export default function llms(ctx, options) {
12
+ const rows = ctx.colors.map((c) => `| \`${c.name}\` | ${c.light} | ${c.dark} |`).join('\n');
13
+ const steps = (prefix, unit = 'px') =>
14
+ group(ctx.prim, prefix)
15
+ .map((t) => `${t.key} ${t.value}${unit}`)
16
+ .join(' · ');
17
+ const fonts = themeFonts(ctx);
18
+ const theme = themeStatus(ctx.source);
19
+ const contents = `<!-- ${ctx.header} -->
20
+ ## Tokens
21
+
22
+ Colours are semantic names only, used as Tailwind classes (\`bg-primary\`, \`text-muted-foreground\`,
23
+ \`border-border\`, \`bg-success/15\`). Never hex, never Tailwind palette colours. Both themes are
24
+ required; every fill/foreground pairing clears WCAG AA (${AA}:1), enforced by \`npm run tokens:build\`.
25
+ A tone's own text on its own tint (\`text-success\` on \`bg-success/15\`) clears AA only for these
26
+ tints; on a stronger one, put \`text-foreground\` on the tint:
27
+ ${describeTintLimits(textOnTintLimits(ctx.colors))}.
28
+
29
+ | Name | Light | Dark |
30
+ |---|---|---|
31
+ ${rows}
32
+
33
+ - 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`}`}
35
+ - Depth: \`shadow-sm\` (controls, cards), \`shadow-md\` (raised, popovers), \`shadow-lg\` (menus, dialogs) are the theme's three elevation levels; no shadow colour modifiers on surfaces (thumbs and floating elements may add \`shadow-black/20\`)
36
+ - Border \`border\` ${ctx.shape.borderWidth}px · icon stroke ${ctx.shape.stroke} (an Icon's \`strokeWidth\` is relative to 2)
37
+ - 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
+ - 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
+ - Font sizes (\`text-*\`): ${steps(['font', 'size'])}
40
+ - Durations: ${steps(['duration'], 'ms')}
41
+ `;
42
+ return [{ path: options.out, contents }];
43
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * NativeWind 4 / Tailwind 3 (shadcn v3 shape): bare HSL triplets in `:root` and `.dark:root`, plus the
3
+ * colour + radius maps tailwind.config.js spreads in.
4
+ * options: { css: 'global.css', tailwind: 'tokens/generated/tailwind.theme.js' }
5
+ */
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
+
10
+ const rem = (px) => (px >= 9999 ? '9999px' : `${Number((px / 16).toFixed(4))}rem`);
11
+
12
+ /** Shape variables come from ctx.shape, which has defaults for a token file from before themes. */
13
+ const SHAPE = new Set(['radius-control', 'border-width', 'icon-stroke']);
14
+
15
+ function cssBlock(selector, tokens, shape) {
16
+ 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)))
19
+ .map((t) => {
20
+ const name = cssName(t);
21
+ if (isColor(t)) return ` --${name}: ${hexToHslTriplet(t.$value)};`;
22
+ if (name === 'radius') return ` --${name}: ${rem(t.$value)};`;
23
+ return ` --${name}: ${t.$value};`;
24
+ });
25
+ if (shape) {
26
+ lines.push(` --radius-control: ${rem(shape.control)};`, ` --border-width: ${shape.borderWidth}px;`, ` --icon-stroke: ${shape.stroke};`);
27
+ for (const size of ['sm', 'md', 'lg', 'x']) lines.push(` --control-${size}: ${shape.density[size]}px;`);
28
+ }
29
+ return ` ${selector} {\n${lines.join('\n')}\n }`;
30
+ }
31
+
32
+ export default function nativewind3(ctx, options) {
33
+ const css = `/* ${ctx.header} */
34
+ @tailwind base;
35
+ @tailwind components;
36
+ @tailwind utilities;
37
+
38
+ @layer base {
39
+ ${cssBlock(':root', ctx.semLight, ctx.shape)}
40
+
41
+ ${cssBlock('.dark:root', ctx.semDark)}
42
+ }
43
+ `;
44
+ 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)]));
46
+ borderRadius.control = 'var(--radius-control)';
47
+ const borderWidth = { DEFAULT: 'var(--border-width)' };
48
+ // The theme's depth takes over Tailwind's shadow scale: 1 = xs/sm/shadow, 2 = md, 3 = lg/xl/2xl. Light
49
+ // values: native can't switch a box-shadow per scheme.
50
+ const s = ctx.shape.shadows.light;
51
+ const boxShadow = { xs: s[1], sm: s[1], DEFAULT: s[1], md: s[2], lg: s[3], xl: s[3], '2xl': s[3] };
52
+ // Density: control heights (h-control-sm / h-control / h-control-lg, and min-h-*) and padding (px-control-x).
53
+ const height = { 'control-sm': 'var(--control-sm)', control: 'var(--control-md)', 'control-lg': 'var(--control-lg)' };
54
+ // The typeset, literal (native has no runtime theme): text-xs…4xl and their line heights scaled, and
55
+ // Prose's leading-prose, gap-flow and max-w-measure (native has no ch: half the text size per character).
56
+ const { scale, leadingFactor, size, leading, flow, measure } = ctx.type;
57
+ const fontSize = Object.fromEntries(
58
+ group(ctx.prim, ['font', 'size'])
59
+ .filter(({ key }) => key in TEXT_LINE_HEIGHT)
60
+ .map(({ key, value }) => [key, [rem(value * scale), { lineHeight: rem(TEXT_LINE_HEIGHT[key] * scale * leadingFactor) }]])
61
+ );
62
+ const lineHeight = { prose: rem(size * leading) };
63
+ const spacing = { 'control-x': 'var(--control-x)', flow: rem(size * flow) };
64
+ 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`;
66
+ return [
67
+ { path: options.css, contents: css },
68
+ { path: options.tailwind, contents: tailwind },
69
+ ];
70
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The theme as shadcn registry `cssVars`, for the `registry:theme` items: `native` in the NativeWind 4 /
3
+ * Tailwind 3 shape (HSL triplets + tailwind config extension), `web` in the Tailwind 4 shape (oklch).
4
+ * options: { out: 'tokens/generated/theme.registry.json' }
5
+ */
6
+ import { hexToHslTriplet, hexToOklch } from '../lib/color.mjs';
7
+ import { radiusCalc } from '../lib/resolve.mjs';
8
+
9
+ export default function registryTheme(ctx, options) {
10
+ const radius = `${ctx.radius.base / 16}rem`;
11
+ const vars = (convert, mode) => Object.fromEntries(ctx.colors.map((c) => [c.name, convert(c[mode])]));
12
+ const theme = {
13
+ native: {
14
+ cssVars: {
15
+ light: { radius, ...vars(hexToHslTriplet, 'light') },
16
+ dark: vars(hexToHslTriplet, 'dark'),
17
+ },
18
+ tailwind: {
19
+ config: {
20
+ theme: {
21
+ extend: {
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)])),
24
+ },
25
+ },
26
+ },
27
+ },
28
+ },
29
+ web: {
30
+ cssVars: {
31
+ theme: Object.fromEntries(ctx.radius.steps.map((s) => [`radius-${s.name}`, radiusCalc(s.scale)])),
32
+ light: { radius, ...vars(hexToOklch, 'light') },
33
+ dark: vars(hexToOklch, 'dark'),
34
+ },
35
+ },
36
+ };
37
+ return [{ path: options.out, contents: JSON.stringify(theme, null, 2) + '\n' }];
38
+ }