@halftone-ds/tokens 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -4
- package/cli.mjs +33 -14
- package/lib/apca.mjs +55 -0
- package/lib/color.mjs +27 -7
- package/lib/contrast.mjs +56 -1
- package/lib/cvd.mjs +97 -0
- package/lib/resolve.mjs +100 -12
- package/lib/system/generate.mjs +212 -0
- package/lib/system/index.d.mts +168 -0
- package/lib/system/index.mjs +33 -0
- package/lib/system/migrate.mjs +38 -0
- package/lib/system/schema.mjs +511 -0
- package/lib/theme/apply.mjs +191 -16
- package/lib/theme/codec.mjs +38 -23
- package/lib/theme/components.mjs +471 -0
- package/lib/theme/extras.mjs +105 -0
- package/lib/theme/fix.mjs +63 -10
- package/lib/theme/fonts.mjs +16 -1
- package/lib/theme/generate.mjs +114 -13
- package/lib/theme/index.d.mts +166 -2
- package/lib/theme/index.mjs +33 -3
- package/lib/theme/presets.mjs +19 -7
- package/lib/theme/ramp.mjs +55 -15
- package/lib/theme/recipe.mjs +107 -0
- package/lib/theme/semantic.mjs +43 -6
- package/lib/theme/shadcn.mjs +265 -0
- package/lib/theme/vars.mjs +81 -3
- package/lib/theme-cli.mjs +179 -17
- package/lib/usage.mjs +136 -0
- package/package.json +5 -1
- package/targets/design-md.mjs +64 -17
- package/targets/figma.mjs +1 -1
- package/targets/fonts.mjs +5 -5
- package/targets/llms.mjs +9 -4
- package/targets/nativewind3.mjs +77 -12
- package/targets/registry-theme.mjs +3 -3
- package/targets/shadcn-web.mjs +106 -6
- package/targets/ts-theme.mjs +42 -0
package/lib/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
|
{
|
|
@@ -12,20 +12,20 @@ export const PRESETS = [
|
|
|
12
12
|
{
|
|
13
13
|
id: 'editorial',
|
|
14
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' },
|
|
15
|
+
description: 'Serif headlines, rust ink, hairlines, underlined fields 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', button: 'solid', field: 'underline' },
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
id: 'soft',
|
|
20
20
|
name: 'Soft',
|
|
21
|
-
description: 'Lavender, generous radius,
|
|
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' },
|
|
21
|
+
description: 'Lavender, generous radius, tinted buttons, filled fields 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', button: 'soft', field: 'filled' },
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
id: 'playful',
|
|
26
26
|
name: 'Playful',
|
|
27
|
-
description: 'Pink, chunky icons, round display type and
|
|
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' },
|
|
27
|
+
description: 'Pink, chunky icons, round display type, lifted cards and raised buttons.',
|
|
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', button: 'raised', field: 'outline' },
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
id: 'brutalist',
|
|
@@ -51,6 +51,18 @@ export const PRESETS = [
|
|
|
51
51
|
description: 'Teal on warm greys, a book serif for headings, thin lines, no shadows.',
|
|
52
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
53
|
},
|
|
54
|
+
{
|
|
55
|
+
id: 'tactile',
|
|
56
|
+
name: 'Tactile',
|
|
57
|
+
description: 'Warm orange, raised buttons you want to press, filled fields, rounded and friendly.',
|
|
58
|
+
recipe: { brand: '#ea580c', neutral: 'stone', radius: 'xl', controls: 'match', font: { heading: 'nunito', body: 'nunito', mono: 'fira-code' }, stroke: 'bold', depth: 'raised', density: 'comfortable', border: 'regular', size: '16', leading: 'normal', flow: 'normal', measure: '70', button: 'raised', field: 'filled' },
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'studio',
|
|
62
|
+
name: 'Studio',
|
|
63
|
+
description: 'Indigo on slate, tinted buttons, underlined fields, hairlines: quiet and precise.',
|
|
64
|
+
recipe: { brand: '#4f46e5', neutral: 'slate', radius: 'md', controls: 'match', font: { heading: 'geist', body: 'geist', mono: 'geist-mono' }, stroke: 'regular', depth: 'flat', density: 'comfortable', border: 'hairline', size: '15', leading: 'normal', flow: 'normal', measure: '70', button: 'soft', field: 'underline' },
|
|
65
|
+
},
|
|
54
66
|
];
|
|
55
67
|
|
|
56
68
|
export const presetById = (id) => PRESETS.find((p) => p.id === id);
|
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
|
+
}
|
package/lib/theme/recipe.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* The typeset (font.mono, size, leading, flow, measure) came later. A recipe without it gets the defaults,
|
|
8
8
|
* which are what the kits always had, and a theme with the default typeset still encodes as a ht1 code.
|
|
9
|
+
* So did the styles (button, field): their defaults are the kits' original look, and a theme that keeps them
|
|
10
|
+
* encodes as ht1 or ht2 as before.
|
|
9
11
|
*/
|
|
10
12
|
import { FONTS } from './fonts.mjs';
|
|
11
13
|
|
|
@@ -34,16 +36,46 @@ export const OPTIONS = {
|
|
|
34
36
|
flow: ['normal', 'tight', 'loose'],
|
|
35
37
|
/** Typeset: the longest line of long-form text (Prose), in characters. */
|
|
36
38
|
measure: ['70', '60', '80', '90'],
|
|
39
|
+
/** Style: the primary button. Solid fill, a brand tint, a brand outline, or solid with a darker bottom edge. */
|
|
40
|
+
button: ['solid', 'soft', 'outline', 'raised'],
|
|
41
|
+
/** Style: text fields (inputs, text areas, select triggers). A box, a tinted field with a line, or a line. */
|
|
42
|
+
field: ['outline', 'filled', 'underline'],
|
|
37
43
|
};
|
|
38
44
|
|
|
39
45
|
/** The recipe keys that make up the typeset, besides font.mono. */
|
|
40
46
|
export const TYPESET_KEYS = ['size', 'leading', 'flow', 'measure'];
|
|
47
|
+
/** The recipe keys that are component styles (ht3 codes). */
|
|
48
|
+
export const STYLE_KEYS = ['button', 'field'];
|
|
41
49
|
|
|
42
50
|
/** Base radius per option: a primitive radius step (a reference) or 0. */
|
|
43
51
|
export const RADIUS = { none: 0, sm: 'sm', md: 'md', lg: 'lg', xl: 'xl', '2xl': '2xl' };
|
|
44
52
|
export const STROKE = { thin: 1.5, regular: 2, bold: 2.5 };
|
|
45
53
|
/** px; hairline renders as one device pixel on a 2x screen. */
|
|
46
54
|
export const BORDER = { hairline: 0.5, regular: 1, heavy: 2 };
|
|
55
|
+
/**
|
|
56
|
+
* Button styles as numbers (px), on top of the colours in semantic.mjs (styleColors). `alpha` is the fill's
|
|
57
|
+
* opacity: an outline button has no fill. `border` is every side, `edge` the bottom (raised: a thicker,
|
|
58
|
+
* darker bottom edge).
|
|
59
|
+
*/
|
|
60
|
+
export const BUTTON = {
|
|
61
|
+
solid: { alpha: 1, border: 0, edge: 0 },
|
|
62
|
+
soft: { alpha: 1, border: 0, edge: 0 },
|
|
63
|
+
outline: { alpha: 0, border: 2, edge: 2 },
|
|
64
|
+
raised: { alpha: 1, border: 0, edge: 3 },
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Field styles. `alpha` per mode (outline: transparent, with a faint tint in dark, as the kits always had).
|
|
68
|
+
* `border` / `edge`: 'border' = the theme's border width, 'line' = at least 1px (it's the field's only edge).
|
|
69
|
+
* `round` / `roundBottom`: 1 = the md radius, 0 = square. `inset`: 1 = the usual inner padding, 0 = flush.
|
|
70
|
+
*/
|
|
71
|
+
export const FIELD = {
|
|
72
|
+
outline: { alpha: { light: 0, dark: 0.3 }, border: 'border', edge: 'border', round: 1, roundBottom: 1, inset: 1 },
|
|
73
|
+
filled: { alpha: { light: 1, dark: 1 }, border: 0, edge: 'line', round: 1, roundBottom: 0, inset: 1 },
|
|
74
|
+
underline: { alpha: { light: 0, dark: 0 }, border: 0, edge: 'line', round: 0, roundBottom: 0, inset: 0 },
|
|
75
|
+
};
|
|
76
|
+
/** A field width in px for a border width. */
|
|
77
|
+
export const fieldWidth = (value, borderWidth) => (value === 'border' ? borderWidth : value === 'line' ? Math.max(1, borderWidth) : value);
|
|
78
|
+
|
|
47
79
|
/** Line height of running text (× size). 1.75 = leading-7 at 16px, what the kits' paragraphs always had. */
|
|
48
80
|
export const LEADING = { tight: 1.6, normal: 1.75, relaxed: 1.9 };
|
|
49
81
|
/** Space between blocks of long-form text, in em. */
|
|
@@ -72,6 +104,74 @@ export const typeScale = ({ size, leading, flow, measure }) => ({
|
|
|
72
104
|
});
|
|
73
105
|
|
|
74
106
|
|
|
107
|
+
/** The Text variants both kits draw (components/ui/text.tsx on mobile; the type-* utilities on the web). */
|
|
108
|
+
export const TEXT_STYLES = ['h1', 'h2', 'h3', 'h4', 'lead', 'p', 'large', 'small', 'muted', 'code', 'blockquote'];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The text styles as the kits draw them at the default typeset (16px, leading 1.75), from the mobile kit's
|
|
112
|
+
* components/ui/text.tsx and Tailwind's sizes: size px, leading × size, tracking em. `leading: 'prose'` is the typeset's own leading
|
|
113
|
+
* (Text p's leading-prose); every other leading scales with the typeset's (leading / 1.75), and sizes with
|
|
114
|
+
* size / 16. `small` is leading-none, which never scales.
|
|
115
|
+
*/
|
|
116
|
+
export const TEXT_DEFAULTS = {
|
|
117
|
+
h1: { font: 'heading', weight: 800, size: 36, leading: 40 / 36, tracking: -0.025 },
|
|
118
|
+
h2: { font: 'heading', weight: 600, size: 30, leading: 36 / 30, tracking: -0.025 },
|
|
119
|
+
h3: { font: 'heading', weight: 600, size: 24, leading: 32 / 24, tracking: -0.025 },
|
|
120
|
+
h4: { font: 'heading', weight: 600, size: 20, leading: 28 / 20, tracking: -0.025 },
|
|
121
|
+
lead: { font: 'body', weight: 400, size: 20, leading: 28 / 20, tracking: 0 },
|
|
122
|
+
p: { font: 'body', weight: 400, size: 16, leading: 'prose', tracking: 0 },
|
|
123
|
+
large: { font: 'body', weight: 600, size: 18, leading: 28 / 18, tracking: 0 },
|
|
124
|
+
small: { font: 'body', weight: 500, size: 14, leading: 1, tracking: 0 },
|
|
125
|
+
muted: { font: 'body', weight: 400, size: 14, leading: 20 / 14, tracking: 0 },
|
|
126
|
+
code: { font: 'mono', weight: 600, size: 14, leading: 20 / 14, tracking: 0 },
|
|
127
|
+
blockquote: { font: 'body', weight: 400, size: 16, leading: 24 / 16, tracking: 0 },
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const FONT_REF = /^\{primitive\.font\.family\.(heading|body|mono)\}$/;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A system's edited text styles from a semantic.type group: a theme's ({ h1: { size, … } }) or a token file's
|
|
134
|
+
* DTCG one ({ h1: { size: { $value } } }). The font is a reference to a role's family; a style's other keys
|
|
135
|
+
* are numbers. The typeset's own numbers (size, leading, flow, measure) are skipped.
|
|
136
|
+
* → { h1: { size, leading, weight, tracking, font: 'heading' } }
|
|
137
|
+
*/
|
|
138
|
+
export function textEdits(group = {}) {
|
|
139
|
+
const out = {};
|
|
140
|
+
for (const name of TEXT_STYLES) {
|
|
141
|
+
const style = group?.[name];
|
|
142
|
+
if (!style || typeof style !== 'object' || '$value' in style) continue;
|
|
143
|
+
const value = (v) => (v && typeof v === 'object' && '$value' in v ? v.$value : v);
|
|
144
|
+
const edit = {};
|
|
145
|
+
for (const key of ['size', 'leading', 'weight', 'tracking']) if (typeof value(style[key]) === 'number') edit[key] = value(style[key]);
|
|
146
|
+
const font = FONT_REF.exec(String(value(style.font) ?? ''));
|
|
147
|
+
if (font) edit.font = font[1];
|
|
148
|
+
out[name] = edit;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Every text style as drawn at a typeset (typeScale), edits on top, unrounded:
|
|
155
|
+
* { h1: { font, weight, size: px, leading: × size, tracking: em, lineHeight: px, edited: bool } }
|
|
156
|
+
* Sizes scale by the typeset's size / 16 and line heights by its leading / 1.75; Text p takes the typeset's
|
|
157
|
+
* leading itself and Text small (leading-none) never scales. With no edits this is exactly what the kits drew
|
|
158
|
+
* with Tailwind's text-* classes.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveTextStyles(type, edits = {}) {
|
|
161
|
+
return Object.fromEntries(
|
|
162
|
+
TEXT_STYLES.map((name) => {
|
|
163
|
+
const d = TEXT_DEFAULTS[name];
|
|
164
|
+
const leading = d.leading === 'prose' ? type.leading : d.leading === 1 ? 1 : d.leading * type.leadingFactor;
|
|
165
|
+
const edit = edits[name] ?? {};
|
|
166
|
+
const style = { font: d.font, weight: d.weight, size: d.size * type.scale, leading, tracking: d.tracking, ...edit, edited: Object.keys(edit).length > 0 };
|
|
167
|
+
// The line height in px, multiplied in the order the text-* classes always were (so it rounds the same).
|
|
168
|
+
const px = Math.round(d.size * (d.leading === 'prose' ? 1 : d.leading) * 1e6) / 1e6;
|
|
169
|
+
style.lineHeight = 'size' in edit || 'leading' in edit ? style.size * style.leading : d.leading === 'prose' ? px * type.scale * type.leading : d.leading === 1 ? style.size : px * type.scale * type.leadingFactor;
|
|
170
|
+
return [name, style];
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
75
175
|
const s = (x, y, blur, spread, color) => ({ offsetX: x, offsetY: y, blur, spread, color });
|
|
76
176
|
/**
|
|
77
177
|
* Three elevation levels per depth (DTCG shadow values, light and dark): 1 for controls and cards,
|
|
@@ -136,6 +236,8 @@ export const DEFAULT_RECIPE = Object.freeze({
|
|
|
136
236
|
leading: 'normal',
|
|
137
237
|
flow: 'normal',
|
|
138
238
|
measure: '70',
|
|
239
|
+
button: 'solid',
|
|
240
|
+
field: 'outline',
|
|
139
241
|
});
|
|
140
242
|
|
|
141
243
|
const HEX = /^#[0-9a-f]{6}$/;
|
|
@@ -143,6 +245,9 @@ const HEX = /^#[0-9a-f]{6}$/;
|
|
|
143
245
|
/** Does the recipe use the default typeset (what every ht1 code means)? */
|
|
144
246
|
export const defaultTypeset = (r) => r.font.mono === DEFAULT_RECIPE.font.mono && TYPESET_KEYS.every((k) => r[k] === DEFAULT_RECIPE[k]);
|
|
145
247
|
|
|
248
|
+
/** Does the recipe keep the default styles (solid buttons, outline fields: every ht1 and ht2 code)? */
|
|
249
|
+
export const defaultStyles = (r) => STYLE_KEYS.every((k) => r[k] === DEFAULT_RECIPE[k]);
|
|
250
|
+
|
|
146
251
|
/** Fill in defaults, normalise, and throw on anything invalid. Returns a new, plain recipe. */
|
|
147
252
|
export function normalizeRecipe(input = {}) {
|
|
148
253
|
const r = { ...DEFAULT_RECIPE, ...input, font: { ...DEFAULT_RECIPE.font, ...input.font } };
|
|
@@ -175,6 +280,8 @@ export function normalizeRecipe(input = {}) {
|
|
|
175
280
|
leading: r.leading,
|
|
176
281
|
flow: r.flow,
|
|
177
282
|
measure: r.measure,
|
|
283
|
+
button: r.button,
|
|
284
|
+
field: r.field,
|
|
178
285
|
};
|
|
179
286
|
if (input.preset) out.preset = String(input.preset);
|
|
180
287
|
return out;
|
package/lib/theme/semantic.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* references per mode. With a curated seed this is exactly the kit's original map; a generated seed
|
|
4
4
|
* moves the brand steps to wherever the seed landed (its anchor).
|
|
5
5
|
*/
|
|
6
|
+
import { STEPS } from './palettes.mjs';
|
|
7
|
+
|
|
6
8
|
const n = (step) => `{primitive.color.neutral.${step}}`;
|
|
7
9
|
const b = (step) => `{primitive.color.brand.${step}}`;
|
|
8
10
|
const p = (ramp, step) => `{primitive.color.${ramp}.${step}}`;
|
|
@@ -11,8 +13,18 @@ const p = (ramp, step) => `{primitive.color.${ramp}.${step}}`;
|
|
|
11
13
|
const DARK_STEP = { 600: '500', 700: '400', 800: '300', 900: '200', 950: '100' };
|
|
12
14
|
export const darkStep = (anchor) => DARK_STEP[anchor] ?? anchor;
|
|
13
15
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The status tones' steps, as offsets from the step their seed sits on (600 for the curated ramps the kits
|
|
18
|
+
* ship), light and dark: a status seed that lands on another step moves its tone with it, as the brand does.
|
|
19
|
+
*/
|
|
20
|
+
const STATUS_STEPS = { danger: [0, -1], success: [1, -1], warning: [1, -2], info: [1, -1] };
|
|
21
|
+
const status = (ramp, anchor = '600') => ({ light: p(ramp, shift(anchor, STATUS_STEPS[ramp][0])), dark: p(ramp, shift(anchor, STATUS_STEPS[ramp][1])) });
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* { name: { light: ref, dark: ref } } in the order the kit's tokens.json lists them. `anchors` holds the step
|
|
25
|
+
* each status seed sits on (`{ danger: '500' }`); a status without one uses its 600, the curated ramps' seed.
|
|
26
|
+
*/
|
|
27
|
+
export function semanticColors(anchor = '600', anchors = {}) {
|
|
16
28
|
const brand = { light: b(anchor), dark: b(darkStep(anchor)) };
|
|
17
29
|
const onDark = { light: n(0), dark: n(950) };
|
|
18
30
|
return {
|
|
@@ -30,13 +42,13 @@ export function semanticColors(anchor = '600') {
|
|
|
30
42
|
'muted-foreground': { light: n(600), dark: n(400) },
|
|
31
43
|
accent: { light: n(100), dark: n(800) },
|
|
32
44
|
'accent-foreground': { light: n(900), dark: n(50) },
|
|
33
|
-
destructive:
|
|
45
|
+
destructive: status('danger', anchors.danger),
|
|
34
46
|
'destructive-foreground': onDark,
|
|
35
|
-
success:
|
|
47
|
+
success: status('success', anchors.success),
|
|
36
48
|
'success-foreground': onDark,
|
|
37
|
-
warning:
|
|
49
|
+
warning: status('warning', anchors.warning),
|
|
38
50
|
'warning-foreground': onDark,
|
|
39
|
-
info:
|
|
51
|
+
info: status('info', anchors.info),
|
|
40
52
|
'info-foreground': onDark,
|
|
41
53
|
border: { light: n(200), dark: n(800) },
|
|
42
54
|
input: { light: n(200), dark: n(800) },
|
|
@@ -57,6 +69,31 @@ export function semanticColors(anchor = '600') {
|
|
|
57
69
|
};
|
|
58
70
|
}
|
|
59
71
|
|
|
72
|
+
function shift(step, by) {
|
|
73
|
+
return STEPS[Math.min(STEPS.length - 1, Math.max(0, STEPS.indexOf(step) + by))];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The colours a recipe's styles add (button, field), from the base colours after their contrast fix, so a moved
|
|
78
|
+
* primary moves the button with it. Field edges that are the field's only boundary (filled, underline) are a
|
|
79
|
+
* mid neutral, which the gate holds to 3:1 (contrast.mjs).
|
|
80
|
+
*/
|
|
81
|
+
export function styleColors({ button, field }, anchor, colors) {
|
|
82
|
+
const dark = darkStep(anchor);
|
|
83
|
+
const buttons = {
|
|
84
|
+
solid: { button: colors.primary, 'button-foreground': colors['primary-foreground'], 'button-edge': colors.primary },
|
|
85
|
+
soft: { button: { light: b(100), dark: b(900) }, 'button-foreground': { light: b(700), dark: b(200) }, 'button-edge': { light: b(100), dark: b(900) } },
|
|
86
|
+
outline: { button: colors.background, 'button-foreground': colors.primary, 'button-edge': colors.primary },
|
|
87
|
+
raised: { button: colors.primary, 'button-foreground': colors['primary-foreground'], 'button-edge': { light: b(shift(anchor, 2)), dark: b(shift(dark, 2)) } },
|
|
88
|
+
};
|
|
89
|
+
const fields = {
|
|
90
|
+
outline: { field: colors.input, 'field-edge': colors.input },
|
|
91
|
+
filled: { field: colors.muted, 'field-edge': { light: n(500), dark: n(400) } },
|
|
92
|
+
underline: { field: colors.background, 'field-edge': { light: n(500), dark: n(500) } },
|
|
93
|
+
};
|
|
94
|
+
return { ...buttons[button], ...fields[field] };
|
|
95
|
+
}
|
|
96
|
+
|
|
60
97
|
/** Tokens that share the brand step; when the fixer moves `primary`, these move with it. */
|
|
61
98
|
export const BRAND_LINKED = ['ring', 'sidebar-primary', 'sidebar-ring'];
|
|
62
99
|
|