@motion-proto/live-tokens 0.40.1 → 0.42.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 (91) hide show
  1. package/CHANGELOG.md +160 -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 +182 -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 +28 -1
  17. package/src/editor/core/store/editorStore.ts +21 -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/migrations/2026-07-29-background-palette-to-canvas.ts +41 -0
  25. package/src/editor/core/themes/migrations/2026-07-29-place-base-anchors.ts +29 -0
  26. package/src/editor/core/themes/themeTypes.ts +18 -3
  27. package/src/editor/docs/CodeBlock.svelte +1 -1
  28. package/src/editor/docs/Docs.svelte +30 -13
  29. package/src/editor/pages/EditorShell.svelte +31 -3
  30. package/src/editor/ui/ColorEditPanel.svelte +22 -16
  31. package/src/editor/ui/EditorViewSwitcher.svelte +43 -9
  32. package/src/editor/ui/FontStackEditor.svelte +0 -1
  33. package/src/editor/ui/PaletteEditor.svelte +117 -155
  34. package/src/editor/ui/ProjectFontsSection.svelte +0 -3
  35. package/src/editor/ui/TextStylesSection.svelte +173 -0
  36. package/src/editor/ui/UILineHeightSelector.svelte +6 -5
  37. package/src/editor/ui/UIPaletteSelector.svelte +6 -1
  38. package/src/editor/ui/UITextTransformSelector.svelte +106 -0
  39. package/src/editor/ui/VariablesTab.svelte +23 -10
  40. package/src/editor/ui/colors/ColorReadouts.svelte +123 -0
  41. package/src/editor/ui/colors/ColorStory.svelte +375 -0
  42. package/src/editor/ui/colors/ColorWheel.svelte +740 -0
  43. package/src/editor/ui/colors/ColorsTab.svelte +530 -0
  44. package/src/editor/ui/colors/HarmonyAxesList.svelte +320 -0
  45. package/src/editor/ui/colors/LightnessBar.svelte +261 -0
  46. package/src/editor/ui/colors/PaletteStepStrip.svelte +142 -0
  47. package/src/editor/ui/colors/colorWheelMath.ts +9 -0
  48. package/src/editor/ui/colors/harmony-icons/analogous.svg +13 -0
  49. package/src/editor/ui/colors/harmony-icons/complementary.svg +10 -0
  50. package/src/editor/ui/colors/harmony-icons/compound.svg +15 -0
  51. package/src/editor/ui/colors/harmony-icons/custom.svg +10 -0
  52. package/src/editor/ui/colors/harmony-icons/monochromatic.svg +11 -0
  53. package/src/editor/ui/colors/harmony-icons/shades.svg +9 -0
  54. package/src/editor/ui/colors/harmony-icons/split-complementary.svg +13 -0
  55. package/src/editor/ui/colors/harmony-icons/square.svg +13 -0
  56. package/src/editor/ui/colors/harmony-icons/tetradic.svg +15 -0
  57. package/src/editor/ui/colors/harmony-icons/triadic.svg +13 -0
  58. package/src/editor/ui/colors/harmonyModeIcons.ts +23 -0
  59. package/src/editor/ui/colors/paletteBaseColor.ts +214 -0
  60. package/src/editor/ui/index.ts +0 -1
  61. package/src/editor/ui/palette/OverridesPanel.svelte +6 -3
  62. package/src/editor/ui/palette/PaletteBase.svelte +16 -8
  63. package/src/editor/ui/palette/dockMagnify.ts +22 -0
  64. package/src/editor/ui/palette/paletteEditorState.ts +5 -4
  65. package/src/editor/ui/palette/paletteMath.ts +93 -181
  66. package/src/editor/ui/sections/textStyles.ts +82 -0
  67. package/src/editor/ui/sections/tokenScales.ts +3 -3
  68. package/src/live-tokens/data/themes/default.json +4 -4
  69. package/src/live-tokens/data/tokens.generated.css +96 -95
  70. package/src/system/components/Badge.svelte +11 -11
  71. package/src/system/components/Button.svelte +7 -7
  72. package/src/system/components/Callout.svelte +8 -8
  73. package/src/system/components/Card.svelte +2 -2
  74. package/src/system/components/CodeSnippet.svelte +1 -1
  75. package/src/system/components/CollapsibleSection.svelte +7 -7
  76. package/src/system/components/CornerBadge.svelte +1 -1
  77. package/src/system/components/Dialog.svelte +2 -2
  78. package/src/system/components/FloatingTokenTags.css +5 -4
  79. package/src/system/components/Input.svelte +6 -6
  80. package/src/system/components/MenuSelect.svelte +4 -4
  81. package/src/system/components/Notification.svelte +8 -8
  82. package/src/system/components/ProgressBar.svelte +2 -2
  83. package/src/system/components/RadioButton.svelte +4 -4
  84. package/src/system/components/SectionDivider.svelte +6 -6
  85. package/src/system/components/SegmentedControl.svelte +5 -5
  86. package/src/system/components/SideNavigation.svelte +12 -12
  87. package/src/system/components/TabBar.svelte +4 -4
  88. package/src/system/components/Table.svelte +2 -2
  89. package/src/system/components/Tooltip.svelte +1 -1
  90. package/src/system/styles/tokens.css +59 -6
  91. 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,12 @@ 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 } from '../palettes/colorHarmony';
