@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/README.md +84 -4
- package/cli.mjs +33 -14
- package/lib/apca.mjs +55 -0
- package/lib/color.mjs +27 -7
- package/lib/contrast.mjs +56 -1
- package/lib/cvd.mjs +97 -0
- package/lib/resolve.mjs +100 -12
- package/lib/system/generate.mjs +212 -0
- package/lib/system/index.d.mts +168 -0
- package/lib/system/index.mjs +33 -0
- package/lib/system/migrate.mjs +38 -0
- package/lib/system/schema.mjs +511 -0
- package/lib/theme/apply.mjs +191 -16
- package/lib/theme/codec.mjs +38 -23
- package/lib/theme/components.mjs +471 -0
- package/lib/theme/extras.mjs +105 -0
- package/lib/theme/fix.mjs +63 -10
- package/lib/theme/fonts.mjs +16 -1
- package/lib/theme/generate.mjs +114 -13
- package/lib/theme/index.d.mts +166 -2
- package/lib/theme/index.mjs +33 -3
- package/lib/theme/presets.mjs +19 -7
- package/lib/theme/ramp.mjs +55 -15
- package/lib/theme/recipe.mjs +107 -0
- package/lib/theme/semantic.mjs +43 -6
- package/lib/theme/shadcn.mjs +265 -0
- package/lib/theme/vars.mjs +81 -3
- package/lib/theme-cli.mjs +179 -17
- package/lib/usage.mjs +136 -0
- package/package.json +5 -1
- package/targets/design-md.mjs +64 -17
- package/targets/figma.mjs +1 -1
- package/targets/fonts.mjs +5 -5
- package/targets/llms.mjs +9 -4
- package/targets/nativewind3.mjs +77 -12
- package/targets/registry-theme.mjs +3 -3
- package/targets/shadcn-web.mjs +106 -6
- package/targets/ts-theme.mjs +42 -0
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.
|
|
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"
|
package/targets/design-md.mjs
CHANGED
|
@@ -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
|
-
//
|
|
52
|
-
const
|
|
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(
|
|
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:
|
|
92
|
-
height:
|
|
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:
|
|
97
|
-
height:
|
|
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:
|
|
107
|
-
height:
|
|
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>\`,
|
|
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
|
-
|
|
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
|
|
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.
|
|
174
|
-
|
|
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:
|
|
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
|
-
*
|
|
10
|
-
* the
|
|
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 {
|
|
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 =
|
|
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)
|
|
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} ${
|
|
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
|
}
|
package/targets/nativewind3.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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}`,
|
|
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
|
},
|