@halftone-ds/tokens 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +65 -2
- package/cli.mjs +139 -0
- package/lib/color.mjs +114 -0
- package/lib/contrast.mjs +105 -0
- package/lib/resolve.mjs +108 -0
- package/lib/scan.mjs +66 -0
- package/lib/theme/apply.mjs +100 -0
- package/lib/theme/codec.mjs +122 -0
- package/lib/theme/fix.mjs +99 -0
- package/lib/theme/fonts.mjs +58 -0
- package/lib/theme/generate.mjs +83 -0
- package/lib/theme/index.d.mts +102 -0
- package/lib/theme/index.mjs +14 -0
- package/lib/theme/palettes.mjs +37 -0
- package/lib/theme/presets.mjs +56 -0
- package/lib/theme/ramp.mjs +82 -0
- package/lib/theme/recipe.mjs +187 -0
- package/lib/theme/refs.mjs +45 -0
- package/lib/theme/semantic.mjs +64 -0
- package/lib/theme/vars.mjs +54 -0
- package/lib/theme-cli.mjs +148 -0
- package/package.json +53 -4
- package/push-figma.mjs +124 -0
- package/targets/design-md.mjs +187 -0
- package/targets/figma.mjs +41 -0
- package/targets/fonts.mjs +91 -0
- package/targets/llms.mjs +43 -0
- package/targets/nativewind3.mjs +70 -0
- package/targets/registry-theme.mjs +38 -0
- package/targets/shadcn-web.mjs +89 -0
- package/targets/ts-theme.mjs +132 -0
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@halftone-ds/tokens",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.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
|
+
}
|
package/targets/llms.mjs
ADDED
|
@@ -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
|
+
}
|