@halftone-ds/tokens 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Commit a theme into a tokens.json object. Only the paths a theme owns are written; everything else
3
+ * (status ramps, spacing, type sizes, motion, descriptions) is left exactly as it was. The recipe is
4
+ * recorded under `$extensions["com.halftonedesignsystem.theme"]`, so the docs picker and `halftone-tokens theme show`
5
+ * can tell which theme is applied, and whether anyone has hand-edited it since (drift).
6
+ */
7
+ import { generateTheme } from './generate.mjs';
8
+ import { normalizeRecipe } from './recipe.mjs';
9
+ import { flatten } from './refs.mjs';
10
+
11
+ export const THEME_EXTENSION = 'com.halftonedesignsystem.theme';
12
+
13
+ const clone = (value) => JSON.parse(JSON.stringify(value));
14
+
15
+ /** The values a theme owns, as { 'path.to.token': $value }. */
16
+ function ownedValues(theme) {
17
+ const out = {};
18
+ for (const [group, ramp] of Object.entries(theme.primitives)) {
19
+ for (const [step, hex] of Object.entries(ramp)) out[`primitive.color.${group}.${step}`] = hex;
20
+ }
21
+ for (const [name, value] of Object.entries(theme.semantic.color)) out[`semantic.color.${name}`] = value;
22
+ out['semantic.radius.base'] = theme.semantic.radius.base;
23
+ out['semantic.radius.control'] = theme.semantic.radius.control;
24
+ out['semantic.border.width'] = theme.semantic.border.width;
25
+ out['semantic.icon.stroke'] = theme.semantic.icon.stroke;
26
+ for (const [level, value] of Object.entries(theme.semantic.shadow)) out[`semantic.shadow.${level}`] = value;
27
+ out['semantic.density.scale'] = theme.semantic.density.scale;
28
+ for (const [size, value] of Object.entries(theme.semantic.density.control)) out[`semantic.density.control.${size}`] = value;
29
+ out['primitive.font.family.heading'] = theme.fonts.heading;
30
+ out['primitive.font.family.body'] = theme.fonts.body;
31
+ out['primitive.font.family.mono'] = theme.fonts.mono;
32
+ for (const [key, value] of Object.entries(theme.semantic.type)) out[`semantic.type.${key}`] = value;
33
+ return out;
34
+ }
35
+
36
+ const TYPES = { radius: 'number', border: 'number', icon: 'number', density: 'number', type: 'number', shadow: 'shadow', font: 'fontFamily', color: 'color' };
37
+ const DESCRIPTIONS = {
38
+ 'semantic.radius.control': 'Corner radius of mobile buttons and chips: rounded-control. 9999 = pills; otherwise the base radius.',
39
+ 'semantic.border.width': 'Width of `border` (px). border-2 and border-hairline stay explicit.',
40
+ 'semantic.icon.stroke': "Lucide stroke width; an icon's own strokeWidth is relative to 2.",
41
+ 'semantic.shadow.1': 'shadow-xs / shadow-sm / shadow: controls and cards.',
42
+ 'semantic.shadow.2': 'shadow-md: raised cards and popovers.',
43
+ 'semantic.shadow.3': 'shadow-lg / shadow-xl: menus, dialogs, floating buttons.',
44
+ 'semantic.density.scale': "Web: multiplies the spacing scale (Tailwind 4 --spacing). Mobile: see density.control.",
45
+ 'semantic.density.control.sm': 'Mobile: small control height (h-control-sm), px.',
46
+ 'semantic.density.control.md': 'Mobile: control height (h-control): buttons, inputs, selects, px.',
47
+ 'semantic.density.control.lg': 'Mobile: large control height (h-control-lg), px.',
48
+ 'semantic.density.control.x': 'Mobile: control horizontal padding (px-control-x), px.',
49
+ 'primitive.font.family.heading': 'Headings (Text h1–h4, titles). Empty = the platform system font.',
50
+ 'primitive.font.family.body': 'Everything else. Empty = the platform system font.',
51
+ 'primitive.font.family.mono': 'Code and figures (font-mono). Menlo = the platform monospace font.',
52
+ 'semantic.type.size': 'Typeset: body text size (px). Every text size (text-xs…4xl) scales by size / 16.',
53
+ 'semantic.type.leading': "Typeset: line height of running text (× size); the text sizes' line heights scale by leading / 1.75.",
54
+ 'semantic.type.flow': 'Typeset: space between blocks of long-form text (Prose), em.',
55
+ 'semantic.type.measure': 'Typeset: the longest line of long-form text (Prose), characters.',
56
+ };
57
+
58
+ function typeOf(path) {
59
+ return TYPES[path.split('.')[1]] ?? 'color';
60
+ }
61
+
62
+ function setPath(root, path, $value) {
63
+ let node = root;
64
+ const keys = path.split('.');
65
+ for (const key of keys.slice(0, -1)) node = node[key] ??= {};
66
+ const leaf = keys.at(-1);
67
+ if (node[leaf] && typeof node[leaf] === 'object' && '$value' in node[leaf]) node[leaf].$value = $value;
68
+ else node[leaf] = { $type: typeOf(path), ...(DESCRIPTIONS[path] ? { $description: DESCRIPTIONS[path] } : {}), $value };
69
+ }
70
+
71
+ /** → { tokens, theme }. `tokens` is a new object; the input is not touched. */
72
+ export function applyRecipe(tokens, recipe) {
73
+ const theme = generateTheme(recipe, tokens);
74
+ const out = clone(tokens);
75
+ for (const [path, value] of Object.entries(ownedValues(theme))) {
76
+ setPath(out, path, value);
77
+ }
78
+ const { $extensions = {}, ...rest } = out;
79
+ const record = { version: theme.recipe.v, code: theme.code, recipe: theme.recipe };
80
+ if (theme.adjustments.length) record.adjustments = theme.adjustments;
81
+ return { tokens: { ...rest, $extensions: { ...$extensions, [THEME_EXTENSION]: record } }, theme };
82
+ }
83
+
84
+ /**
85
+ * What theme a tokens.json carries:
86
+ * { applied: false } no theme recorded
87
+ * { applied: true, code, recipe, drift: [paths] } drift = owned paths edited since the theme was applied
88
+ */
89
+ export function themeStatus(tokens) {
90
+ const record = tokens.$extensions?.[THEME_EXTENSION];
91
+ if (!record?.recipe) return { applied: false };
92
+ const expected = ownedValues(generateTheme(record.recipe, tokens));
93
+ const actual = Object.fromEntries(flatten(tokens).map((t) => [t.path.join('.'), t.$value]));
94
+ // A path the file doesn't have yet (a newer halftone-tokens owns more) is not an edit; apply adds it.
95
+ const drift = Object.entries(expected)
96
+ .filter(([path, value]) => path in actual && JSON.stringify(actual[path]) !== JSON.stringify(value))
97
+ .map(([path]) => path);
98
+ // Normalised: a recipe recorded by an older halftone-tokens gets the axes it didn't have (the typeset).
99
+ return { applied: true, code: record.code, recipe: normalizeRecipe(record.recipe), drift };
100
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Theme codes: a recipe packed into bits and written in Crockford base32 after a prefix. Short enough to
3
+ * read out, paste into a terminal or a URL.
4
+ *
5
+ * ht1- 12 characters, 60 bits: a theme with the default typeset (every code made before the typeset)
6
+ * ht2- 16 characters, 80 bits: any theme
7
+ *
8
+ * bits field ht1 ht2
9
+ * recipe version (RECIPE_VERSION) 4 4
10
+ * brand hex 24 24
11
+ * neutral, radius, controls 3+3+1 3+3+1 (option indexes, recipe.mjs)
12
+ * heading, body (, mono) 5+5 6+6+6 (indexes into FONTS, fonts.mjs)
13
+ * stroke, depth, density, border 2+2+2+2 2+2+2+2
14
+ * size, leading, flow, measure – 3+3+3+3
15
+ * checksum (payload mod 127) 7 7 a typo is caught instead of making another theme
16
+ *
17
+ * encodeRecipe writes ht1 whenever it can, so a theme that doesn't touch the typeset keeps the code it
18
+ * always had (and older halftone-tokens can read it). The preset name is not in the code: a code is the theme,
19
+ * whichever preset it started from.
20
+ */
21
+ import { FONTS } from './fonts.mjs';
22
+ import { defaultTypeset, normalizeRecipe, OPTIONS, RECIPE_VERSION } from './recipe.mjs';
23
+
24
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
25
+ const LAYOUT = [
26
+ ['neutral', 3],
27
+ ['radius', 3],
28
+ ['controls', 1],
29
+ ];
30
+ const TAIL = [
31
+ ['stroke', 2],
32
+ ['depth', 2],
33
+ ['density', 2],
34
+ ['border', 2],
35
+ ];
36
+ const TYPESET = [
37
+ ['size', 3],
38
+ ['leading', 3],
39
+ ['flow', 3],
40
+ ['measure', 3],
41
+ ];
42
+ /** Per format: prefix, characters, font width, whether the typeset is in it. */
43
+ const FORMATS = {
44
+ ht1: { prefix: 'ht1-', chars: 12, font: 5, typeset: false },
45
+ ht2: { prefix: 'ht2-', chars: 16, font: 6, typeset: true },
46
+ };
47
+
48
+ const fontIndex = (id) => FONTS.findIndex((f) => f.id === id);
49
+
50
+ export function encodeRecipe(input) {
51
+ const r = normalizeRecipe(input);
52
+ const ht1 = defaultTypeset(r) && fontIndex(r.font.heading) < 32 && fontIndex(r.font.body) < 32;
53
+ const format = ht1 ? FORMATS.ht1 : FORMATS.ht2;
54
+ let bits = BigInt(r.v);
55
+ const push = (value, width) => {
56
+ if (value < 0 || value >= 2 ** width) throw new Error(`encodeRecipe: ${value} does not fit in ${width} bits`);
57
+ bits = (bits << BigInt(width)) | BigInt(value);
58
+ };
59
+ push(parseInt(r.brand.slice(1), 16), 24);
60
+ for (const [key, width] of LAYOUT) push(OPTIONS[key].indexOf(r[key]), width);
61
+ push(fontIndex(r.font.heading), format.font);
62
+ push(fontIndex(r.font.body), format.font);
63
+ if (format.typeset) push(fontIndex(r.font.mono), format.font);
64
+ for (const [key, width] of TAIL) push(OPTIONS[key].indexOf(r[key]), width);
65
+ if (format.typeset) for (const [key, width] of TYPESET) push(OPTIONS[key].indexOf(r[key]), width);
66
+ bits = (bits << 7n) | (bits % 127n);
67
+
68
+ let out = '';
69
+ for (let i = 0; i < format.chars; i++) {
70
+ out = ALPHABET[Number(bits & 31n)] + out;
71
+ bits >>= 5n;
72
+ }
73
+ return format.prefix + out;
74
+ }
75
+
76
+ /** Code → recipe. Throws on a malformed code, a bad checksum or an unknown version. */
77
+ export function decodeRecipe(code) {
78
+ const clean = String(code).trim().toUpperCase();
79
+ const format = /^HT2/.test(clean) ? FORMATS.ht2 : FORMATS.ht1;
80
+ const raw = clean.replace(/^HT[12]-?/, '').replace(/[IL]/g, '1').replace(/O/g, '0').replace(/-/g, '');
81
+ if (!/^HT[12]/.test(clean) && /^HT\d/.test(clean)) {
82
+ throw new Error(`"${code}" is a newer theme code than this halftone-tokens reads. Update @halftone-ds/tokens.`);
83
+ }
84
+ if (!new RegExp(`^[0-9A-HJKMNP-TV-Z]{${format.chars}}$`).test(raw)) {
85
+ throw new Error(`"${code}" is not a theme code (expected ht1- and 12 characters, or ht2- and 16)`);
86
+ }
87
+ let bits = 0n;
88
+ for (const ch of raw) bits = (bits << 5n) | BigInt(ALPHABET.indexOf(ch));
89
+ const checksum = bits & 127n;
90
+ bits >>= 7n;
91
+ if (bits % 127n !== checksum) throw new Error(`"${code}" has a typo (checksum mismatch)`);
92
+
93
+ const pull = (width) => {
94
+ const value = Number(bits & ((1n << BigInt(width)) - 1n));
95
+ bits >>= BigInt(width);
96
+ return value;
97
+ };
98
+ const opt = (key, width) => {
99
+ const value = OPTIONS[key][pull(width)];
100
+ if (value === undefined) throw new Error(`"${code}" has an unknown ${key}`);
101
+ return value;
102
+ };
103
+ const font = (width) => {
104
+ const f = FONTS[pull(width)];
105
+ if (!f) throw new Error(`"${code}" has an unknown font`);
106
+ return f.id;
107
+ };
108
+ const fields = (list) => Object.fromEntries([...list].reverse().map(([key, width]) => [key, opt(key, width)]));
109
+ // Read back to front.
110
+ const typeset = format.typeset ? fields(TYPESET) : {};
111
+ const tail = fields(TAIL);
112
+ const mono = format.typeset ? font(format.font) : undefined;
113
+ const body = font(format.font);
114
+ const heading = font(format.font);
115
+ const head = fields(LAYOUT);
116
+ const brand = '#' + pull(24).toString(16).padStart(6, '0');
117
+ const v = Number(bits);
118
+ if (v !== RECIPE_VERSION) throw new Error(`"${code}" is a version ${v} theme; this halftone-tokens reads version ${RECIPE_VERSION}. Update @halftone-ds/tokens.`);
119
+ return normalizeRecipe({ v, brand, ...head, font: { heading, body, ...(mono ? { mono } : {}) }, ...tail, ...typeset });
120
+ }
121
+
122
+ export const isThemeCode = (value) => /^(ht1-?[0-9a-z]{12}|ht2-?[0-9a-z]{16})$/i.test(String(value).trim());
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Contrast auto-fix. A theme from the picker must never fail the build, so instead of rejecting a brand
3
+ * colour that doesn't read, the fixer keeps its hue and nudges its lightness the least it can, per mode:
4
+ *
5
+ * 1. a label on its fill (`primary-foreground on primary`): flip the label between neutral.0 and
6
+ * neutral.950; if neither reads, move the fill away from the better one
7
+ * 2. a tone as ink or an icon on its tint (`text-primary on muted`): move the tone away from the page
8
+ * (darker in light mode, lighter in dark), then re-check its label
9
+ * 3. body or muted text on a surface: move the text away from the surface
10
+ *
11
+ * It checks the STRICT set (tones on muted/accent too). A moved token becomes a literal hex for that
12
+ * mode, and every move is reported as { token, mode, from, to } so the picker can show it.
13
+ */
14
+ import { contrast, hexToOklchParts } from '../color.mjs';
15
+ import { contrastPairs } from '../contrast.mjs';
16
+ import { toGamutHex } from './ramp.mjs';
17
+ import { BRAND_LINKED, FOREGROUND_CHOICES } from './semantic.mjs';
18
+
19
+ const MAX_PASSES = 24;
20
+ const STEP = 0.004;
21
+
22
+ const failing = (pairs) => pairs.filter((r) => r.ratio < r.threshold);
23
+
24
+ /** Move `hex` in lightness (dir -1 darker, +1 lighter) until ok(candidate); null if it never is. */
25
+ function nudge(hex, dir, ok) {
26
+ const [L, C, H] = hexToOklchParts(hex);
27
+ for (let i = 1; i * STEP <= 1; i++) {
28
+ const next = Math.min(1, Math.max(0, L + dir * i * STEP));
29
+ const candidate = toGamutHex([next, C, H]);
30
+ if (ok(candidate)) return candidate;
31
+ if (next === 0 || next === 1) break;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * semantic: { name: { light, dark } } of references or hex; lookup: (value) → hex.
38
+ * Returns { semantic, adjustments } — a new map, the input is not touched.
39
+ */
40
+ export function fixContrast(semantic, lookup) {
41
+ const out = Object.fromEntries(Object.entries(semantic).map(([k, v]) => [k, { ...v }]));
42
+ const adjustments = [];
43
+
44
+ for (const mode of ['light', 'dark']) {
45
+ const original = Object.fromEntries(Object.entries(out).map(([k, v]) => [k, lookup(v[mode])]));
46
+ const away = mode === 'light' ? -1 : 1; // the direction that moves ink away from the page
47
+ const hexNow = () => Object.fromEntries(Object.entries(out).map(([k, v]) => [k, lookup(v[mode])]));
48
+ const set = (name, value) => {
49
+ out[name][mode] = value;
50
+ };
51
+
52
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
53
+ const hex = hexNow();
54
+ const [f] = failing(contrastPairs(hex, mode, { strict: true }));
55
+ if (!f) break;
56
+
57
+ if (f.fgName === `${f.bgName}-foreground`) {
58
+ // 1. A label on its own fill.
59
+ const [best] = FOREGROUND_CHOICES.map((ref) => ({ ref, ratio: contrast(lookup(ref), hex[f.bgName]) })).sort((a, b) => b.ratio - a.ratio);
60
+ if (best.ratio >= f.threshold && out[f.fgName][mode] !== best.ref) {
61
+ set(f.fgName, best.ref);
62
+ continue;
63
+ }
64
+ const label = lookup(best.ref);
65
+ // A light label (it reads on black) needs a darker fill; a dark label a lighter one.
66
+ const dir = contrast(label, '#000000') > contrast(label, '#ffffff') ? -1 : 1;
67
+ const moved = nudge(hex[f.bgName], dir, (c) => contrast(label, c) >= f.threshold);
68
+ if (!moved) throw new Error(`fixContrast: cannot fix ${f.label} (${mode})`);
69
+ set(f.fgName, best.ref);
70
+ moveFill(f.bgName, moved);
71
+ continue;
72
+ }
73
+
74
+ // 2 and 3: move the ink until every pairing it takes part in as ink passes.
75
+ const ink = f.fgName;
76
+ const asInk = (candidate) => failing(contrastPairs({ ...hex, [ink]: candidate }, mode, { strict: true, fg: ink })).length === 0;
77
+ const moved = nudge(hex[ink], away, asInk);
78
+ if (!moved) throw new Error(`fixContrast: cannot fix ${f.label} (${mode})`);
79
+ moveFill(ink, moved);
80
+ }
81
+
82
+ const left = failing(contrastPairs(hexNow(), mode, { strict: true }));
83
+ if (left.length) throw new Error(`fixContrast: ${left[0].label} still fails in ${mode} after ${MAX_PASSES} passes`);
84
+
85
+ for (const [name, before] of Object.entries(original)) {
86
+ const after = lookup(out[name][mode]);
87
+ if (after !== before && out[name][mode].startsWith('#')) adjustments.push({ token: name, mode, from: before, to: after });
88
+ }
89
+
90
+ function moveFill(name, hex) {
91
+ const was = lookup(out[name][mode]);
92
+ set(name, hex);
93
+ if (name === 'primary') {
94
+ for (const linked of BRAND_LINKED) if (lookup(out[linked][mode]) === was) set(linked, hex);
95
+ }
96
+ }
97
+ }
98
+ return { semantic: out, adjustments };
99
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The curated font list for theme recipes. Every family is on Google Fonts (for the docs preview and
3
+ * next/font) and has an @expo-google-fonts package (for native), with 400–700 available: the kits' live
4
+ * preview asks Google Fonts for exactly those weights, and a family without one fails the whole request.
5
+ *
6
+ * APPEND-ONLY: a theme code stores a font's index in this list. Never reorder or remove an entry;
7
+ * retire one by marking it `hidden: true` (old codes still resolve, the picker stops offering it).
8
+ *
9
+ * id stable id, also the @expo-google-fonts package suffix
10
+ * family the Google Fonts family name
11
+ * category sans | serif | display | mono — for grouping in the picker; mono fonts are the mono role's
12
+ * choices (code, figures), and can be a heading or body font too
13
+ * system the platform's own font for its role: nothing to load
14
+ * weights the static weights we load (400–700); a class asking for another snaps to the nearest
15
+ * expoPrefix the export prefix of the @expo-google-fonts package (Inter_600SemiBold → 'Inter')
16
+ */
17
+ export const FONTS = [
18
+ { id: 'system', family: 'System', category: 'sans', weights: [400, 500, 600, 700, 800], system: true },
19
+ { id: 'inter', family: 'Inter', category: 'sans', weights: [400, 500, 600, 700], expoPrefix: 'Inter' },
20
+ { id: 'geist', family: 'Geist', category: 'sans', weights: [400, 500, 600, 700], expoPrefix: 'Geist' },
21
+ { id: 'dm-sans', family: 'DM Sans', category: 'sans', weights: [400, 500, 600, 700], expoPrefix: 'DMSans' },
22
+ { id: 'manrope', family: 'Manrope', category: 'sans', weights: [400, 500, 600, 700], expoPrefix: 'Manrope' },
23
+ { id: 'plus-jakarta-sans', family: 'Plus Jakarta Sans', category: 'sans', weights: [400, 500, 600, 700], expoPrefix: 'PlusJakartaSans' },
24
+ { id: 'outfit', family: 'Outfit', category: 'sans', weights: [400, 500, 600, 700], expoPrefix: 'Outfit' },
25
+ { id: 'space-grotesk', family: 'Space Grotesk', category: 'sans', weights: [400, 500, 600, 700], expoPrefix: 'SpaceGrotesk' },
26
+ { id: 'ibm-plex-sans', family: 'IBM Plex Sans', category: 'sans', weights: [400, 500, 600, 700], expoPrefix: 'IBMPlexSans' },
27
+ { id: 'nunito', family: 'Nunito', category: 'sans', weights: [400, 500, 600, 700], expoPrefix: 'Nunito' },
28
+ { id: 'bricolage-grotesque', family: 'Bricolage Grotesque', category: 'display', weights: [400, 500, 600, 700], expoPrefix: 'BricolageGrotesque' },
29
+ { id: 'fraunces', family: 'Fraunces', category: 'serif', weights: [400, 500, 600, 700], expoPrefix: 'Fraunces' },
30
+ { id: 'playfair-display', family: 'Playfair Display', category: 'serif', weights: [400, 500, 600, 700], expoPrefix: 'PlayfairDisplay' },
31
+ { id: 'lora', family: 'Lora', category: 'serif', weights: [400, 500, 600, 700], expoPrefix: 'Lora' },
32
+ { id: 'source-serif-4', family: 'Source Serif 4', category: 'serif', weights: [400, 500, 600, 700], expoPrefix: 'SourceSerif4' },
33
+ // Mono (the typeset's mono role). system-mono is the platform's monospace font; Menlo is what tokens.json
34
+ // has always said for it.
35
+ { id: 'system-mono', family: 'Menlo', category: 'mono', weights: [400, 500, 600, 700], system: true },
36
+ { id: 'jetbrains-mono', family: 'JetBrains Mono', category: 'mono', weights: [400, 500, 600, 700], expoPrefix: 'JetBrainsMono' },
37
+ { id: 'geist-mono', family: 'Geist Mono', category: 'mono', weights: [400, 500, 600, 700], expoPrefix: 'GeistMono' },
38
+ { id: 'ibm-plex-mono', family: 'IBM Plex Mono', category: 'mono', weights: [400, 500, 600, 700], expoPrefix: 'IBMPlexMono' },
39
+ { id: 'roboto-mono', family: 'Roboto Mono', category: 'mono', weights: [400, 500, 600, 700], expoPrefix: 'RobotoMono' },
40
+ { id: 'fira-code', family: 'Fira Code', category: 'mono', weights: [400, 500, 600, 700], expoPrefix: 'FiraCode' },
41
+ { id: 'source-code-pro', family: 'Source Code Pro', category: 'mono', weights: [400, 500, 600, 700], expoPrefix: 'SourceCodePro' },
42
+ ];
43
+
44
+ export const fontById = (id) => FONTS.find((f) => f.id === id);
45
+
46
+ /** The nearest loaded weight of a font to the one a class asks for. */
47
+ export function nearestWeight(font, weight) {
48
+ return font.weights.reduce((best, w) => (Math.abs(w - weight) < Math.abs(best - weight) ? w : best), font.weights[0]);
49
+ }
50
+
51
+ /** Google Fonts CSS for a set of families (docs preview and embeds; the kits bundle their fonts). */
52
+ export function googleFontsHref(ids) {
53
+ const families = [...new Set(ids)]
54
+ .map(fontById)
55
+ .filter((f) => f && !f.system)
56
+ .map((f) => `family=${f.family.replace(/ /g, '+')}:wght@${f.weights.join(';')}`);
57
+ return families.length ? `https://fonts.googleapis.com/css2?${families.join('&')}&display=swap` : null;
58
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * generateTheme — a recipe expanded into a theme. Pure ESM, no Node APIs: the docs site runs it in
3
+ * the browser for the live picker, the CLI runs it to commit a theme into tokens.json.
4
+ *
5
+ * generateTheme(recipe, base) base = the app's tokens.json (status ramps, radius steps come from it)
6
+ * → { recipe, code, primitives, semantic, resolved, radius, type, adjustments }
7
+ *
8
+ * Status colours (success, warning, danger, info) are not themed in v1; they come from `base`.
9
+ */
10
+ import { fixContrast } from './fix.mjs';
11
+ import { encodeRecipe } from './codec.mjs';
12
+ import { fontById } from './fonts.mjs';
13
+ import { BORDER, DENSITY, DEPTH, FLOW, LEADING, normalizeRecipe, RADIUS, STROKE, typeScale } from './recipe.mjs';
14
+ import { anchorStep, brandRamp, neutralRamp } from './ramp.mjs';
15
+ import { resolvedMap } from './refs.mjs';
16
+ import { semanticColors } from './semantic.mjs';
17
+
18
+
19
+ const ramp = (values) => Object.fromEntries(Object.entries(values).map(([step, $value]) => [step, { $type: 'color', $value }]));
20
+
21
+ /** The theme for a recipe, on top of `base` (a tokens.json object). `fix: false` skips the contrast fixer. */
22
+ export function generateTheme(input, base, { fix = true } = {}) {
23
+ const recipe = normalizeRecipe(input);
24
+ const brand = brandRamp(recipe.brand);
25
+ const neutral = neutralRamp(recipe.neutral, recipe.brand);
26
+
27
+ // Primitive lookup: the base primitives with this theme's ramps swapped in.
28
+ const primitive = { ...base.primitive, color: { ...base.primitive.color, brand: ramp(brand), neutral: ramp(neutral) } };
29
+ const prim = resolvedMap({ primitive }, 'light');
30
+ const lookup = (value) => {
31
+ if (typeof value !== 'string' || !value.startsWith('{')) return value;
32
+ const hex = prim[value.slice(1, -1)];
33
+ if (hex === undefined) throw new Error(`generateTheme: ${value} is not in the base tokens`);
34
+ return hex;
35
+ };
36
+
37
+ let colors = semanticColors(anchorStep(recipe.brand));
38
+ let adjustments = [];
39
+ if (fix) ({ semantic: colors, adjustments } = fixContrast(colors, lookup));
40
+
41
+ const radiusRef = RADIUS[recipe.radius];
42
+ const radiusValue = typeof radiusRef === 'string' ? `{primitive.radius.${radiusRef}}` : radiusRef;
43
+ const resolved = Object.fromEntries(
44
+ ['light', 'dark'].map((mode) => [mode, Object.fromEntries(Object.entries(colors).map(([name, v]) => [name, lookup(v[mode])]))])
45
+ );
46
+
47
+ const radius = lookup(radiusValue);
48
+ const controlRadius = recipe.controls === 'pill' ? 9999 : radiusValue;
49
+ // Heading and body: '' is the platform font. Mono: system-mono is Menlo, what tokens.json always had.
50
+ const font = (id) => (id === 'system' ? '' : fontById(id).family);
51
+ const density = DENSITY[recipe.density];
52
+ const size = Number(recipe.size);
53
+ const type = {
54
+ size,
55
+ leading: LEADING[recipe.leading],
56
+ flow: FLOW[recipe.flow],
57
+ measure: Number(recipe.measure),
58
+ };
59
+
60
+ return {
61
+ recipe,
62
+ code: encodeRecipe(recipe),
63
+ primitives: { brand, neutral },
64
+ fonts: { heading: font(recipe.font.heading), body: font(recipe.font.body), mono: font(recipe.font.mono) },
65
+ semantic: {
66
+ color: colors,
67
+ radius: { base: radiusValue, control: controlRadius },
68
+ border: { width: BORDER[recipe.border] },
69
+ icon: { stroke: STROKE[recipe.stroke] },
70
+ shadow: DEPTH[recipe.depth],
71
+ density: { scale: density.scale, control: { sm: density.sm, md: density.md, lg: density.lg, x: density.x } },
72
+ type,
73
+ },
74
+ density,
75
+ type: typeScale(type),
76
+ resolved,
77
+ radius,
78
+ radiusControl: lookup(controlRadius),
79
+ borderWidth: BORDER[recipe.border],
80
+ stroke: STROKE[recipe.stroke],
81
+ adjustments,
82
+ };
83
+ }
@@ -0,0 +1,102 @@
1
+ /** Types for @halftone-ds/tokens/theme (index.mjs). */
2
+
3
+ export type Neutral = 'zinc' | 'neutral' | 'slate' | 'stone' | 'gray' | 'brand';
4
+ export type Radius = 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
5
+ export type Controls = 'pill' | 'match';
6
+ export type Stroke = 'regular' | 'thin' | 'bold';
7
+ export type Depth = 'soft' | 'flat' | 'raised' | 'hard';
8
+ export type Density = 'comfortable' | 'compact' | 'spacious';
9
+ export type Border = 'regular' | 'hairline' | 'heavy';
10
+ export type Size = '16' | '14' | '15' | '18';
11
+ export type Leading = 'normal' | 'tight' | 'relaxed';
12
+ export type Flow = 'normal' | 'tight' | 'loose';
13
+ export type Measure = '70' | '60' | '80' | '90';
14
+ export type Mode = 'light' | 'dark';
15
+
16
+ export type Recipe = {
17
+ v: number;
18
+ brand: string;
19
+ neutral: Neutral;
20
+ radius: Radius;
21
+ controls: Controls;
22
+ font: { heading: string; body: string; mono: string };
23
+ stroke: Stroke;
24
+ depth: Depth;
25
+ density: Density;
26
+ border: Border;
27
+ size: Size;
28
+ leading: Leading;
29
+ flow: Flow;
30
+ measure: Measure;
31
+ preset?: string;
32
+ };
33
+ export type RecipeInput = Partial<Omit<Recipe, 'font'>> & { font?: Partial<Recipe['font']> };
34
+
35
+ export type Adjustment = { token: string; mode: Mode; from: string; to: string };
36
+
37
+ export type Theme = {
38
+ recipe: Recipe;
39
+ code: string;
40
+ primitives: { brand: Record<string, string>; neutral: Record<string, string> };
41
+ semantic: Record<string, unknown>;
42
+ /** Semantic colours as hex per mode, kebab-case names (`primary-foreground`). */
43
+ resolved: Record<Mode, Record<string, string>>;
44
+ /** Base radius in px. */
45
+ radius: number;
46
+ adjustments: Adjustment[];
47
+ /** Font families by role ('' = system; mono 'Menlo' = the platform monospace). */
48
+ fonts: { heading: string; body: string; mono: string };
49
+ /** The typeset: size (px), scale (size / 16), leading, leadingFactor (leading / 1.75), flow (em), measure (ch). */
50
+ type: TypeScale;
51
+ radiusControl: number;
52
+ borderWidth: number;
53
+ stroke: number;
54
+ density: { sm: number; md: number; lg: number; x: number; scale: number };
55
+ [key: string]: unknown;
56
+ };
57
+
58
+ export type TypeScale = { size: number; scale: number; leading: number; leadingFactor: number; flow: number; measure: number };
59
+ export type Font = { id: string; family: string; category: 'sans' | 'serif' | 'display' | 'mono'; weights: number[]; expoPrefix?: string; hidden?: boolean; system?: boolean };
60
+ export type Preset = { id: string; name: string; description: string; recipe: RecipeInput };
61
+ export type ThemeStatus = { applied: false } | { applied: true; code: string; recipe: Recipe; drift: string[] };
62
+ export type TokensJson = Record<string, unknown> & { primitive: Record<string, unknown>; semantic: Record<string, unknown> };
63
+
64
+ export const RECIPE_VERSION: number;
65
+ export const DEFAULT_RECIPE: Readonly<Recipe>;
66
+ export const OPTIONS: {
67
+ neutral: Neutral[];
68
+ radius: Radius[];
69
+ controls: Controls[];
70
+ stroke: Stroke[];
71
+ depth: Depth[];
72
+ density: Density[];
73
+ border: Border[];
74
+ size: Size[];
75
+ leading: Leading[];
76
+ flow: Flow[];
77
+ measure: Measure[];
78
+ };
79
+ export const TYPESET_KEYS: ['size', 'leading', 'flow', 'measure'];
80
+ export const LEADING: Record<Leading, number>;
81
+ export const FLOW: Record<Flow, number>;
82
+ export const FONTS: Font[];
83
+ export const PRESETS: Preset[];
84
+ export const BRAND_RAMPS: Record<string, Record<string, string>>;
85
+ export const THEME_EXTENSION: string;
86
+
87
+ export function normalizeRecipe(input?: RecipeInput): Recipe;
88
+ export function sameRecipe(a: RecipeInput, b: RecipeInput): boolean;
89
+ export function generateTheme(recipe: RecipeInput, base: TokensJson, options?: { fix?: boolean }): Theme;
90
+ export function encodeRecipe(recipe: RecipeInput): string;
91
+ export function decodeRecipe(code: string): Recipe;
92
+ export function isThemeCode(value: unknown): boolean;
93
+ export function fontById(id: string): Font | undefined;
94
+ export function googleFontsHref(ids: string[]): string | null;
95
+ export function presetById(id: string): Preset | undefined;
96
+ export function defaultTypeset(recipe: Recipe): boolean;
97
+ export function typeVars(type: TypeScale): Record<string, string>;
98
+ export function themeVars(theme: Theme, format: 'hsl' | 'oklch'): Record<Mode, Record<string, string>>;
99
+ export function themeHex(theme: Theme): Record<Mode, Record<string, string>>;
100
+ export function themeCss(theme: Theme, options?: { format?: 'hsl' | 'oklch'; light?: string; dark?: string }): string;
101
+ export function applyRecipe(tokens: TokensJson, recipe: RecipeInput): { tokens: TokensJson; theme: Theme };
102
+ export function themeStatus(tokens: TokensJson): ThemeStatus;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @halftone-ds/tokens/theme — theme recipes, the generator, codes, presets and CSS output. Pure ESM
3
+ * with no Node APIs: the docs site runs it in the browser for the live picker, and the CLI runs it to
4
+ * commit a theme into tokens.json (`halftone-tokens theme apply`).
5
+ */
6
+ export { generateTheme } from './generate.mjs';
7
+ export { encodeRecipe, decodeRecipe, isThemeCode } from './codec.mjs';
8
+ export { DEFAULT_RECIPE, defaultTypeset, FLOW, LEADING, OPTIONS, normalizeRecipe, sameRecipe, RECIPE_VERSION, TYPESET_KEYS } from './recipe.mjs';
9
+ export { FONTS, fontById, googleFontsHref } from './fonts.mjs';
10
+ export { PRESETS, presetById } from './presets.mjs';
11
+ export { themeVars, themeCss, themeHex, typeVars } from './vars.mjs';
12
+ export { applyRecipe, themeStatus, THEME_EXTENSION } from './apply.mjs';
13
+ export { BRAND_RAMPS } from './palettes.mjs';
14
+
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Curated colour ramps (Tailwind CSS v3, MIT). A brand seed that equals one of these ramps' 600 step
3
+ * uses the ramp as-is, so the swatches in the picker give exactly the colours designers know; any
4
+ * other seed is generated (ramp.mjs). Neutrals are always curated, except the brand-tinted one.
5
+ *
6
+ * APPEND-ONLY in spirit: theme codes store the seed hex, not a name, so renaming is safe, but changing
7
+ * a value changes what an existing code produces.
8
+ */
9
+ export const STEPS = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950'];
10
+
11
+ export const BRAND_RAMPS = {
12
+ red: { 50: '#fef2f2', 100: '#fee2e2', 200: '#fecaca', 300: '#fca5a5', 400: '#f87171', 500: '#ef4444', 600: '#dc2626', 700: '#b91c1c', 800: '#991b1b', 900: '#7f1d1d', 950: '#450a0a' },
13
+ orange: { 50: '#fff7ed', 100: '#ffedd5', 200: '#fed7aa', 300: '#fdba74', 400: '#fb923c', 500: '#f97316', 600: '#ea580c', 700: '#c2410c', 800: '#9a3412', 900: '#7c2d12', 950: '#431407' },
14
+ amber: { 50: '#fffbeb', 100: '#fef3c7', 200: '#fde68a', 300: '#fcd34d', 400: '#fbbf24', 500: '#f59e0b', 600: '#d97706', 700: '#b45309', 800: '#92400e', 900: '#78350f', 950: '#451a03' },
15
+ yellow: { 50: '#fefce8', 100: '#fef9c3', 200: '#fef08a', 300: '#fde047', 400: '#facc15', 500: '#eab308', 600: '#ca8a04', 700: '#a16207', 800: '#854d0e', 900: '#713f12', 950: '#422006' },
16
+ lime: { 50: '#f7fee7', 100: '#ecfccb', 200: '#d9f99d', 300: '#bef264', 400: '#a3e635', 500: '#84cc16', 600: '#65a30d', 700: '#4d7c0f', 800: '#3f6212', 900: '#365314', 950: '#1a2e05' },
17
+ green: { 50: '#f0fdf4', 100: '#dcfce7', 200: '#bbf7d0', 300: '#86efac', 400: '#4ade80', 500: '#22c55e', 600: '#16a34a', 700: '#15803d', 800: '#166534', 900: '#14532d', 950: '#052e16' },
18
+ emerald: { 50: '#ecfdf5', 100: '#d1fae5', 200: '#a7f3d0', 300: '#6ee7b7', 400: '#34d399', 500: '#10b981', 600: '#059669', 700: '#047857', 800: '#065f46', 900: '#064e3b', 950: '#022c22' },
19
+ teal: { 50: '#f0fdfa', 100: '#ccfbf1', 200: '#99f6e4', 300: '#5eead4', 400: '#2dd4bf', 500: '#14b8a6', 600: '#0d9488', 700: '#0f766e', 800: '#115e59', 900: '#134e4a', 950: '#042f2e' },
20
+ cyan: { 50: '#ecfeff', 100: '#cffafe', 200: '#a5f3fc', 300: '#67e8f9', 400: '#22d3ee', 500: '#06b6d4', 600: '#0891b2', 700: '#0e7490', 800: '#155e75', 900: '#164e63', 950: '#083344' },
21
+ sky: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e', 950: '#082f49' },
22
+ blue: { 50: '#eff6ff', 100: '#dbeafe', 200: '#bfdbfe', 300: '#93c5fd', 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8', 800: '#1e40af', 900: '#1e3a8a', 950: '#172554' },
23
+ indigo: { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc', 400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', 900: '#312e81', 950: '#1e1b4b' },
24
+ violet: { 50: '#f5f3ff', 100: '#ede9fe', 200: '#ddd6fe', 300: '#c4b5fd', 400: '#a78bfa', 500: '#8b5cf6', 600: '#7c3aed', 700: '#6d28d9', 800: '#5b21b6', 900: '#4c1d95', 950: '#2e1065' },
25
+ purple: { 50: '#faf5ff', 100: '#f3e8ff', 200: '#e9d5ff', 300: '#d8b4fe', 400: '#c084fc', 500: '#a855f7', 600: '#9333ea', 700: '#7e22ce', 800: '#6b21a8', 900: '#581c87', 950: '#3b0764' },
26
+ fuchsia: { 50: '#fdf4ff', 100: '#fae8ff', 200: '#f5d0fe', 300: '#f0abfc', 400: '#e879f9', 500: '#d946ef', 600: '#c026d3', 700: '#a21caf', 800: '#86198f', 900: '#701a75', 950: '#4a044e' },
27
+ pink: { 50: '#fdf2f8', 100: '#fce7f3', 200: '#fbcfe8', 300: '#f9a8d4', 400: '#f472b6', 500: '#ec4899', 600: '#db2777', 700: '#be185d', 800: '#9d174d', 900: '#831843', 950: '#500724' },
28
+ rose: { 50: '#fff1f2', 100: '#ffe4e6', 200: '#fecdd3', 300: '#fda4af', 400: '#fb7185', 500: '#f43f5e', 600: '#e11d48', 700: '#be123c', 800: '#9f1239', 900: '#881337', 950: '#4c0519' },
29
+ };
30
+
31
+ export const NEUTRAL_RAMPS = {
32
+ zinc: { 50: '#fafafa', 100: '#f4f4f5', 200: '#e4e4e7', 300: '#d4d4d8', 400: '#a1a1aa', 500: '#71717a', 600: '#52525b', 700: '#3f3f46', 800: '#27272a', 900: '#18181b', 950: '#09090b' },
33
+ neutral: { 50: '#fafafa', 100: '#f5f5f5', 200: '#e5e5e5', 300: '#d4d4d4', 400: '#a3a3a3', 500: '#737373', 600: '#525252', 700: '#404040', 800: '#262626', 900: '#171717', 950: '#0a0a0a' },
34
+ slate: { 50: '#f8fafc', 100: '#f1f5f9', 200: '#e2e8f0', 300: '#cbd5e1', 400: '#94a3b8', 500: '#64748b', 600: '#475569', 700: '#334155', 800: '#1e293b', 900: '#0f172a', 950: '#020617' },
35
+ gray: { 50: '#f9fafb', 100: '#f3f4f6', 200: '#e5e7eb', 300: '#d1d5db', 400: '#9ca3af', 500: '#6b7280', 600: '#4b5563', 700: '#374151', 800: '#1f2937', 900: '#111827', 950: '#030712' },
36
+ stone: { 50: '#fafaf9', 100: '#f5f5f4', 200: '#e7e5e4', 300: '#d6d3d1', 400: '#a8a29e', 500: '#78716c', 600: '#57534e', 700: '#44403c', 800: '#292524', 900: '#1c1917', 950: '#0c0a09' },
37
+ };