@motion-proto/live-tokens 0.40.1 → 0.41.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 (89) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/dist-plugin/{chunk-D77VD4Z6.js → chunk-H4TRUINI.js} +102 -13
  3. package/dist-plugin/index.cjs +288 -67
  4. package/dist-plugin/index.js +171 -55
  5. package/dist-plugin/tokensCssMigrations/index.cjs +102 -13
  6. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  7. package/package.json +3 -2
  8. package/src/app/site.css +97 -20
  9. package/src/editor/core/palettes/colorHarmony.ts +228 -0
  10. package/src/editor/core/palettes/contrast.ts +128 -0
  11. package/src/editor/core/palettes/oklch.ts +8 -0
  12. package/src/editor/core/palettes/paletteDerivation.ts +267 -72
  13. package/src/editor/core/palettes/recommendText.ts +216 -0
  14. package/src/editor/core/palettes/solveTextContrast.ts +251 -0
  15. package/src/editor/core/store/editorCore.ts +2 -1
  16. package/src/editor/core/store/editorPersistence.ts +18 -1
  17. package/src/editor/core/store/editorStore.ts +20 -1
  18. package/src/editor/core/store/editorTypes.ts +4 -0
  19. package/src/editor/core/store/editorViewStore.ts +7 -3
  20. package/src/editor/core/themes/migrations/2026-06-05-palette-unification.ts +11 -9
  21. package/src/editor/core/themes/migrations/2026-07-21-palette-oklch-basis.ts +51 -0
  22. package/src/editor/core/themes/migrations/2026-07-24-base-anchor-placement.ts +37 -0
  23. package/src/editor/core/themes/migrations/2026-07-25-background-spot-to-base.ts +40 -0
  24. package/src/editor/core/themes/themeTypes.ts +23 -3
  25. package/src/editor/docs/CodeBlock.svelte +1 -1
  26. package/src/editor/docs/Docs.svelte +30 -13
  27. package/src/editor/pages/EditorShell.svelte +31 -3
  28. package/src/editor/ui/ColorEditPanel.svelte +22 -16
  29. package/src/editor/ui/EditorViewSwitcher.svelte +43 -9
  30. package/src/editor/ui/FontStackEditor.svelte +0 -1
  31. package/src/editor/ui/PaletteEditor.svelte +117 -155
  32. package/src/editor/ui/ProjectFontsSection.svelte +0 -3
  33. package/src/editor/ui/TextStylesSection.svelte +173 -0
  34. package/src/editor/ui/UILineHeightSelector.svelte +6 -5
  35. package/src/editor/ui/UIPaletteSelector.svelte +6 -1
  36. package/src/editor/ui/UITextTransformSelector.svelte +106 -0
  37. package/src/editor/ui/VariablesTab.svelte +23 -10
  38. package/src/editor/ui/colors/ColorReadouts.svelte +123 -0
  39. package/src/editor/ui/colors/ColorStory.svelte +375 -0
  40. package/src/editor/ui/colors/ColorWheel.svelte +740 -0
  41. package/src/editor/ui/colors/ColorsTab.svelte +508 -0
  42. package/src/editor/ui/colors/HarmonyAxesList.svelte +320 -0
  43. package/src/editor/ui/colors/LightnessBar.svelte +261 -0
  44. package/src/editor/ui/colors/PaletteStepStrip.svelte +142 -0
  45. package/src/editor/ui/colors/colorWheelMath.ts +9 -0
  46. package/src/editor/ui/colors/harmony-icons/analogous.svg +13 -0
  47. package/src/editor/ui/colors/harmony-icons/complementary.svg +10 -0
  48. package/src/editor/ui/colors/harmony-icons/compound.svg +15 -0
  49. package/src/editor/ui/colors/harmony-icons/custom.svg +10 -0
  50. package/src/editor/ui/colors/harmony-icons/monochromatic.svg +11 -0
  51. package/src/editor/ui/colors/harmony-icons/shades.svg +9 -0
  52. package/src/editor/ui/colors/harmony-icons/split-complementary.svg +13 -0
  53. package/src/editor/ui/colors/harmony-icons/square.svg +13 -0
  54. package/src/editor/ui/colors/harmony-icons/tetradic.svg +15 -0
  55. package/src/editor/ui/colors/harmony-icons/triadic.svg +13 -0
  56. package/src/editor/ui/colors/harmonyModeIcons.ts +23 -0
  57. package/src/editor/ui/colors/paletteBaseColor.ts +214 -0
  58. package/src/editor/ui/index.ts +0 -1
  59. package/src/editor/ui/palette/OverridesPanel.svelte +6 -3
  60. package/src/editor/ui/palette/PaletteBase.svelte +16 -8
  61. package/src/editor/ui/palette/dockMagnify.ts +22 -0
  62. package/src/editor/ui/palette/paletteEditorState.ts +5 -4
  63. package/src/editor/ui/palette/paletteMath.ts +93 -181
  64. package/src/editor/ui/sections/textStyles.ts +82 -0
  65. package/src/editor/ui/sections/tokenScales.ts +3 -3
  66. package/src/live-tokens/data/themes/default.json +3 -3
  67. package/src/live-tokens/data/tokens.generated.css +96 -95
  68. package/src/system/components/Badge.svelte +11 -11
  69. package/src/system/components/Button.svelte +7 -7
  70. package/src/system/components/Callout.svelte +8 -8
  71. package/src/system/components/Card.svelte +2 -2
  72. package/src/system/components/CodeSnippet.svelte +1 -1
  73. package/src/system/components/CollapsibleSection.svelte +7 -7
  74. package/src/system/components/CornerBadge.svelte +1 -1
  75. package/src/system/components/Dialog.svelte +2 -2
  76. package/src/system/components/FloatingTokenTags.css +5 -4
  77. package/src/system/components/Input.svelte +6 -6
  78. package/src/system/components/MenuSelect.svelte +4 -4
  79. package/src/system/components/Notification.svelte +8 -8
  80. package/src/system/components/ProgressBar.svelte +2 -2
  81. package/src/system/components/RadioButton.svelte +4 -4
  82. package/src/system/components/SectionDivider.svelte +6 -6
  83. package/src/system/components/SegmentedControl.svelte +5 -5
  84. package/src/system/components/SideNavigation.svelte +12 -12
  85. package/src/system/components/TabBar.svelte +4 -4
  86. package/src/system/components/Table.svelte +2 -2
  87. package/src/system/components/Tooltip.svelte +1 -1
  88. package/src/system/styles/tokens.css +59 -6
  89. package/src/editor/ui/TextTab.svelte +0 -203
