@halftone-ds/tokens 1.2.0 → 1.3.1
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 +110 -22
- 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 +40 -2
package/lib/theme/fix.mjs
CHANGED
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
*
|
|
11
11
|
* It checks the STRICT set (tones on muted/accent too). A moved token becomes a literal hex for that
|
|
12
12
|
* mode, and every move is reported as { token, mode, from, to } so the picker can show it.
|
|
13
|
+
*
|
|
14
|
+
* A system's hand-made choices are never moved; each is a { light: Set, dark: Set } of token names:
|
|
15
|
+
* locked a role mapped by hand: the fixer changes nothing about it (no label flip, no nudge)
|
|
16
|
+
* pinned a role on an edited step: as a label it may still flip between the neutrals, but its colour is
|
|
17
|
+
* never nudged off the step
|
|
18
|
+
* The fixer still flips a generated label on a fixed fill, and moves generated ink off a fixed surface. It never
|
|
19
|
+
* moves a fill to suit a hand-picked label (that would move every surface the fill is). A pairing it leaves
|
|
20
|
+
* failing is reported in `warnings` as { mode, label, fg, bg, ratio, threshold, locked }.
|
|
13
21
|
*/
|
|
14
22
|
import { contrast, hexToOklchParts } from '../color.mjs';
|
|
15
23
|
import { contrastPairs } from '../contrast.mjs';
|
|
@@ -37,9 +45,10 @@ function nudge(hex, dir, ok) {
|
|
|
37
45
|
* semantic: { name: { light, dark } } of references or hex; lookup: (value) → hex.
|
|
38
46
|
* Returns { semantic, adjustments } — a new map, the input is not touched.
|
|
39
47
|
*/
|
|
40
|
-
export function fixContrast(semantic, lookup) {
|
|
48
|
+
export function fixContrast(semantic, lookup, { locked, pinned } = {}) {
|
|
41
49
|
const out = Object.fromEntries(Object.entries(semantic).map(([k, v]) => [k, { ...v }]));
|
|
42
50
|
const adjustments = [];
|
|
51
|
+
const warnings = [];
|
|
43
52
|
|
|
44
53
|
for (const mode of ['light', 'dark']) {
|
|
45
54
|
const original = Object.fromEntries(Object.entries(out).map(([k, v]) => [k, lookup(v[mode])]));
|
|
@@ -48,39 +57,83 @@ export function fixContrast(semantic, lookup) {
|
|
|
48
57
|
const set = (name, value) => {
|
|
49
58
|
out[name][mode] = value;
|
|
50
59
|
};
|
|
60
|
+
const lockedHere = locked?.[mode] ?? new Set();
|
|
61
|
+
const pinnedHere = pinned?.[mode] ?? new Set();
|
|
62
|
+
const isLocked = (name) => lockedHere.has(name);
|
|
63
|
+
// Its colour stays: locked, or on an edited step.
|
|
64
|
+
const isFixed = (name) => lockedHere.has(name) || pinnedHere.has(name);
|
|
65
|
+
// Pairings left as they are: only a locked colour could fix them.
|
|
66
|
+
const accepted = new Set();
|
|
67
|
+
const accept = (f) => accepted.add(f.label);
|
|
68
|
+
// Without locks, a pairing nothing fixes is a bug (it throws, as it always has); with them, a warning.
|
|
69
|
+
const cannot = (f) => {
|
|
70
|
+
if (!lockedHere.size && !pinnedHere.size) throw new Error(`fixContrast: cannot fix ${f.label} (${mode})`);
|
|
71
|
+
accept(f);
|
|
72
|
+
};
|
|
51
73
|
|
|
52
|
-
for (let pass = 0; pass < MAX_PASSES;
|
|
74
|
+
for (let pass = 0; pass < MAX_PASSES; ) {
|
|
53
75
|
const hex = hexNow();
|
|
54
|
-
const [f] = failing(contrastPairs(hex, mode, { strict: true }));
|
|
76
|
+
const [f] = failing(contrastPairs(hex, mode, { strict: true })).filter((p) => !accepted.has(p.label));
|
|
55
77
|
if (!f) break;
|
|
56
78
|
|
|
57
79
|
if (f.fgName === `${f.bgName}-foreground`) {
|
|
58
80
|
// 1. A label on its own fill.
|
|
59
81
|
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) {
|
|
82
|
+
if (!isLocked(f.fgName) && best.ratio >= f.threshold && out[f.fgName][mode] !== best.ref) {
|
|
61
83
|
set(f.fgName, best.ref);
|
|
84
|
+
pass++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (isFixed(f.bgName) || isLocked(f.fgName)) {
|
|
88
|
+
accept(f);
|
|
62
89
|
continue;
|
|
63
90
|
}
|
|
64
91
|
const label = lookup(best.ref);
|
|
65
92
|
// A light label (it reads on black) needs a darker fill; a dark label a lighter one.
|
|
66
93
|
const dir = contrast(label, '#000000') > contrast(label, '#ffffff') ? -1 : 1;
|
|
67
94
|
const moved = nudge(hex[f.bgName], dir, (c) => contrast(label, c) >= f.threshold);
|
|
68
|
-
if (!moved)
|
|
95
|
+
if (!moved) {
|
|
96
|
+
cannot(f);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
69
99
|
set(f.fgName, best.ref);
|
|
70
100
|
moveFill(f.bgName, moved);
|
|
101
|
+
pass++;
|
|
71
102
|
continue;
|
|
72
103
|
}
|
|
73
104
|
|
|
74
105
|
// 2 and 3: move the ink until every pairing it takes part in as ink passes.
|
|
75
106
|
const ink = f.fgName;
|
|
76
|
-
|
|
107
|
+
if (isFixed(ink)) {
|
|
108
|
+
accept(f);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const asInk = (candidate) =>
|
|
112
|
+
failing(contrastPairs({ ...hex, [ink]: candidate }, mode, { strict: true, fg: ink })).filter((p) => !accepted.has(p.label)).length === 0;
|
|
77
113
|
const moved = nudge(hex[ink], away, asInk);
|
|
78
|
-
if (!moved)
|
|
114
|
+
if (!moved) {
|
|
115
|
+
cannot(f);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
79
118
|
moveFill(ink, moved);
|
|
119
|
+
pass++;
|
|
80
120
|
}
|
|
81
121
|
|
|
82
|
-
const
|
|
122
|
+
const all = failing(contrastPairs(hexNow(), mode, { strict: true }));
|
|
123
|
+
const left = all.filter((p) => !accepted.has(p.label));
|
|
83
124
|
if (left.length) throw new Error(`fixContrast: ${left[0].label} still fails in ${mode} after ${MAX_PASSES} passes`);
|
|
125
|
+
// The strict set can name a pairing twice (muted-foreground on muted): warn once.
|
|
126
|
+
for (const p of all.filter((p, i) => all.findIndex((q) => q.label === p.label) === i)) {
|
|
127
|
+
warnings.push({
|
|
128
|
+
mode,
|
|
129
|
+
label: p.label,
|
|
130
|
+
fg: p.fgName,
|
|
131
|
+
bg: p.bgName,
|
|
132
|
+
ratio: Number(p.ratio.toFixed(2)),
|
|
133
|
+
threshold: p.threshold,
|
|
134
|
+
locked: [p.fgName, p.bgName].filter(isFixed),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
84
137
|
|
|
85
138
|
for (const [name, before] of Object.entries(original)) {
|
|
86
139
|
const after = lookup(out[name][mode]);
|
|
@@ -91,9 +144,9 @@ export function fixContrast(semantic, lookup) {
|
|
|
91
144
|
const was = lookup(out[name][mode]);
|
|
92
145
|
set(name, hex);
|
|
93
146
|
if (name === 'primary') {
|
|
94
|
-
for (const linked of BRAND_LINKED) if (lookup(out[linked][mode]) === was) set(linked, hex);
|
|
147
|
+
for (const linked of BRAND_LINKED) if (!isFixed(linked) && lookup(out[linked][mode]) === was) set(linked, hex);
|
|
95
148
|
}
|
|
96
149
|
}
|
|
97
150
|
}
|
|
98
|
-
return { semantic: out, adjustments };
|
|
151
|
+
return { semantic: out, adjustments, warnings };
|
|
99
152
|
}
|
package/lib/theme/fonts.mjs
CHANGED
|
@@ -45,6 +45,19 @@ export const FONTS = [
|
|
|
45
45
|
|
|
46
46
|
export const fontById = (id) => FONTS.find((f) => f.id === id);
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* A font for any Google Fonts family (a system's fonts can be any): its curated entry, or one made from the
|
|
50
|
+
* name the way @expo-google-fonts and next/font name it ('Inter Tight' → inter-tight, InterTight_600SemiBold,
|
|
51
|
+
* Inter_Tight), loading 400–700. A family without one of those weights fails to load; pick weights in the Studio.
|
|
52
|
+
*/
|
|
53
|
+
export function fontByFamily(family) {
|
|
54
|
+
const name = String(family).trim();
|
|
55
|
+
const curated = FONTS.find((f) => f.family.toLowerCase() === name.toLowerCase());
|
|
56
|
+
if (curated) return curated;
|
|
57
|
+
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
58
|
+
return { id, family: name, category: 'sans', weights: [400, 500, 600, 700], expoPrefix: name.replace(/[^A-Za-z0-9]/g, '') };
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
/** The nearest loaded weight of a font to the one a class asks for. */
|
|
49
62
|
export function nearestWeight(font, weight) {
|
|
50
63
|
return font.weights.reduce((best, w) => (Math.abs(w - weight) < Math.abs(best - weight) ? w : best), font.weights[0]);
|
package/lib/theme/generate.mjs
CHANGED
|
@@ -3,29 +3,72 @@
|
|
|
3
3
|
* the browser for the live picker, the CLI runs it to commit a theme into tokens.json.
|
|
4
4
|
*
|
|
5
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 }
|
|
6
|
+
* → { recipe, code, primitives, semantic, resolved, radius, type, adjustments, warnings }
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* A recipe doesn't theme the status colours (success, warning, danger, info): they come from `base`. A system
|
|
9
|
+
* document does (lib/system/generate.mjs), through `colors`, the hook below that only generateSystem uses:
|
|
10
|
+
* colors.ramps { brand?, neutral?, danger?, … }: finished ramps in place of the recipe's (or base's)
|
|
11
|
+
* colors.anchor the brand seed's step; colors.anchors the status seeds' steps
|
|
12
|
+
* colors.roles { name: { light?, dark? } } references or hex chosen by hand: never moved by the fixer
|
|
13
|
+
* colors.edited { ramp: [steps] } edited by hand: a role on one keeps its colour (a warning if it fails)
|
|
14
|
+
* Without it, the theme is exactly the recipe's.
|
|
9
15
|
*/
|
|
10
16
|
import { fixContrast } from './fix.mjs';
|
|
11
17
|
import { encodeRecipe } from './codec.mjs';
|
|
12
18
|
import { fontById } from './fonts.mjs';
|
|
13
|
-
import {
|
|
19
|
+
import { componentLayer } from './components.mjs';
|
|
20
|
+
import { BORDER, BUTTON, DENSITY, DEPTH, FIELD, fieldWidth, FLOW, LEADING, normalizeRecipe, RADIUS, resolveTextStyles, STROKE, textEdits, typeScale } from './recipe.mjs';
|
|
14
21
|
import { anchorStep, brandRamp, neutralRamp } from './ramp.mjs';
|
|
15
22
|
import { resolvedMap } from './refs.mjs';
|
|
16
|
-
import { semanticColors, styleColors } from './semantic.mjs';
|
|
23
|
+
import { BRAND_LINKED, semanticColors, styleColors } from './semantic.mjs';
|
|
17
24
|
|
|
18
25
|
|
|
19
26
|
const ramp = (values) => Object.fromEntries(Object.entries(values).map(([step, $value]) => [step, { $type: 'color', $value }]));
|
|
27
|
+
const STATUS = ['danger', 'success', 'warning', 'info'];
|
|
28
|
+
const modes = () => ({ light: new Set(), dark: new Set() });
|
|
29
|
+
|
|
30
|
+
/** Role overrides on top of a semantic map; the tokens that share primary's step follow a primary override. */
|
|
31
|
+
function withRoles(colors, roles, linked = false) {
|
|
32
|
+
if (!roles) return colors;
|
|
33
|
+
const out = Object.fromEntries(Object.entries(colors).map(([k, v]) => [k, { ...v }]));
|
|
34
|
+
for (const mode of ['light', 'dark']) {
|
|
35
|
+
const primary = roles.primary?.[mode];
|
|
36
|
+
if (linked && primary !== undefined) for (const name of BRAND_LINKED) if (!roles[name]?.[mode] && out[name]?.[mode] === colors.primary[mode]) out[name][mode] = primary;
|
|
37
|
+
for (const [name, value] of Object.entries(roles)) if (name in out && value[mode] !== undefined) out[name][mode] = value[mode];
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Which colours the fixer leaves alone, per mode: `locked` = mapped by hand (and what follows a hand-mapped
|
|
44
|
+
* primary), `pinned` = on an edited step. `from` locks a style colour that is a locked base colour (a solid
|
|
45
|
+
* button is the primary).
|
|
46
|
+
*/
|
|
47
|
+
function locks(colors, { roles = {}, edited = {} }, from) {
|
|
48
|
+
const locked = modes();
|
|
49
|
+
const pinned = modes();
|
|
50
|
+
const editedRef = new Set(Object.entries(edited).flatMap(([name, steps]) => steps.map((step) => `{primitive.color.${name}.${step}}`)));
|
|
51
|
+
for (const mode of ['light', 'dark']) {
|
|
52
|
+
for (const [name, value] of Object.entries(colors)) {
|
|
53
|
+
if (roles[name]?.[mode] !== undefined) locked[mode].add(name);
|
|
54
|
+
else if (roles.primary?.[mode] !== undefined && BRAND_LINKED.includes(name) && value[mode] === roles.primary[mode]) locked[mode].add(name);
|
|
55
|
+
else if (from && !(name in from.colors) && Object.entries(from.colors).some(([base, v]) => from.locked[mode].has(base) && v[mode] === value[mode])) locked[mode].add(name);
|
|
56
|
+
else if (editedRef.has(value[mode])) pinned[mode].add(name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { locked, pinned };
|
|
60
|
+
}
|
|
20
61
|
|
|
21
62
|
/** 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 } = {}) {
|
|
63
|
+
export function generateTheme(input, base, { fix = true, colors: system } = {}) {
|
|
23
64
|
const recipe = normalizeRecipe(input);
|
|
24
|
-
const brand = brandRamp(recipe.brand);
|
|
25
|
-
const neutral = neutralRamp(recipe.neutral, recipe.brand);
|
|
65
|
+
const brand = system?.ramps?.brand ?? brandRamp(recipe.brand);
|
|
66
|
+
const neutral = system?.ramps?.neutral ?? neutralRamp(recipe.neutral, recipe.brand);
|
|
67
|
+
const status = Object.fromEntries(STATUS.filter((name) => system?.ramps?.[name]).map((name) => [name, system.ramps[name]]));
|
|
68
|
+
const statusTokens = Object.fromEntries(Object.entries(status).map(([name, values]) => [name, ramp(values)]));
|
|
26
69
|
|
|
27
70
|
// 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) } };
|
|
71
|
+
const primitive = { ...base.primitive, color: { ...base.primitive.color, brand: ramp(brand), neutral: ramp(neutral), ...statusTokens } };
|
|
29
72
|
const prim = resolvedMap({ primitive }, 'light');
|
|
30
73
|
const lookup = (value) => {
|
|
31
74
|
if (typeof value !== 'string' || !value.startsWith('{')) return value;
|
|
@@ -34,16 +77,21 @@ export function generateTheme(input, base, { fix = true } = {}) {
|
|
|
34
77
|
return hex;
|
|
35
78
|
};
|
|
36
79
|
|
|
37
|
-
const anchor = anchorStep(recipe.brand);
|
|
38
|
-
let colors = semanticColors(anchor);
|
|
80
|
+
const anchor = system?.anchor ?? anchorStep(recipe.brand);
|
|
81
|
+
let colors = withRoles(semanticColors(anchor, system?.anchors), system?.roles, true);
|
|
82
|
+
const baseLocks = locks(colors, system ?? {});
|
|
39
83
|
let adjustments = [];
|
|
40
|
-
|
|
84
|
+
let warnings = [];
|
|
85
|
+
if (fix) ({ semantic: colors, adjustments, warnings } = fixContrast(colors, lookup, baseLocks));
|
|
41
86
|
// The styles' colours build on the fixed ones, then get their own check (a soft button's ink, a field's line).
|
|
42
|
-
|
|
87
|
+
const fixed = colors;
|
|
88
|
+
colors = { ...colors, ...withRoles(styleColors(recipe, anchor, colors), system?.roles) };
|
|
43
89
|
if (fix) {
|
|
44
|
-
const styled = fixContrast(colors, lookup);
|
|
90
|
+
const styled = fixContrast(colors, lookup, locks(colors, system ?? {}, { colors: fixed, locked: baseLocks.locked }));
|
|
45
91
|
colors = styled.semantic;
|
|
46
92
|
adjustments = [...adjustments, ...styled.adjustments];
|
|
93
|
+
// The second pass sees every pairing again: keep each warning once.
|
|
94
|
+
warnings = styled.warnings;
|
|
47
95
|
}
|
|
48
96
|
|
|
49
97
|
const radiusRef = RADIUS[recipe.radius];
|
|
@@ -70,16 +118,13 @@ export function generateTheme(input, base, { fix = true } = {}) {
|
|
|
70
118
|
const fld = FIELD[recipe.field];
|
|
71
119
|
const button = { alpha: btn.alpha, border: btn.border, edge: btn.edge };
|
|
72
120
|
const field = { alpha: fld.alpha, border: fieldWidth(fld.border, borderWidth), edge: fieldWidth(fld.edge, borderWidth), inset: fld.inset };
|
|
73
|
-
// As tokens: widths named apart from the colours (semantic.color.button-edge is a colour).
|
|
74
|
-
const buttonTokens = { alpha: button.alpha, 'border-width': button.border, 'edge-width': button.edge };
|
|
75
|
-
const fieldTokens = { alpha: field.alpha, 'border-width': field.border, 'edge-width': field.edge, round: fld.round, 'round-bottom': fld.roundBottom, inset: field.inset };
|
|
76
121
|
// rounded-md is the radius × md / lg (resolve.mjs); a field's radius is that or square.
|
|
77
122
|
const md = lookup('{primitive.radius.md}') / lookup('{primitive.radius.lg}');
|
|
78
123
|
|
|
79
|
-
|
|
124
|
+
const theme = {
|
|
80
125
|
recipe,
|
|
81
126
|
code: encodeRecipe(recipe),
|
|
82
|
-
primitives: { brand, neutral },
|
|
127
|
+
primitives: { brand, neutral, ...status },
|
|
83
128
|
fonts: { heading: font(recipe.font.heading), body: font(recipe.font.body), mono: font(recipe.font.mono) },
|
|
84
129
|
semantic: {
|
|
85
130
|
color: colors,
|
|
@@ -89,8 +134,6 @@ export function generateTheme(input, base, { fix = true } = {}) {
|
|
|
89
134
|
shadow: DEPTH[recipe.depth],
|
|
90
135
|
density: { scale: density.scale, control: { sm: density.sm, md: density.md, lg: density.lg, x: density.x } },
|
|
91
136
|
type,
|
|
92
|
-
button: buttonTokens,
|
|
93
|
-
field: fieldTokens,
|
|
94
137
|
},
|
|
95
138
|
density,
|
|
96
139
|
type: typeScale(type),
|
|
@@ -102,5 +145,40 @@ export function generateTheme(input, base, { fix = true } = {}) {
|
|
|
102
145
|
field: { ...field, radius: lookup(radiusValue) * md * fld.round, radiusBottom: lookup(radiusValue) * md * fld.roundBottom },
|
|
103
146
|
stroke: STROKE[recipe.stroke],
|
|
104
147
|
adjustments,
|
|
148
|
+
warnings,
|
|
105
149
|
};
|
|
150
|
+
return withComponents(theme, base);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The theme with its component layer (lib/theme/components.mjs): semantic.component (the tokens) and `components`
|
|
155
|
+
* (resolved). Built from the theme's own corners, density, border, styles, text styles and colours, so
|
|
156
|
+
* generateSystem calls it again once a system's edits are in, with the system's `components` as `overrides`.
|
|
157
|
+
*/
|
|
158
|
+
export function withComponents(theme, base, overrides = {}) {
|
|
159
|
+
const primitive = { ...base.primitive, color: { ...base.primitive.color, ...Object.fromEntries(Object.entries(theme.primitives).map(([name, values]) => [name, ramp(values)])) } };
|
|
160
|
+
const prim = resolvedMap({ primitive }, 'light');
|
|
161
|
+
const lg = prim['primitive.radius.lg'];
|
|
162
|
+
const edited = theme.semantic.radius;
|
|
163
|
+
const fld = FIELD[theme.recipe.field];
|
|
164
|
+
const { tokens, shape } = componentLayer({
|
|
165
|
+
styles: { button: theme.recipe.button, field: theme.recipe.field },
|
|
166
|
+
controls: theme.recipe.controls,
|
|
167
|
+
corner: (name) => (typeof edited[name] === 'number' ? edited[name] : (theme.radius * prim[`primitive.radius.${name}`]) / lg),
|
|
168
|
+
density: theme.density,
|
|
169
|
+
borderWidth: theme.borderWidth,
|
|
170
|
+
button: theme.button,
|
|
171
|
+
field: { ...theme.field, round: fld.round, roundBottom: fld.roundBottom },
|
|
172
|
+
textStyles: resolveTextStyles(theme.type, textEdits(theme.semantic.type)),
|
|
173
|
+
type: theme.type,
|
|
174
|
+
hex: (ref, mode) => {
|
|
175
|
+
const name = /^\{semantic\.color\.(.+)\}$/.exec(ref)?.[1];
|
|
176
|
+
if (name) return theme.resolved[mode][name];
|
|
177
|
+
const value = prim[ref.slice(1, -1)];
|
|
178
|
+
if (value === undefined) throw new Error(`generateTheme: ${ref} is not in the base tokens`);
|
|
179
|
+
return value;
|
|
180
|
+
},
|
|
181
|
+
overrides,
|
|
182
|
+
});
|
|
183
|
+
return { ...theme, semantic: { ...theme.semantic, component: tokens }, components: shape };
|
|
106
184
|
}
|
package/lib/theme/index.d.mts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
/** Types for @halftone-ds/tokens/theme (index.mjs). */
|
|
2
|
+
import type { HalftoneSystem } from '../system/index.mjs';
|
|
3
|
+
// Systems (lib/system): also @halftone-ds/tokens/system.
|
|
4
|
+
export * from '../system/index.mjs';
|
|
2
5
|
|
|
3
6
|
export type Neutral = 'zinc' | 'neutral' | 'slate' | 'stone' | 'gray' | 'brand';
|
|
4
7
|
export type Radius = 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
@@ -43,17 +46,22 @@ export type Recipe = {
|
|
|
43
46
|
export type RecipeInput = Partial<Omit<Recipe, 'font'>> & { font?: Partial<Recipe['font']> };
|
|
44
47
|
|
|
45
48
|
export type Adjustment = { token: string; mode: Mode; from: string; to: string };
|
|
49
|
+
/** A pairing the contrast fixer left failing because a colour in it was chosen by hand (a system's role or step). */
|
|
50
|
+
export type ThemeWarning = { mode: Mode; label: string; fg: string; bg: string; ratio: number; threshold: number; locked: string[] };
|
|
46
51
|
|
|
47
52
|
export type Theme = {
|
|
48
53
|
recipe: Recipe;
|
|
49
54
|
code: string;
|
|
50
|
-
|
|
55
|
+
/** brand and neutral, plus a status ramp a system seeded or edited. */
|
|
56
|
+
primitives: { brand: Record<string, string>; neutral: Record<string, string> } & Partial<Record<'danger' | 'success' | 'warning' | 'info', Record<string, string>>>;
|
|
51
57
|
semantic: Record<string, unknown>;
|
|
52
58
|
/** Semantic colours as hex per mode, kebab-case names (`primary-foreground`). */
|
|
53
59
|
resolved: Record<Mode, Record<string, string>>;
|
|
54
60
|
/** Base radius in px. */
|
|
55
61
|
radius: number;
|
|
56
62
|
adjustments: Adjustment[];
|
|
63
|
+
/** Always empty for a recipe; a system's hand-picked colours that fail contrast. */
|
|
64
|
+
warnings: ThemeWarning[];
|
|
57
65
|
/** Font families by role ('' = system; mono 'Menlo' = the platform monospace). */
|
|
58
66
|
fonts: { heading: string; body: string; mono: string };
|
|
59
67
|
/** The typeset: size (px), scale (size / 16), leading, leadingFactor (leading / 1.75), flow (em), measure (ch). */
|
|
@@ -66,13 +74,80 @@ export type Theme = {
|
|
|
66
74
|
button: { alpha: number; border: number; edge: number };
|
|
67
75
|
/** The field style's numbers: fill opacity per mode, border and bottom edge (px), radii (px), inset (0 or 1). */
|
|
68
76
|
field: { alpha: Record<Mode, number>; border: number; edge: number; inset: number; radius: number; radiusBottom: number };
|
|
77
|
+
/** The component layer (Button, Chip, Badge, Input), resolved: what both kits draw. */
|
|
78
|
+
components: ComponentShapes;
|
|
69
79
|
[key: string]: unknown;
|
|
70
80
|
};
|
|
71
81
|
|
|
82
|
+
export type ComponentName = 'button' | 'chip' | 'badge' | 'input';
|
|
83
|
+
export type Corner = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
|
84
|
+
export type ComponentSlot = 'fill' | 'label' | 'border' | 'pressed';
|
|
85
|
+
/** A component colour as drawn: its role (null for a style's ramp step), hex and opacity per mode. */
|
|
86
|
+
export type ComponentColor = {
|
|
87
|
+
role: string | null;
|
|
88
|
+
light: string;
|
|
89
|
+
dark: string;
|
|
90
|
+
alpha: Record<Mode, number>;
|
|
91
|
+
ref?: Record<Mode, string>;
|
|
92
|
+
/** Set in the system (not the default). */
|
|
93
|
+
edited?: boolean;
|
|
94
|
+
/** Pressed derived from the fill, a ring from the border. */
|
|
95
|
+
derived?: boolean;
|
|
96
|
+
};
|
|
97
|
+
export type ComponentVariant = Partial<Record<ComponentSlot | 'ring', ComponentColor>> & { borderWidth?: number; edgeWidth?: number };
|
|
98
|
+
export type ComponentShape = {
|
|
99
|
+
style?: string;
|
|
100
|
+
/** The Foundations corner it follows, or null when set in px. */
|
|
101
|
+
corner?: Corner | null;
|
|
102
|
+
/** px (9999 = a pill); Input: the top corners. */
|
|
103
|
+
radius: number;
|
|
104
|
+
radiusBottom?: number;
|
|
105
|
+
/** Heights (px) by size. */
|
|
106
|
+
h: Partial<Record<'sm' | 'md' | 'lg', number>>;
|
|
107
|
+
/** Horizontal padding (px) by size. */
|
|
108
|
+
x: Partial<Record<'sm' | 'md' | 'lg', number>>;
|
|
109
|
+
/** Font role, size and line height (px), tracking (em); `style` the text style it follows. */
|
|
110
|
+
text: { style?: string; font: 'heading' | 'body' | 'mono'; size: number; leading: number; tracking: number };
|
|
111
|
+
variants: Record<string, ComponentVariant>;
|
|
112
|
+
};
|
|
113
|
+
export type ComponentShapes = Record<ComponentName, ComponentShape>;
|
|
114
|
+
export type ComponentDefinition = {
|
|
115
|
+
prefix: string;
|
|
116
|
+
label: string;
|
|
117
|
+
styles: string[];
|
|
118
|
+
sizes: Array<'sm' | 'md' | 'lg' | 'x'>;
|
|
119
|
+
variants: Record<string, ComponentSlot[]>;
|
|
120
|
+
text: string;
|
|
121
|
+
};
|
|
122
|
+
export const COMPONENT_NAMES: ComponentName[];
|
|
123
|
+
export const COMPONENTS: Record<ComponentName, ComponentDefinition>;
|
|
124
|
+
export const CORNERS: Corner[];
|
|
125
|
+
export const SLOTS: ComponentSlot[];
|
|
126
|
+
export const SLOT_VAR: Record<ComponentSlot | 'ring', string>;
|
|
127
|
+
/** The touch-target floor (px) the mobile kit applies on phones. */
|
|
128
|
+
export const TOUCH_TARGET: number;
|
|
129
|
+
export function defaultCorner(name: ComponentName, options?: { controls?: string; style?: string }): Corner;
|
|
130
|
+
export function defaultStyle(name: ComponentName, recipe?: Partial<Recipe>): string;
|
|
131
|
+
export function fittedLeading(style: string): number;
|
|
132
|
+
export function componentVars(shape: ComponentShapes, format: 'hsl' | 'oklch', convert: (hex: string) => string, font?: (role: string) => string): Record<Mode, Record<string, string>>;
|
|
133
|
+
export function componentVarNames(): string[];
|
|
134
|
+
export function componentColors(shape: ComponentShapes, css: (key: string, alpha: boolean) => string): Record<string, string>;
|
|
135
|
+
export function componentPairs(
|
|
136
|
+
shape: ComponentShapes,
|
|
137
|
+
mode: Mode,
|
|
138
|
+
background: string,
|
|
139
|
+
blend: (hex: string, base: string, alpha: number) => string
|
|
140
|
+
): Array<{ component: ComponentName; variant: string; label: string; fg: string; bg: string; fgRole: string | null; bgRole: string | null }>;
|
|
141
|
+
/** Every variant's label on its fill, both modes: rows like contrastReport's, with the component and variant. */
|
|
142
|
+
export function componentContrast(shape: ComponentShapes, background: Record<Mode, string>): Array<ContrastPair & { component: ComponentName; variant: string }>;
|
|
143
|
+
|
|
72
144
|
export type TypeScale = { size: number; scale: number; leading: number; leadingFactor: number; flow: number; measure: number };
|
|
73
145
|
export type Font = { id: string; family: string; category: 'sans' | 'serif' | 'display' | 'mono'; weights: number[]; expoPrefix?: string; hidden?: boolean; system?: boolean };
|
|
74
146
|
export type Preset = { id: string; name: string; description: string; recipe: RecipeInput };
|
|
75
|
-
export type ThemeStatus =
|
|
147
|
+
export type ThemeStatus =
|
|
148
|
+
| { applied: false }
|
|
149
|
+
/** code is null for a system with overrides; system is set when a system document was applied. */
|
|
150
|
+
| { applied: true; code: string | null; recipe: Recipe; system?: HalftoneSystem; drift: string[] };
|
|
76
151
|
export type TokensJson = Record<string, unknown> & { primitive: Record<string, unknown>; semantic: Record<string, unknown> };
|
|
77
152
|
|
|
78
153
|
export const RECIPE_VERSION: number;
|
|
@@ -111,11 +186,25 @@ export function encodeRecipe(recipe: RecipeInput): string;
|
|
|
111
186
|
export function decodeRecipe(code: string): Recipe;
|
|
112
187
|
export function isThemeCode(value: unknown): boolean;
|
|
113
188
|
export function fontById(id: string): Font | undefined;
|
|
189
|
+
/** The curated font for a family, or one made from any Google Fonts family name (400–700). */
|
|
190
|
+
export function fontByFamily(family: string): Font;
|
|
114
191
|
export function googleFontsHref(ids: string[]): string | null;
|
|
115
192
|
export function presetById(id: string): Preset | undefined;
|
|
116
193
|
export function defaultTypeset(recipe: Recipe): boolean;
|
|
117
194
|
export function defaultStyles(recipe: Recipe): boolean;
|
|
118
195
|
export function typeVars(type: TypeScale): Record<string, string>;
|
|
196
|
+
export type TextStyleName = 'h1' | 'h2' | 'h3' | 'h4' | 'lead' | 'p' | 'large' | 'small' | 'muted' | 'code' | 'blockquote';
|
|
197
|
+
export type TextFontRole = 'heading' | 'body' | 'mono';
|
|
198
|
+
/** A text style as drawn: size and lineHeight px, leading × size, tracking em; edited: a system changed it. */
|
|
199
|
+
export type DrawnTextStyle = { font: TextFontRole; weight: number; size: number; leading: number; tracking: number; lineHeight: number; edited: boolean };
|
|
200
|
+
export const TEXT_STYLES: TextStyleName[];
|
|
201
|
+
export const TEXT_DEFAULTS: Record<TextStyleName, { font: TextFontRole; weight: number; size: number; leading: number | 'prose'; tracking: number }>;
|
|
202
|
+
/** A system's edited styles from a semantic.type group (a theme's, or a token file's DTCG one). */
|
|
203
|
+
export function textEdits(group?: Record<string, unknown>): Partial<Record<TextStyleName, Partial<Omit<DrawnTextStyle, 'lineHeight' | 'edited'>>>>;
|
|
204
|
+
export function resolveTextStyles(type: TypeScale, edits?: ReturnType<typeof textEdits>): Record<TextStyleName, DrawnTextStyle>;
|
|
205
|
+
export function themeTextStyles(theme: Theme): Record<TextStyleName, DrawnTextStyle>;
|
|
206
|
+
/** --type-<style>-{size,leading,weight,tracking} (+ -font on the web) in a kit's units. */
|
|
207
|
+
export function textStyleVars(styles: Record<string, DrawnTextStyle>, format: 'hsl' | 'oklch', font?: (role: TextFontRole) => string): Record<string, string>;
|
|
119
208
|
export function styleVars(styles: Pick<Theme, 'button' | 'field'>, format: 'hsl' | 'oklch'): Record<Mode, Record<string, string>>;
|
|
120
209
|
export function themeVars(theme: Theme, format: 'hsl' | 'oklch'): Record<Mode, Record<string, string>>;
|
|
121
210
|
export function themeHex(theme: Theme): Record<Mode, Record<string, string>>;
|
package/lib/theme/index.mjs
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export { generateTheme } from './generate.mjs';
|
|
7
7
|
export { encodeRecipe, decodeRecipe, isThemeCode } from './codec.mjs';
|
|
8
|
-
export { DEFAULT_RECIPE, defaultStyles, defaultTypeset, FLOW, LEADING, OPTIONS, normalizeRecipe, sameRecipe, RECIPE_VERSION, STYLE_KEYS, TYPESET_KEYS } from './recipe.mjs';
|
|
9
|
-
export { FONTS, fontById, googleFontsHref } from './fonts.mjs';
|
|
8
|
+
export { DEFAULT_RECIPE, defaultStyles, defaultTypeset, FLOW, LEADING, OPTIONS, normalizeRecipe, sameRecipe, RECIPE_VERSION, resolveTextStyles, STYLE_KEYS, TEXT_DEFAULTS, TEXT_STYLES, textEdits, TYPESET_KEYS } from './recipe.mjs';
|
|
9
|
+
export { FONTS, fontByFamily, fontById, googleFontsHref } from './fonts.mjs';
|
|
10
10
|
export { PRESETS, presetById } from './presets.mjs';
|
|
11
|
-
export { styleVars, themeVars, themeCss, themeHex, typeVars } from './vars.mjs';
|
|
11
|
+
export { styleVars, textStyleVars, themeTextStyles, themeVars, themeCss, themeHex, typeVars } from './vars.mjs';
|
|
12
12
|
export { applyRecipe, themeStatus, THEME_EXTENSION } from './apply.mjs';
|
|
13
|
+
// Systems (lib/system): also @halftone-ds/tokens/system.
|
|
14
|
+
export * from '../system/index.mjs';
|
|
13
15
|
export { BRAND_RAMPS } from './palettes.mjs';
|
|
14
16
|
export {
|
|
15
17
|
decodeShadcnPreset,
|
|
@@ -23,5 +25,20 @@ export {
|
|
|
23
25
|
} from './shadcn.mjs';
|
|
24
26
|
export { CVD_TYPES, cvdWarnings, describeCvdWarnings, DISTINCT_PAIRS, MIN_DELTA_E, pairDistances, simulateCvd } from '../cvd.mjs';
|
|
25
27
|
|
|
26
|
-
export { AA, AA_NON_TEXT, contrastPairs, contrastReport } from '../contrast.mjs';
|
|
28
|
+
export { AA, AA_NON_TEXT, componentContrast, contrastPairs, contrastReport } from '../contrast.mjs';
|
|
29
|
+
export {
|
|
30
|
+
COMPONENT_NAMES,
|
|
31
|
+
COMPONENTS,
|
|
32
|
+
componentColors,
|
|
33
|
+
componentPairs,
|
|
34
|
+
componentVarNames,
|
|
35
|
+
componentVars,
|
|
36
|
+
CORNERS,
|
|
37
|
+
defaultCorner,
|
|
38
|
+
defaultStyle,
|
|
39
|
+
fittedLeading,
|
|
40
|
+
SLOT_VAR,
|
|
41
|
+
SLOTS,
|
|
42
|
+
TOUCH_TARGET,
|
|
43
|
+
} from './components.mjs';
|
|
27
44
|
export { apcaContrast } from '../apca.mjs';
|
package/lib/theme/presets.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
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
|
|
3
|
+
* Drafts — review them in Studio on the docs site (/studio) and adjust here. Order is the gallery order.
|
|
4
4
|
*/
|
|
5
5
|
export const PRESETS = [
|
|
6
6
|
{
|
package/lib/theme/ramp.mjs
CHANGED
|
@@ -32,38 +32,64 @@ export function curatedRamp(seed) {
|
|
|
32
32
|
return Object.entries(BRAND_RAMPS).find(([, ramp]) => ramp[600] === hex)?.[0] ?? null;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** The step nearest a lightness, in a lightness table. */
|
|
36
|
+
const nearestStep = (L, table) => STEPS.reduce((best, s) => (Math.abs(table[s] - L) < Math.abs(table[best] - L) ? s : best), '600');
|
|
37
|
+
|
|
35
38
|
/** The step a seed sits on: its curated 600, or the generated step nearest its lightness. */
|
|
36
39
|
export function anchorStep(seed) {
|
|
37
40
|
if (curatedRamp(seed)) return '600';
|
|
38
|
-
|
|
39
|
-
return STEPS.reduce((best, s) => (Math.abs(LIGHTNESS[s] - L) < Math.abs(LIGHTNESS[best] - L) ? s : best), '600');
|
|
41
|
+
return nearestStep(hexToOklchParts(seed)[0], LIGHTNESS);
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The 11 steps around a seed, in OKLCH, with the seed exactly on `anchor`.
|
|
46
|
+
* lightness, chroma the profile per step (chroma is relative: scaled so the anchor keeps the seed's chroma)
|
|
47
|
+
* auto the anchor nearest the seed's lightness: the seed's offset fades out over three steps
|
|
48
|
+
* forced any other anchor: each side is stretched between the seed and its end of the profile,
|
|
49
|
+
* which keeps the ramp in order however far the seed is from its step
|
|
50
|
+
*/
|
|
51
|
+
function seededRamp(seed, anchor, lightness, chroma) {
|
|
47
52
|
const [Ls, Cs, Hs] = hexToOklchParts(seed);
|
|
48
|
-
const anchor = anchorStep(seed);
|
|
49
53
|
const ai = STEPS.indexOf(anchor);
|
|
50
|
-
const
|
|
51
|
-
const
|
|
54
|
+
const auto = nearestStep(Ls, lightness) === anchor;
|
|
55
|
+
const dL = Ls - lightness[anchor];
|
|
56
|
+
const chromaScale = chroma[anchor] ? Cs / chroma[anchor] : 0;
|
|
57
|
+
// Forced: the ends of the ramp, kept a little beyond the seed so a step never repeats it.
|
|
58
|
+
const top = Math.min(1, Math.max(lightness[STEPS[0]], Ls + 0.01 * ai));
|
|
59
|
+
const bottom = Math.max(0, Math.min(lightness[STEPS.at(-1)], Ls - 0.01 * (STEPS.length - 1 - ai)));
|
|
52
60
|
const ramp = {};
|
|
53
61
|
STEPS.forEach((s, i) => {
|
|
54
62
|
if (s === anchor) {
|
|
55
63
|
ramp[s] = seed.toLowerCase();
|
|
56
64
|
return;
|
|
57
65
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
let L;
|
|
67
|
+
if (auto) {
|
|
68
|
+
// The seed's offset from the target lightness fades out over three steps either side, which keeps
|
|
69
|
+
// neighbours in order (|dL| is at most half the gap to the next step).
|
|
70
|
+
const fade = Math.max(0, 1 - Math.abs(i - ai) / 3);
|
|
71
|
+
L = lightness[s] + dL * fade;
|
|
72
|
+
} else {
|
|
73
|
+
const end = i < ai ? STEPS[0] : STEPS.at(-1);
|
|
74
|
+
const t = (lightness[s] - lightness[anchor]) / (lightness[end] - lightness[anchor]);
|
|
75
|
+
L = Ls + t * ((i < ai ? top : bottom) - Ls);
|
|
76
|
+
}
|
|
77
|
+
ramp[s] = toGamutHex([L, chroma[s] * chromaScale, Hs]);
|
|
63
78
|
});
|
|
64
79
|
return ramp;
|
|
65
80
|
}
|
|
66
81
|
|
|
82
|
+
/**
|
|
83
|
+
* { 50: '#…', …, 950: '#…' } for a seed. `anchor` pins the seed to another step than its nearest one (the
|
|
84
|
+
* studio's "pinned step"); a curated seed with any other anchor is generated like any seed.
|
|
85
|
+
*/
|
|
86
|
+
export function brandRamp(seed, { anchor } = {}) {
|
|
87
|
+
const curated = curatedRamp(seed);
|
|
88
|
+
if (curated && (!anchor || anchor === '600')) return { ...BRAND_RAMPS[curated] };
|
|
89
|
+
const [L] = hexToOklchParts(seed);
|
|
90
|
+
return seededRamp(seed, anchor ?? nearestStep(L, LIGHTNESS), LIGHTNESS, CHROMA);
|
|
91
|
+
}
|
|
92
|
+
|
|
67
93
|
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
94
|
|
|
69
95
|
/** The 13-step neutral: 0 (white), the 11 steps, 1000 (black). */
|
|
@@ -80,3 +106,17 @@ export function neutralRamp(kind, seed) {
|
|
|
80
106
|
ramp[1000] = '#000000';
|
|
81
107
|
return ramp;
|
|
82
108
|
}
|
|
109
|
+
|
|
110
|
+
/** Zinc's lightness per step: a neutral seed's ramp keeps the grey ramps' range (950 is near black). */
|
|
111
|
+
const NEUTRAL_LIGHTNESS = Object.fromEntries(STEPS.map((s) => [s, hexToOklchParts(NEUTRAL_RAMPS.zinc[s])[0]]));
|
|
112
|
+
|
|
113
|
+
/** The step a neutral seed sits on: the zinc step nearest its lightness. */
|
|
114
|
+
export const neutralAnchorStep = (seed) => nearestStep(hexToOklchParts(seed)[0], NEUTRAL_LIGHTNESS);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The 13-step neutral for a custom seed (any grey, or any colour as a tint): zinc's lightness, the tint's
|
|
118
|
+
* chroma profile scaled to the seed, the seed exactly on its step.
|
|
119
|
+
*/
|
|
120
|
+
export function neutralSeedRamp(seed, { anchor } = {}) {
|
|
121
|
+
return { 0: '#ffffff', ...seededRamp(seed, anchor ?? neutralAnchorStep(seed), NEUTRAL_LIGHTNESS, TINT_CHROMA), 1000: '#000000' };
|
|
122
|
+
}
|