24
+ import {
25
+ renameBackgroundPaletteKey,
26
+ renameBackgroundHarmonyFamily,
27
+ } from '../themes/migrations/2026-07-29-background-palette-to-canvas';
28
+ import { placeUnplacedBaseAnchors } from '../themes/migrations/2026-07-29-place-base-anchors';
23
29
 
24
30
  // Resolve the persist key lazily (per-call) so library consumers that invoke
25
31
  // `configureEditor({storagePrefix})` before the first store write get the
@@ -87,6 +93,27 @@ export function normalizeComponents(state: EditorState): EditorState {
87
93
  return { ...state, components };
88
94
  }
89
95
 
96
+ // A session persisted before the rename keys its palette by the old label and
97
+ // binds an axis to it. Both have to move before the axes are sanitized, which
98
+ // would otherwise drop the now-ineligible family and unbind the axis.
99
+ export function normalizePaletteLabels(state: EditorState): EditorState {
100
+ return { ...state, palettes: renameBackgroundPaletteKey(state.palettes) };
101
+ }
102
+
103
+ // `hydrate` shallow-merges persisted state over `emptyState()`, so the axes that
104
+ // arrive here may be the injected defaults sitting over the session's real
105
+ // palettes. Reconcile them against those palettes.
106
+ export function normalizeHarmonyAxes(state: EditorState): EditorState {
107
+ const axes = renameBackgroundHarmonyFamily(state.harmonyAxes);
108
+ return { ...state, harmonyAxes: sanitizeHarmonyAxes(axes, state.palettes) };
109
+ }
110
+
111
+ // A session persisted before placements existed has anchored palettes with no
112
+ // `anchorPlacement`, so their ramps never show the anchored slot.
113
+ export function normalizeBaseAnchors(state: EditorState): EditorState {
114
+ return { ...state, palettes: placeUnplacedBaseAnchors(state.palettes) };
115
+ }
116
+
90
117
  export function hydrate(): void {
91
118
  // Corrupt state, missing key, or unavailable storage all return null;
92
119
  // the editor falls through to the empty default in that case.
@@ -95,7 +122,7 @@ export function hydrate(): void {
95
122
  // Shallow-merge onto default shape so older persisted state missing
96
123
  // newly-added domain fields still loads.
97
124
  const merged = { ...emptyStateFactory(), ...(parsed as object) } as EditorState;
98
- store.set(normalizeComponents(migrateGradients(merged)));
125
+ store.set(normalizeHarmonyAxes(normalizeBaseAnchors(normalizePaletteLabels(normalizeComponents(migrateGradients(merged))))));
99
126
  }
100
127
  // m13 fix: seed shadows from the DOM at hydrate time so the editor
101
128
  // captures the tokens.css baseline regardless of whether the user opens
@@ -28,7 +28,16 @@ import {
28
28
  runMigrations,
29
29
  } from '../themes/migrations';
30
30
  import { renamePrimaryPaletteKey } from '../themes/migrations/2026-05-13-primary-to-brand';
31
+ import {
32
+ renameBackgroundPaletteKey,
33
+ renameBackgroundHarmonyFamily,
34
+ } from '../themes/migrations/2026-07-29-background-palette-to-canvas';
31
35
  import { unifyGrayPalettes } from '../themes/migrations/2026-06-05-palette-unification';
36
+ import { migratePaletteColorsToOklch, type PreOklchPaletteConfig } from '../themes/migrations/2026-07-21-palette-oklch-basis';
37
+ import { adoptLegacyBaseAnchor } from '../themes/migrations/2026-07-24-base-anchor-placement';
38
+ import { placeUnplacedBaseAnchors } from '../themes/migrations/2026-07-29-place-base-anchors';
39
+ import { adoptBackgroundSpotAsBase } from '../themes/migrations/2026-07-25-background-spot-to-base';
40
+ import { defaultHarmonyAxes, sanitizeHarmonyAxes } from '../palettes/colorHarmony';
32
41
  import { __resetRendererCacheForTests, installRenderer } from './editorRenderer';
33
42
  import {
34
43
  store,
@@ -87,6 +96,7 @@ function emptyState(): EditorState {
87
96
  columns: { ...DEFAULT_COLUMNS },
88
97
  components: {},
89
98
  gradients: { tokens: makeDefaultGradients() },
99
+ harmonyAxes: defaultHarmonyAxes(),
90
100
  cssVars: {},
91
101
  };
92
102
  }
@@ -512,7 +522,15 @@ const domainLoaders: Record<string, DomainLoader> = {
512
522
  */
513
523
  export function loadFromFile(theme: Theme): void {
514
524
  const next = emptyState();
515
- next.palettes = unifyGrayPalettes(renamePrimaryPaletteKey(structuredClone(theme.editorConfigs ?? {})));
525
+ // Structural palette migrations, innermost → outermost: rename Primary→Brand
526
+ // and Background→Canvas (both key renames, so they precede anything that looks
527
+ // a palette up by label), drop the gray "mode", convert every color channel to
528
+ // the numeric OKLCH basis, adopt any legacy locked-500 anchor as a persisted
529
+ // placement, fold a legacy background spot into the base color, then place any
530
+ // still-unplaced anchor by base luminance. The clone is still pre-basis (hex
531
+ // or already-numeric) on disk.
532
+ const raw = structuredClone(theme.editorConfigs ?? {}) as Record<string, PreOklchPaletteConfig>;
533
+ next.palettes = placeUnplacedBaseAnchors(adoptBackgroundSpotAsBase(adoptLegacyBaseAnchor(migratePaletteColorsToOklch(unifyGrayPalettes(renameBackgroundPaletteKey(renamePrimaryPaletteKey(raw)))))));
516
534
  next.fonts.sources = structuredClone(theme.fontSources ?? []);
517
535
  next.fonts.stacks = structuredClone(theme.fontStacks ?? []);
518
536
  const rawVars = runMigrations(
@@ -525,6 +543,7 @@ export function loadFromFile(theme: Theme): void {
525
543
  // (or in components' case, vars that wouldn't have been in the theme to
526
544
  // begin with).
527
545
  for (const load of Object.values(domainLoaders)) load(next, rawVars);
546
+ next.harmonyAxes = sanitizeHarmonyAxes(renameBackgroundHarmonyFamily(theme.harmonyAxes), next.palettes);
528
547
  next.cssVars = rawVars;
529
548
  resetHistoryForLoad();
530
549
  store.set(next);
@@ -558,6 +577,7 @@ export function toTheme(state: EditorState, meta: { name: string }): Theme {
558
577
  cssVariables,
559
578
  fontSources: state.fonts.sources,
560
579
  fontStacks: state.fonts.stacks,
580
+ harmonyAxes: state.harmonyAxes,
561
581
  schemaVersion: CURRENT_THEME_SCHEMA_VERSION,
562
582
  };
563
583
  }
@@ -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);