@@ -13,7 +13,7 @@
13
13
  * derives the same CSS vars from the persisted config.
14
14
  */
15
15
 
16
- import { hexToOklch, oklchToHex, gamutClamp } from './oklch';
16
+ import { hexToOklch, oklchToHexClamped, gamutClamp, type Oklch } from './oklch';
17
17
  import { type CurveAnchor, sampleCurve, makeAnchor } from '../../ui/curveEngine';
18
18
  import type { PaletteConfig } from '../themes/themeTypes';
19
19
 
@@ -21,40 +21,54 @@ export interface PaletteSpec {
21
21
  label: string;
22
22
  cssNamespace: string;
23
23
  emptySelector?: boolean;
24
- initialColor: string;
24
+ /** Seed OKLCH intent used only when a config carries no `baseColor` (never
25
+ * for a loaded theme). Numeric so it composes with the OKLCH basis. */
26
+ initialColor: Oklch;
25
27
  /** Seed-default role only: neutrals start with a calm low-chroma base and
26
28
  * the neutral lightness ramp. The derivation path is identical for all. */
27
29
  neutral?: boolean;
30
+ /** Editor-only heading override (Alternate reads "Alternate (neutral)"). */
31
+ displayLabel?: string | null;
28
32
  }
29
33
 
30
34
  /**
31
35
  * Single source of truth for which palettes exist and how their CSS namespaces
32
36
  * map. Was previously hardcoded inside `VariablesTab.svelte`; centralising it
33
- * here lets the store seed boot-time vars without depending on the UI tree.
37
+ * here lets the store seed boot-time vars without depending on the UI tree, and
38
+ * drives the editor's palette-editor order + (Waves 4/5) the Colors swatch row.
34
39
  */
