@ankhorage/surface 0.2.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/examples/DocsExamples.d.ts.map +1 -1
  3. package/dist/examples/DocsExamples.js +0 -2
  4. package/dist/examples/DocsExamples.js.map +1 -1
  5. package/dist/internal/resolvers/resolveControlSize.d.ts +2 -2
  6. package/dist/internal/resolvers/resolveControlSize.d.ts.map +1 -1
  7. package/dist/internal/resolvers/resolveControlSize.js.map +1 -1
  8. package/dist/internal/resolvers/resolveIconSize.d.ts +2 -2
  9. package/dist/internal/resolvers/resolveIconSize.d.ts.map +1 -1
  10. package/dist/internal/resolvers/resolveIconSize.js.map +1 -1
  11. package/dist/internal/resolvers/resolveInteractiveColors.d.ts +3 -3
  12. package/dist/internal/resolvers/resolveInteractiveColors.d.ts.map +1 -1
  13. package/dist/internal/resolvers/resolveInteractiveColors.js.map +1 -1
  14. package/dist/internal/resolvers/resolveSelectionControlColors.d.ts +2 -2
  15. package/dist/internal/resolvers/resolveSelectionControlColors.d.ts.map +1 -1
  16. package/dist/internal/resolvers/resolveSelectionControlColors.js.map +1 -1
  17. package/dist/internal/resolvers/resolveTextColor.d.ts +3 -3
  18. package/dist/internal/resolvers/resolveTextColor.d.ts.map +1 -1
  19. package/dist/internal/resolvers/resolveTextColor.js.map +1 -1
  20. package/dist/internal/resolvers/resolveTextStyles.d.ts +3 -3
  21. package/dist/internal/resolvers/resolveTextStyles.d.ts.map +1 -1
  22. package/dist/internal/resolvers/resolveTextStyles.js.map +1 -1
  23. package/dist/internal/resolvers/resolveTone.d.ts +2 -2
  24. package/dist/internal/resolvers/resolveTone.d.ts.map +1 -1
  25. package/dist/internal/resolvers/resolveTone.js.map +1 -1
  26. package/dist/layout/Container.d.ts +2 -2
  27. package/dist/layout/Container.d.ts.map +1 -1
  28. package/dist/layout/Container.js.map +1 -1
  29. package/dist/layout/helpers.d.ts +9 -9
  30. package/dist/layout/helpers.d.ts.map +1 -1
  31. package/dist/layout/helpers.js.map +1 -1
  32. package/dist/primitives/heading/resolveHeadingStyle.d.ts +2 -2
  33. package/dist/primitives/heading/resolveHeadingStyle.d.ts.map +1 -1
  34. package/dist/primitives/heading/resolveHeadingStyle.js.map +1 -1
  35. package/dist/primitives/icon/Icon.d.ts +3 -3
  36. package/dist/primitives/icon/Icon.d.ts.map +1 -1
  37. package/dist/primitives/icon/Icon.js.map +1 -1
  38. package/dist/theme/ThemeContext.d.ts +3 -3
  39. package/dist/theme/ThemeContext.d.ts.map +1 -1
  40. package/dist/theme/ThemeContext.js.map +1 -1
  41. package/dist/theme/colorEngine.d.ts +10 -11
  42. package/dist/theme/colorEngine.d.ts.map +1 -1
  43. package/dist/theme/colorEngine.js +102 -412
  44. package/dist/theme/colorEngine.js.map +1 -1
  45. package/dist/theme/createTheme.d.ts +3 -3
  46. package/dist/theme/createTheme.d.ts.map +1 -1
  47. package/dist/theme/createTheme.js +2 -4
  48. package/dist/theme/createTheme.js.map +1 -1
  49. package/dist/theme/types.d.ts +5 -17
  50. package/dist/theme/types.d.ts.map +1 -1
  51. package/dist/theme/types.js.map +1 -1
  52. package/package.json +3 -4
  53. package/src/examples/DocsExamples.tsx +0 -2
  54. package/src/internal/resolvers/resolveControlSize.ts +5 -2
  55. package/src/internal/resolvers/resolveIconSize.ts +2 -2
  56. package/src/internal/resolvers/resolveInteractiveColors.ts +3 -3
  57. package/src/internal/resolvers/resolveSelectionControlColors.ts +2 -2
  58. package/src/internal/resolvers/resolveTextColor.ts +3 -3
  59. package/src/internal/resolvers/resolveTextStyles.ts +6 -6
  60. package/src/internal/resolvers/resolveTone.ts +2 -2
  61. package/src/layout/Container.tsx +2 -2
  62. package/src/layout/helpers.test.ts +2 -2
  63. package/src/layout/helpers.ts +12 -9
  64. package/src/primitives/heading/resolveHeadingStyle.ts +2 -2
  65. package/src/primitives/icon/Icon.tsx +3 -3
  66. package/src/theme/ThemeContext.tsx +2 -2
  67. package/src/theme/colorEngine.test.ts +158 -154
  68. package/src/theme/colorEngine.ts +128 -477
  69. package/src/theme/createTheme.ts +6 -8
  70. package/src/theme/types.ts +15 -18
  71. package/src/utils/deepMerge.test.ts +0 -4
