@motion-proto/live-tokens 0.40.0 → 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 +115 -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 +6 -5
  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
@@ -0,0 +1,216 @@
1
+ // Suggested neutral text hierarchy for the Color Story.
2
+ //
3
+ // The solver (`solveTextContrast`) optimizes floors: it pushes every step to
4
+ // the minimum L that clears its ratio, which can collapse secondary and
5
+ // tertiary onto nearly the same value. This module instead anchors on the
6
+ // user's current primary and derives secondary/tertiary as two even OKLCH-L
7
+ // drops toward the background — hierarchy first, with WCAG AA (4.5) as a floor
8
+ // rather than the driver. The floor is solved against the adverse extreme of
9
+ // the neutral surface band so a compliant suggestion holds AA on EVERY band
10
+ // surface (broadest reach); when the seed's reachable-L window can't span the
11
+ // band, it falls back to the default surface and the coverage report says so.
12
+ // Coverage is informational — callers alert on partial reach, never block.
13
+
14
+ import { gamutClamp, oklchToHexClamped } from './oklch';
15
+ import {
16
+ palettesToValues,
17
+ defaultScaleCurves,
18
+ BW_GUARD_MIN_L,
19
+ BW_GUARD_MAX_L,
20
+ type DerivedValue,
21
+ type SchemeDirection,
22
+ } from './paletteDerivation';
23
+
24
+ export { BW_GUARD_MIN_L, BW_GUARD_MAX_L };
25
+ import { NEUTRAL_BAND } from './solveTextContrast';
26
+ import { findLForContrast, contrastRatio, AA_BODY } from './contrast';
27
+ import { sampleCurve, makeAnchor, type CurveAnchor } from '../../ui/curveEngine';
28
+ import type { PaletteConfig } from '../themes/themeTypes';
29
+
30
+ /** One even hierarchy drop in OKLCH L. Two drops span primary → tertiary. */
31
+ export const SUGGESTED_STEP_DROP = 0.07;
32
+
33
+ /** L difference below which current and suggested count as the same value. */
34
+ const DIFF_EPS = 0.01;
35
+
36
+ export interface SurfaceCheck {
37
+ var: string;
38
+ ratio: number;
39
+ meets: boolean;
40
+ }
41
+
42
+ export interface Coverage {
43
+ cleared: number;
44
+ total: number;
45
+ full: boolean;
46
+ /** Most adverse band surface met with every less-adverse one also met;
47
+ * null when even the least adverse surface fails. */
48
+ reachVar: string | null;
49
+ /** Adversity-ascending (least adverse band surface first). */
50
+ checks: SurfaceCheck[];
51
+ }
52
+
53
+ export interface TextSuggestion {
54
+ step: 'primary' | 'secondary' | 'tertiary';
55
+ textVar: string;
56
+ current: { hex: string; l: number; coverage: Coverage };
57
+ suggested: { hex: string; l: number; c: number; coverage: Coverage };
58
+ differs: boolean;
59
+ }
60
+
61
+ export interface NeutralTextRecommendation {
62
+ scheme: SchemeDirection;
63
+ suggestions: TextSuggestion[];
64
+ anyDiffers: boolean;
65
+ /** Neutral Text-scale curve patch reproducing the suggested Ls at x=0/25/50
66
+ * and pinning current muted/disabled at x=75/100. Null when nothing differs. */
67
+ patch: { lightness: CurveAnchor[]; saturation: CurveAnchor[] } | null;
68
+ }
69
+
70
+ const STEPS: { step: TextSuggestion['step']; x: number; textVar: string }[] = [
71
+ { step: 'primary', x: 0, textVar: '--text-primary' },
72
+ { step: 'secondary', x: 25, textVar: '--text-secondary' },
73
+ { step: 'tertiary', x: 50, textVar: '--text-tertiary' },
74
+ ];
75
+
76
+ function colorL(v: DerivedValue | undefined): number | null {
77
+ return v?.kind === 'color' ? v.l : null;
78
+ }
79
+
80
+ export function recommendNeutralText(
81
+ palettes: Record<string, PaletteConfig>,
82
+ scheme?: SchemeDirection,
83
+ useBlackWhite = false,
84
+ ): NeutralTextRecommendation {
85
+ const neutral = palettes['Neutral']!;
86
+ const values = palettesToValues(palettes);
87
+
88
+ const pageBgL = colorL(values['--page-bg']) ?? colorL(values['--surface-neutral'])!;
89
+ const resolvedScheme: SchemeDirection = scheme ?? (pageBgL >= 0.5 ? 'light' : 'dark');
90
+ const direction: 'lighter' | 'darker' = resolvedScheme === 'light' ? 'darker' : 'lighter';
91
+ // Toward more contrast: +L for light text on dark surfaces, −L for dark text.
92
+ const sign = direction === 'lighter' ? 1 : -1;
93
+
94
+ const band = NEUTRAL_BAND
95
+ .map((v) => {
96
+ const value = values[v];
97
+ return value?.kind === 'color'
98
+ ? { var: v, l: value.l, hex: oklchToHexClamped(value.l, value.c, value.h) }
99
+ : null;
100
+ })
101
+ .filter((s): s is { var: string; l: number; hex: string } => s !== null)
102
+ .sort((a, b) => (a.l - b.l) * sign);
103
+ const bandAdverse = band[band.length - 1];
104
+ const surfaceDefaultHex = band.find((s) => s.var === '--surface-neutral')!.hex;
105
+
106
+ const seed = neutral.baseColor;
107
+ const seedL = Math.max(seed.l, 1e-4);
108
+ const baseC = Math.max(seed.c, 1e-9);
109
+ const baseH = seed.h;
110
+ const lMax = Math.min(1, 2 * seedL);
111
+
112
+ const curveOffset = neutral.curveOffset ?? {};
113
+ const lOff = curveOffset['Text-lightness'] ?? 0;
114
+ const sOff = curveOffset['Text-saturation'] ?? 0;
115
+ const satCurve = neutral.scaleCurves?.Text?.saturation ?? defaultScaleCurves.Text.saturation();
116
+
117
+ const stepChroma = (x: number): number =>
118
+ baseC * Math.max(0, Math.min(2, (sampleCurve(satCurve, x) + sOff) / 100));
119
+
120
+ function coverage(hex: string): Coverage {
121
+ const checks: SurfaceCheck[] = band.map((s) => {
122
+ const ratio = contrastRatio(hex, s.hex);
123
+ return { var: s.var, ratio, meets: ratio >= AA_BODY };
124
+ });
125
+ let reachVar: string | null = null;
126
+ for (const c of checks) {
127
+ if (!c.meets) break;
128
+ reachVar = c.var;
129
+ }
130
+ const cleared = checks.filter((c) => c.meets).length;
131
+ return { cleared, total: checks.length, full: cleared === checks.length, reachVar, checks };
132
+ }
133
+
134
+ // AA floors. Primary carries the broadest-reach preference: it floors
135
+ // against the band's adverse extreme so body text holds AA on every band
136
+ // surface the window can span (fallback: default surface, surfaced as
137
+ // partial coverage). Secondary/tertiary floor against the default surface —
138
+ // flooring THEM at the band extreme too would collapse the whole hierarchy
139
+ // onto near-white whenever the lightest band surface is a mid-tone.
140
+ const solveFloor = (against: string, c: number) =>
141
+ findLForContrast({ against, ratio: AA_BODY, direction, c, h: baseH, lMin: 0, lMax });
142
+
143
+ let primaryFloor = solveFloor(bandAdverse.hex, stepChroma(0));
144
+ if (contrastRatio(primaryFloor.hex, bandAdverse.hex) < AA_BODY) {
145
+ primaryFloor = solveFloor(surfaceDefaultHex, stepChroma(0));
146
+ }
147
+ const stepFloor = solveFloor(surfaceDefaultHex, stepChroma(50));
148
+
149
+ const towardContrast = (a: number, b: number): number => (sign > 0 ? Math.max(a, b) : Math.min(a, b));
150
+ const clampWindow = (l: number): number => Math.max(0, Math.min(lMax, l));
151
+
152
+ const currentL: Record<string, number> = {};
153
+ for (const s of STEPS) currentL[s.step] = colorL(values[s.textVar]) ?? seedL;
154
+
155
+ // Pure black/white is an affirmative choice (most designers find it too
156
+ // stark): opted in, primary anchors at the scheme extreme; otherwise the
157
+ // anchor is pulled inside the guard band, yielding only to the AA floor.
158
+ let primaryL: number;
159
+ if (useBlackWhite) {
160
+ primaryL = clampWindow(sign > 0 ? lMax : 0);
161
+ } else {
162
+ const guard = sign > 0
163
+ ? Math.max(BW_GUARD_MAX_L, primaryFloor.l)
164
+ : Math.min(BW_GUARD_MIN_L, primaryFloor.l);
165
+ const anchored = clampWindow(towardContrast(currentL.primary, primaryFloor.l));
166
+ primaryL = sign > 0 ? Math.min(anchored, guard) : Math.max(anchored, guard);
167
+ }
168
+ const tertiaryL = towardContrast(stepFloor.l, primaryL - sign * 2 * SUGGESTED_STEP_DROP);
169
+ const suggestedL: Record<string, number> = {
170
+ primary: primaryL,
171
+ secondary: towardContrast(stepFloor.l, (primaryL + tertiaryL) / 2),
172
+ tertiary: tertiaryL,
173
+ };
174
+
175
+ const suggestions: TextSuggestion[] = STEPS.map((s) => {
176
+ const currentValue = values[s.textVar];
177
+ const currentHex = currentValue?.kind === 'color'
178
+ ? oklchToHexClamped(currentValue.l, currentValue.c, currentValue.h)
179
+ : oklchToHexClamped(seedL, 0, 0);
180
+ const g = gamutClamp(suggestedL[s.step], stepChroma(s.x), baseH);
181
+ const hex = oklchToHexClamped(g.l, g.c, g.h);
182
+ return {
183
+ step: s.step,
184
+ textVar: s.textVar,
185
+ current: { hex: currentHex, l: currentL[s.step], coverage: coverage(currentHex) },
186
+ suggested: { hex, l: g.l, c: g.c, coverage: coverage(hex) },
187
+ differs: Math.abs(g.l - currentL[s.step]) > DIFF_EPS,
188
+ };
189
+ });
190
+
191
+ const anyDiffers = suggestions.some((s) => s.differs);
192
+
193
+ let patch: NeutralTextRecommendation['patch'] = null;
194
+ if (anyDiffers) {
195
+ const clampY = (v: number) => Math.max(0, Math.min(200, v));
196
+ const lightAnchors: CurveAnchor[] = [];
197
+ const satAnchors: CurveAnchor[] = [];
198
+ for (const s of suggestions) {
199
+ const x = STEPS.find((st) => st.step === s.step)!.x;
200
+ lightAnchors.push(makeAnchor(x, clampY((100 * s.suggested.l) / seedL - lOff)));
201
+ satAnchors.push(makeAnchor(x, clampY((100 * s.suggested.c) / baseC - sOff)));
202
+ }
203
+ // Pin muted/disabled at their current derived values so applying the
204
+ // suggestion never moves the steps it doesn't own.
205
+ for (const [x, textVar] of [[75, '--text-muted'], [100, '--text-disabled']] as const) {
206
+ const v = values[textVar];
207
+ const l = v?.kind === 'color' ? v.l : seedL;
208
+ const c = v?.kind === 'color' ? v.c : 0;
209
+ lightAnchors.push(makeAnchor(x, clampY((100 * l) / seedL - lOff)));
210
+ satAnchors.push(makeAnchor(x, clampY((100 * c) / baseC - sOff)));
211
+ }
212
+ patch = { lightness: lightAnchors, saturation: satAnchors };
213
+ }
214
+
215
+ return { scheme: resolvedScheme, suggestions, anyDiffers, patch };
216
+ }
@@ -0,0 +1,251 @@
1
+ // "Derive accessible text": solve each family's Text lightness curve so the
2
+ // derived text colors clear WCAG AA against the surfaces they sit on.
3
+ //
4
+ // The Text scale derives L as seedL × (curveSample/100), clamped to [0,2], so a
5
+ // family can only reach L ∈ [0, min(1, 2·seedL)]. The solver works inside that
6
+ // window (passed as `lMax`) and emits a 5-anchor lightness curve whose sample
7
+ // at each step x reproduces the solved L; a companion saturation curve pins the
8
+ // solved (possibly chroma-decayed) chroma so the round-trip is exact. Surface
9
+ // and page colors come from the real `palettesToValues` derivation (numeric
10
+ // OKLCH); the solver converts to hex only at its own sRGB (WCAG) boundary.
11
+
12
+ import { oklchToHexClamped } from './oklch';
13
+ import {
14
+ palettesToValues,
15
+ serializeDerivedValue,
16
+ PALETTE_SPECS,
17
+ scaleToCssVar,
18
+ defaultScaleCurves,
19
+ type DerivedValue,
20
+ type SchemeDirection,
21
+ } from './paletteDerivation';
22
+ import { findLForContrast, contrastRatio } from './contrast';
23
+ import { sampleCurve, makeAnchor, type CurveAnchor } from '../../ui/curveEngine';
24
+ import type { PaletteConfig } from '../themes/themeTypes';
25
+
26
+ export interface ContrastPairing {
27
+ family: string;
28
+ step: string;
29
+ textVar: string;
30
+ againstVar: string;
31
+ targetRatio: number;
32
+ achievedRatio: number;
33
+ meets: boolean;
34
+ /** True when this pairing carries an AA guarantee (vs. a deliberately graded,
35
+ * non-AA step like `muted`/`disabled` on chromatic families). */
36
+ guaranteed: boolean;
37
+ }
38
+
39
+ export interface TextSolveResult {
40
+ patches: Record<string, Pick<PaletteConfig, 'scaleCurves'>>;
41
+ report: ContrastPairing[];
42
+ }
43
+
44
+ const TEXT_STEPS: { name: string; x: number }[] = [
45
+ { name: 'primary', x: 0 },
46
+ { name: 'secondary', x: 25 },
47
+ { name: 'tertiary', x: 50 },
48
+ { name: 'muted', x: 75 },
49
+ { name: 'disabled', x: 100 },
50
+ ];
51
+
52
+ export const NEUTRAL_BAND = [
53
+ '--surface-neutral-lowest', '--surface-neutral-lower', '--surface-neutral-low',
54
+ '--surface-neutral', '--surface-neutral-high', '--surface-neutral-higher',
55
+ '--surface-neutral-highest',
56
+ ];
57
+
58
+ interface StepPlan {
59
+ ratio: number;
60
+ guaranteed: boolean;
61
+ /** 'band' = adverse extreme of the neutral surface band; 'default' = surface
62
+ * default; 'worst' = adverse of {surface default, page bg}. */
63
+ target: 'band' | 'default' | 'worst';
64
+ }
65
+
66
+ // Neutral family sits on neutral surfaces; primary/secondary must clear the
67
+ // adverse band extreme, tertiary/muted the default surface. Disabled is graded.
68
+ const NEUTRAL_PLAN: Record<string, StepPlan> = {
69
+ primary: { ratio: 7.0, guaranteed: true, target: 'band' },
70
+ secondary: { ratio: 4.5, guaranteed: true, target: 'band' },
71
+ tertiary: { ratio: 4.5, guaranteed: true, target: 'default' },
72
+ muted: { ratio: 3.0, guaranteed: true, target: 'default' },
73
+ disabled: { ratio: 2.0, guaranteed: false, target: 'default' },
74
+ };
75
+
76
+ // Chromatic families: primary must clear the worse of surface-default / page-bg
77
+ // (chroma decay allowed), secondary the default surface; the rest grade toward
78
+ // the surface (non-AA).
79
+ const CHROMATIC_PLAN: Record<string, StepPlan> = {
80
+ primary: { ratio: 4.5, guaranteed: true, target: 'worst' },
81
+ secondary: { ratio: 4.5, guaranteed: true, target: 'default' },
82
+ tertiary: { ratio: 3.0, guaranteed: false, target: 'default' },
83
+ muted: { ratio: 2.2, guaranteed: false, target: 'default' },
84
+ disabled: { ratio: 1.6, guaranteed: false, target: 'default' },
85
+ };
86
+
87
+ interface Surface {
88
+ var: string;
89
+ l: number;
90
+ c: number;
91
+ h: number;
92
+ }
93
+
94
+ function colorSurface(varName: string, value: DerivedValue | undefined): Surface | null {
95
+ return value?.kind === 'color' ? { var: varName, l: value.l, c: value.c, h: value.h } : null;
96
+ }
97
+
98
+ // The solver terminates in sRGB (WCAG luminance is sRGB-defined); convert the
99
+ // numeric surface to hex only at this boundary. Clamp so contrast math sees the
100
+ // same chroma-reduced projection the CSS ships (unclamped override surfaces).
101
+ function surfaceHex(s: Surface): string {
102
+ return oklchToHexClamped(s.l, s.c, s.h);
103
+ }
104
+
105
+ // The hardest surface to clear: for lighter text the highest-luminance surface,
106
+ // for darker text the lowest.
107
+ function pickAdverse(surfaces: Surface[], direction: 'lighter' | 'darker'): Surface {
108
+ let best = surfaces[0];
109
+ let bestL = best.l;
110
+ for (const s of surfaces.slice(1)) {
111
+ if (direction === 'lighter' ? s.l > bestL : s.l < bestL) {
112
+ best = s;
113
+ bestL = s.l;
114
+ }
115
+ }
116
+ return best;
117
+ }
118
+
119
+ export function solveTextCurves(
120
+ palettes: Record<string, PaletteConfig>,
121
+ scheme?: SchemeDirection,
122
+ ): TextSolveResult {
123
+ const values = palettesToValues(palettes);
124
+ const surfaceDefault = colorSurface('--surface-neutral', values['--surface-neutral'])!;
125
+ const rawPageBg = values['--page-bg'];
126
+ const pageBg: Surface = rawPageBg?.kind === 'color'
127
+ ? colorSurface('--page-bg', rawPageBg)!
128
+ : surfaceDefault;
129
+ const band: Surface[] = NEUTRAL_BAND
130
+ .map((v) => colorSurface(v, values[v]))
131
+ .filter((s): s is Surface => s !== null);
132
+
133
+ const resolvedScheme: SchemeDirection =
134
+ scheme ?? (pageBg.l >= 0.5 ? 'light' : 'dark');
135
+ const direction: 'lighter' | 'darker' = resolvedScheme === 'light' ? 'darker' : 'lighter';
136
+
137
+ const bandAdverse = pickAdverse(band, direction);
138
+ const worstAdverse = pickAdverse([surfaceDefault, pageBg], direction);
139
+
140
+ const patches: Record<string, Pick<PaletteConfig, 'scaleCurves'>> = {};
141
+ const report: ContrastPairing[] = [];
142
+
143
+ for (const spec of PALETTE_SPECS) {
144
+ const config = palettes[spec.label];
145
+ if (!config) continue;
146
+
147
+ const seed = config.baseColor;
148
+ const seedL = Math.max(seed.l, 1e-4);
149
+ const baseC = Math.max(seed.c, 1e-9);
150
+ const baseH = seed.h;
151
+ const lMax = Math.min(1, 2 * seedL);
152
+
153
+ const curveOffset = config.curveOffset ?? {};
154
+ const lOff = curveOffset['Text-lightness'] ?? 0;
155
+ const sOff = curveOffset['Text-saturation'] ?? 0;
156
+ const satCurve = config.scaleCurves?.Text?.saturation ?? defaultScaleCurves.Text.saturation();
157
+
158
+ const isNeutral = spec.label === 'Neutral';
159
+ const plan = isNeutral ? NEUTRAL_PLAN : CHROMATIC_PLAN;
160
+
161
+ const lightAnchors: CurveAnchor[] = [];
162
+ const satAnchors: CurveAnchor[] = [];
163
+
164
+ for (const step of TEXT_STEPS) {
165
+ const stepPlan = plan[step.name];
166
+ const against =
167
+ stepPlan.target === 'band' ? bandAdverse :
168
+ stepPlan.target === 'worst' ? worstAdverse : surfaceDefault;
169
+
170
+ const origSatMul = Math.max(0, Math.min(2, (sampleCurve(satCurve, step.x) + sOff) / 100));
171
+ const stepChroma = baseC * origSatMul;
172
+
173
+ const solved = findLForContrast({
174
+ against: surfaceHex(against),
175
+ ratio: stepPlan.ratio,
176
+ direction,
177
+ c: stepChroma,
178
+ h: baseH,
179
+ lMin: 0,
180
+ lMax,
181
+ });
182
+
183
+ const lightY = clamp(0, 200, (100 * solved.l) / seedL - lOff);
184
+ const satY = clamp(0, 200, (100 * solved.c) / baseC - sOff);
185
+ lightAnchors.push(makeAnchor(step.x, lightY));
186
+ satAnchors.push(makeAnchor(step.x, satY));
187
+ }
188
+
189
+ patches[spec.label] = {
190
+ scaleCurves: { Text: { lightness: lightAnchors, saturation: satAnchors } },
191
+ };
192
+ }
193
+
194
+ // Report against the actually-derived text hexes (post-round-trip verification
195
+ // at the solver level, mirroring Global invariant 8).
196
+ const derivedValues = palettesToValues(applyPatches(palettes, patches));
197
+ for (const spec of PALETTE_SPECS) {
198
+ if (!patches[spec.label]) continue;
199
+ const isNeutral = spec.label === 'Neutral';
200
+ const plan = isNeutral ? NEUTRAL_PLAN : CHROMATIC_PLAN;
201
+ for (const step of TEXT_STEPS) {
202
+ const stepPlan = plan[step.name];
203
+ const textVar = scaleToCssVar('Text', step.name, spec.cssNamespace);
204
+ if (!textVar) continue;
205
+ const against =
206
+ stepPlan.target === 'band' ? bandAdverse :
207
+ stepPlan.target === 'worst' ? worstAdverse : surfaceDefault;
208
+ const textValue = derivedValues[textVar];
209
+ const againstValue = derivedValues[against.var];
210
+ const againstHex = againstValue?.kind === 'color' ? serializeDerivedValue(againstValue) : surfaceHex(against);
211
+ const achieved = textValue?.kind === 'color' ? contrastRatio(serializeDerivedValue(textValue), againstHex) : 1;
212
+ report.push({
213
+ family: spec.label,
214
+ step: step.name,
215
+ textVar,
216
+ againstVar: against.var,
217
+ targetRatio: stepPlan.ratio,
218
+ achievedRatio: achieved,
219
+ meets: achieved >= minGuarantee(step.name, stepPlan),
220
+ guaranteed: stepPlan.guaranteed,
221
+ });
222
+ }
223
+ }
224
+
225
+ return { patches, report };
226
+ }
227
+
228
+ // The AA floor a guaranteed step must clear (primary/secondary/tertiary = body
229
+ // 4.5; neutral muted = large 3.0). Graded steps floor at 1 (informational).
230
+ function minGuarantee(stepName: string, plan: StepPlan): number {
231
+ if (!plan.guaranteed) return 1;
232
+ return stepName === 'muted' ? 3.0 : 4.5;
233
+ }
234
+
235
+ function clamp(lo: number, hi: number, v: number): number {
236
+ return Math.max(lo, Math.min(hi, v));
237
+ }
238
+
239
+ function applyPatches(
240
+ palettes: Record<string, PaletteConfig>,
241
+ patches: Record<string, Pick<PaletteConfig, 'scaleCurves'>>,
242
+ ): Record<string, PaletteConfig> {
243
+ const out: Record<string, PaletteConfig> = {};
244
+ for (const [label, config] of Object.entries(palettes)) {
245
+ const patch = patches[label];
246
+ out[label] = patch
247
+ ? { ...config, scaleCurves: { ...config.scaleCurves, ...patch.scaleCurves } }
248
+ : config;
249
+ }
250
+ return out;
251
+ }
@@ -412,7 +412,8 @@ export function __getPastAt(idx: number): EditorState | undefined {
412
412
  function paletteSnapshot(s: EditorState): Record<string, { base: string; overrides: number }> {
413
413
  const out: Record<string, { base: string; overrides: number }> = {};
414
414
  for (const [k, v] of Object.entries(s.palettes ?? {})) {
415
- out[k] = { base: v.baseColor, overrides: Object.keys(v.overrides).length };
415
+ const { l, c, h } = v.baseColor;
416
+ out[k] = { base: `oklch(${l} ${c} ${h})`, overrides: Object.keys(v.overrides).length };
416
417
  }
417
418
  return out;
418
419
  }
@@ -20,6 +20,7 @@ import { store } from './editorCore';
20
20
  import { quietGet, quietSet } from '../storage/storage';
21
21
  import { makeDefaultGradients } from '../themes/slices/gradients';
22
22
  import { seedShadowsFromDom } from '../themes/slices/shadows';
23
+ import { sanitizeHarmonyAxes, axesFromLegacyOrder } from '../palettes/colorHarmony';
23
24
 
24
25
  // Resolve the persist key lazily (per-call) so library consumers that invoke
25
26
  // `configureEditor({storagePrefix})` before the first store write get the
@@ -87,6 +88,22 @@ export function normalizeComponents(state: EditorState): EditorState {
87
88
  return { ...state, components };
88
89
  }
89
90
 
91
+ // `hydrate` shallow-merges persisted state over `emptyState()`, which always
92
+ // seeds a default `harmonyAxes`. So a pre-axes session (persisted `harmonyOrder`,
93
+ // no `harmonyAxes`) surfaces here with the injected default axes over its real
94
+ // palettes. The new store never persists `harmonyOrder`, so its presence is the
95
+ // definitive pre-axes signal: migrate from it, seeding hues off the palettes.
96
+ // Otherwise reconcile the persisted axes. Either way, drop the stale legacy key.
97
+ export function normalizeHarmonyAxes(state: EditorState): EditorState {
98
+ const legacy = (state as { harmonyOrder?: unknown }).harmonyOrder;
99
+ const axes = legacy !== undefined
100
+ ? axesFromLegacyOrder(legacy, state.palettes)
101
+ : sanitizeHarmonyAxes(state.harmonyAxes, state.palettes);
102
+ const next = { ...state, harmonyAxes: axes };
103
+ delete (next as { harmonyOrder?: unknown }).harmonyOrder;
104
+ return next;
105
+ }
106
+
90
107
  export function hydrate(): void {
91
108
  // Corrupt state, missing key, or unavailable storage all return null;
92
109
  // the editor falls through to the empty default in that case.
@@ -95,7 +112,7 @@ export function hydrate(): void {
95
112
  // Shallow-merge onto default shape so older persisted state missing
96
113
  // newly-added domain fields still loads.
97
114
  const merged = { ...emptyStateFactory(), ...(parsed as object) } as EditorState;
98
- store.set(normalizeComponents(migrateGradients(merged)));
115
+ store.set(normalizeHarmonyAxes(normalizeComponents(migrateGradients(merged))));
99
116
  }
100
117
  // m13 fix: seed shadows from the DOM at hydrate time so the editor
101
118
  // captures the tokens.css baseline regardless of whether the user opens
@@ -29,6 +29,10 @@ import {
29
29
  } from '../themes/migrations';
30
30
  import { renamePrimaryPaletteKey } from '../themes/migrations/2026-05-13-primary-to-brand';
31
31
  import { unifyGrayPalettes } from '../themes/migrations/2026-06-05-palette-unification';
32
+ import { migratePaletteColorsToOklch, type PreOklchPaletteConfig } from '../themes/migrations/2026-07-21-palette-oklch-basis';
33
+ import { adoptLegacyBaseAnchor } from '../themes/migrations/2026-07-24-base-anchor-placement';
34
+ import { adoptBackgroundSpotAsBase } from '../themes/migrations/2026-07-25-background-spot-to-base';
35
+ import { defaultHarmonyAxes, sanitizeHarmonyAxes, axesFromLegacyOrder } from '../palettes/colorHarmony';
32
36
  import { __resetRendererCacheForTests, installRenderer } from './editorRenderer';
33
37
  import {
34
38
  store,
@@ -87,6 +91,7 @@ function emptyState(): EditorState {
87
91
  columns: { ...DEFAULT_COLUMNS },
88
92
  components: {},
89
93
  gradients: { tokens: makeDefaultGradients() },
94
+ harmonyAxes: defaultHarmonyAxes(),
90
95
  cssVars: {},
91
96
  };
92
97
  }
@@ -512,7 +517,13 @@ const domainLoaders: Record<string, DomainLoader> = {
512
517
  */
513
518
  export function loadFromFile(theme: Theme): void {
514
519
  const next = emptyState();
515
- next.palettes = unifyGrayPalettes(renamePrimaryPaletteKey(structuredClone(theme.editorConfigs ?? {})));
520
+ // Structural palette migrations, innermost → outermost: rename Primary→Brand,
521
+ // drop the gray "mode", convert every color channel to the numeric OKLCH
522
+ // basis, adopt any legacy locked-500 anchor as a persisted placement, then
523
+ // fold a legacy background spot into the base color. The clone is still
524
+ // pre-basis (hex or already-numeric) on disk.
525
+ const raw = structuredClone(theme.editorConfigs ?? {}) as Record<string, PreOklchPaletteConfig>;
526
+ next.palettes = adoptBackgroundSpotAsBase(adoptLegacyBaseAnchor(migratePaletteColorsToOklch(unifyGrayPalettes(renamePrimaryPaletteKey(raw)))));
516
527
  next.fonts.sources = structuredClone(theme.fontSources ?? []);
517
528
  next.fonts.stacks = structuredClone(theme.fontStacks ?? []);
518
529
  const rawVars = runMigrations(
@@ -525,6 +536,9 @@ export function loadFromFile(theme: Theme): void {
525
536
  // (or in components' case, vars that wouldn't have been in the theme to
526
537
  // begin with).
527
538
  for (const load of Object.values(domainLoaders)) load(next, rawVars);
539
+ next.harmonyAxes = theme.harmonyAxes
540
+ ? sanitizeHarmonyAxes(theme.harmonyAxes, next.palettes)
541
+ : axesFromLegacyOrder(theme.harmonyOrder, next.palettes);
528
542
  next.cssVars = rawVars;
529
543
  resetHistoryForLoad();
530
544
  store.set(next);
@@ -550,6 +564,9 @@ export function toTheme(state: EditorState, meta: { name: string }): Theme {
550
564
  if (state.shadows.tokens.length > 0) {
551
565
  Object.assign(cssVariables, shadowsToVars(state.shadows));
552
566
  }
567
+ // Compat: bound families in axis order for older builds. Omitted when nothing
568
+ // is bound — an empty legacy list would resurrect the default trio on load.
569
+ const boundOrder = state.harmonyAxes.filter((a) => a.family !== null).map((a) => a.family!);
553
570
  return {
554
571
  name: meta.name,
555
572
  createdAt: now,
@@ -558,6 +575,8 @@ export function toTheme(state: EditorState, meta: { name: string }): Theme {
558
575
  cssVariables,
559
576
  fontSources: state.fonts.sources,
560
577
  fontStacks: state.fonts.stacks,
578
+ harmonyAxes: state.harmonyAxes,
579
+ ...(boundOrder.length > 0 ? { harmonyOrder: boundOrder } : {}),
561
580
  schemaVersion: CURRENT_THEME_SCHEMA_VERSION,
562
581
  };
563
582
  }
@@ -1,4 +1,5 @@
1
1
  import type { PaletteConfig, FontSource, FontStack } from '../themes/themeTypes';
2
+ import type { HarmonyAxis } from '../palettes/colorHarmony';
2
3
 
3
4
  export interface ShadowGlobals {
4
5
  angle: number;
@@ -136,5 +137,8 @@ export interface EditorState {
136
137
  columns: ColumnsState;
137
138
  components: Record<string, ComponentSlice>;
138
139
  gradients: { tokens: GradientToken[] };
140
+ /** Four fixed axes (index = role: Anchor/Secondary/Tertiary/Quaternary); each
141
+ * owns a hue; family is the live-bound color or null. */
142
+ harmonyAxes: HarmonyAxis[];
139
143
  cssVars: Record<string, string>;
140
144
  }
@@ -1,15 +1,19 @@
1
1
  import { writable } from 'svelte/store';
2
2
 
3
- export type EditorView = 'tokens' | 'components';
3
+ export type EditorView = 'tokens' | 'components' | 'colors';
4
4
  export type SidebarCondensed = boolean | 'auto';
5
5
 
6
6
  const VIEW_KEY = 'lt.editorView';
7
7
  const CONDENSED_KEY = 'lt.sidebarCondensed';
8
8
 
9
+ function isEditorView(v: unknown): v is EditorView {
10
+ return v === 'tokens' || v === 'components' || v === 'colors';
11
+ }
12
+
9
13
  function readView(): EditorView {
10
14
  try {
11
15
  const v = localStorage.getItem(VIEW_KEY);
12
- if (v === 'components' || v === 'tokens') return v;
16
+ if (isEditorView(v)) return v;
13
17
  } catch {}
14
18
  return 'tokens';
15
19
  }
@@ -41,7 +45,7 @@ sidebarCondensed.subscribe((v) => {
41
45
  if (typeof window !== 'undefined') {
42
46
  window.addEventListener('storage', (e) => {
43
47
  if (e.key === VIEW_KEY) {
44
- if (e.newValue === 'tokens' || e.newValue === 'components') editorView.set(e.newValue);
48
+ if (isEditorView(e.newValue)) editorView.set(e.newValue);
45
49
  } else if (e.key === CONDENSED_KEY) {
46
50
  if (e.newValue === 'true') sidebarCondensed.set(true);
47
51
  else if (e.newValue === 'false') sidebarCondensed.set(false);