35
40
  export const PALETTE_SPECS: readonly PaletteSpec[] = [
36
- { label: 'Neutral', cssNamespace: 'neutral', neutral: true, initialColor: '#70787e' },
37
- { label: 'Alternate', cssNamespace: 'alternate', neutral: true, initialColor: '#817b78' },
38
- { label: 'Background', cssNamespace: 'canvas', emptySelector: true, initialColor: '#1a1a2e' },
39
- { label: 'Brand', cssNamespace: 'brand', initialColor: '#c93636' },
40
- { label: 'Accent', cssNamespace: 'accent', initialColor: '#f49e0b' },
41
- { label: 'Special', cssNamespace: 'special', initialColor: '#8b5cf6' },
42
- { label: 'Info', cssNamespace: 'info', initialColor: '#3077e8' },
43
- { label: 'Success', cssNamespace: 'success', initialColor: '#21c45d' },
44
- { label: 'Warning', cssNamespace: 'warning', initialColor: '#e66e1a' },
45
- { label: 'Danger', cssNamespace: 'danger', initialColor: '#e8304f' },
41
+ { label: 'Brand', cssNamespace: 'brand', initialColor: { l: 0.5573, c: 0.1841, h: 25.49 } },
42
+ { label: 'Accent', cssNamespace: 'accent', initialColor: { l: 0.7674, c: 0.164, h: 70.44 } },
43
+ { label: 'Background', cssNamespace: 'canvas', emptySelector: true, initialColor: { l: 0.2284, c: 0.0384, h: 282.93 } },
44
+ { label: 'Neutral', cssNamespace: 'neutral', neutral: true, initialColor: { l: 0.5679, c: 0.0134, h: 240.07 } },
45
+ { label: 'Alternate', cssNamespace: 'alternate', neutral: true, displayLabel: 'Alternate (neutral)', initialColor: { l: 0.5873, c: 0.0087, h: 48.57 } },
46
+ { label: 'Special', cssNamespace: 'special', initialColor: { l: 0.6056, c: 0.2189, h: 292.72 } },
47
+ { label: 'Info', cssNamespace: 'info', initialColor: { l: 0.5871, c: 0.1855, h: 259.56 } },
48
+ { label: 'Success', cssNamespace: 'success', initialColor: { l: 0.7198, c: 0.1918, h: 149.52 } },
49
+ { label: 'Warning', cssNamespace: 'warning', initialColor: { l: 0.6704, c: 0.1716, h: 48.75 } },
50
+ { label: 'Danger', cssNamespace: 'danger', initialColor: { l: 0.6103, c: 0.2165, h: 18.1 } },
46
51
  ] as const;
47
52
 