@@ -1,314 +1,113 @@
1
- import { formatHex, modeOklch, oklch, useMode } from 'culori';
1
+ import type {
2
+ ColorSwatch,
3
+ GeneratedThemeModeColors,
4
+ GeneratedThemeSwatches,
5
+ HexColor,
6
+ SemanticColorReferenceMap,
7
+ SemanticColorToken,
8
+ } from '@ankhorage/color-theory';
9
+ import {
10
+ generateColorSwatch,
11
+ generateThemeModeColors,
12
+ getReadableForeground,
13
+ parseHexColorOrThrow,
14
+ } from '@ankhorage/color-theory';
15
+ import type { ThemeConfig } from '@ankhorage/contracts';
2
16
 
3
17
  import type {
4
18
  ActionSemantics,
5
19
  BorderSemantics,
6
- ColorHarmony,
7
- ColorScale,
8
- ColorTone,
9
20
  ContentSemantics,
10
21
  NeutralSemantics,
11
22
  RoleSemantics,
12
23
  SurfaceSemantics,
13
- ThemeConfig,
14
24
  ThemeSemantics,
15
25
  } from './types';
16
26
 
17
- useMode(modeOklch);
18
-
19
- interface OklchColor {
20
- mode: 'oklch';
21
- l: number;
22
- c: number;
23
- h?: number;
24
- }
25
-
26
- export const SCALE_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;
27
-
28
- type RolePaletteKind =
29
- | 'grayscale'
30
- | 'neutral'
31
- | 'pastel'
32
- | 'earth'
33
- | 'mineral'
34
- | 'muted'
35
- | 'jewel'
36
- | 'fluorescent'
37
- | 'obsidian'
38
- | 'vaporwave'
39
- | 'monochromeAccent';
40
-
41
- /**
42
- * Deterministic Lightness Curves (OKLCH L)
43
- */
44
- const LIGHTNESS_CURVES = {
45
- light: [0.98, 0.95, 0.9, 0.82, 0.72, 0.62, 0.52, 0.42, 0.32, 0.22, 0.15],
46
- dark: [0.12, 0.16, 0.2, 0.26, 0.34, 0.62, 0.7, 0.78, 0.86, 0.92, 0.96],
47
- } as const;
48
-
49
- /**
50
- * Lightness anchors per internal palette kind
51
- */
52
- const ROLE_PALETTE_LIGHTNESS_ANCHORS: Record<RolePaletteKind, number> = {
53
- grayscale: 0.62,
54
- neutral: 0.62,
55
- pastel: 0.7,
56
- earth: 0.55,
57
- mineral: 0.58,
58
- muted: 0.6,
59
- jewel: 0.62,
60
- fluorescent: 0.65,
61
- obsidian: 0.12,
62
- vaporwave: 0.7,
63
- monochromeAccent: 0.62,
64
- };
27
+ // Fixed hex values for semantic status colors (not theme-generated)
28
+ const DANGER_HEX = parseHexColorOrThrow('#ef4444');
29
+ const SUCCESS_HEX = parseHexColorOrThrow('#22c55e');
30
+ const WARNING_HEX = parseHexColorOrThrow('#f59e0b');
65
31
 
