@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.
@@ -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 studio (/themes) and adjust here. Order is the gallery order.
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, brand-tinted greys and room to breathe.',
22
- recipe: { brand: '#8b5cf6', neutral: 'brand', radius: '2xl', controls: 'pill', font: { heading: 'plus-jakarta-sans', body: 'plus-jakarta-sans', mono: 'geist-mono' }, stroke: 'regular', depth: 'soft', density: 'spacious', border: 'hairline', size: '16', leading: 'relaxed', flow: 'loose', measure: '60' },
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 lifted cards.',
28
- recipe: { brand: '#db2777', neutral: 'neutral', radius: 'xl', controls: 'pill', font: { heading: 'bricolage-grotesque', body: 'nunito', mono: 'fira-code' }, stroke: 'bold', depth: 'raised', density: 'comfortable', border: 'regular', size: '16', leading: 'relaxed', flow: 'normal', measure: '60' },
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);
@@ -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
- const [L] = hexToOklchParts(seed);
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
- /** { 50: '#…', …, 950: '#…' } for a brand seed. */
43
- export function brandRamp(seed) {
44
- const curated = curatedRamp(seed);
45
- if (curated) return { ...BRAND_RAMPS[curated] };
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 dL = Ls - LIGHTNESS[anchor];
51
- const chromaScale = Cs / CHROMA[anchor];
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
- // The seed's offset from the target lightness fades out over three steps either side, which keeps
59
- // neighbours in order (|dL| is at most half the gap to the next step).
60
- const fade = Math.max(0, 1 - Math.abs(i - ai) / 3);
61
- const L = LIGHTNESS[s] + dL * fade;
62
- ramp[s] = toGamutHex([L, CHROMA[s] * chromaScale, Hs]);
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
+ }
@@ -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;
@@ -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
- /** { name: { light: ref, dark: ref } } in the order the kit's tokens.json lists them. */
15
- export function semanticColors(anchor = '600') {
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: { light: p('danger', 600), dark: p('danger', 500) },
45
+ destructive: status('danger', anchors.danger),
34
46
  'destructive-foreground': onDark,
35
- success: { light: p('success', 700), dark: p('success', 500) },
47
+ success: status('success', anchors.success),
36
48
  'success-foreground': onDark,
37
- warning: { light: p('warning', 700), dark: p('warning', 400) },
49
+ warning: status('warning', anchors.warning),
38
50
  'warning-foreground': onDark,
39
- info: { light: p('info', 700), dark: p('info', 500) },
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