48
- const PALETTE_STEPS = [
53
+ export const PALETTE_STEPS = [
49
54
  { label: '100' }, { label: '200' }, { label: '300' }, { label: '400' },
50
55
  { label: '500' }, { label: '600' }, { label: '700' }, { label: '800' },
51
56
  { label: '850' }, { label: '900' }, { label: '950' },
52
57
  ];
53
58
 
54
- interface ScaleStep { name: string; position: number; }
55
- interface Scale { title: string; isText: boolean; steps: ScaleStep[]; }
59
+ export interface Step {
60
+ name: string;
61
+ position: number;
62
+ lightness?: number;
63
+ saturation?: number;
64
+ }
65
+ export interface Scale {
66
+ title: string;
67
+ isText: boolean;
68
+ steps: Step[];
69
+ }
56
70
 
57
- const SCALES: readonly Scale[] = [
71
+ export const SCALES: readonly Scale[] = [
58
72
  {
59
73
  title: 'Surfaces', isText: false,
60
74
  steps: [
@@ -89,63 +103,208 @@ const SCALES: readonly Scale[] = [
89
103
  },
90
104
  ];
91
105
 
106
+ /** Guard band for derived near-black/near-white text: values stay between
107
+ * ≈ #111 and ≈ #e8e8e8 unless pure black/white is opted into. The AA floor
108
+ * beats the guard (see `recommendText.ts`). */
109
+ export const BW_GUARD_MIN_L = 0.17;
110
+ export const BW_GUARD_MAX_L = 0.93;
111
+
92
112
  export const DEFAULT_PALETTE_LIGHTNESS = (): CurveAnchor[] => [makeAnchor(0, 95, 5), makeAnchor(100, 8, 5)];
93
113
  export const DEFAULT_PALETTE_SATURATION = (): CurveAnchor[] => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)];
94
114
 
95
- export const defaultScaleCurves = {
96
- Surfaces: {
97
- lightness: () => [makeAnchor(0, 15, 5), makeAnchor(100, 47, 5)],
98
- saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
99
- },
100
- Borders: {
101
- lightness: () => [makeAnchor(0, 25, 5), makeAnchor(100, 80, 5)],
102
- saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
103
- },
104
- Text: {
105
- lightness: () => [makeAnchor(0, 120, 30), makeAnchor(100, 55, 30)],
106
- saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 15, 30)],
107
- },
108
- } as const;
115
+ export type SchemeDirection = 'light' | 'dark';
116
+
117
+ export type ScaleCurveDefaults = Record<
118
+ string,
119
+ { lightness: () => CurveAnchor[]; saturation: () => CurveAnchor[] }
120
+ >;
121
+
122
+ /**
123
+ * Scheme-parameterized seed anchors for the Surfaces/Borders/Text scales.
124
+ * `'dark'` is the historical default (dark surfaces, light text); `'light'`
125
+ * inverts the lightness bands (light surfaces, dark text). Saturation curves
126
+ * are scheme-independent. Derivation math is untouched — only these seed
127
+ * defaults differ, so saved themes (which carry explicit `scaleCurves`) render
128
+ * identically regardless of scheme.
129
+ *
130
+ * Light-scheme anchors are proposed values, tunable during QA. The dark anchors
131
+ * are frozen: existing themes depend on them via the `defaultScaleCurves` alias.
132
+ */
133
+ export function scaleCurveDefaults(scheme: SchemeDirection = 'dark'): ScaleCurveDefaults {
134
+ if (scheme === 'light') {
135
+ return {
136
+ Surfaces: {
137
+ lightness: () => [makeAnchor(0, 98, 5), makeAnchor(100, 82, 5)],
138
+ saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
139
+ },
140
+ Borders: {
141
+ lightness: () => [makeAnchor(0, 92, 5), makeAnchor(100, 45, 5)],
142
+ saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
143
+ },
144
+ Text: {
145
+ lightness: () => [makeAnchor(0, 30, 30), makeAnchor(100, 120, 30)],
146
+ saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 15, 30)],
147
+ },
148
+ };
149
+ }
150
+ return {
151
+ Surfaces: {
152
+ lightness: () => [makeAnchor(0, 15, 5), makeAnchor(100, 47, 5)],
153
+ saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
154
+ },
155
+ Borders: {
156
+ lightness: () => [makeAnchor(0, 25, 5), makeAnchor(100, 80, 5)],
157
+ saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 100, 30)],
158
+ },
159
+ Text: {
160
+ lightness: () => [makeAnchor(0, 120, 30), makeAnchor(100, 55, 30)],
161
+ saturation: () => [makeAnchor(0, 100, 30), makeAnchor(100, 15, 30)],
162
+ },
163
+ };
164
+ }
165
+
166
+ export const defaultScaleCurves: ScaleCurveDefaults = scaleCurveDefaults('dark');
109
167
 
110
- function paletteStepKey(label: string): string { return `Palette-${label}`; }
111
- function stepKey(scaleTitle: string, stepName: string): string { return `${scaleTitle}-${stepName}`; }
168
+ export function paletteStepKey(label: string): string { return `Palette-${label}`; }
169
+ export function stepKey(scaleTitle: string, stepName: string): string { return `${scaleTitle}-${stepName}`; }
112
170
 