66
32
  /**
67
- * Chroma curve per step to prevent "tinted whites" and "glow"
68
- * Peak energy at 500, falloff at extremes.
33
+ * Surface semantic resolver: maps color-theory SemanticColorToken references
34
+ * to hex values from the generated swatches.
69
35
  */
70
- const CHROMA_BY_STEP = [0.1, 0.18, 0.3, 0.45, 0.7, 1.0, 0.92, 0.8, 0.6, 0.4, 0.25] as const;
71
-
72
- /**
73
- * Deterministic Chroma Anchors (OKLCH C)
74
- */
75
- const ROLE_PALETTE_CHROMA_ANCHORS: Record<RolePaletteKind, number> = {
76
- grayscale: 0,
77
- neutral: 0.02,
78
- pastel: 0.08,
79
- earth: 0.05,
80
- mineral: 0.07,
81
- muted: 0.04,
82
- jewel: 0.16,
83
- fluorescent: 0.26,
84
- obsidian: 0.01,
85
- vaporwave: 0.2,
86
- monochromeAccent: 0.02,
87
- };
88
-
89
- /**
90
- * Chroma Hierarchy Rule
91
- */
92
- const CHROMA_HIERARCHY = {
93
- primary: 1.0,
94
- secondary: 0.7,
95
- accent: 0.4,
96
- surface: 0.1,
97
- } as const;
98
-
99
- /**
100
- * Semantic Step Mapping
101
- */
102
- const SEMANTIC_STEPS = {
103
- light: {
104
- bg: 0, // 50
105
- bgSubtle: 1, // 100
106
- surface: 1, // 100
107
- surfaceHover: 2, // 200
108
- surfaceActive: 3, // 300
109
- border: 3, // 300
110
- borderStrong: 4, // 400
111
- divider: 2, // 200
112
- text: 9, // 900
113
- textMuted: 7, // 700
114
- textSubtle: 6, // 600
115
- solid: 5, // 500
116
- softBg: 1, // 100
117
- softHover: 2, // 200
118
- softActive: 3, // 300
119
- outline: 4, // 400
120
- },
121
- dark: {
122
- bg: 0, // 50
123
- bgSubtle: 1, // 100
124
- surface: 1, // 100
125
- surfaceHover: 2, // 200
126
- surfaceActive: 3, // 300
127
- border: 3, // 300
128
- borderStrong: 4, // 400
129
- divider: 2, // 200
130
- text: 10, // 950
131
- textMuted: 8, // 800
132
- textSubtle: 7, // 700
133
- solid: 5, // 500
134
- softBg: 1, // 100
135
- softHover: 2, // 200
136
- softActive: 3, // 300
137
- outline: 4, // 400
138
- },
139
- } as const;
140
-
141
- export function generateColorScale(baseColor: OklchColor, isDark: boolean): ColorScale {
142
- const scale: Partial<ColorScale> = {};
143
- const curve = isDark ? LIGHTNESS_CURVES.dark : LIGHTNESS_CURVES.light;
144
-
145
- // Dark-mode chroma rule: reduce chroma by 25%
146
- const chromaMultiplier = isDark ? 0.75 : 1.0;
147
- const targetChroma = baseColor.c * chromaMultiplier;
148
-
149
- SCALE_STEPS.forEach((step, index) => {
150
- const lightness = curve[index];
151
- const chromaByStep = CHROMA_BY_STEP[index];
152
- if (lightness === undefined || chromaByStep === undefined) {
153
- return;
154
- }
155
- // Apply chroma falloff curve per step
156
- const stepChroma = targetChroma * chromaByStep;
157
- const stepColor = { ...baseColor, l: lightness, c: stepChroma };
158
- scale[step as keyof ColorScale] = formatHex(stepColor);
159
- });
160
-
161
- return scale as ColorScale;
36
+ export type SurfaceSemanticColors = Record<SemanticColorToken, HexColor>;
37
+
38
+ export function resolveSemanticColors(
39
+ generated: GeneratedThemeModeColors,
40
+ references: SemanticColorReferenceMap,
41
+ ): SurfaceSemanticColors {
42
+ return Object.fromEntries(
43
+ Object.entries(references).map(([token, ref]) => {
44
+ const swatch = generated.swatches[ref.role];
45
+ if (!swatch) {
46
+ throw new Error(`Missing swatch for role '${ref.role}' (token: '${token}')`);
47
+ }
48
+ return [token, swatch[ref.step]];
49
+ }),
50
+ ) as SurfaceSemanticColors;
162
51
  }
163
52
 
164
- /**
165
- * Deterministic Harmony Hues
166
- */
167
- function getHarmonyHues(baseHue: number, mode: ColorHarmony): number[] {
168
- const h = (baseHue + 360) % 360;
169
- switch (mode) {
170
- case 'monochromatic':
171
- return [h];
172
- case 'analogous':
173
- return [h, (h - 30 + 360) % 360, (h + 30) % 360];
174
- case 'complementary':
175
- return [h, (h + 180) % 360];
176
- case 'splitComplementary':
177
- return [h, (h + 150) % 360, (h + 210) % 360];
178
- case 'triadic':
179
- return [h, (h + 120) % 360, (h + 240) % 360];
180
- case 'tetradic':
181
- return [h, (h + 90) % 360, (h + 180) % 360, (h + 270) % 360];
182
- default:
183
- return [h];
53
+ function buildNeutralSemantics(neutralSwatch: ColorSwatch, isDark: boolean): NeutralSemantics {
54
+ if (isDark) {
55
+ return {
56
+ bg: neutralSwatch[950],
57
+ bgSubtle: neutralSwatch[900],
58
+ surface: neutralSwatch[900],
59
+ surfaceHover: neutralSwatch[800],
60
+ surfaceActive: neutralSwatch[700],
61
+ border: neutralSwatch[800],
62
+ borderStrong: neutralSwatch[600],
63
+ divider: neutralSwatch[800],
64
+ text: neutralSwatch[50],
65
+ textMuted: neutralSwatch[200],
66
+ textSubtle: neutralSwatch[300],
67
+ };
184
68
  }
69
+ return {
70
+ bg: neutralSwatch[50],
71
+ bgSubtle: neutralSwatch[100],
72
+ surface: neutralSwatch[100],
73
+ surfaceHover: neutralSwatch[200],
74
+ surfaceActive: neutralSwatch[300],
75
+ border: neutralSwatch[200],
76
+ borderStrong: neutralSwatch[300],
77
+ divider: neutralSwatch[200],
78
+ text: neutralSwatch[900],
79
+ textMuted: neutralSwatch[700],
80
+ textSubtle: neutralSwatch[600],
81
+ };
185
82
  }
186
83
 
187
- function getColorToneRolePalette(colorTone: ColorTone): {
188
- bg: RolePaletteKind;
189
- surface: RolePaletteKind;
190
- primary: RolePaletteKind;
191
- secondary: RolePaletteKind;
192
- accent: RolePaletteKind;
193
- highlight: RolePaletteKind;
194
- } {
195
- switch (colorTone) {
196
- case 'pastel':
197
- return {
198
- bg: 'pastel',
199
- surface: 'pastel',
200
- primary: 'jewel',
201
- secondary: 'jewel',
202
- accent: 'jewel',
203
- highlight: 'fluorescent',
204
- };
205
-
206
- case 'earth':
207
- return {
208
- bg: 'earth',
209
- surface: 'earth',
210
- primary: 'mineral',
211
- secondary: 'mineral',
212
- accent: 'jewel',
213
- highlight: 'jewel',
214
- };
215
-
216
- case 'mineral':
217
- return {
218
- bg: 'mineral',
219
- surface: 'mineral',
220
- primary: 'jewel',
221
- secondary: 'mineral',
222
- accent: 'jewel',
223
- highlight: 'fluorescent',
224
- };
225
-
226
- case 'muted':
227
- return {
228
- bg: 'muted',
229
- surface: 'muted',
230
- primary: 'jewel',
231
- secondary: 'muted',
232
- accent: 'jewel',
233
- highlight: 'fluorescent',
234
- };
84
+ function buildRoleSemantics(swatch: ColorSwatch, isDark: boolean): RoleSemantics {
85
+ const base = swatch[500];
86
+ const { foreground: onSolidText } = getReadableForeground(base);
235
87
 
236
- case 'jewel':
237
- return {
238
- bg: 'grayscale',
239
- surface: 'grayscale',
240
- primary: 'jewel',
241
- secondary: 'jewel',
242
- accent: 'jewel',
243
- highlight: 'fluorescent',
244
- };
245
-
246
- case 'fluorescent':
247
- return {
248
- bg: 'obsidian',
249
- surface: 'obsidian',
250
- primary: 'fluorescent',
251
- secondary: 'jewel',
252
- accent: 'fluorescent',
253
- highlight: 'fluorescent',
254
- };
255
-
256
- case 'obsidian':
257
- return {
258
- bg: 'obsidian',
259
- surface: 'obsidian',
260
- primary: 'fluorescent',
261
- secondary: 'jewel',
262
- accent: 'fluorescent',
263
- highlight: 'fluorescent',
264
- };
265
-
266
- case 'vaporwave':
267
- return {
268
- bg: 'pastel',
269
- surface: 'pastel',
270
- primary: 'fluorescent',
271
- secondary: 'jewel',
272
- accent: 'fluorescent',
273
- highlight: 'fluorescent',
274
- };
275
-
276
- case 'monochromeAccent':
277
- return {
278
- bg: 'grayscale',
279
- surface: 'grayscale',
280
- primary: 'jewel',
281
- secondary: 'grayscale',
282
- accent: 'jewel',
283
- highlight: 'fluorescent',
284
- };
285
-
286
- default: // neutral
287
- return {
288
- bg: 'grayscale',
289
- surface: 'grayscale',
290
- primary: 'jewel',
291
- secondary: 'pastel',
292
- accent: 'jewel',
293
- highlight: 'fluorescent',
294
- };
88
+ if (isDark) {
89
+ return {
90
+ base,
91
+ hover: swatch[400],
92
+ strong: swatch[300],
93
+ softBg: swatch[900],
94
+ softHover: swatch[800],
95
+ softActive: swatch[700],
96
+ outline: swatch[500],
97
+ onSolidText,
98
+ };
295
99
  }
296
- }
297
-
298
- /**
299
- * Simple OKLCH L-based contrast check
300
- */
301
- function getBestContrast(solidHex: string, neutral50: string, neutral950: string): string {
302
- const solid = oklch(solidHex);
303
- const n50 = oklch(neutral50);
304
- const n950 = oklch(neutral950);
305
100
 
306
- if (!solid || !n50 || !n950) return neutral50;
307
-
308
- const diff50 = Math.abs(solid.l - n50.l);
309
- const diff950 = Math.abs(solid.l - n950.l);
310
-
311
- return diff50 >= diff950 ? neutral50 : neutral950;
101
+ return {
102
+ base,
103
+ hover: swatch[600],
104
+ strong: swatch[700],
105
+ softBg: swatch[100],
106
+ softHover: swatch[200],
107
+ softActive: swatch[300],
108
+ outline: swatch[400],
109
+ onSolidText,
110
+ };
312
111
  }
313
112
 
314
113
  export function generatePalette(
@@ -316,200 +115,37 @@ export function generatePalette(
316
115
  mode: 'light' | 'dark' = 'light',
317
116
  ): {
318
117
  colors: Record<string, string>;
319
- scales: Record<string, ColorScale>;
118
+ swatches: GeneratedThemeSwatches;
320
119
  semantics: ThemeSemantics;
321
120
  } {
322
121
  const modeConfig = mode === 'dark' ? config.dark : config.light;
323
- const { primaryColor, harmony, colorTone } = modeConfig;
324
-
325
- let base = oklch(primaryColor);
326
- if (!base) {
327
- console.warn(
328
- `[colorEngine] Invalid primary color: "${primaryColor}". Falling back to default blue.`,
329
- );
330
- base = oklch('#3B82F6');
331
- }
332
-
333
- const baseHue = base?.h ?? 0;
334
- const hues = getHarmonyHues(baseHue, harmony);
335
- const rolePalette = getColorToneRolePalette(colorTone);
336
-
337
- // 1. Resolve Chromas
338
- const getC = (t: RolePaletteKind) => ROLE_PALETTE_CHROMA_ANCHORS[t];
339
- const getHue = (index: number, fallback: number) => hues[index] ?? fallback;
340
-
341
- const primaryChroma = getC(rolePalette.primary);
342
- const secondaryChroma = getC(rolePalette.secondary) * CHROMA_HIERARCHY.secondary;
343
- const tertiaryChroma = getC(rolePalette.accent) * CHROMA_HIERARCHY.accent;
344
- const highlightChroma = getC(rolePalette.highlight);
345
- const surfaceChroma = Math.min(0.02, getC(rolePalette.bg) * CHROMA_HIERARCHY.surface);
346
-
347
- // 2. Stable Role Assignment
348
- let pHue = getHue(0, baseHue);
349
- let sHue = getHue(0, baseHue);
350
- let aHue = getHue(0, baseHue);
351
- let hHue =
352
- harmony === 'tetradic' ? getHue(3, getHue(0, baseHue)) : (getHue(0, baseHue) + 60) % 360; // Yellow-ish offset if no tetradic
353
-
354
- switch (harmony) {
355
- case 'complementary':
356
- pHue = getHue(0, pHue);
357
- aHue = getHue(1, pHue);
358
- sHue = (getHue(0, pHue) + 30) % 360;
359
- break;
360
- case 'splitComplementary':
361
- case 'triadic':
362
- pHue = getHue(0, pHue);
363
- sHue = getHue(1, pHue);
364
- aHue = getHue(2, sHue);
365
- break;
366
- case 'tetradic':
367
- pHue = getHue(0, pHue);
368
- sHue = getHue(1, pHue);
369
- aHue = getHue(2, sHue);
370
- hHue = getHue(3, aHue);
371
- break;
372
- case 'analogous':
373
- pHue = getHue(1, pHue);
374
- sHue = getHue(0, pHue);
375
- aHue = getHue(2, sHue);
376
- break;
377
- }
378
-
379
- // 3. Build Bases with tuned lightness
380
- const getL = (t: RolePaletteKind) => ROLE_PALETTE_LIGHTNESS_ANCHORS[t];
381
-
382
- const primaryBase: OklchColor = {
383
- mode: 'oklch',
384
- l: getL(rolePalette.primary),
385
- c: primaryChroma,
386
- h: pHue,
387
- };
388
- const secondaryBase: OklchColor = {
389
- mode: 'oklch',
390
- l: getL(rolePalette.secondary),
391
- c: secondaryChroma,
392
- h: sHue,
393
- };
394
- const accentBase: OklchColor = {
395
- mode: 'oklch',
396
- l: getL(rolePalette.accent),
397
- c: tertiaryChroma,
398
- h: aHue,
399
- };
400
- const highlightBase: OklchColor = {
401
- mode: 'oklch',
402
- l: getL(rolePalette.highlight),
403
- c: highlightChroma,
404
- h: hHue,
405
- };
406
- const dangerBase: OklchColor = {
407
- mode: 'oklch',
408
- l: 0.6,
409
- c: 0.2,
410
- h: 25,
411
- };
412
- const successBase: OklchColor = {
413
- mode: 'oklch',
414
- l: 0.6,
415
- c: 0.2,
416
- h: 145,
417
- };
418
- const warningBase: OklchColor = {
419
- mode: 'oklch',
420
- l: 0.75,
421
- c: 0.15,
422
- h: 85,
423
- };
424
-
425
- const neutralBase: OklchColor =
426
- surfaceChroma === 0
427
- ? { mode: 'oklch', l: 0.62, c: 0 }
428
- : { mode: 'oklch', l: 0.62, c: surfaceChroma, h: 260 };
429
-
430
- // 4. Generate Scales
431
122
  const isDark = mode === 'dark';
432
- const scales = {
433
- primary: generateColorScale(primaryBase, isDark),
434
- secondary: generateColorScale(secondaryBase, isDark),
435
- accent: generateColorScale(accentBase, isDark),
436
- highlight: generateColorScale(highlightBase, isDark),
437
- neutral: generateColorScale(neutralBase, isDark),
438
- danger: generateColorScale(dangerBase, isDark),
439
- success: generateColorScale(successBase, isDark),
440
- warning: generateColorScale(warningBase, isDark),
441
- };
442
123
 
443
- // 5. Mappings
444
- const steps = isDark ? SEMANTIC_STEPS.dark : SEMANTIC_STEPS.light;
445
- const getStep = (s: ColorScale, idx: number) => {
446
- const key = SCALE_STEPS[idx] ?? SCALE_STEPS[SCALE_STEPS.length - 1] ?? 950;
447
- return s[key];
448
- };
124
+ // Throws deterministically on invalid primary color
125
+ parseHexColorOrThrow(modeConfig.primaryColor);
449
126
 
450
- const getNeutralMapping = (scale: ColorScale): NeutralSemantics => ({
451
- bg: getStep(scale, steps.bg),
452
- bgSubtle: getStep(scale, steps.bgSubtle),
453
- surface: getStep(scale, steps.surface),
454
- surfaceHover: getStep(scale, steps.surfaceHover),
455
- surfaceActive: getStep(scale, steps.surfaceActive),
456
- border: getStep(scale, steps.border),
457
- borderStrong: getStep(scale, steps.borderStrong),
458
- divider: getStep(scale, steps.divider),
459
- text: getStep(scale, steps.text),
460
- textMuted: getStep(scale, steps.textMuted),
461
- textSubtle: getStep(scale, steps.textSubtle),
462
- });
127
+ const generated = generateThemeModeColors(modeConfig);
463
128
 
464
- const getColorMapping = (scale: ColorScale, neutralScale: ColorScale): RoleSemantics => {
465
- const solid = getStep(scale, steps.solid);
466
- const softChromaLimit = 0.08;
129
+ const { swatches } = generated;
130
+ const neutralSwatch = swatches.neutral;
467
131
 
468
- // Chroma capping for soft tokens
469
- const getSoftStep = (idx: number) => {
470
- const hex = getStep(scale, idx);
471
- const color = oklch(hex);
472
- if (color && color.c > softChromaLimit) {
473
- return formatHex({ ...color, c: softChromaLimit });
474
- }
475
- return hex;
476
- };
132
+ const neutral = buildNeutralSemantics(neutralSwatch, isDark);
133
+ const brand = buildRoleSemantics(swatches.primary, isDark);
477
134
 
478
- return {
479
- base: solid,
480
- hover: getStep(scale, 6), // 600
481
- strong: getStep(scale, 7), // 700
482
- softBg: getSoftStep(steps.softBg),
483
- softHover: getSoftStep(steps.softHover),
484
- softActive: getSoftStep(steps.softActive),
485
- outline: getStep(scale, steps.outline),
486
- onSolidText: getBestContrast(solid, getStep(neutralScale, 0), getStep(neutralScale, 10)),
487
- };
488
- };
135
+ // Fallback to primary swatch for missing ordinal roles
136
+ const secondarySwatch = swatches.secondary ?? swatches.primary;
137
+ const tertiarySwatch = swatches.tertiary ?? swatches.primary;
138
+ const quaternarySwatch = swatches.quaternary ?? swatches.primary;
489
139
 
490
- const neutral = getNeutralMapping(scales.neutral);
491
- const brand = getColorMapping(scales.primary, scales.neutral);
492
- const neutralAction = getColorMapping(scales.neutral, scales.neutral);
493
- const danger = getColorMapping(scales.danger, scales.neutral);
494
- const success = getColorMapping(scales.success, scales.neutral);
495
- const warning = getColorMapping(scales.warning, scales.neutral);
140
+ const dangerSwatch = generateColorSwatch(DANGER_HEX).swatch;
141
+ const successSwatch = generateColorSwatch(SUCCESS_HEX).swatch;
142
+ const warningSwatch = generateColorSwatch(WARNING_HEX).swatch;
496
143
 
497
- const colors: Record<string, string> = {
498
- primary: getStep(scales.primary, steps.solid),
499
- secondary: getStep(scales.secondary, steps.solid),
500
- accent: getStep(scales.accent, steps.solid),
501
- highlight: getStep(scales.highlight, steps.solid),
502
- background: neutral.bg,
503
- surface: neutral.surface,
504
- text: neutral.text,
505
- textSecondary: neutral.textMuted,
506
- border: neutral.border,
507
- error: getStep(scales.danger, steps.solid),
508
- success: getStep(scales.success, steps.solid),
509
- warning: getStep(scales.warning, steps.solid),
510
- };
144
+ const danger = buildRoleSemantics(dangerSwatch, isDark);
145
+ const success = buildRoleSemantics(successSwatch, isDark);
146
+ const warning = buildRoleSemantics(warningSwatch, isDark);
511
147
 
512
- const surface: SurfaceSemantics = {
148
+ const surfaceSemantics: SurfaceSemantics = {
513
149
  default: neutral.surface,
514
150
  subtle: neutral.bgSubtle,
515
151
  raised: neutral.surface,
@@ -530,23 +166,38 @@ export function generatePalette(
530
166
 
531
167
  const action: ActionSemantics = {
532
168
  primary: brand,
533
- neutral: neutralAction,
169
+ neutral: buildRoleSemantics(neutralSwatch, isDark),
534
170
  danger,
535
171
  };
536
172
 
173
+ const colors: Record<string, string> = {
174
+ primary: brand.base,
175
+ secondary: secondarySwatch[500],
176
+ accent: tertiarySwatch[500],
177
+ highlight: quaternarySwatch[500],
178
+ background: neutral.bg,
179
+ surface: neutral.surface,
180
+ text: neutral.text,
181
+ textSecondary: neutral.textMuted,
182
+ border: neutral.border,
183
+ error: danger.base,
184
+ success: success.base,
185
+ warning: warning.base,
186
+ };
187
+
537
188
  return {
538
189
  colors,
539
- scales,
190
+ swatches,
540
191
  semantics: {
541
192
  neutral,
542
193
  brand,
543
- secondary: getColorMapping(scales.secondary, scales.neutral),
544
- accent: getColorMapping(scales.accent, scales.neutral),
545
- highlight: getColorMapping(scales.highlight, scales.neutral),
194
+ secondary: buildRoleSemantics(secondarySwatch, isDark),
195
+ accent: buildRoleSemantics(tertiarySwatch, isDark),
196
+ highlight: buildRoleSemantics(quaternarySwatch, isDark),
546
197
  danger,
547
198
  success,
548
199
  warning,
549
- surface,
200
+ surface: surfaceSemantics,
550
201
  content,
551
202
  border,
552
203
  action,