@newtonedev/components 0.1.5 → 0.1.6

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 (54) hide show
  1. package/dist/composites/actions/Button/Button.d.ts.map +1 -1
  2. package/dist/composites/actions/Button/Button.styles.d.ts +3 -1
  3. package/dist/composites/actions/Button/Button.styles.d.ts.map +1 -1
  4. package/dist/index.cjs +360 -226
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.ts +4 -3
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +357 -228
  9. package/dist/index.js.map +1 -1
  10. package/dist/primitives/Frame/Frame.d.ts +2 -3
  11. package/dist/primitives/Frame/Frame.d.ts.map +1 -1
  12. package/dist/primitives/Frame/Frame.types.d.ts +4 -15
  13. package/dist/primitives/Frame/Frame.types.d.ts.map +1 -1
  14. package/dist/primitives/Text/Text.d.ts.map +1 -1
  15. package/dist/primitives/Text/Text.types.d.ts +9 -4
  16. package/dist/primitives/Text/Text.types.d.ts.map +1 -1
  17. package/dist/primitives/Wrapper/Wrapper.d.ts +1 -1
  18. package/dist/primitives/Wrapper/Wrapper.types.d.ts +1 -1
  19. package/dist/registry/registry.d.ts.map +1 -1
  20. package/dist/theme/FrameContext.d.ts +7 -5
  21. package/dist/theme/FrameContext.d.ts.map +1 -1
  22. package/dist/theme/NewtoneProvider.d.ts +5 -6
  23. package/dist/theme/NewtoneProvider.d.ts.map +1 -1
  24. package/dist/theme/defaults.d.ts.map +1 -1
  25. package/dist/theme/types.d.ts +38 -24
  26. package/dist/theme/types.d.ts.map +1 -1
  27. package/dist/tokens/computeTokens.d.ts +82 -7
  28. package/dist/tokens/computeTokens.d.ts.map +1 -1
  29. package/dist/tokens/types.d.ts +58 -16
  30. package/dist/tokens/types.d.ts.map +1 -1
  31. package/dist/tokens/useTokens.d.ts +2 -23
  32. package/dist/tokens/useTokens.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/composites/actions/Button/Button.styles.ts +53 -80
  35. package/src/composites/actions/Button/Button.tsx +6 -2
  36. package/src/composites/form-controls/Select/SelectOption.tsx +2 -2
  37. package/src/composites/form-controls/Toggle/Toggle.styles.ts +1 -1
  38. package/src/composites/range-inputs/ColorScaleSlider/ColorScaleSlider.styles.ts +1 -1
  39. package/src/composites/range-inputs/Slider/Slider.styles.ts +2 -2
  40. package/src/index.ts +11 -4
  41. package/src/primitives/Frame/Frame.tsx +10 -18
  42. package/src/primitives/Frame/Frame.types.ts +5 -17
  43. package/src/primitives/Text/Text.tsx +18 -8
  44. package/src/primitives/Text/Text.types.ts +9 -4
  45. package/src/primitives/Wrapper/Wrapper.tsx +1 -1
  46. package/src/primitives/Wrapper/Wrapper.types.ts +1 -1
  47. package/src/registry/registry.ts +28 -7
  48. package/src/theme/FrameContext.tsx +7 -5
  49. package/src/theme/NewtoneProvider.tsx +5 -10
  50. package/src/theme/defaults.ts +0 -9
  51. package/src/theme/types.ts +53 -26
  52. package/src/tokens/computeTokens.ts +338 -116
  53. package/src/tokens/types.ts +74 -16
  54. package/src/tokens/useTokens.ts +16 -33
@@ -1,7 +1,119 @@
1
- import { getColor, getColorByContrast } from 'newtone';
1
+ import { getColor } from 'newtone';
2
2
  import type { PaletteConfig } from 'newtone';