113
- function stepIndexToX(index: number, total: number): number {
114
- return total > 1 ? (index / (total - 1)) * 100 : 50;
171
+ export function stepIndexToX(index: number): number {
172
+ return (index / (PALETTE_STEPS.length - 1)) * 100;
115
173
  }
116
174
 
117
- function computePaletteColor(
175
+ export function scaleStepToX(step: Step, scale: Scale): number {
176
+ const idx = scale.steps.indexOf(step);
177
+ return scale.steps.length > 1 ? (idx / (scale.steps.length - 1)) * 100 : 50;
178
+ }
179
+
180
+ /**
181
+ * Typed intermediate representation for a derived CSS-variable value. The
182
+ * derived-vars map is heterogeneous — colors, but also gradient strings
183
+ * (`--page-bg`) and keywords (`--page-bg-attachment`) — so derivation and
184
+ * serialization are split: derivation yields this IR (`palettesToValues`),
185
+ * `serializeDerivedValue` renders it to a CSS string. `color` carries numeric
186
+ * OKLCH intent (gamut-clamped at derivation, the projection-only clamp point);
187
+ * `raw` carries a verbatim CSS string.
188
+ */
189
+ export type DerivedValue =
190
+ | { kind: 'color'; l: number; c: number; h: number }
191
+ | { kind: 'raw'; css: string };
192
+
193
+ /** The single color → CSS-string projection, and the single clamp point for
194
+ * derived color: unclamped stored intent is chroma-reduced into sRGB gamut,
195
+ * then serialized (hex this wave; `oklch()` after Part B). Idempotent on the
196
+ * already-in-gamut per-step derivation output. `raw` passes through untouched. */
197
+ export function serializeDerivedValue(value: DerivedValue): string {
198
+ return value.kind === 'raw' ? value.css : oklchToHexClamped(value.l, value.c, value.h);
199
+ }
200
+
201
+ export function computePaletteOklch(
118
202
  index: number,
119
- base: string,
203
+ base: Oklch,
120
204
  lightnessCurve: CurveAnchor[],
121
205
  saturationCurve: CurveAnchor[],
122
206
  curveOffset: Record<string, number>,
123
- ): string {
124
- const { c: baseC, h } = hexToOklch(base);
125
- const xPos = stepIndexToX(index, PALETTE_STEPS.length);
207
+ ): Oklch {
208
+ const { c: baseC, h } = base;
209
+ const xPos = stepIndexToX(index);
126
210
  const targetL = Math.max(0, Math.min(100, sampleCurve(lightnessCurve, xPos) + (curveOffset.lightness ?? 0))) / 100;
127
211
  const satMul = Math.max(0, Math.min(2, (sampleCurve(saturationCurve, xPos) + (curveOffset.saturation ?? 0)) / 100));
128
212
  const targetC = baseC * satMul;
129
- const clamped = gamutClamp(targetL, targetC, h);
130
- return oklchToHex(clamped.l, clamped.c, clamped.h);
213
+ return gamutClamp(targetL, targetC, h);
214
+ }
215
+
216
+ /** The palette step whose curve lightness is nearest the picked color's L
217
+ * (both in curve space, 0–100; `curveOffset` shifts anchor and steps alike,
218
+ * so it cancels out of the comparison). */
219
+ export function nearestPaletteStep(lightnessCurve: CurveAnchor[], baseL: number): number {
220
+ let best = 0;
221
+ let bestD = Infinity;
222
+ for (let i = 0; i < PALETTE_STEPS.length; i++) {
223
+ const d = Math.abs(sampleCurve(lightnessCurve, stepIndexToX(i)) - baseL * 100);
224
+ if (d < bestD) { bestD = d; best = i; }
225
+ }
226
+ return best;
227
+ }
228
+
229
+ /** Insert an anchor at x, or overwrite one already there (endpoints included).
230
+ * Returns the y it displaced so the caller can restore it later. */
231
+ export function setCurveAnchor(curve: CurveAnchor[], x: number, y: number): { curve: CurveAnchor[]; displacedY?: number } {
232
+ const existing = curve.findIndex((a) => Math.abs(a.x - x) < 0.5);
233
+ if (existing >= 0) {
234
+ const displacedY = curve[existing].y;
235
+ return { curve: curve.map((a, i) => (i === existing ? { ...a, x, y } : a)), displacedY };
236
+ }
237
+ let insertAt = curve.findIndex((a) => a.x > x);
238
+ if (insertAt < 0) insertAt = curve.length;
239
+ return { curve: [...curve.slice(0, insertAt), makeAnchor(x, y, 15), ...curve.slice(insertAt)] };
131
240
  }
132
241
 
133
- function computeDerivedColor(
134
- step: ScaleStep,
135
- base: string,
242
+ /** Undo a placement at x: restore the displaced y when one was recorded,
243
+ * remove the anchor when the placement created it. Endpoints are never
244
+ * removed — a curve needs its boundaries. */
245
+ export function liftCurveAnchor(curve: CurveAnchor[], x: number, displacedY?: number): CurveAnchor[] {
246
+ const idx = curve.findIndex((a) => Math.abs(a.x - x) < 0.5);
247
+ if (idx < 0) return curve;
248
+ if (displacedY !== undefined) return curve.map((a, i) => (i === idx ? { ...a, y: displacedY } : a));
249
+ if (idx === 0 || idx === curve.length - 1) return curve;
250
+ return curve.filter((_, i) => i !== idx);
251
+ }
252
+
253
+ /**
254
+ * Place the base color in the ramp: pin a lightness anchor (base L) and a
255
+ * saturation anchor (multiplier 100) at the step whose lightness is nearest
256
+ * the base L, so the picked color renders verbatim at that step. Placement
257
+ * is measured against the curve with the previous placement lifted out —
258
+ * the pinned anchor equals base L at its own x by construction, so measuring
259
+ * against it would be circular and the anchor could never move. Runs inside
260
+ * the same store mutation as the base-color write; mutates `cfg` in place.
261
+ */
262
+ export function syncBaseAnchor(cfg: PaletteConfig): void {
263
+ if (cfg.anchorToBase === false) return;
264
+ const prev = cfg.anchorPlacement;
265
+ let lCurve = cfg.lightnessCurve;
266
+ let sCurve = cfg.saturationCurve;
267
+ if (prev) {
268
+ const prevX = stepIndexToX(prev.step);
269
+ lCurve = liftCurveAnchor(lCurve, prevX, prev.displacedL);
270
+ sCurve = liftCurveAnchor(sCurve, prevX, prev.displacedS);
271
+ }
272
+ const step = nearestPaletteStep(lCurve, cfg.baseColor.l);
273
+ const x = stepIndexToX(step);
274
+ const l = setCurveAnchor(lCurve, x, cfg.baseColor.l * 100);
275
+ const s = setCurveAnchor(sCurve, x, 100);
276
+ cfg.lightnessCurve = l.curve;
277
+ cfg.saturationCurve = s.curve;
278
+ cfg.anchorPlacement = { step, displacedL: l.displacedY, displacedS: s.displacedY };
279
+ }
280
+
281
+ /** Toggle-off: lift the placement out of both curves and drop the flag. */
282
+ export function clearBaseAnchor(cfg: PaletteConfig): void {
283
+ const prev = cfg.anchorPlacement;
284
+ if (prev) {
285
+ const x = stepIndexToX(prev.step);
286
+ cfg.lightnessCurve = liftCurveAnchor(cfg.lightnessCurve, x, prev.displacedL);
287
+ cfg.saturationCurve = liftCurveAnchor(cfg.saturationCurve, x, prev.displacedS);
288
+ cfg.anchorPlacement = undefined;
289
+ }
290
+ cfg.anchorToBase = false;
291
+ }
292
+
293
+ export function computeDerivedOklch(
294
+ step: Step,
295
+ base: Oklch,
136
296
  scaleTitle: string,
137
297
  scaleCurves: Record<string, { lightness: CurveAnchor[]; saturation: CurveAnchor[] }>,
138
298
  curveOffset: Record<string, number>,
139
- ): string {
299
+ ): Oklch {
140
300
  const scale = SCALES.find((s) => s.title === scaleTitle)!;
141
- const idx = scale.steps.indexOf(step);
142
- const xPos = stepIndexToX(idx, scale.steps.length);
143
- const defs = defaultScaleCurves[scaleTitle as keyof typeof defaultScaleCurves];
301
+ const xPos = scaleStepToX(step, scale);
302
+ const defs = defaultScaleCurves[scaleTitle];
144
303
  const lCurve = scaleCurves[scaleTitle]?.lightness ?? defs.lightness();
145
304
  const sCurve = scaleCurves[scaleTitle]?.saturation ?? defs.saturation();
146
305
  const lOff = curveOffset[`${scaleTitle}-lightness`] ?? 0;
147
306
  const sOff = curveOffset[`${scaleTitle}-saturation`] ?? 0;
148
- const { l: baseL, c: baseC, h: baseH } = hexToOklch(base);
307
+ const { l: baseL, c: baseC, h: baseH } = base;
149
308
  let targetL: number;
150
309
  if (scale.isText) {
151
310
  const lMul = Math.max(0, Math.min(2, (sampleCurve(lCurve, xPos) + lOff) / 100));
@@ -155,8 +314,7 @@ function computeDerivedColor(
155
314
  }
156
315
  const satMul = Math.max(0, Math.min(2, (sampleCurve(sCurve, xPos) + sOff) / 100));
157
316
  const targetC = baseC * satMul;
158
- const clamped = gamutClamp(targetL, targetC, baseH);
159
- return oklchToHex(clamped.l, clamped.c, clamped.h);
317
+ return gamutClamp(targetL, targetC, baseH);
160
318
  }
161
319
 
162
320
  export function scaleToCssVar(scaleTitle: string, stepName: string, cssNamespace: string | null): string | null {
@@ -176,8 +334,8 @@ export function scaleToCssVar(scaleTitle: string, stepName: string, cssNamespace
176
334
  return null;
177
335
  }
178
336
 
179
- export function derivePaletteVars(spec: PaletteSpec, config: PaletteConfig | undefined): Record<string, string> {
180
- const out: Record<string, string> = {};
337
+ export function derivePaletteValues(spec: PaletteSpec, config: PaletteConfig | undefined): Record<string, DerivedValue> {
338
+ const out: Record<string, DerivedValue> = {};
181
339
  if (!config) return out;
182
340
 
183
341
  const baseColor = config.baseColor ?? spec.initialColor;
@@ -189,25 +347,36 @@ export function derivePaletteVars(spec: PaletteSpec, config: PaletteConfig | und
189
347
 
190
348
  PALETTE_STEPS.forEach((ps, index) => {
191
349
  const k = paletteStepKey(ps.label);
192
- const hex = computePaletteColor(index, baseColor, lightnessCurve, saturationCurve, curveOffset);
193
- const effective = (k in overrides) ? overrides[k] : hex;
194
- out[`--color-${spec.cssNamespace}-${ps.label}`] = effective;
350
+ // Overrides are numeric OKLCH intent; pass straight through as color-kind.
351
+ const value: DerivedValue = (k in overrides)
352
+ ? { kind: 'color', ...overrides[k] }
353
+ : { kind: 'color', ...computePaletteOklch(index, baseColor, lightnessCurve, saturationCurve, curveOffset) };
354
+ out[`--color-${spec.cssNamespace}-${ps.label}`] = value;
195
355
  });
196
356
 
197
357
  for (const scale of SCALES) {
198
358
  for (const step of scale.steps) {
199
359
  const k = stepKey(scale.title, step.name);
200
- const hex = (k in overrides)
201
- ? overrides[k]
202
- : computeDerivedColor(step, baseColor, scale.title, scaleCurves, curveOffset);
360
+ const value: DerivedValue = (k in overrides)
361
+ ? { kind: 'color', ...overrides[k] }
362
+ : { kind: 'color', ...computeDerivedOklch(step, baseColor, scale.title, scaleCurves, curveOffset) };
203
363
  const varName = scaleToCssVar(scale.title, step.name, spec.cssNamespace);
204
- if (varName) out[varName] = hex;
364
+ if (varName) out[varName] = value;
205
365
  }
206
366
  }
207
367
 
368
+ // `--text-inverted` is the derived flip of primary — mirrored L, same
369
+ // hue/chroma — pulled inside the guard band so a near-white primary yields
370
+ // ≈ #111 rather than pure black. Only Neutral emits `--text-primary`, so
371
+ // its presence gates this to the Neutral palette.
372
+ const textPrimary = out['--text-primary'];
373
+ if (textPrimary?.kind === 'color') {
374
+ const mirroredL = Math.max(BW_GUARD_MIN_L, Math.min(BW_GUARD_MAX_L, 1 - textPrimary.l));
375
+ out['--text-inverted'] = { kind: 'color', ...gamutClamp(mirroredL, textPrimary.c, textPrimary.h) };
376
+ }
377
+
208
378
  if (spec.emptySelector) {
209
379
  const emptyMode = config.emptyMode ?? 'solid';
210
- const emptyStep = config.emptyStep ?? '850';
211
380
  const gradientStyle = config.gradientStyle ?? 'linear';
212
381
  const gradientAngle = config.gradientAngle ?? 180;
213
382
  const gradientReverse = config.gradientReverse ?? false;
@@ -218,15 +387,23 @@ export function derivePaletteVars(spec: PaletteSpec, config: PaletteConfig | und
218
387
  ];
219
388
 
220
389
  if (emptyMode === 'solid') {
221
- const stepHex = out[`--color-${spec.cssNamespace}-${emptyStep}`];
222
- if (stepHex) out['--page-bg'] = stepHex;
223
- out['--page-bg-attachment'] = 'scroll';
390
+ // The solid page background is the base color itself, not a selected
391
+ // step: the base anchor already renders it verbatim in the ramp, so a
392
+ // separate spot selection could only disagree with it.
393
+ out['--page-bg'] = { kind: 'color', ...baseColor };
394
+ out['--page-bg-attachment'] = { kind: 'raw', css: 'scroll' };
224
395
  } else {
396
+ // `--page-bg` is a raw kind composed from the already-serialized step
397
+ // colors — the gradient string is a projection, not a basis value.
225
398
  const sortedStops = [...gradientStops].sort((a, b) =>
226
399
  gradientReverse ? b.position - a.position : a.position - b.position,
227
400
  );
228
401
  const stopsCss = sortedStops
229
- .map((s) => `${out[`--color-${spec.cssNamespace}-${s.paletteLabel}`] ?? '#000000'} ${s.position}%`)
402
+ .map((s) => {
403
+ const stopValue = out[`--color-${spec.cssNamespace}-${s.paletteLabel}`];
404
+ const hex = stopValue ? serializeDerivedValue(stopValue) : '#000000';
405
+ return `${hex} ${s.position}%`;
406
+ })
230
407
  .join(', ');
231
408
  let gradient: string;
232
409
  switch (gradientStyle) {
@@ -234,18 +411,34 @@ export function derivePaletteVars(spec: PaletteSpec, config: PaletteConfig | und
234
411
  case 'conic': gradient = `conic-gradient(from ${gradientAngle}deg, ${stopsCss})`; break;
235
412
  default: gradient = `linear-gradient(${gradientAngle}deg, ${stopsCss})`;
236
413
  }
237
- out['--page-bg'] = gradient;
238
- out['--page-bg-attachment'] = gradientSize === 'window' ? 'fixed' : 'scroll';
414
+ out['--page-bg'] = { kind: 'raw', css: gradient };
415
+ out['--page-bg-attachment'] = { kind: 'raw', css: gradientSize === 'window' ? 'fixed' : 'scroll' };
239
416
  }
240
417
  }
241
418
 
242
419
  return out;
243
420
  }
244
421
 
245
- export function palettesToVars(palettes: Record<string, PaletteConfig>): Record<string, string> {
422
+ export function derivePaletteVars(spec: PaletteSpec, config: PaletteConfig | undefined): Record<string, string> {
246
423
  const out: Record<string, string> = {};
424
+ for (const [k, v] of Object.entries(derivePaletteValues(spec, config))) {
425
+ out[k] = serializeDerivedValue(v);
426
+ }
427
+ return out;
428
+ }
429
+
430
+ export function palettesToValues(palettes: Record<string, PaletteConfig>): Record<string, DerivedValue> {
431
+ const out: Record<string, DerivedValue> = {};
247
432
  for (const spec of PALETTE_SPECS) {
248
- Object.assign(out, derivePaletteVars(spec, palettes[spec.label]));
433
+ Object.assign(out, derivePaletteValues(spec, palettes[spec.label]));
434
+ }
435
+ return out;
436
+ }
437
+
438
+ export function palettesToVars(palettes: Record<string, PaletteConfig>): Record<string, string> {
439
+ const out: Record<string, string> = {};
440
+ for (const [k, v] of Object.entries(palettesToValues(palettes))) {
441
+ out[k] = serializeDerivedValue(v);
249
442
  }
250
443
  return out;
251
444
  }
@@ -294,8 +487,10 @@ export function reconcilePalettesFromCssVars(
294
487
  if (current._imported === true) {
295
488
  const anchorHex = cssVars[`--color-${spec.cssNamespace}-500`];
296
489
  if (anchorHex && HEX_RE.test(anchorHex.trim())) {
297
- const hex = anchorHex.trim();
298
- next[spec.label] = { ...current, baseColor: hex, _imported: false };
490
+ // Input boundary: the imported cssVariables anchor is a hex string;
491
+ // parse it to the OKLCH basis exactly once, here.
492
+ next[spec.label] = { ...current, baseColor: hexToOklch(anchorHex.trim()), _imported: false };
493
+ syncBaseAnchor(next[spec.label]);
299
494
  snapped.add(spec.label);
300
495
  } else {
301
496
  // No anchor in cssVariables to snap to — flag has nothing to do; clear