@halftone-ds/tokens 1.2.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 +43 -1
- package/cli.mjs +5 -2
- package/lib/contrast.mjs +33 -0
- package/lib/resolve.mjs +80 -20
- 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 +187 -28
- 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 +13 -0
- package/lib/theme/generate.mjs +98 -20
- package/lib/theme/index.d.mts +91 -2
- package/lib/theme/index.mjs +21 -4
- package/lib/theme/presets.mjs +1 -1
- package/lib/theme/ramp.mjs +55 -15
- package/lib/theme/recipe.mjs +68 -0
- package/lib/theme/semantic.mjs +19 -7
- package/lib/theme/shadcn.mjs +1 -1
- package/lib/theme/vars.mjs +61 -8
- package/lib/theme-cli.mjs +87 -17
- package/lib/usage.mjs +136 -0
- package/package.json +5 -1
- package/targets/design-md.mjs +32 -15
- package/targets/figma.mjs +1 -1
- package/targets/fonts.mjs +5 -5
- package/targets/llms.mjs +8 -4
- package/targets/nativewind3.mjs +58 -8
- package/targets/registry-theme.mjs +3 -3
- package/targets/shadcn-web.mjs +96 -6
- package/targets/ts-theme.mjs +35 -0
package/lib/theme/recipe.mjs
CHANGED
|
@@ -104,6 +104,74 @@ export const typeScale = ({ size, leading, flow, measure }) => ({
|
|
|
104
104
|
});
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
/** The Text variants both kits draw (components/ui/text.tsx on mobile; the type-* utilities on the web). */
|
|
108
|
+
export const TEXT_STYLES = ['h1', 'h2', 'h3', 'h4', 'lead', 'p', 'large', 'small', 'muted', 'code', 'blockquote'];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The text styles as the kits draw them at the default typeset (16px, leading 1.75), from the mobile kit's
|
|
112
|
+
* components/ui/text.tsx and Tailwind's sizes: size px, leading × size, tracking em. `leading: 'prose'` is the typeset's own leading
|
|
113
|
+
* (Text p's leading-prose); every other leading scales with the typeset's (leading / 1.75), and sizes with
|
|
114
|
+
* size / 16. `small` is leading-none, which never scales.
|
|
115
|
+
*/
|
|
116
|
+
export const TEXT_DEFAULTS = {
|
|
117
|
+
h1: { font: 'heading', weight: 800, size: 36, leading: 40 / 36, tracking: -0.025 },
|
|
118
|
+
h2: { font: 'heading', weight: 600, size: 30, leading: 36 / 30, tracking: -0.025 },
|
|
119
|
+
h3: { font: 'heading', weight: 600, size: 24, leading: 32 / 24, tracking: -0.025 },
|
|
120
|
+
h4: { font: 'heading', weight: 600, size: 20, leading: 28 / 20, tracking: -0.025 },
|
|
121
|
+
lead: { font: 'body', weight: 400, size: 20, leading: 28 / 20, tracking: 0 },
|
|
122
|
+
p: { font: 'body', weight: 400, size: 16, leading: 'prose', tracking: 0 },
|
|
123
|
+
large: { font: 'body', weight: 600, size: 18, leading: 28 / 18, tracking: 0 },
|
|
124
|
+
small: { font: 'body', weight: 500, size: 14, leading: 1, tracking: 0 },
|
|
125
|
+
muted: { font: 'body', weight: 400, size: 14, leading: 20 / 14, tracking: 0 },
|
|
126
|
+
code: { font: 'mono', weight: 600, size: 14, leading: 20 / 14, tracking: 0 },
|
|
127
|
+
blockquote: { font: 'body', weight: 400, size: 16, leading: 24 / 16, tracking: 0 },
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const FONT_REF = /^\{primitive\.font\.family\.(heading|body|mono)\}$/;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A system's edited text styles from a semantic.type group: a theme's ({ h1: { size, … } }) or a token file's
|
|
134
|
+
* DTCG one ({ h1: { size: { $value } } }). The font is a reference to a role's family; a style's other keys
|
|
135
|
+
* are numbers. The typeset's own numbers (size, leading, flow, measure) are skipped.
|
|
136
|
+
* → { h1: { size, leading, weight, tracking, font: 'heading' } }
|
|
137
|
+
*/
|
|
138
|
+
export function textEdits(group = {}) {
|
|
139
|
+
const out = {};
|
|
140
|
+
for (const name of TEXT_STYLES) {
|
|
141
|
+
const style = group?.[name];
|
|
142
|
+
if (!style || typeof style !== 'object' || '$value' in style) continue;
|
|
143
|
+
const value = (v) => (v && typeof v === 'object' && '$value' in v ? v.$value : v);
|
|
144
|
+
const edit = {};
|
|
145
|
+
for (const key of ['size', 'leading', 'weight', 'tracking']) if (typeof value(style[key]) === 'number') edit[key] = value(style[key]);
|
|
146
|
+
const font = FONT_REF.exec(String(value(style.font) ?? ''));
|
|
147
|
+
if (font) edit.font = font[1];
|
|
148
|
+
out[name] = edit;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Every text style as drawn at a typeset (typeScale), edits on top, unrounded:
|
|
155
|
+
* { h1: { font, weight, size: px, leading: × size, tracking: em, lineHeight: px, edited: bool } }
|
|
156
|
+
* Sizes scale by the typeset's size / 16 and line heights by its leading / 1.75; Text p takes the typeset's
|
|
157
|
+
* leading itself and Text small (leading-none) never scales. With no edits this is exactly what the kits drew
|
|
158
|
+
* with Tailwind's text-* classes.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveTextStyles(type, edits = {}) {
|
|
161
|
+
return Object.fromEntries(
|
|
162
|
+
TEXT_STYLES.map((name) => {
|
|
163
|
+
const d = TEXT_DEFAULTS[name];
|
|
164
|
+
const leading = d.leading === 'prose' ? type.leading : d.leading === 1 ? 1 : d.leading * type.leadingFactor;
|
|
165
|
+
const edit = edits[name] ?? {};
|
|
166
|
+
const style = { font: d.font, weight: d.weight, size: d.size * type.scale, leading, tracking: d.tracking, ...edit, edited: Object.keys(edit).length > 0 };
|
|
167
|
+
// The line height in px, multiplied in the order the text-* classes always were (so it rounds the same).
|
|
168
|
+
const px = Math.round(d.size * (d.leading === 'prose' ? 1 : d.leading) * 1e6) / 1e6;
|
|
169
|
+
style.lineHeight = 'size' in edit || 'leading' in edit ? style.size * style.leading : d.leading === 'prose' ? px * type.scale * type.leading : d.leading === 1 ? style.size : px * type.scale * type.leadingFactor;
|
|
170
|
+
return [name, style];
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
107
175
|
const s = (x, y, blur, spread, color) => ({ offsetX: x, offsetY: y, blur, spread, color });
|
|
108
176
|
/**
|
|
109
177
|
* Three elevation levels per depth (DTCG shadow values, light and dark): 1 for controls and cards,
|
package/lib/theme/semantic.mjs
CHANGED
|
@@ -13,8 +13,18 @@ const p = (ramp, step) => `{primitive.color.${ramp}.${step}}`;
|
|
|
13
13
|
const DARK_STEP = { 600: '500', 700: '400', 800: '300', 900: '200', 950: '100' };
|
|
14
14
|
export const darkStep = (anchor) => DARK_STEP[anchor] ?? anchor;
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The status tones' steps, as offsets from the step their seed sits on (600 for the curated ramps the kits
|
|
18
|
+
* ship), light and dark: a status seed that lands on another step moves its tone with it, as the brand does.
|
|
19
|
+
*/
|
|
20
|
+
const STATUS_STEPS = { danger: [0, -1], success: [1, -1], warning: [1, -2], info: [1, -1] };
|
|
21
|
+
const status = (ramp, anchor = '600') => ({ light: p(ramp, shift(anchor, STATUS_STEPS[ramp][0])), dark: p(ramp, shift(anchor, STATUS_STEPS[ramp][1])) });
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* { name: { light: ref, dark: ref } } in the order the kit's tokens.json lists them. `anchors` holds the step
|
|
25
|
+
* each status seed sits on (`{ danger: '500' }`); a status without one uses its 600, the curated ramps' seed.
|
|
26
|
+
*/
|
|
27
|
+
export function semanticColors(anchor = '600', anchors = {}) {
|
|
18
28
|
const brand = { light: b(anchor), dark: b(darkStep(anchor)) };
|
|
19
29
|
const onDark = { light: n(0), dark: n(950) };
|
|
20
30
|
return {
|
|
@@ -32,13 +42,13 @@ export function semanticColors(anchor = '600') {
|
|
|
32
42
|
'muted-foreground': { light: n(600), dark: n(400) },
|
|
33
43
|
accent: { light: n(100), dark: n(800) },
|
|
34
44
|
'accent-foreground': { light: n(900), dark: n(50) },
|
|
35
|
-
destructive:
|
|
45
|
+
destructive: status('danger', anchors.danger),
|
|
36
46
|
'destructive-foreground': onDark,
|
|
37
|
-
success:
|
|
47
|
+
success: status('success', anchors.success),
|
|
38
48
|
'success-foreground': onDark,
|
|
39
|
-
warning:
|
|
49
|
+
warning: status('warning', anchors.warning),
|
|
40
50
|
'warning-foreground': onDark,
|
|
41
|
-
info:
|
|
51
|
+
info: status('info', anchors.info),
|
|
42
52
|
'info-foreground': onDark,
|
|
43
53
|
border: { light: n(200), dark: n(800) },
|
|
44
54
|
input: { light: n(200), dark: n(800) },
|
|
@@ -59,7 +69,9 @@ export function semanticColors(anchor = '600') {
|
|
|
59
69
|
};
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
|
|
72
|
+
function shift(step, by) {
|
|
73
|
+
return STEPS[Math.min(STEPS.length - 1, Math.max(0, STEPS.indexOf(step) + by))];
|
|
74
|
+
}
|
|
63
75
|
|
|
64
76
|
/**
|
|
65
77
|
* The colours a recipe's styles add (button, field), from the base colours after their contrast fix, so a moved
|
package/lib/theme/shadcn.mjs
CHANGED
|
@@ -247,7 +247,7 @@ export function shadcnTheme(theme, { name, title: label, description, fontsUrl =
|
|
|
247
247
|
$schema: 'https://ui.shadcn.com/schema/registry-item.json',
|
|
248
248
|
name: name ?? `halftone-${theme.code}`,
|
|
249
249
|
title: label ?? `Halftone theme ${theme.code}`,
|
|
250
|
-
description: description ?? `A Halftone theme (${theme.code}): colours for light and dark, radius, shadows and fonts. halftonedesignsystem.com/
|
|
250
|
+
description: description ?? `A Halftone theme (${theme.code}): colours for light and dark, radius, shadows and fonts. halftonedesignsystem.com/studio/?t=${theme.code}`,
|
|
251
251
|
type: 'registry:theme',
|
|
252
252
|
...(dependencies.length ? { registryDependencies: dependencies } : {}),
|
|
253
253
|
cssVars: {
|
package/lib/theme/vars.mjs
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* 'oklch' Tailwind 4 / shadcn web (`oklch(0.546 0.2152 262.88)`) — the web kit and the docs site
|
|
6
6
|
*/
|
|
7
7
|
import { hexToHslTriplet, hexToOklch } from '../color.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { componentVars } from './components.mjs';
|
|
9
|
+
import { extraList, extraTextStyles } from './extras.mjs';
|
|
10
|
+
import { resolveTextStyles, shadowCss, textEdits } from './recipe.mjs';
|
|
9
11
|
|
|
10
12
|
export const rem = (px) => (px >= 9999 ? '9999px' : `${px / 16}rem`);
|
|
11
13
|
|
|
@@ -20,10 +22,40 @@ export const typeVars = (type) => ({
|
|
|
20
22
|
'--typeset-measure': `${type.measure}ch`,
|
|
21
23
|
});
|
|
22
24
|
|
|
25
|
+
const fixed = (n, places = 4) => Number(n.toFixed(places));
|
|
26
|
+
|
|
27
|
+
/** A theme's text styles as drawn (recipe.mjs resolveTextStyles): its typeset, its edited styles on top. */
|
|
28
|
+
export const themeTextStyles = (theme) => resolveTextStyles(theme.type, textEdits(theme.semantic?.type));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The text styles' variables, --type-<style>-{size,leading,weight,tracking,font}, from resolveTextStyles. Literal
|
|
32
|
+
* values at the theme's typeset, in each kit's units:
|
|
33
|
+
* 'hsl' mobile (NativeWind): size and line height in rem (native line heights are lengths); the font is
|
|
34
|
+
* the role, which components/ui/text.tsx picks in JS, so no variable. Tracking is em on native too:
|
|
35
|
+
* NativeWind resolves it against the style's own size (its preset's tracking-* are fixed px instead)
|
|
36
|
+
* 'oklch' web: rem and a unitless line height; the font is the role's family variable. `font(role)` writes
|
|
37
|
+
* it (the build adds fallbacks; a live theme always sets --kit-font-*)
|
|
38
|
+
*/
|
|
39
|
+
export function textStyleVars(styles, format, font = (role) => `var(--kit-font-${role})`) {
|
|
40
|
+
const vars = {};
|
|
41
|
+
// Mobile rounds rem to 4 places, as nativewind3's text-* sizes always were.
|
|
42
|
+
const length = (px) => (format === 'hsl' ? `${fixed(px / 16)}rem` : rem(fixed(px)));
|
|
43
|
+
for (const [name, s] of Object.entries(styles)) {
|
|
44
|
+
const v = `--type-${name}`;
|
|
45
|
+
vars[`${v}-size`] = length(s.size);
|
|
46
|
+
vars[`${v}-leading`] = format === 'hsl' ? length(s.lineHeight ?? s.size * s.leading) : String(fixed(s.leading, 6));
|
|
47
|
+
vars[`${v}-weight`] = String(s.weight);
|
|
48
|
+
vars[`${v}-tracking`] = `${s.tracking}em`;
|
|
49
|
+
if (format !== 'hsl') vars[`${v}-font`] = font(s.font);
|
|
50
|
+
}
|
|
51
|
+
return vars;
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
/**
|
|
24
55
|
* The button and field styles' variables (both formats), from a theme's or a token file's { button, field }
|
|
25
|
-
* (generate.mjs, resolve.mjs). Widths
|
|
26
|
-
*
|
|
56
|
+
* (generate.mjs, resolve.mjs). Widths are numbers on the light block; the field's fill opacity is per mode. A
|
|
57
|
+
* field's corners and padding are the component layer's (componentVars: --field-radius, --field-radius-b,
|
|
58
|
+
* --field-x).
|
|
27
59
|
*/
|
|
28
60
|
export function styleVars({ button, field }, format) {
|
|
29
61
|
return {
|
|
@@ -34,9 +66,6 @@ export function styleVars({ button, field }, format) {
|
|
|
34
66
|
'--field-alpha': String(field.alpha.light),
|
|
35
67
|
'--field-border-w': `${field.border}px`,
|
|
36
68
|
'--field-edge-w': `${field.edge}px`,
|
|
37
|
-
'--field-radius': rem(field.radius),
|
|
38
|
-
'--field-radius-b': rem(field.radiusBottom),
|
|
39
|
-
'--field-x': field.inset ? (format === 'hsl' ? '1rem' : 'calc(var(--spacing) * 3)') : '0px',
|
|
40
69
|
// Web: a boxed field keeps shadcn's shadow-xs; a filled or underline field has none.
|
|
41
70
|
...(format === 'hsl' ? {} : { '--field-shadow': field.border ? 'var(--shadow-1)' : '0 0 #0000' }),
|
|
42
71
|
},
|
|
@@ -44,7 +73,29 @@ export function styleVars({ button, field }, format) {
|
|
|
44
73
|
};
|
|
45
74
|
}
|
|
46
75
|
|
|
47
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* A system's extras as variables (lib/theme/extras.mjs): --<colour> (a ramp's --<name>-<step>), --radius-<name>,
|
|
78
|
+
* --shadow-<name> per mode, --border-<name>-width and --type-<name>-*. `font` as textStyleVars takes it.
|
|
79
|
+
*/
|
|
80
|
+
export function extraVars(list, format, font) {
|
|
81
|
+
const convert = format === 'hsl' ? hexToHslTriplet : hexToOklch;
|
|
82
|
+
const light = {};
|
|
83
|
+
const dark = {};
|
|
84
|
+
for (const c of list.color) {
|
|
85
|
+
light[`--${c.name}`] = convert(c.light);
|
|
86
|
+
dark[`--${c.name}`] = convert(c.dark);
|
|
87
|
+
}
|
|
88
|
+
for (const r of list.radius) light[`--radius-${r.name}`] = rem(r.px);
|
|
89
|
+
for (const s of list.shadow) {
|
|
90
|
+
light[`--shadow-${s.name}`] = shadowCss(s.light);
|
|
91
|
+
dark[`--shadow-${s.name}`] = shadowCss(s.dark);
|
|
92
|
+
}
|
|
93
|
+
for (const b of list.border) light[`--border-${b.name}-width`] = `${b.width}px`;
|
|
94
|
+
Object.assign(light, textStyleVars(extraTextStyles(list), format, font));
|
|
95
|
+
return { light, dark };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** { light: { '--primary': '…', '--radius': '0.625rem' }, dark: { … } } (a system's extras too) */
|
|
48
99
|
export function themeVars(theme, format) {
|
|
49
100
|
const convert = format === 'hsl' ? hexToHslTriplet : hexToOklch;
|
|
50
101
|
const mode = (m) => {
|
|
@@ -56,10 +107,12 @@ export function themeVars(theme, format) {
|
|
|
56
107
|
vars['--icon-stroke'] = String(theme.stroke);
|
|
57
108
|
vars['--density'] = String(theme.density.scale);
|
|
58
109
|
for (const size of ['sm', 'md', 'lg', 'x']) vars[`--control-${size}`] = `${theme.density[size]}px`;
|
|
59
|
-
Object.assign(vars, typeVars(theme.type));
|
|
110
|
+
Object.assign(vars, typeVars(theme.type), textStyleVars(themeTextStyles(theme), format));
|
|
60
111
|
}
|
|
61
112
|
Object.assign(vars, styleVars(theme, format)[m]);
|
|
113
|
+
if (theme.components) Object.assign(vars, componentVars(theme.components, format, convert)[m]);
|
|
62
114
|
for (const [level, value] of Object.entries(theme.semantic.shadow)) vars[`--shadow-${level}`] = shadowCss(value[m]);
|
|
115
|
+
if (theme.extra) Object.assign(vars, extraVars(extraList(theme.extra), format)[m]);
|
|
63
116
|
return vars;
|
|
64
117
|
};
|
|
65
118
|
return { light: mode('light'), dark: mode('dark') };
|
package/lib/theme-cli.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* `halftone-tokens theme …` — commit a theme from the docs picker into tokens.json.
|
|
3
3
|
*
|
|
4
4
|
* theme apply <theme> [--dry-run] [--force]
|
|
5
|
-
* write the theme into tokens.json (only the paths a theme owns) and rebuild
|
|
5
|
+
* write the theme into tokens.json (only the paths a theme owns) and rebuild; a system document is
|
|
6
|
+
* recorded in tokens.json's $extensions (without its brand images)
|
|
6
7
|
* theme show [--check] the applied theme, its code and any hand edits since (drift)
|
|
7
8
|
* --check: exit 1 if the recorded theme no longer reproduces (CI)
|
|
8
9
|
* theme presets list the presets and their codes
|
|
@@ -12,19 +13,22 @@
|
|
|
12
13
|
* theme shadcn <theme> [--out file] [--fonts-url url]
|
|
13
14
|
* the theme as a shadcn registry:theme item (stdout, or a file), for `npx shadcn add`
|
|
14
15
|
*
|
|
15
|
-
* <theme> is a Halftone code (ht1-…, ht2-…, ht3-…), a preset name, a recipe .json,
|
|
16
|
-
* (ui.shadcn.com/create, e.g. b5owWMfJ8l),
|
|
17
|
-
* or drops is listed on stderr.
|
|
16
|
+
* <theme> is a Halftone code (ht1-…, ht2-…, ht3-…), a preset name, a recipe .json, a system document
|
|
17
|
+
* (name.halftone.json, from the Studio), or a shadcn/ui preset code (ui.shadcn.com/create, e.g. b5owWMfJ8l),
|
|
18
|
+
* which becomes the nearest Halftone recipe: what it approximates or drops is listed on stderr.
|
|
18
19
|
*/
|
|
19
20
|
import { spawnSync } from 'node:child_process';
|
|
20
21
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
-
import { join } from 'node:path';
|
|
22
|
+
import { join, resolve } from 'node:path';
|
|
22
23
|
|
|
23
24
|
import { cvdWarnings, describeCvdWarnings, MIN_DELTA_E } from './cvd.mjs';
|
|
24
|
-
import { FONTS } from './theme/fonts.mjs';
|
|
25
|
+
import { FONTS, fontByFamily } from './theme/fonts.mjs';
|
|
26
|
+
import { isSystem, normalizeSystem, SystemError, systemOverrides } from './system/schema.mjs';
|
|
27
|
+
import { systemCode } from './system/generate.mjs';
|
|
25
28
|
|
|
26
29
|
import {
|
|
27
30
|
applyRecipe,
|
|
31
|
+
applySystem,
|
|
28
32
|
decodeRecipe,
|
|
29
33
|
encodeRecipe,
|
|
30
34
|
fontById,
|
|
@@ -37,6 +41,7 @@ import {
|
|
|
37
41
|
recipeFromShadcn,
|
|
38
42
|
SHADCN_FONTS_URL,
|
|
39
43
|
shadcnTheme,
|
|
44
|
+
THEME_EXTENSION,
|
|
40
45
|
themeStatus,
|
|
41
46
|
} from './theme/index.mjs';
|
|
42
47
|
|
|
@@ -51,8 +56,55 @@ function printNotes(code, notes) {
|
|
|
51
56
|
console.error(`From shadcn preset ${code}, approximated or not carried:\n${notes.map((n) => ` - ${n}`).join('\n')}`);
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
/** { recipe, system? }: a system document when <theme> is one (name.halftone.json), else a recipe. */
|
|
60
|
+
function readTheme(arg, root) {
|
|
61
|
+
// resolve: an absolute path stays itself (join would put it under root).
|
|
62
|
+
const file = arg ? resolve(root, arg) : '';
|
|
63
|
+
if (arg?.endsWith('.json') && existsSync(file)) {
|
|
64
|
+
let json;
|
|
65
|
+
try {
|
|
66
|
+
json = JSON.parse(readFileSync(file, 'utf8'));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
fail(`${arg} is not valid JSON: ${error.message}`);
|
|
69
|
+
}
|
|
70
|
+
if (isSystem(json)) {
|
|
71
|
+
try {
|
|
72
|
+
const system = normalizeSystem(json);
|
|
73
|
+
return { recipe: system.recipe, system };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof SystemError) fail(`${arg}: ${error.message}`);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { recipe: readRecipe(arg, root) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Why a system has no code: its overrides, briefly. */
|
|
84
|
+
function noCode(system) {
|
|
85
|
+
const overrides = systemOverrides(system);
|
|
86
|
+
const shown = overrides.slice(0, 6).join(', ') + (overrides.length > 6 ? `, and ${overrides.length - 6} more` : '');
|
|
87
|
+
return `"${system.name}" has ${overrides.length} override(s) a theme code can't carry (${shown}). Share its .halftone.json instead.`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A system's overrides, grouped by section, for `show` and `apply`. */
|
|
91
|
+
function describeOverrides(system) {
|
|
92
|
+
const bySection = {};
|
|
93
|
+
for (const path of systemOverrides(system)) {
|
|
94
|
+
const [section, ...rest] = path.split('.');
|
|
95
|
+
(bySection[section] ??= []).push(rest.join('.'));
|
|
96
|
+
}
|
|
97
|
+
return Object.entries(bySection).map(([section, list]) => `${section}: ${list.join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function printWarnings(warnings) {
|
|
101
|
+
if (!warnings?.length) return;
|
|
102
|
+
console.log(`\nBelow WCAG AA because of colours chosen by hand (warnings; the fixer leaves hand-picked colours alone):`);
|
|
103
|
+
for (const w of warnings) console.log(` ${w.mode.padEnd(5)} ${w.label.padEnd(40)} ${w.ratio}:1 (needs ${w.threshold})${w.locked.length ? ` · chosen: ${w.locked.join(', ')}` : ''}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
54
106
|
function readRecipe(arg, root) {
|
|
55
|
-
if (!arg) fail('say which theme: a code (ht1-…, ht2-… or ht3-…), a preset name, a recipe .json or a shadcn preset code', 2);
|
|
107
|
+
if (!arg) fail('say which theme: a code (ht1-…, ht2-… or ht3-…), a preset name, a recipe .json, a system .halftone.json or a shadcn preset code', 2);
|
|
56
108
|
if (isThemeCode(arg)) {
|
|
57
109
|
try {
|
|
58
110
|
return decodeRecipe(arg);
|
|
@@ -62,7 +114,7 @@ function readRecipe(arg, root) {
|
|
|
62
114
|
}
|
|
63
115
|
const preset = presetById(arg.toLowerCase());
|
|
64
116
|
if (preset) return normalizeRecipe({ ...preset.recipe, preset: preset.id });
|
|
65
|
-
const file =
|
|
117
|
+
const file = resolve(root, arg);
|
|
66
118
|
if (arg.endsWith('.json') && existsSync(file)) return normalizeRecipe(JSON.parse(readFileSync(file, 'utf8')));
|
|
67
119
|
// Last, so a preset name that happens to look like one (bold, brutalist) stays Halftone's preset.
|
|
68
120
|
const shadcn = isShadcnPresetCode(arg) ? decodeShadcnPreset(arg) : null;
|
|
@@ -98,7 +150,8 @@ function installFonts(root, config, theme, flags) {
|
|
|
98
150
|
if (config.targets?.fonts?.platform !== 'expo') return;
|
|
99
151
|
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
100
152
|
const have = new Set(Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((d) => d.startsWith('@expo-google-fonts/')));
|
|
101
|
-
|
|
153
|
+
// The theme's families: curated ones, or any Google Fonts family a system names.
|
|
154
|
+
const ids = Object.values(theme.fonts).filter(Boolean).map(fontByFamily).filter((f) => !f.system).map((f) => f.id);
|
|
102
155
|
const need = [...new Set(ids)].map((id) => `@expo-google-fonts/${id}`);
|
|
103
156
|
const missing = need.filter((p) => !have.has(p));
|
|
104
157
|
const unused = [...have].filter((p) => !need.includes(p) && FONTS.some((f) => `@expo-google-fonts/${f.id}` === p));
|
|
@@ -124,7 +177,9 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
124
177
|
}
|
|
125
178
|
|
|
126
179
|
if (sub === 'encode') {
|
|
127
|
-
|
|
180
|
+
const { recipe, system } = readTheme(arg, root);
|
|
181
|
+
if (system && !systemCode(system)) fail(noCode(system));
|
|
182
|
+
console.log(encodeRecipe(recipe));
|
|
128
183
|
return;
|
|
129
184
|
}
|
|
130
185
|
|
|
@@ -166,6 +221,7 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
166
221
|
if (sub === 'resolve') {
|
|
167
222
|
const status = themeStatus(tokens());
|
|
168
223
|
if (!status.applied) fail('no theme recorded in tokens.json, so there is no code for it. Apply one with `halftone-tokens theme apply <theme>`.');
|
|
224
|
+
if (!status.code) fail(`the applied system ${noCode(status.system)} Its document is recorded in tokens.json ($extensions).`);
|
|
169
225
|
if (status.drift.length) fail(`tokens.json was edited by hand since ${status.code} was applied (${status.drift.length} token(s)); no code reproduces it. \`theme show\` lists them.`);
|
|
170
226
|
if (flags.json) console.log(JSON.stringify({ code: status.code, recipe: status.recipe }, null, 2));
|
|
171
227
|
else console.log(status.code);
|
|
@@ -173,8 +229,8 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
173
229
|
}
|
|
174
230
|
|
|
175
231
|
if (sub === 'shadcn') {
|
|
176
|
-
const recipe =
|
|
177
|
-
const { theme } = applyRecipe(tokens(), recipe);
|
|
232
|
+
const { recipe, system } = readTheme(arg, root);
|
|
233
|
+
const { theme } = system ? applySystem(tokens(), system) : applyRecipe(tokens(), recipe);
|
|
178
234
|
const item = shadcnTheme(theme, {
|
|
179
235
|
...(recipe.preset ? { name: `halftone-${recipe.preset}`, title: `Halftone ${presetById(recipe.preset)?.name} theme` } : {}),
|
|
180
236
|
fontsUrl: typeof flags['fonts-url'] === 'string' ? flags['fonts-url'] : SHADCN_FONTS_URL,
|
|
@@ -194,8 +250,16 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
194
250
|
if (flags.check) process.exit(0);
|
|
195
251
|
return;
|
|
196
252
|
}
|
|
197
|
-
|
|
198
|
-
|
|
253
|
+
if (status.system) {
|
|
254
|
+
const { system } = status;
|
|
255
|
+
const overrides = describeOverrides(system);
|
|
256
|
+
console.log(`System "${system.name}"${status.code ? ` ${status.code}` : ' (no theme code: it has overrides)'}\n ${describe(status.recipe)}`);
|
|
257
|
+
if (overrides.length) console.log(`\nOverrides:\n${overrides.map((o) => ` ${o}`).join('\n')}`);
|
|
258
|
+
printWarnings(tokens().$extensions?.[THEME_EXTENSION]?.warnings);
|
|
259
|
+
} else {
|
|
260
|
+
const name = status.recipe.preset ? ` (${presetById(status.recipe.preset)?.name ?? status.recipe.preset})` : '';
|
|
261
|
+
console.log(`Theme ${status.code}${name}\n ${describe(status.recipe)}`);
|
|
262
|
+
}
|
|
199
263
|
if (status.drift.length) {
|
|
200
264
|
console.log(`\nEdited by hand since it was applied (${status.drift.length}):\n${status.drift.map((p) => ` ${p}`).join('\n')}`);
|
|
201
265
|
if (flags.check) fail('tokens.json no longer matches its recorded theme. Re-apply it, or apply with --force to record the edits away.');
|
|
@@ -204,7 +268,7 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
204
268
|
}
|
|
205
269
|
|
|
206
270
|
if (sub === 'apply') {
|
|
207
|
-
const recipe =
|
|
271
|
+
const { recipe, system } = readTheme(arg, root);
|
|
208
272
|
const current = tokens();
|
|
209
273
|
const status = themeStatus(current);
|
|
210
274
|
const blocked = !status.applied
|
|
@@ -214,12 +278,18 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
214
278
|
? `these theme tokens were edited by hand since ${status.code} was applied:\n${status.drift.map((p) => ` ${p}`).join('\n')}\nRe-run with --force to overwrite them.`
|
|
215
279
|
: null;
|
|
216
280
|
if (blocked && !flags.force && !flags['dry-run']) fail(blocked);
|
|
217
|
-
const { tokens: next, theme } = applyRecipe(current, recipe);
|
|
218
|
-
|
|
281
|
+
const { tokens: next, theme } = system ? applySystem(current, system) : applyRecipe(current, recipe);
|
|
282
|
+
if (system) {
|
|
283
|
+
const code = systemCode(system);
|
|
284
|
+
const overrides = describeOverrides(system);
|
|
285
|
+
console.log(`System "${system.name}"${code ? ` ${code}` : ''}\n ${describe(theme.recipe)}`);
|
|
286
|
+
if (overrides.length) console.log(`\nOverrides (no theme code carries these; tokens.json records the document):\n${overrides.map((o) => ` ${o}`).join('\n')}`);
|
|
287
|
+
} else console.log(`Theme ${theme.code}${recipe.preset ? ` (${presetById(recipe.preset)?.name})` : ''}\n ${describe(theme.recipe)}`);
|
|
219
288
|
if (theme.adjustments.length) {
|
|
220
289
|
console.log(`\nAdjusted for contrast (WCAG AA), hue kept:`);
|
|
221
290
|
for (const a of theme.adjustments) console.log(` ${a.mode.padEnd(5)} ${a.token.padEnd(28)} ${a.from} → ${a.to}`);
|
|
222
291
|
}
|
|
292
|
+
printWarnings(theme.warnings);
|
|
223
293
|
const cvd = cvdWarnings(theme.resolved);
|
|
224
294
|
if (cvd.length) {
|
|
225
295
|
console.log(`\nColour vision: ${cvd.length} pair(s) of tones closer than ΔE ${MIN_DELTA_E} (a warning; give them an icon or a label as well):`);
|
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"
|