3
- import type { ColorSystemConfig, ColorMode, ThemeMapping, ElevationLevel, FontConfig } from '../theme/types';
4
- import type { ResolvedTokens } from './types';
3
+ import type { ColorSystemConfig, ColorMode, ElevationLevel, FontConfig, TokenOverrides } from '../theme/types';
4
+ import type { ResolvedTokens, PaletteTokens } from './types';
5
+
6
+ /**
7
+ * Per-mode fallback defaults shape shared by all palette defaults.
8
+ */
9
+ export type PaletteDefaults = {
10
+ readonly light: {
11
+ readonly background: { readonly elevated: number; readonly ground: number; readonly sunken: number };
12
+ readonly text: { readonly primary: number; readonly secondary: number; readonly tertiary: number; readonly disabled: number };
13
+ readonly action: { readonly enabled: number; readonly hovered: number; readonly pressed: number };
14
+ readonly border: { readonly enabled: number; readonly focused: number; readonly filled: number };
15
+ };
16
+ readonly dark: {
17
+ readonly background: { readonly elevated: number; readonly ground: number; readonly sunken: number };
18
+ readonly text: { readonly primary: number; readonly secondary: number; readonly tertiary: number; readonly disabled: number };
19
+ readonly action: { readonly enabled: number; readonly hovered: number; readonly pressed: number };
20
+ readonly border: { readonly enabled: number; readonly focused: number; readonly filled: number };
21
+ };
22
+ };
23
+
24
+ /**
25
+ * Per-mode fallback defaults for the neutral palette (normalized scale).
26
+ * Single source of truth — consumed by computeTokens and the admin Token Tuner.
27
+ *
28
+ * Light: 0 = lightest, 1 = darkest. Dark: 0 = darkest, 1 = lightest.
29
+ * Structured by token group so all per-palette defaults follow the same shape.
30
+ */
31
+ export const NEUTRAL_DEFAULTS: PaletteDefaults = {
32
+ light: {
33
+ background: { elevated: 0, ground: 0.03, sunken: 0.06 },
34
+ text: { primary: 0.9, secondary: 0.7, tertiary: 0.5, disabled: 0.3 },
35
+ action: { enabled: 0.04, hovered: 0.06, pressed: 0.08 },
36
+ border: { enabled: 0.08, focused: 0.16, filled: 0.24 },
37
+ },
38
+ dark: {
39
+ background: { elevated: 0.24, ground: 0.20, sunken: 0.16 },
40
+ text: { primary: 1.0, secondary: 0.85, tertiary: 0.7, disabled: 0.55 },
41
+ action: { enabled: 0.04, hovered: 0.06, pressed: 0.08 },
42
+ border: { enabled: 0.08, focused: 0.16, filled: 0.24 },
43
+ },
44
+ };
45
+
46
+ /**
47
+ * Accent palette defaults. Initial values match neutral — will be tuned per-palette later.
48
+ */
49
+ export const ACCENT_DEFAULTS: PaletteDefaults = {
50
+ light: {
51
+ background: { elevated: 0, ground: 0.03, sunken: 0.06 },
52
+ text: { primary: 0.9, secondary: 0.7, tertiary: 0.5, disabled: 0.3 },
53
+ action: { enabled: 0.04, hovered: 0.06, pressed: 0.08 },
54
+ border: { enabled: 0.08, focused: 0.16, filled: 0.24 },
55
+ },
56
+ dark: {
57
+ background: { elevated: 0.24, ground: 0.20, sunken: 0.16 },
58
+ text: { primary: 1.0, secondary: 0.85, tertiary: 0.7, disabled: 0.55 },
59
+ action: { enabled: 0.04, hovered: 0.06, pressed: 0.08 },
60
+ border: { enabled: 0.08, focused: 0.16, filled: 0.24 },
61
+ },
62
+ };
63
+
64
+ /**
65
+ * Success palette defaults. Initial values match neutral — will be tuned per-palette later.
66
+ */
67
+ export const SUCCESS_DEFAULTS: PaletteDefaults = {
68
+ light: {
69
+ background: { elevated: 0, ground: 0.03, sunken: 0.06 },
70
+ text: { primary: 0.9, secondary: 0.7, tertiary: 0.5, disabled: 0.3 },
71
+ action: { enabled: 0.04, hovered: 0.06, pressed: 0.08 },
72
+ border: { enabled: 0.08, focused: 0.16, filled: 0.24 },
73
+ },
74
+ dark: {
75
+ background: { elevated: 0.24, ground: 0.20, sunken: 0.16 },
76
+ text: { primary: 1.0, secondary: 0.85, tertiary: 0.7, disabled: 0.55 },
77
+ action: { enabled: 0.04, hovered: 0.06, pressed: 0.08 },
78
+ border: { enabled: 0.08, focused: 0.16, filled: 0.24 },
79
+ },
80
+ };
81
+
82
+ /**
83
+ * Warning palette defaults. Initial values match neutral — will be tuned per-palette later.
84
+ */
85
+ export const WARNING_DEFAULTS: PaletteDefaults = {
86
+ light: {
87
+ background: { elevated: 0, ground: 0.03, sunken: 0.06 },
88
+ text: { primary: 0.9, secondary: 0.7, tertiary: 0.5, disabled: 0.3 },
89
+ action: { enabled: 0.04, hovered: 0.06, pressed: 0.08 },
90
+ border: { enabled: 0.08, focused: 0.16, filled: 0.24 },
91
+ },
92
+ dark: {
93
+ background: { elevated: 0.24, ground: 0.20, sunken: 0.16 },
94
+ text: { primary: 1.0, secondary: 0.85, tertiary: 0.7, disabled: 0.55 },
95
+ action: { enabled: 0.04, hovered: 0.06, pressed: 0.08 },
96
+ border: { enabled: 0.08, focused: 0.16, filled: 0.24 },
97
+ },
98
+ };
99
+
100
+ /**
101
+ * Error palette defaults. Initial values match neutral — will be tuned per-palette later.
102
+ */
103
+ export const ERROR_DEFAULTS: PaletteDefaults = {
104
+ light: {
105
+ background: { elevated: 0, ground: 0.03, sunken: 0.06 },
106
+ text: { primary: 0.9, secondary: 0.7, tertiary: 0.5, disabled: 0.3 },
107
+ action: { enabled: 0.04, hovered: 0.06, pressed: 0.08 },
108
+ border: { enabled: 0.08, focused: 0.16, filled: 0.24 },
109
+ },
110
+ dark: {
111
+ background: { elevated: 0.24, ground: 0.20, sunken: 0.16 },
112
+ text: { primary: 1.0, secondary: 0.85, tertiary: 0.7, disabled: 0.55 },
113
+ action: { enabled: 0.04, hovered: 0.06, pressed: 0.08 },
114
+ border: { enabled: 0.08, focused: 0.16, filled: 0.24 },
115
+ },
116
+ };
5
117
 
