@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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting points by personality. Each is a full recipe, typeset included; the picker starts from one and tweaks.
|
|
3
|
+
* Drafts — review them in the docs studio (/themes) and adjust here. Order is the gallery order.
|
|
4
|
+
*/
|
|
5
|
+
export const PRESETS = [
|
|
6
|
+
{
|
|
7
|
+
id: 'default',
|
|
8
|
+
name: 'Default',
|
|
9
|
+
description: 'The kit as it ships: system type, blue, soft cards.',
|
|
10
|
+
recipe: { brand: '#2563eb', neutral: 'zinc', radius: 'lg', controls: 'pill', font: { heading: 'system', body: 'system', mono: 'system-mono' }, stroke: 'regular', depth: 'soft', density: 'comfortable', border: 'regular', size: '16', leading: 'normal', flow: 'normal', measure: '70' },
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: 'editorial',
|
|
14
|
+
name: 'Editorial',
|
|
15
|
+
description: 'Serif headlines, rust ink, hairlines and almost no rounding.',
|
|
16
|
+
recipe: { brand: '#9a3412', neutral: 'stone', radius: 'sm', controls: 'match', font: { heading: 'fraunces', body: 'inter', mono: 'ibm-plex-mono' }, stroke: 'thin', depth: 'flat', density: 'comfortable', border: 'hairline', size: '18', leading: 'relaxed', flow: 'normal', measure: '70' },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'soft',
|
|
20
|
+
name: 'Soft',
|
|
21
|
+
description: 'Lavender, generous radius, brand-tinted greys and room to breathe.',
|
|
22
|
+
recipe: { brand: '#8b5cf6', neutral: 'brand', radius: '2xl', controls: 'pill', font: { heading: 'plus-jakarta-sans', body: 'plus-jakarta-sans', mono: 'geist-mono' }, stroke: 'regular', depth: 'soft', density: 'spacious', border: 'hairline', size: '16', leading: 'relaxed', flow: 'loose', measure: '60' },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'playful',
|
|
26
|
+
name: 'Playful',
|
|
27
|
+
description: 'Pink, chunky icons, round display type and lifted cards.',
|
|
28
|
+
recipe: { brand: '#db2777', neutral: 'neutral', radius: 'xl', controls: 'pill', font: { heading: 'bricolage-grotesque', body: 'nunito', mono: 'fira-code' }, stroke: 'bold', depth: 'raised', density: 'comfortable', border: 'regular', size: '16', leading: 'relaxed', flow: 'normal', measure: '60' },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'brutalist',
|
|
32
|
+
name: 'Brutalist',
|
|
33
|
+
description: 'Square corners, heavy borders, hard offset shadows, grotesk type.',
|
|
34
|
+
recipe: { brand: '#e11d48', neutral: 'neutral', radius: 'none', controls: 'match', font: { heading: 'space-grotesk', body: 'space-grotesk', mono: 'jetbrains-mono' }, stroke: 'bold', depth: 'hard', density: 'comfortable', border: 'heavy', size: '15', leading: 'tight', flow: 'tight', measure: '80' },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'mono',
|
|
38
|
+
name: 'Mono',
|
|
39
|
+
description: 'Black and white, Geist, flat and compact: a tool, not a toy.',
|
|
40
|
+
recipe: { brand: '#18181b', neutral: 'zinc', radius: 'md', controls: 'match', font: { heading: 'geist', body: 'geist', mono: 'geist-mono' }, stroke: 'regular', depth: 'flat', density: 'compact', border: 'regular', size: '14', leading: 'tight', flow: 'tight', measure: '80' },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'bold',
|
|
44
|
+
name: 'Bold',
|
|
45
|
+
description: 'Electric violet, heavy icons, big controls and raised surfaces.',
|
|
46
|
+
recipe: { brand: '#7c3aed', neutral: 'slate', radius: 'xl', controls: 'pill', font: { heading: 'outfit', body: 'inter', mono: 'jetbrains-mono' }, stroke: 'bold', depth: 'raised', density: 'spacious', border: 'regular', size: '18', leading: 'normal', flow: 'normal', measure: '60' },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'calm',
|
|
50
|
+
name: 'Calm',
|
|
51
|
+
description: 'Teal on warm greys, a book serif for headings, thin lines, no shadows.',
|
|
52
|
+
recipe: { brand: '#0d9488', neutral: 'stone', radius: 'xl', controls: 'match', font: { heading: 'lora', body: 'manrope', mono: 'source-code-pro' }, stroke: 'thin', depth: 'flat', density: 'spacious', border: 'hairline', size: '16', leading: 'relaxed', flow: 'loose', measure: '70' },
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export const presetById = (id) => PRESETS.find((p) => p.id === id);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour ramps from a seed. A seed that is a curated ramp's 600 step returns that ramp untouched;
|
|
3
|
+
* anything else is generated in OKLCH:
|
|
4
|
+
* - lightness per step follows the average of Tailwind's chromatic ramps (LIGHTNESS below)
|
|
5
|
+
* - the seed lands EXACTLY on the step nearest its lightness (the anchor), and the steps around it
|
|
6
|
+
* bend smoothly towards it, so the ramp stays monotonic
|
|
7
|
+
* - chroma follows Tailwind's relative chroma profile, scaled so the anchor keeps the seed's chroma
|
|
8
|
+
* - hue stays the seed's; a step outside sRGB loses chroma until it fits (never clipped)
|
|
9
|
+
*/
|
|
10
|
+
import { hexToOklchParts, inGamut, oklchPartsToHex } from '../color.mjs';
|
|
11
|
+
import { BRAND_RAMPS, NEUTRAL_RAMPS, STEPS } from './palettes.mjs';
|
|
12
|
+
|
|
13
|
+
const LIGHTNESS = { 50: 0.977, 100: 0.95, 200: 0.906, 300: 0.84, 400: 0.761, 500: 0.683, 600: 0.598, 700: 0.515, 800: 0.446, 900: 0.395, 950: 0.278 };
|
|
14
|
+
const CHROMA = { 50: 0.094, 100: 0.222, 200: 0.418, 300: 0.653, 400: 0.855, 500: 0.952, 600: 0.934, 700: 0.83, 800: 0.696, 900: 0.58, 950: 0.418 };
|
|
15
|
+
|
|
16
|
+
/** Map an OKLCH colour into sRGB by lowering chroma. */
|
|
17
|
+
export function toGamutHex([L, C, H]) {
|
|
18
|
+
let lo = 0;
|
|
19
|
+
let hi = C;
|
|
20
|
+
if (inGamut([L, C, H])) return oklchPartsToHex([L, C, H]);
|
|
21
|
+
for (let i = 0; i < 24; i++) {
|
|
22
|
+
const mid = (lo + hi) / 2;
|
|
23
|
+
if (inGamut([L, mid, H])) lo = mid;
|
|
24
|
+
else hi = mid;
|
|
25
|
+
}
|
|
26
|
+
return oklchPartsToHex([L, lo, H]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The curated ramp whose 600 step is this seed, if any. */
|
|
30
|
+
export function curatedRamp(seed) {
|
|
31
|
+
const hex = seed.toLowerCase();
|
|
32
|
+
return Object.entries(BRAND_RAMPS).find(([, ramp]) => ramp[600] === hex)?.[0] ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The step a seed sits on: its curated 600, or the generated step nearest its lightness. */
|
|
36
|
+
export function anchorStep(seed) {
|
|
37
|
+
if (curatedRamp(seed)) return '600';
|
|
38
|
+
const [L] = hexToOklchParts(seed);
|
|
39
|
+
return STEPS.reduce((best, s) => (Math.abs(LIGHTNESS[s] - L) < Math.abs(LIGHTNESS[best] - L) ? s : best), '600');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** { 50: '#…', …, 950: '#…' } for a brand seed. */
|
|
43
|
+
export function brandRamp(seed) {
|
|
44
|
+
const curated = curatedRamp(seed);
|
|
45
|
+
if (curated) return { ...BRAND_RAMPS[curated] };
|
|
46
|
+
|
|
47
|
+
const [Ls, Cs, Hs] = hexToOklchParts(seed);
|
|
48
|
+
const anchor = anchorStep(seed);
|
|
49
|
+
const ai = STEPS.indexOf(anchor);
|
|
50
|
+
const dL = Ls - LIGHTNESS[anchor];
|
|
51
|
+
const chromaScale = Cs / CHROMA[anchor];
|
|
52
|
+
const ramp = {};
|
|
53
|
+
STEPS.forEach((s, i) => {
|
|
54
|
+
if (s === anchor) {
|
|
55
|
+
ramp[s] = seed.toLowerCase();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// The seed's offset from the target lightness fades out over three steps either side, which keeps
|
|
59
|
+
// neighbours in order (|dL| is at most half the gap to the next step).
|
|
60
|
+
const fade = Math.max(0, 1 - Math.abs(i - ai) / 3);
|
|
61
|
+
const L = LIGHTNESS[s] + dL * fade;
|
|
62
|
+
ramp[s] = toGamutHex([L, CHROMA[s] * chromaScale, Hs]);
|
|
63
|
+
});
|
|
64
|
+
return ramp;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TINT_CHROMA = { 50: 0.003, 100: 0.005, 200: 0.008, 300: 0.012, 400: 0.02, 500: 0.024, 600: 0.022, 700: 0.02, 800: 0.016, 900: 0.014, 950: 0.012 };
|
|
68
|
+
|
|
69
|
+
/** The 13-step neutral: 0 (white), the 11 steps, 1000 (black). */
|
|
70
|
+
export function neutralRamp(kind, seed) {
|
|
71
|
+
if (kind !== 'brand') return { 0: '#ffffff', ...NEUTRAL_RAMPS[kind], 1000: '#000000' };
|
|
72
|
+
// Brand-tinted: zinc's lightness, a whisper of the brand hue (more in the mid steps, as slate does).
|
|
73
|
+
const [, Cs, Hs] = hexToOklchParts(seed);
|
|
74
|
+
const strength = Math.min(1, Cs / 0.12);
|
|
75
|
+
const ramp = { 0: '#ffffff' };
|
|
76
|
+
for (const s of STEPS) {
|
|
77
|
+
const [L] = hexToOklchParts(NEUTRAL_RAMPS.zinc[s]);
|
|
78
|
+
ramp[s] = toGamutHex([L, TINT_CHROMA[s] * strength, Hs]);
|
|
79
|
+
}
|
|
80
|
+
ramp[1000] = '#000000';
|
|
81
|
+
return ramp;
|
|
82
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A theme RECIPE: the handful of choices a theme is made from. The generator (index.mjs) expands it
|
|
3
|
+
* into primitives, semantic tokens and CSS variables; the codec (codec.mjs) packs it into a short code.
|
|
4
|
+
*
|
|
5
|
+
* Every option list is APPEND-ONLY: codes store an option's index. Add new options at the end.
|
|
6
|
+
*
|
|
7
|
+
* The typeset (font.mono, size, leading, flow, measure) came later. A recipe without it gets the defaults,
|
|
8
|
+
* which are what the kits always had, and a theme with the default typeset still encodes as a ht1 code.
|
|
9
|
+
*/
|
|
10
|
+
import { FONTS } from './fonts.mjs';
|
|
11
|
+
|
|
12
|
+
export const RECIPE_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
export const OPTIONS = {
|
|
15
|
+
/** The neutral ramp: four curated greys plus one tinted with the brand hue. */
|
|
16
|
+
neutral: ['zinc', 'neutral', 'slate', 'stone', 'gray', 'brand'],
|
|
17
|
+
/** Base corner radius (px). Tailwind's rounded-sm…2xl are offsets from it. */
|
|
18
|
+
radius: ['none', 'sm', 'md', 'lg', 'xl', '2xl'],
|
|
19
|
+
/** Controls (buttons, inputs, chips) follow the radius, or are pills. */
|
|
20
|
+
controls: ['pill', 'match'],
|
|
21
|
+
/** Lucide stroke width. */
|
|
22
|
+
stroke: ['regular', 'thin', 'bold'],
|
|
23
|
+
/** Elevation of cards, sheets and menus. */
|
|
24
|
+
depth: ['soft', 'flat', 'raised', 'hard'],
|
|
25
|
+
/** Control heights and padding. */
|
|
26
|
+
density: ['comfortable', 'compact', 'spacious'],
|
|
27
|
+
/** Border width of cards, inputs and outlines. */
|
|
28
|
+
border: ['regular', 'hairline', 'heavy'],
|
|
29
|
+
/** Typeset: the body text size (px). Every text size scales with it (text-xs…4xl). */
|
|
30
|
+
size: ['16', '14', '15', '18'],
|
|
31
|
+
/** Typeset: line height of running text; the text sizes' own line heights scale with it. */
|
|
32
|
+
leading: ['normal', 'tight', 'relaxed'],
|
|
33
|
+
/** Typeset: the space between blocks of long-form text (Prose). */
|
|
34
|
+
flow: ['normal', 'tight', 'loose'],
|
|
35
|
+
/** Typeset: the longest line of long-form text (Prose), in characters. */
|
|
36
|
+
measure: ['70', '60', '80', '90'],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The recipe keys that make up the typeset, besides font.mono. */
|
|
40
|
+
export const TYPESET_KEYS = ['size', 'leading', 'flow', 'measure'];
|
|
41
|
+
|
|
42
|
+
/** Base radius per option: a primitive radius step (a reference) or 0. */
|
|
43
|
+
export const RADIUS = { none: 0, sm: 'sm', md: 'md', lg: 'lg', xl: 'xl', '2xl': '2xl' };
|
|
44
|
+
export const STROKE = { thin: 1.5, regular: 2, bold: 2.5 };
|
|
45
|
+
/** px; hairline renders as one device pixel on a 2x screen. */
|
|
46
|
+
export const BORDER = { hairline: 0.5, regular: 1, heavy: 2 };
|
|
47
|
+
/** Line height of running text (× size). 1.75 = leading-7 at 16px, what the kits' paragraphs always had. */
|
|
48
|
+
export const LEADING = { tight: 1.6, normal: 1.75, relaxed: 1.9 };
|
|
49
|
+
/** Space between blocks of long-form text, in em. */
|
|
50
|
+
export const FLOW = { tight: 1, normal: 1.25, loose: 2 };
|
|
51
|
+
/**
|
|
52
|
+
* Line height (px) of each text size at 16px: Tailwind's defaults (3 and 4 agree). The typeset scales both;
|
|
53
|
+
* text-5xl and up keep Tailwind's (line height 1).
|
|
54
|
+
*/
|
|
55
|
+
export const TEXT_LINE_HEIGHT = { xs: 16, sm: 20, base: 24, lg: 28, xl: 28, '2xl': 32, '3xl': 36, '4xl': 40 };
|
|
56
|
+
|
|
57
|
+
/** The default typeset as tokens (semantic.type), for a token file from before the typeset. */
|
|
58
|
+
export const TYPE = { size: 16, leading: LEADING.normal, flow: FLOW.normal, measure: 70 };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The typeset as the numbers the targets use: every text size scales by size / 16, every text size's line
|
|
62
|
+
* height by leading / 1.75 (so the default typeset is exactly Tailwind's scale), and long-form text (Prose)
|
|
63
|
+
* gets the leading, flow (em) and measure (ch) as they are.
|
|
64
|
+
*/
|
|
65
|
+
export const typeScale = ({ size, leading, flow, measure }) => ({
|
|
66
|
+
size,
|
|
67
|
+
scale: Number((size / 16).toFixed(4)),
|
|
68
|
+
leading,
|
|
69
|
+
leadingFactor: Number((leading / 1.75).toFixed(4)),
|
|
70
|
+
flow,
|
|
71
|
+
measure,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
const s = (x, y, blur, spread, color) => ({ offsetX: x, offsetY: y, blur, spread, color });
|
|
76
|
+
/**
|
|
77
|
+
* Three elevation levels per depth (DTCG shadow values, light and dark): 1 for controls and cards,
|
|
78
|
+
* 2 for raised cards and popovers, 3 for menus, dialogs and floating buttons. An empty list is no shadow.
|
|
79
|
+
* Flat keeps a faint level 3 so a menu still separates from the page.
|
|
80
|
+
*/
|
|
81
|
+
export const DEPTH = {
|
|
82
|
+
flat: {
|
|
83
|
+
1: { light: [], dark: [] },
|
|
84
|
+
2: { light: [], dark: [] },
|
|
85
|
+
3: { light: [s(0, 4, 12, 0, '#0000001a')], dark: [s(0, 4, 12, 0, '#00000066')] },
|
|
86
|
+
},
|
|
87
|
+
soft: {
|
|
88
|
+
1: { light: [s(0, 1, 2, 0, '#0000000d')], dark: [s(0, 1, 2, 0, '#0000004d')] },
|
|
89
|
+
2: { light: [s(0, 4, 12, -2, '#00000014')], dark: [s(0, 4, 12, -2, '#00000066')] },
|
|
90
|
+
3: { light: [s(0, 12, 24, -6, '#0000001f')], dark: [s(0, 12, 24, -6, '#00000080')] },
|
|
91
|
+
},
|
|
92
|
+
raised: {
|
|
93
|
+
1: { light: [s(0, 2, 8, -2, '#0000001f')], dark: [s(0, 2, 8, -2, '#00000066')] },
|
|
94
|
+
2: { light: [s(0, 8, 20, -6, '#0000002e')], dark: [s(0, 8, 20, -6, '#00000080')] },
|
|
95
|
+
3: { light: [s(0, 20, 40, -10, '#00000040')], dark: [s(0, 20, 40, -10, '#00000099')] },
|
|
96
|
+
},
|
|
97
|
+
hard: {
|
|
98
|
+
1: { light: [s(2, 2, 0, 0, '#09090b')], dark: [s(2, 2, 0, 0, '#fafafa')] },
|
|
99
|
+
2: { light: [s(3, 3, 0, 0, '#09090b')], dark: [s(3, 3, 0, 0, '#fafafa')] },
|
|
100
|
+
3: { light: [s(5, 5, 0, 0, '#09090b')], dark: [s(5, 5, 0, 0, '#fafafa')] },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** A DTCG shadow list as CSS. */
|
|
105
|
+
export const shadowCss = (list) =>
|
|
106
|
+
list.length ? list.map((x) => `${x.offsetX}px ${x.offsetY}px ${x.blur}px ${x.spread}px ${hexAlphaToRgba(x.color)}`).join(', ') : '0 0 #0000';
|
|
107
|
+
|
|
108
|
+
function hexAlphaToRgba(hex) {
|
|
109
|
+
const n = parseInt(hex.slice(1, 7), 16);
|
|
110
|
+
const a = hex.length === 9 ? parseInt(hex.slice(7), 16) / 255 : 1;
|
|
111
|
+
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${Number(a.toFixed(3))})`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Density: control heights (sm / md / lg) and horizontal padding on mobile, and a scale for the web kit's
|
|
116
|
+
* whole spacing scale (Tailwind 4's --spacing, the way Radix Themes' `scaling` works).
|
|
117
|
+
*/
|
|
118
|
+
export const DENSITY = {
|
|
119
|
+
compact: { sm: 36, md: 44, lg: 52, x: 16, scale: 0.875 },
|
|
120
|
+
comfortable: { sm: 40, md: 48, lg: 56, x: 20, scale: 1 },
|
|
121
|
+
spacious: { sm: 44, md: 52, lg: 60, x: 24, scale: 1.125 },
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const DEFAULT_RECIPE = Object.freeze({
|
|
125
|
+
v: RECIPE_VERSION,
|
|
126
|
+
brand: '#2563eb',
|
|
127
|
+
neutral: 'zinc',
|
|
128
|
+
radius: 'lg',
|
|
129
|
+
controls: 'pill',
|
|
130
|
+
font: Object.freeze({ heading: 'system', body: 'system', mono: 'system-mono' }),
|
|
131
|
+
stroke: 'regular',
|
|
132
|
+
depth: 'soft',
|
|
133
|
+
density: 'comfortable',
|
|
134
|
+
border: 'regular',
|
|
135
|
+
size: '16',
|
|
136
|
+
leading: 'normal',
|
|
137
|
+
flow: 'normal',
|
|
138
|
+
measure: '70',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const HEX = /^#[0-9a-f]{6}$/;
|
|
142
|
+
|
|
143
|
+
/** Does the recipe use the default typeset (what every ht1 code means)? */
|
|
144
|
+
export const defaultTypeset = (r) => r.font.mono === DEFAULT_RECIPE.font.mono && TYPESET_KEYS.every((k) => r[k] === DEFAULT_RECIPE[k]);
|
|
145
|
+
|
|
146
|
+
/** Fill in defaults, normalise, and throw on anything invalid. Returns a new, plain recipe. */
|
|
147
|
+
export function normalizeRecipe(input = {}) {
|
|
148
|
+
const r = { ...DEFAULT_RECIPE, ...input, font: { ...DEFAULT_RECIPE.font, ...input.font } };
|
|
149
|
+
r.v = RECIPE_VERSION;
|
|
150
|
+
r.brand = String(r.brand).toLowerCase();
|
|
151
|
+
if (!HEX.test(r.brand)) throw new Error(`Recipe brand must be a 6-digit hex colour, got "${input.brand}"`);
|
|
152
|
+
for (const [key, list] of Object.entries(OPTIONS)) {
|
|
153
|
+
if (typeof r[key] === 'number') r[key] = String(r[key]);
|
|
154
|
+
if (!list.includes(r[key])) throw new Error(`Recipe ${key} must be one of ${list.join(', ')}; got "${r[key]}"`);
|
|
155
|
+
}
|
|
156
|
+
for (const role of ['heading', 'body', 'mono']) {
|
|
157
|
+
const font = FONTS.find((f) => f.id === r.font[role]);
|
|
158
|
+
if (!font) throw new Error(`Unknown ${role} font "${r.font[role]}"`);
|
|
159
|
+
// The system fonts belong to their role: System for heading and body, System mono for mono.
|
|
160
|
+
if (font.system && (font.category === 'mono') !== (role === 'mono')) throw new Error(`"${font.id}" can't be the ${role} font`);
|
|
161
|
+
if (role === 'mono' && font.category !== 'mono') throw new Error(`The mono font must be a mono font; "${font.id}" is ${font.category}`);
|
|
162
|
+
}
|
|
163
|
+
const out = {
|
|
164
|
+
v: r.v,
|
|
165
|
+
brand: r.brand,
|
|
166
|
+
neutral: r.neutral,
|
|
167
|
+
radius: r.radius,
|
|
168
|
+
controls: r.controls,
|
|
169
|
+
font: { heading: r.font.heading, body: r.font.body, mono: r.font.mono },
|
|
170
|
+
stroke: r.stroke,
|
|
171
|
+
depth: r.depth,
|
|
172
|
+
density: r.density,
|
|
173
|
+
border: r.border,
|
|
174
|
+
size: r.size,
|
|
175
|
+
leading: r.leading,
|
|
176
|
+
flow: r.flow,
|
|
177
|
+
measure: r.measure,
|
|
178
|
+
};
|
|
179
|
+
if (input.preset) out.preset = String(input.preset);
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Same theme? (ignores the preset label) */
|
|
184
|
+
export function sameRecipe(a, b) {
|
|
185
|
+
const strip = ({ preset, ...rest }) => rest;
|
|
186
|
+
return JSON.stringify(strip(normalizeRecipe(a))) === JSON.stringify(strip(normalizeRecipe(b)));
|
|
187
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small DTCG reference resolver that runs anywhere (browser included). The build resolves with Style
|
|
3
|
+
* Dictionary (lib/resolve.mjs, Node only); the theme generator and the docs site's live picker use this
|
|
4
|
+
* one, and a test holds the two to the same answer on the real tokens.json.
|
|
5
|
+
*
|
|
6
|
+
* resolveTree(source, mode) → [{ path: ['semantic','color','primary'], $type, $value }] in source order,
|
|
7
|
+
* with every { light, dark } value collapsed to `mode` and every "{a.b.c}" reference followed.
|
|
8
|
+
*/
|
|
9
|
+
export const MODES = ['light', 'dark'];
|
|
10
|
+
|
|
11
|
+
export const isModed = (value) =>
|
|
12
|
+
value !== null && typeof value === 'object' && !Array.isArray(value) && MODES.every((m) => m in value);
|
|
13
|
+
|
|
14
|
+
const REF = /^\{([^{}]+)\}$/;
|
|
15
|
+
|
|
16
|
+
/** Flatten a token tree: [{ path, $type, $value }] for every node with a $value, in order. */
|
|
17
|
+
export function flatten(node, path = []) {
|
|
18
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node)) return [];
|
|
19
|
+
if ('$value' in node) return [{ path, $type: node.$type, $value: node.$value }];
|
|
20
|
+
return Object.entries(node)
|
|
21
|
+
.filter(([key]) => !key.startsWith('$'))
|
|
22
|
+
.flatMap(([key, child]) => flatten(child, [...path, key]));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveTree(source, mode) {
|
|
26
|
+
const tokens = flatten(source);
|
|
27
|
+
const byPath = new Map(tokens.map((t) => [t.path.join('.'), t]));
|
|
28
|
+
const value = (t) => (isModed(t.$value) ? t.$value[mode] : t.$value);
|
|
29
|
+
|
|
30
|
+
const resolve = (raw, seen) => {
|
|
31
|
+
const m = typeof raw === 'string' ? REF.exec(raw) : null;
|
|
32
|
+
if (!m) return raw;
|
|
33
|
+
const target = byPath.get(m[1]);
|
|
34
|
+
if (!target) throw new Error(`Unresolved reference ${raw}`);
|
|
35
|
+
if (seen.has(m[1])) throw new Error(`Circular reference ${[...seen, m[1]].join(' → ')}`);
|
|
36
|
+
return resolve(value(target), new Set([...seen, m[1]]));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return tokens.map((t) => ({ ...t, $value: resolve(value(t), new Set([t.path.join('.')])) }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** { 'semantic.color.primary': '#2563eb', … } for one mode. */
|
|
43
|
+
export function resolvedMap(source, mode) {
|
|
44
|
+
return Object.fromEntries(resolveTree(source, mode).map((t) => [t.path.join('.'), t.$value]));
|
|
45
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The semantic colour map a theme writes: shadcn names (plus success/warning/info) → primitive
|
|
3
|
+
* references per mode. With a curated seed this is exactly the kit's original map; a generated seed
|
|
4
|
+
* moves the brand steps to wherever the seed landed (its anchor).
|
|
5
|
+
*/
|
|
6
|
+
const n = (step) => `{primitive.color.neutral.${step}}`;
|
|
7
|
+
const b = (step) => `{primitive.color.brand.${step}}`;
|
|
8
|
+
const p = (ramp, step) => `{primitive.color.${ramp}.${step}}`;
|
|
9
|
+
|
|
10
|
+
/** The brand step for dark mode: light seeds stay, darker ones lift so they read on a dark page. */
|
|
11
|
+
const DARK_STEP = { 600: '500', 700: '400', 800: '300', 900: '200', 950: '100' };
|
|
12
|
+
export const darkStep = (anchor) => DARK_STEP[anchor] ?? anchor;
|
|
13
|
+
|
|
14
|
+
/** { name: { light: ref, dark: ref } } in the order the kit's tokens.json lists them. */
|
|
15
|
+
export function semanticColors(anchor = '600') {
|
|
16
|
+
const brand = { light: b(anchor), dark: b(darkStep(anchor)) };
|
|
17
|
+
const onDark = { light: n(0), dark: n(950) };
|
|
18
|
+
return {
|
|
19
|
+
background: { light: n(0), dark: n(950) },
|
|
20
|
+
foreground: { light: n(950), dark: n(50) },
|
|
21
|
+
card: { light: n(0), dark: n(900) },
|
|
22
|
+
'card-foreground': { light: n(950), dark: n(50) },
|
|
23
|
+
popover: { light: n(0), dark: n(900) },
|
|
24
|
+
'popover-foreground': { light: n(950), dark: n(50) },
|
|
25
|
+
primary: brand,
|
|
26
|
+
'primary-foreground': onDark,
|
|
27
|
+
secondary: { light: n(100), dark: n(800) },
|
|
28
|
+
'secondary-foreground': { light: n(900), dark: n(50) },
|
|
29
|
+
muted: { light: n(100), dark: n(800) },
|
|
30
|
+
'muted-foreground': { light: n(600), dark: n(400) },
|
|
31
|
+
accent: { light: n(100), dark: n(800) },
|
|
32
|
+
'accent-foreground': { light: n(900), dark: n(50) },
|
|
33
|
+
destructive: { light: p('danger', 600), dark: p('danger', 500) },
|
|
34
|
+
'destructive-foreground': onDark,
|
|
35
|
+
success: { light: p('success', 700), dark: p('success', 500) },
|
|
36
|
+
'success-foreground': onDark,
|
|
37
|
+
warning: { light: p('warning', 700), dark: p('warning', 400) },
|
|
38
|
+
'warning-foreground': onDark,
|
|
39
|
+
info: { light: p('info', 700), dark: p('info', 500) },
|
|
40
|
+
'info-foreground': onDark,
|
|
41
|
+
border: { light: n(200), dark: n(800) },
|
|
42
|
+
input: { light: n(200), dark: n(800) },
|
|
43
|
+
ring: brand,
|
|
44
|
+
sidebar: { light: n(50), dark: n(900) },
|
|
45
|
+
'sidebar-foreground': { light: n(950), dark: n(50) },
|
|
46
|
+
'sidebar-primary': brand,
|
|
47
|
+
'sidebar-primary-foreground': onDark,
|
|
48
|
+
'sidebar-accent': { light: n(100), dark: n(800) },
|
|
49
|
+
'sidebar-accent-foreground': { light: n(900), dark: n(50) },
|
|
50
|
+
'sidebar-border': { light: n(200), dark: n(800) },
|
|
51
|
+
'sidebar-ring': brand,
|
|
52
|
+
'chart-1': { light: b(500), dark: b(400) },
|
|
53
|
+
'chart-2': { light: p('success', 500), dark: p('success', 400) },
|
|
54
|
+
'chart-3': { light: p('warning', 500), dark: p('warning', 400) },
|
|
55
|
+
'chart-4': { light: p('danger', 500), dark: p('danger', 400) },
|
|
56
|
+
'chart-5': { light: p('info', 500), dark: p('info', 400) },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Tokens that share the brand step; when the fixer moves `primary`, these move with it. */
|
|
61
|
+
export const BRAND_LINKED = ['ring', 'sidebar-primary', 'sidebar-ring'];
|
|
62
|
+
|
|
63
|
+
/** The neutral steps a foreground can flip between. */
|
|
64
|
+
export const FOREGROUND_CHOICES = [n(0), n(950)];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generated theme as CSS variables, in the format each app's build writes them, so a live override
|
|
3
|
+
* matches what `tokens:build` would produce:
|
|
4
|
+
* 'hsl' NativeWind 4 / shadcn v3 triplets (`221.2 83.2% 53.3%`) — the mobile kit
|
|
5
|
+
* 'oklch' Tailwind 4 / shadcn web (`oklch(0.546 0.2152 262.88)`) — the web kit and the docs site
|
|
6
|
+
*/
|
|
7
|
+
import { hexToHslTriplet, hexToOklch } from '../color.mjs';
|
|
8
|
+
import { shadowCss } from './recipe.mjs';
|
|
9
|
+
|
|
10
|
+
export const rem = (px) => (px >= 9999 ? '9999px' : `${px / 16}rem`);
|
|
11
|
+
|
|
12
|
+
const camel = (s) => s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
13
|
+
|
|
14
|
+
/** The typeset's variables (both formats): text sizes and line heights scale with the first two. */
|
|
15
|
+
export const typeVars = (type) => ({
|
|
16
|
+
'--type-scale': String(type.scale),
|
|
17
|
+
'--leading-factor': String(type.leadingFactor),
|
|
18
|
+
'--typeset-leading': String(type.leading),
|
|
19
|
+
'--typeset-flow': `${type.flow}em`,
|
|
20
|
+
'--typeset-measure': `${type.measure}ch`,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** { light: { '--primary': '…', '--radius': '0.625rem' }, dark: { … } } */
|
|
24
|
+
export function themeVars(theme, format) {
|
|
25
|
+
const convert = format === 'hsl' ? hexToHslTriplet : hexToOklch;
|
|
26
|
+
const mode = (m) => {
|
|
27
|
+
const vars = Object.fromEntries(Object.entries(theme.resolved[m]).map(([name, hex]) => [`--${name}`, convert(hex)]));
|
|
28
|
+
if (m === 'light') {
|
|
29
|
+
vars['--radius'] = rem(theme.radius);
|
|
30
|
+
vars['--radius-control'] = rem(theme.radiusControl);
|
|
31
|
+
vars['--border-width'] = `${theme.borderWidth}px`;
|
|
32
|
+
vars['--icon-stroke'] = String(theme.stroke);
|
|
33
|
+
vars['--density'] = String(theme.density.scale);
|
|
34
|
+
for (const size of ['sm', 'md', 'lg', 'x']) vars[`--control-${size}`] = `${theme.density[size]}px`;
|
|
35
|
+
Object.assign(vars, typeVars(theme.type));
|
|
36
|
+
}
|
|
37
|
+
for (const [level, value] of Object.entries(theme.semantic.shadow)) vars[`--shadow-${level}`] = shadowCss(value[m]);
|
|
38
|
+
return vars;
|
|
39
|
+
};
|
|
40
|
+
return { light: mode('light'), dark: mode('dark') };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** { light: { primary: '#…', primaryForeground: '#…' }, dark: { … } } — the shape of the kit's THEME. */
|
|
44
|
+
export function themeHex(theme) {
|
|
45
|
+
const mode = (m) => Object.fromEntries(Object.entries(theme.resolved[m]).map(([name, hex]) => [camel(name), hex]));
|
|
46
|
+
return { light: mode('light'), dark: mode('dark') };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A stylesheet that applies the theme: `:root { … } <dark> { … }`. */
|
|
50
|
+
export function themeCss(theme, { format = 'oklch', light = ':root', dark = '.dark' } = {}) {
|
|
51
|
+
const vars = themeVars(theme, format);
|
|
52
|
+
const block = (selector, map) => `${selector} {\n${Object.entries(map).map(([k, v]) => ` ${k}: ${v};`).join('\n')}\n}`;
|
|
53
|
+
return `${block(light, vars.light)}\n${block(dark, vars.dark)}\n`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `halftone-tokens theme …` — commit a theme from the docs picker into tokens.json.
|
|
3
|
+
*
|
|
4
|
+
* theme apply <code|preset|recipe.json> [--dry-run] [--force]
|
|
5
|
+
* write the theme into tokens.json (only the paths a theme owns) and rebuild
|
|
6
|
+
* theme show [--check] the applied theme, its code and any hand edits since (drift)
|
|
7
|
+
* --check: exit 1 if the recorded theme no longer reproduces (CI)
|
|
8
|
+
* theme presets list the presets and their codes
|
|
9
|
+
* theme encode <recipe.json> print the code for a recipe file
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { FONTS } from './theme/fonts.mjs';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
applyRecipe,
|
|
19
|
+
decodeRecipe,
|
|
20
|
+
encodeRecipe,
|
|
21
|
+
fontById,
|
|
22
|
+
isThemeCode,
|
|
23
|
+
normalizeRecipe,
|
|
24
|
+
presetById,
|
|
25
|
+
PRESETS,
|
|
26
|
+
themeStatus,
|
|
27
|
+
} from './theme/index.mjs';
|
|
28
|
+
|
|
29
|
+
const fail = (message, code = 1) => {
|
|
30
|
+
console.error(`halftone-tokens theme: ${message}`);
|
|
31
|
+
process.exit(code);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function readRecipe(arg, root) {
|
|
35
|
+
if (!arg) fail('say which theme: a code (ht1-… or ht2-…), a preset name or a recipe .json', 2);
|
|
36
|
+
if (isThemeCode(arg)) {
|
|
37
|
+
try {
|
|
38
|
+
return decodeRecipe(arg);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
fail(error.message);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const preset = presetById(arg.toLowerCase());
|
|
44
|
+
if (preset) return normalizeRecipe({ ...preset.recipe, preset: preset.id });
|
|
45
|
+
const file = join(root, arg);
|
|
46
|
+
if (arg.endsWith('.json') && existsSync(file)) return normalizeRecipe(JSON.parse(readFileSync(file, 'utf8')));
|
|
47
|
+
fail(`"${arg}" is not a theme code, a preset (${PRESETS.map((p) => p.id).join(', ')}) or a recipe file`, 2);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const describe = (input) => {
|
|
51
|
+
// A recipe recorded by an older halftone-tokens has no typeset: it gets the defaults.
|
|
52
|
+
const recipe = normalizeRecipe(input);
|
|
53
|
+
const font = (id) => {
|
|
54
|
+
const f = fontById(id);
|
|
55
|
+
return f?.system ? (f.category === 'mono' ? 'system mono' : 'system') : (f?.family ?? id);
|
|
56
|
+
};
|
|
57
|
+
return [
|
|
58
|
+
`brand ${recipe.brand} · neutral ${recipe.neutral} · radius ${recipe.radius} · controls ${recipe.controls}`,
|
|
59
|
+
`type ${font(recipe.font.heading)} / ${font(recipe.font.body)} · stroke ${recipe.stroke} · depth ${recipe.depth} · density ${recipe.density} · border ${recipe.border}`,
|
|
60
|
+
`typeset ${recipe.size}px · leading ${recipe.leading} · flow ${recipe.flow} · measure ${recipe.measure}ch · mono ${font(recipe.font.mono)}`,
|
|
61
|
+
].join('\n ');
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** positional: [subcommand, arg]; flags: { force, 'dry-run', check, … } */
|
|
65
|
+
/**
|
|
66
|
+
* An Expo app needs the theme's @expo-google-fonts packages installed (the fonts target imports them).
|
|
67
|
+
* Installs the missing ones with `npx expo install`; lists any the theme no longer uses.
|
|
68
|
+
*/
|
|
69
|
+
function installFonts(root, config, theme, flags) {
|
|
70
|
+
if (config.targets?.fonts?.platform !== 'expo') return;
|
|
71
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
72
|
+
const have = new Set(Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((d) => d.startsWith('@expo-google-fonts/')));
|
|
73
|
+
const ids = Object.values(theme.recipe.font).filter((id) => !fontById(id)?.system);
|
|
74
|
+
const need = [...new Set(ids)].map((id) => `@expo-google-fonts/${id}`);
|
|
75
|
+
const missing = need.filter((p) => !have.has(p));
|
|
76
|
+
const unused = [...have].filter((p) => !need.includes(p) && FONTS.some((f) => `@expo-google-fonts/${f.id}` === p));
|
|
77
|
+
if (missing.length) {
|
|
78
|
+
if (flags['no-install']) {
|
|
79
|
+
console.log(`\nInstall the theme's fonts: npx expo install ${missing.join(' ')}`);
|
|
80
|
+
} else {
|
|
81
|
+
console.log(`\nInstalling ${missing.join(', ')}…`);
|
|
82
|
+
const r = spawnSync('npx', ['expo', 'install', ...missing], { cwd: root, stdio: 'inherit' });
|
|
83
|
+
if (r.status !== 0) fail(`could not install ${missing.join(' ')}; run \`npx expo install ${missing.join(' ')}\` yourself, then \`npm run tokens:build\`.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (unused.length) console.log(`\nNo longer used by the theme (remove when you like): npm uninstall ${unused.join(' ')}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function themeCommand({ positional, flags, root, sourcePath, config, build }) {
|
|
90
|
+
const [sub = 'show', arg] = positional;
|
|
91
|
+
const tokens = () => JSON.parse(readFileSync(sourcePath, 'utf8'));
|
|
92
|
+
|
|
93
|
+
if (sub === 'presets') {
|
|
94
|
+
for (const p of PRESETS) console.log(`${p.id.padEnd(12)} ${encodeRecipe(p.recipe)} ${p.description}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (sub === 'encode') {
|
|
99
|
+
console.log(encodeRecipe(readRecipe(arg, root)));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (sub === 'show') {
|
|
104
|
+
const status = themeStatus(tokens());
|
|
105
|
+
if (!status.applied) {
|
|
106
|
+
console.log('No theme recorded in tokens.json. Apply one with `halftone-tokens theme apply <code|preset>`.');
|
|
107
|
+
if (flags.check) process.exit(0);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const name = status.recipe.preset ? ` (${presetById(status.recipe.preset)?.name ?? status.recipe.preset})` : '';
|
|
111
|
+
console.log(`Theme ${status.code}${name}\n ${describe(status.recipe)}`);
|
|
112
|
+
if (status.drift.length) {
|
|
113
|
+
console.log(`\nEdited by hand since it was applied (${status.drift.length}):\n${status.drift.map((p) => ` ${p}`).join('\n')}`);
|
|
114
|
+
if (flags.check) fail('tokens.json no longer matches its recorded theme. Re-apply it, or apply with --force to record the edits away.');
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (sub === 'apply') {
|
|
120
|
+
const recipe = readRecipe(arg, root);
|
|
121
|
+
const current = tokens();
|
|
122
|
+
const status = themeStatus(current);
|
|
123
|
+
const blocked = !status.applied
|
|
124
|
+
? 'tokens.json has no theme recorded, so applying one would overwrite its brand and neutral ramps, semantic colours and radius ' +
|
|
125
|
+
'without knowing whether they were customised. Re-run with --force to go ahead.'
|
|
126
|
+
: status.drift.length
|
|
127
|
+
? `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.`
|
|
128
|
+
: null;
|
|
129
|
+
if (blocked && !flags.force && !flags['dry-run']) fail(blocked);
|
|
130
|
+
const { tokens: next, theme } = applyRecipe(current, recipe);
|
|
131
|
+
console.log(`Theme ${theme.code}${recipe.preset ? ` (${presetById(recipe.preset)?.name})` : ''}\n ${describe(theme.recipe)}`);
|
|
132
|
+
if (theme.adjustments.length) {
|
|
133
|
+
console.log(`\nAdjusted for contrast (WCAG AA), hue kept:`);
|
|
134
|
+
for (const a of theme.adjustments) console.log(` ${a.mode.padEnd(5)} ${a.token.padEnd(28)} ${a.from} → ${a.to}`);
|
|
135
|
+
}
|
|
136
|
+
if (flags['dry-run']) {
|
|
137
|
+
console.log(`\n--dry-run: nothing written.${blocked && !flags.force ? `\nNote: ${blocked}` : ''}`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
writeFileSync(sourcePath, JSON.stringify(next, null, 2) + '\n');
|
|
141
|
+
console.log(`\nWrote ${sourcePath.slice(root.length + 1)}.`);
|
|
142
|
+
installFonts(root, config, theme, flags);
|
|
143
|
+
await build();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
fail(`unknown command "theme ${sub}". Use apply, show, presets or encode.`, 2);
|
|
148
|
+
}
|