6
118
  /**
7
119
  * Convert FontConfig to CSS font-family string
@@ -11,6 +123,104 @@ function fontConfigToFamily(font: FontConfig): string {
11
123
  return `${family}, ${font.fallback}`;
12
124
  }
13
125
 
126
+ const clamp = (n: number) => Math.max(0, Math.min(1, n));
127
+
128
+ /**
129
+ * Compute the complete PaletteTokens for a non-neutral palette.
130
+ *
131
+ * Uses the palette's hue/saturation with positions from the palette's defaults,
132
+ * plus fill tokens at the palette's key NV position.
133
+ *
134
+ * @param palette - The palette config (hue, saturation, key NV, etc.)
135
+ * @param defaults - Per-mode defaults for this palette (positions + offsets)
136
+ * @param mode - Current color mode
137
+ * @param elevation - Current elevation level
138
+ * @param dynamicRange - Global dynamic range
139
+ * @param elevationDelta - Compensation delta (current surface vs elevated reference)
140
+ * @param effectiveTextMode - Derived from actual background lightness
141
+ * @param autoAccentNv - Auto-derived key NV fallback
142
+ * @param neutralTextPrimary - Neutral textPrimary for onFill (dark text on light fills)
143
+ * @param neutralBgElevated - Neutral backgroundElevated for onFill (light text on dark fills)
144
+ */
145
+ function computePaletteTokens(
146
+ palette: PaletteConfig,
147
+ defaults: PaletteDefaults,
148
+ mode: ColorMode,
149
+ elevation: ElevationLevel,
150
+ dynamicRange: { readonly lightest: number; readonly darkest: number },
151
+ elevationDelta: number,
152
+ effectiveTextMode: ColorMode,
153
+ autoAccentNv: number,
154
+ neutralTextPrimary: ReturnType<typeof getColor>,
155
+ neutralBgElevated: ReturnType<typeof getColor>,
156
+ ): PaletteTokens {
157
+ const modeDefaults = defaults[mode];
158
+ const toEngineNv = (nv: number) => mode === 'light' ? 1 - nv : nv;
159
+ const textToEngineNv = (nv: number) => effectiveTextMode === 'light' ? 1 - nv : nv;
160
+
161
+ const colorAt = (engineNv: number) => getColor(
162
+ palette.hue, palette.saturation, dynamicRange,
163
+ clamp(engineNv), palette.desaturation, palette.paletteHueGrading
164
+ );
165
+
166
+ // --- Fill: key color (user-chosen or auto-derived), elevation-compensated ---
167
+ const resolveKeyNv = (p: PaletteConfig): number | undefined =>
168
+ mode === 'dark' ? p.keyNormalizedValueDark : p.keyNormalizedValue;
169
+
170
+ const keyNv = resolveKeyNv(palette);
171
+ const fillBaseNv = keyNv ?? autoAccentNv;
172
+ const fillNv = clamp(fillBaseNv + elevationDelta);
173
+ const fill = colorAt(fillNv);
174
+
175
+ const hoverDir = effectiveTextMode === 'light' ? -modeDefaults.action.hovered : modeDefaults.action.hovered;
176
+ const activeDir = effectiveTextMode === 'light' ? -modeDefaults.action.pressed : modeDefaults.action.pressed;
177
+ const fillHover = colorAt(clamp(fillNv + hoverDir));
178
+ const fillActive = colorAt(clamp(fillNv + activeDir));
179
+
180
+ // onFill: high-contrast text on the fill color
181
+ const onFill = fill.oklch.L > 0.6 ? neutralTextPrimary : neutralBgElevated;
182
+
183
+ // --- Surface: palette-tinted backgrounds at standard positions ---
184
+ const bgNormalized = elevation === 2
185
+ ? modeDefaults.background.elevated
186
+ : elevation === 1
187
+ ? modeDefaults.background.ground
188
+ : modeDefaults.background.sunken;
189
+ const bgNv = clamp(toEngineNv(bgNormalized));
190
+ const background = colorAt(bgNv);
191
+ const backgroundElevated = colorAt(clamp(toEngineNv(modeDefaults.background.elevated)));
192
+ const backgroundSunken = colorAt(clamp(toEngineNv(modeDefaults.background.sunken)));
193
+
194
+ // --- Interactive surface: offset from palette background ---
195
+ const interactiveOffset = modeDefaults.action.enabled;
196
+ const interactiveNv = clamp(bgNv + (effectiveTextMode === 'light' ? -interactiveOffset : interactiveOffset));
197
+ const backgroundInteractive = colorAt(interactiveNv);
198
+
199
+ const hoverShift = modeDefaults.action.hovered;
200
+ const activeShift = modeDefaults.action.pressed;
201
+ const backgroundInteractiveHover = colorAt(clamp(interactiveNv + (effectiveTextMode === 'light' ? -hoverShift : hoverShift)));
202
+ const backgroundInteractiveActive = colorAt(clamp(interactiveNv + (effectiveTextMode === 'light' ? -activeShift : activeShift)));
203
+
204
+ // --- Text: palette-hued text at standard positions, elevation-compensated ---
205
+ const textPrimary = colorAt(clamp(textToEngineNv(modeDefaults.text.primary) + elevationDelta));
206
+ const textSecondary = colorAt(clamp(textToEngineNv(modeDefaults.text.secondary) + elevationDelta));
207
+ const textTertiary = colorAt(clamp(textToEngineNv(modeDefaults.text.tertiary) + elevationDelta));
208
+ const textDisabled = colorAt(clamp(textToEngineNv(modeDefaults.text.disabled) + elevationDelta));
209
+
210
+ // --- Border: offset from palette background ---
211
+ const borderOffset = modeDefaults.border.enabled;
212
+ const borderNv = effectiveTextMode === 'light' ? bgNv - borderOffset : bgNv + borderOffset;
213
+ const border = colorAt(clamp(borderNv));
214
+
215
+ return {
216
+ fill, fillHover, fillActive, onFill,
217
+ background, backgroundElevated, backgroundSunken,
218
+ backgroundInteractive, backgroundInteractiveHover, backgroundInteractiveActive,
219
+ textPrimary, textSecondary, textTertiary, textDisabled,
220
+ border,
221
+ };
222
+ }
223
+
14
224
  /**
15
225
  * Compute design tokens for a specific mode/theme/elevation combination.
16
226
  *
@@ -18,11 +228,13 @@ function fontConfigToFamily(font: FontConfig): string {
18
228
  * based on the current theme context. All colors are computed on-demand using
19
229
  * the pure functions from the engine.
20
230
  *
231
+ * Background surfaces use absolute positions from NEUTRAL_DEFAULTS (or tokenOverrides
232
+ * when present). Elevation compensation is derived from the difference between
233
+ * the current surface and the elevated reference surface.
234
+ *
21
235
  * @param config - Complete color system configuration (dynamic range + palettes)
22
236
  * @param mode - Current color mode ('light' or 'dark')
23
- * @param themeMapping - Theme configuration (which palette and NV to use)
24
237
  * @param elevation - Elevation level (0=sunken, 1=default, 2=elevated)
25
- * @param elevationOffsets - NV offsets for each elevation level
26
238
  * @param spacing - Spacing scale for paddings, gaps, and margins
27
239
  * @param radius - Border radius scale for component roundness
28
240
  * @param typography - Typography configuration with fonts and scales
@@ -34,9 +246,7 @@ function fontConfigToFamily(font: FontConfig): string {
34
246
  * const tokens = computeTokens(
35
247
  * config.colorSystem,
36
248
  * 'light',
37
- * config.themes.neutral,
38
249
  * 1,
39
- * config.elevation.offsets,
40
250
  * config.spacing,
41
251
  * config.radius,
42
252
  * config.typography,
@@ -48,9 +258,7 @@ function fontConfigToFamily(font: FontConfig): string {
48
258
  export function computeTokens(
49
259
  config: ColorSystemConfig,
50
260
  mode: ColorMode,
51
- themeMapping: ThemeMapping,
52
261
  elevation: ElevationLevel,
53
- elevationOffsets: readonly [number, number, number],
54
262
  spacing: { readonly '00': number; readonly '02': number; readonly '04': number; readonly '06': number; readonly '08': number; readonly '10': number; readonly '12': number; readonly '16': number; readonly '20': number; readonly '24': number; readonly '32': number; readonly '40': number; readonly '48': number },
55
263
  radius: { readonly none: number; readonly sm: number; readonly md: number; readonly lg: number; readonly xl: number; readonly pill: 999 },
56
264
  typography: {
@@ -63,19 +271,44 @@ export function computeTokens(
63
271
  readonly variant: 'outlined' | 'rounded' | 'sharp';
64
272
  readonly weight: 100 | 200 | 300 | 400 | 500 | 600 | 700;
65
273
  readonly autoGrade: boolean;
66
- }
274
+ },
275
+ tokenOverrides?: TokenOverrides
67
276
  ): ResolvedTokens {
68
277
  const { dynamicRange, palettes } = config;
69
- const palette = palettes[themeMapping.paletteIndex];
278
+ const palette = palettes[0]; // Always neutral palette for backgrounds
70
279
 
71
280
  if (!palette) {
72
- throw new Error(`Palette at index ${themeMapping.paletteIndex} not found`);
281
+ throw new Error('Neutral palette (index 0) not found');
73
282
  }
74
283
 
75
- // Determine base NV for this mode + elevation
76
- const baseNv = mode === 'light' ? themeMapping.lightModeNv : themeMapping.darkModeNv;
77
- const elevationOffset = elevationOffsets[elevation];
78
- const backgroundNv = Math.max(0, Math.min(1, baseNv + elevationOffset));
284
+ const neutralDefaults = NEUTRAL_DEFAULTS[mode];
285
+
286
+ // --- Mode-specific normalized field resolution ---
287
+ const toEngineNv = (nv: number) => mode === 'light' ? 1 - nv : nv;
288
+ const bgElevatedNorm = mode === 'light' ? tokenOverrides?.backgroundElevated : tokenOverrides?.backgroundElevatedDark;
289
+ const bgDefaultNorm = mode === 'light' ? tokenOverrides?.backgroundDefault : tokenOverrides?.backgroundDefaultDark;
290
+ const bgSunkenNorm = mode === 'light' ? tokenOverrides?.backgroundSunken : tokenOverrides?.backgroundSunkenDark;
291
+ const textPrimaryNorm = mode === 'light' ? tokenOverrides?.textPrimaryNormalized : tokenOverrides?.textPrimaryNormalizedDark;
292
+ const textSecondaryNorm = mode === 'light' ? tokenOverrides?.textSecondaryNormalized : tokenOverrides?.textSecondaryNormalizedDark;
293
+ const textTertiaryNorm = mode === 'light' ? tokenOverrides?.textTertiaryNormalized : tokenOverrides?.textTertiaryNormalizedDark;
294
+ const textDisabledNorm = mode === 'light' ? tokenOverrides?.textDisabledNormalized : tokenOverrides?.textDisabledNormalizedDark;
295
+
296
+ // --- Background NV resolution ---
297
+ // Absolute positions from tokenOverrides or NEUTRAL_DEFAULTS.
298
+ // Ground = Background/01 (elevated). Everything diverges from there.
299
+ const bgNormalized = elevation === 2
300
+ ? (bgElevatedNorm ?? neutralDefaults.background.elevated)
301
+ : elevation === 1
302
+ ? (bgDefaultNorm ?? neutralDefaults.background.ground)
303
+ : (bgSunkenNorm ?? neutralDefaults.background.sunken);
304
+ const backgroundNv = clamp(toEngineNv(bgNormalized));
305
+ const elevatedNv = clamp(toEngineNv(bgElevatedNorm ?? neutralDefaults.background.elevated));
306
+ const sunkenNv = clamp(toEngineNv(bgSunkenNorm ?? neutralDefaults.background.sunken));
307
+
308
+ // Elevation compensation: how far the current surface is from the reference (bg01/elevated).
309
+ // Tokens designed at bg01 shift by this delta on deeper surfaces to preserve perceptual contrast.
310
+ // Always 0 on bg01. Negative on bg02/bg03 in both modes (surface is darker → tokens darken too).
311
+ const elevationDelta = backgroundNv - elevatedNv;
79
312
 
80
313
  // Derive effective text mode from actual background lightness.
81
314
  // This handles inverted themes (e.g., strong: dark bg in light mode)
@@ -92,8 +325,7 @@ export function computeTokens(
92
325
  palette.paletteHueGrading
93
326
  );
94
327
 
95
- // Compute elevated surface (always at elevation 2 offset)
96
- const elevatedNv = Math.max(0, Math.min(1, baseNv + elevationOffsets[2]));
328
+ // Compute elevated surface
97
329
  const backgroundElevated = getColor(
98
330
  palette.hue,
99
331
  palette.saturation,
@@ -103,8 +335,7 @@ export function computeTokens(
103
335
  palette.paletteHueGrading
104
336
  );
105
337
 
106
- // Compute sunken surface (always at elevation 0 offset)
107
- const sunkenNv = Math.max(0, Math.min(1, baseNv + elevationOffsets[0]));
338
+ // Compute sunken surface
108
339
  const backgroundSunken = getColor(
109
340
  palette.hue,
110
341
  palette.saturation,
@@ -114,12 +345,26 @@ export function computeTokens(
114
345
  palette.paletteHueGrading
115
346
  );
116
347
 
117
- // Compute interactive component background (FIXED -0.035 NV offset from current elevation)
348
+ // --- Tunable constants (overridable via tokenOverrides, per-mode) ---
349
+ // All values are magnitudes — direction auto-inverts per effectiveTextMode
350
+ // (darker in light mode, lighter in dark mode)
351
+ const INTERACTIVE_COMPONENT_OFFSET = mode === 'light'
352
+ ? (tokenOverrides?.interactiveComponentOffset ?? neutralDefaults.action.enabled)
353
+ : (tokenOverrides?.interactiveComponentOffsetDark ?? neutralDefaults.action.enabled);
354
+ const HOVER_SHIFT = mode === 'light'
355
+ ? (tokenOverrides?.hoverShift ?? neutralDefaults.action.hovered)
356
+ : (tokenOverrides?.hoverShiftDark ?? neutralDefaults.action.hovered);
357
+ const ACTIVE_SHIFT = mode === 'light'
358
+ ? (tokenOverrides?.activeShift ?? neutralDefaults.action.pressed)
359
+ : (tokenOverrides?.activeShiftDark ?? neutralDefaults.action.pressed);
360
+ const BORDER_OFFSET = mode === 'light'
361
+ ? (tokenOverrides?.borderOffset ?? neutralDefaults.border.enabled)
362
+ : (tokenOverrides?.borderOffsetDark ?? neutralDefaults.border.enabled);
363
+ // Compute interactive component background (FIXED NV offset from current elevation)
118
364
  // Unlike backgroundElevated/backgroundSunken which use discrete levels, this uses a fixed
119
365
  // luminosity offset to ensure CONSISTENT visual contrast across all elevations (-2 to 2).
120
366
  // Used by: Button (neutral primary variant) and future components with neutral filled backgrounds.
121
- const INTERACTIVE_COMPONENT_OFFSET = -0.035; // Slightly darker than container for depth
122
- const interactiveComponentNv = Math.max(0, Math.min(1, backgroundNv + INTERACTIVE_COMPONENT_OFFSET));
367
+ const interactiveComponentNv = clamp(backgroundNv + (effectiveTextMode === 'light' ? -INTERACTIVE_COMPONENT_OFFSET : INTERACTIVE_COMPONENT_OFFSET));
123
368
  const backgroundInteractive = getColor(
124
369
  palette.hue,
125
370
  palette.saturation,
@@ -129,141 +374,118 @@ export function computeTokens(
129
374
  palette.paletteHueGrading
130
375
  );
131
376
 
132
- // Compute text colors with WCAG contrast against actual background
133
- // Primary text: WCAG AA (4.5:1 for body text)
134
- const textPrimary = getColorByContrast(
377
+ // Neutral hover/active: shift from interactive component base (same direction as accent hover/active)
378
+ const neutralHoverNv = clamp(interactiveComponentNv + (effectiveTextMode === 'light' ? -HOVER_SHIFT : HOVER_SHIFT));
379
+ const backgroundInteractiveHover = getColor(
135
380
  palette.hue,
136
381
  palette.saturation,
137
382
  dynamicRange,
138
- 4.5,
139
- effectiveTextMode,
383
+ neutralHoverNv,
140
384
  palette.desaturation,
141
- palette.paletteHueGrading,
142
- background,
385
+ palette.paletteHueGrading
143
386
  );
144
387
 
145
- // Secondary text: Lower contrast (3.0:1 for captions)
146
- const textSecondary = getColorByContrast(
388
+ const neutralActiveNv = clamp(interactiveComponentNv + (effectiveTextMode === 'light' ? -ACTIVE_SHIFT : ACTIVE_SHIFT));
389
+ const backgroundInteractiveActive = getColor(
147
390
  palette.hue,
148
391
  palette.saturation,
149
392
  dynamicRange,
150
- 3.0,
151
- effectiveTextMode,
393
+ neutralActiveNv,
152
394
  palette.desaturation,
153
- palette.paletteHueGrading,
154
- background,
395
+ palette.paletteHueGrading
155
396
  );
156
397
 
157
- // Interactive colors: Use accent palette (index 1)
158
- const accentPalette = palettes[1];
398
+ // --- Text color resolution (elevation-compensated) ---
399
+ // Normalized positions define text contrast at bg01 (elevated). On deeper surfaces,
400
+ // elevationDelta shifts text toward the background to preserve the same contrast distance.
401
+ // Uses effectiveTextMode for NV conversion so inverted themes (e.g., strong: dark bg in
402
+ // light mode) auto-correct text direction — light text on dark bg, dark text on light bg.
403
+ const textToEngineNv = (nv: number) => effectiveTextMode === 'light' ? 1 - nv : nv;
159
404
 
160
- if (!accentPalette) {
161
- throw new Error('Accent palette (index 1) not found');
162
- }
405
+ const textPrimary = getColor(palette.hue, palette.saturation, dynamicRange,
406
+ clamp(textToEngineNv(textPrimaryNorm ?? neutralDefaults.text.primary) + elevationDelta),
407
+ palette.desaturation, palette.paletteHueGrading);
163
408
 
164
- // Resolve per-mode key color: light mode uses keyNormalizedValue, dark uses keyNormalizedValueDark
165
- const resolveKeyNv = (p: PaletteConfig): number | undefined =>
166
- mode === 'dark' ? p.keyNormalizedValueDark : p.keyNormalizedValue;
409
+ const textSecondary = getColor(palette.hue, palette.saturation, dynamicRange,
410
+ clamp(textToEngineNv(textSecondaryNorm ?? neutralDefaults.text.secondary) + elevationDelta),
411
+ palette.desaturation, palette.paletteHueGrading);
167
412
 
168
- // Interactive base: use user-chosen key color or auto WCAG AA contrast
169
- const accentKeyNv = resolveKeyNv(accentPalette);
170
- const interactive = accentKeyNv !== undefined
171
- ? getColor(
172
- accentPalette.hue,
173
- accentPalette.saturation,
174
- dynamicRange,
175
- accentKeyNv,
176
- accentPalette.desaturation,
177
- accentPalette.paletteHueGrading
178
- )
179
- : getColorByContrast(
180
- accentPalette.hue,
181
- accentPalette.saturation,
182
- dynamicRange,
183
- 4.5,
184
- effectiveTextMode,
185
- accentPalette.desaturation,
186
- accentPalette.paletteHueGrading,
187
- background,
188
- );
189
-
190
- // Hover/active states: Shift NV slightly from interactive base
191
- // In light mode (light bg), go darker; in dark mode (dark bg), go lighter
192
- const interactiveNv = accentKeyNv ?? (effectiveTextMode === 'light' ? 0.3 : 0.7);
193
-
194
- const interactiveHover = getColor(
195
- accentPalette.hue,
196
- accentPalette.saturation,
197
- dynamicRange,
198
- interactiveNv + (effectiveTextMode === 'light' ? -0.05 : 0.05),
199
- accentPalette.desaturation,
200
- accentPalette.paletteHueGrading
201
- );
413
+ const textTertiary = getColor(palette.hue, palette.saturation, dynamicRange,
414
+ clamp(textToEngineNv(textTertiaryNorm ?? neutralDefaults.text.tertiary) + elevationDelta),
415
+ palette.desaturation, palette.paletteHueGrading);
202
416
 
203
- const interactiveActive = getColor(
204
- accentPalette.hue,
205
- accentPalette.saturation,
206
- dynamicRange,
207
- interactiveNv + (effectiveTextMode === 'light' ? -0.1 : 0.1),
208
- accentPalette.desaturation,
209
- accentPalette.paletteHueGrading
210
- );
417
+ const textDisabled = getColor(palette.hue, palette.saturation, dynamicRange,
418
+ clamp(textToEngineNv(textDisabledNorm ?? neutralDefaults.text.disabled) + elevationDelta),
419
+ palette.desaturation, palette.paletteHueGrading);
211
420
 
212
421
  // Border: Subtle contrast from background
213
- const borderNv = effectiveTextMode === 'light' ? backgroundNv - 0.1 : backgroundNv + 0.1;
422
+ const borderNv = effectiveTextMode === 'light' ? backgroundNv - BORDER_OFFSET : backgroundNv + BORDER_OFFSET;
214
423
  const border = getColor(
215
424
  palette.hue,
216
425
  palette.saturation,
217
426
  dynamicRange,
218
- Math.max(0, Math.min(1, borderNv)),
427
+ clamp(borderNv),
219
428
  palette.desaturation,
220
429
  palette.paletteHueGrading
221
430
  );
222
431
 
223
- // Semantic status colors: success (palette 2), warning (palette 3), error (palette 4)
224
- // Each computed at WCAG AA contrast against actual background
432
+ // --- Per-palette token computation ---
433
+ // Auto accent NV: derived from text primary position (used when no explicit key color is set)
434
+ const autoAccentNv = clamp(textToEngineNv(textPrimaryNorm ?? neutralDefaults.text.primary));
435
+
436
+ const accentPalette = palettes[1];
437
+ if (!accentPalette) {
438
+ throw new Error('Accent palette (index 1) not found');
439
+ }
440
+
441
+ const accent = computePaletteTokens(
442
+ accentPalette, ACCENT_DEFAULTS, mode, elevation, dynamicRange,
443
+ elevationDelta, effectiveTextMode, autoAccentNv,
444
+ textPrimary, backgroundElevated,
445
+ );
446
+
225
447
  const successPalette = palettes[2];
226
448
  const warningPalette = palettes[3];
227
449
  const errorPalette = palettes[4];
228
450
 
229
- const successKeyNv = successPalette ? resolveKeyNv(successPalette) : undefined;
451
+ // Semantic palettes: fall back to accent palette if not present
230
452
  const success = successPalette
231
- ? (successKeyNv !== undefined
232
- ? getColor(successPalette.hue, successPalette.saturation, dynamicRange,
233
- successKeyNv, successPalette.desaturation, successPalette.paletteHueGrading)
234
- : getColorByContrast(successPalette.hue, successPalette.saturation, dynamicRange,
235
- 4.5, effectiveTextMode, successPalette.desaturation, successPalette.paletteHueGrading, background))
236
- : interactive;
237
-
238
- const warningKeyNv = warningPalette ? resolveKeyNv(warningPalette) : undefined;
453
+ ? computePaletteTokens(
454
+ successPalette, SUCCESS_DEFAULTS, mode, elevation, dynamicRange,
455
+ elevationDelta, effectiveTextMode, autoAccentNv,
456
+ textPrimary, backgroundElevated,
457
+ )
458
+ : accent;
459
+
239
460
  const warning = warningPalette
240
- ? (warningKeyNv !== undefined
241
- ? getColor(warningPalette.hue, warningPalette.saturation, dynamicRange,
242
- warningKeyNv, warningPalette.desaturation, warningPalette.paletteHueGrading)
243
- : getColorByContrast(warningPalette.hue, warningPalette.saturation, dynamicRange,
244
- 4.5, effectiveTextMode, warningPalette.desaturation, warningPalette.paletteHueGrading, background))
245
- : interactive;
246
-
247
- const errorKeyNv = errorPalette ? resolveKeyNv(errorPalette) : undefined;
461
+ ? computePaletteTokens(
462
+ warningPalette, WARNING_DEFAULTS, mode, elevation, dynamicRange,
463
+ elevationDelta, effectiveTextMode, autoAccentNv,
464
+ textPrimary, backgroundElevated,
465
+ )
466
+ : accent;
467
+
248
468
  const error = errorPalette
249
- ? (errorKeyNv !== undefined
250
- ? getColor(errorPalette.hue, errorPalette.saturation, dynamicRange,
251
- errorKeyNv, errorPalette.desaturation, errorPalette.paletteHueGrading)
252
- : getColorByContrast(errorPalette.hue, errorPalette.saturation, dynamicRange,
253
- 4.5, effectiveTextMode, errorPalette.desaturation, errorPalette.paletteHueGrading, background))
254
- : interactive;
469
+ ? computePaletteTokens(
470
+ errorPalette, ERROR_DEFAULTS, mode, elevation, dynamicRange,
471
+ elevationDelta, effectiveTextMode, autoAccentNv,
472
+ textPrimary, backgroundElevated,
473
+ )
474
+ : accent;
255
475
 
256
476
  return {
257
477
  background,
258
478
  backgroundElevated,
259
479
  backgroundSunken,
260
480
  backgroundInteractive,
481
+ backgroundInteractiveHover,
482
+ backgroundInteractiveActive,
261
483
  textPrimary,
262
484
  textSecondary,
263
- interactive,
264
- interactiveHover,
265
- interactiveActive,
485
+ textTertiary,
486
+ textDisabled,
266
487
  border,
488
+ accent,
267
489
  success,
268
490
  warning,
269
491
  error,