@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
package/src/app/site.css CHANGED
@@ -10,58 +10,67 @@
10
10
  */
11
11
 
12
12
  h1 {
13
- font-family: var(--font-display);
14
- font-size: var(--font-size-4xl);
15
- font-weight: var(--font-weight-semibold);
13
+ font-family: var(--heading-xl-font-family);
14
+ font-size: var(--heading-xl-font-size);
15
+ font-weight: var(--heading-xl-font-weight);
16
+ line-height: var(--heading-xl-line-height);
17
+ letter-spacing: var(--heading-xl-letter-spacing);
16
18
  color: var(--text-primary);
17
19
  margin: 0 0 var(--space-12);
18
- line-height: var(--line-height-sm);
19
20
  overflow-wrap: break-word;
20
21
  }
21
22
 
22
23
  h2 {
23
- font-family: var(--font-serif);
24
- font-size: var(--font-size-2xl);
25
- font-weight: var(--font-weight-semibold);
24
+ font-family: var(--heading-lg-font-family);
25
+ font-size: var(--heading-lg-font-size);
26
+ font-weight: var(--heading-lg-font-weight);
27
+ line-height: var(--heading-lg-line-height);
28
+ letter-spacing: var(--heading-lg-letter-spacing);
26
29
  color: var(--text-primary);
27
- letter-spacing: var(--letter-spacing-normal);
28
30
  margin: var(--space-32) 0 var(--space-12);
29
- line-height: var(--line-height-sm);
30
31
  overflow-wrap: break-word;
31
32
  }
32
33
 
33
34
  h3 {
34
- font-family: var(--font-serif);
35
- font-size: var(--font-size-xl);
36
- font-weight: var(--font-weight-normal);
35
+ font-family: var(--heading-md-font-family);
36
+ font-size: var(--heading-md-font-size);
37
+ font-weight: var(--heading-md-font-weight);
38
+ line-height: var(--heading-md-line-height);
39
+ letter-spacing: var(--heading-md-letter-spacing);
37
40
  color: var(--text-primary);
38
41
  margin: var(--space-24) 0 var(--space-8);
39
- line-height: var(--line-height-sm);
40
42
  overflow-wrap: break-word;
41
43
  }
42
44
 
45
+ h4 {
46
+ font-family: var(--heading-sm-font-family);
47
+ font-size: var(--heading-sm-font-size);
48
+ font-weight: var(--heading-sm-font-weight);
49
+ line-height: var(--heading-sm-line-height);
50
+ letter-spacing: var(--heading-sm-letter-spacing);
51
+ color: var(--text-primary);
52
+ margin: var(--space-16) 0 var(--space-4);
53
+ }
54
+
43
55
  @media (max-width: 768px) {
44
56
  h1 {
45
- line-height: 1.1;
46
57
  margin-bottom: var(--space-8);
47
58
  }
48
59
 
49
60
  h2 {
50
- line-height: 1.15;
51
61
  margin-top: var(--space-24);
52
62
  }
53
63
 
54
64
  h3 {
55
- line-height: 1.2;
56
65
  margin-top: var(--space-20);
57
66
  }
58
67
  }
59
68
 
60
69
  p {
61
- font-family: var(--font-serif);
62
- font-size: var(--font-size-md);
70
+ font-family: var(--body-md-font-family);
71
+ font-size: var(--body-md-font-size);
63
72
  color: var(--text-secondary);
64
- line-height: var(--line-height-md);
73
+ line-height: var(--body-md-line-height);
65
74
  margin: 0 0 14px;
66
75
  }
67
76
 
@@ -69,6 +78,17 @@ p:last-child {
69
78
  margin-bottom: 0;
70
79
  }
71
80
 
81
+ code {
82
+ font-family: var(--code-font-family);
83
+ font-size: var(--code-font-size);
84
+ }
85
+
86
+ pre {
87
+ font-family: var(--code-font-family);
88
+ font-size: var(--code-font-size);
89
+ line-height: var(--code-line-height);
90
+ }
91
+
72
92
  a {
73
93
  color: var(--text-brand);
74
94
  text-decoration: none;
@@ -94,7 +114,7 @@ ul li {
94
114
  font-family: var(--font-serif);
95
115
  font-size: var(--font-size-md);
96
116
  color: var(--text-secondary);
97
- line-height: 1.75;
117
+ line-height: var(--line-height-relaxed);
98
118
  margin-bottom: var(--space-4);
99
119
  }
100
120
 
@@ -136,3 +156,60 @@ blockquote {
136
156
  color: var(--text-secondary);
137
157
  font-style: italic;
138
158
  }
159
+
160
+ .heading-xl {
161
+ font-family: var(--heading-xl-font-family);
162
+ font-size: var(--heading-xl-font-size);
163
+ font-weight: var(--heading-xl-font-weight);
164
+ line-height: var(--heading-xl-line-height);
165
+ letter-spacing: var(--heading-xl-letter-spacing);
166
+ }
167
+
168
+ .heading-lg {
169
+ font-family: var(--heading-lg-font-family);
170
+ font-size: var(--heading-lg-font-size);
171
+ font-weight: var(--heading-lg-font-weight);
172
+ line-height: var(--heading-lg-line-height);
173
+ letter-spacing: var(--heading-lg-letter-spacing);
174
+ }
175
+
176
+ .heading-md {
177
+ font-family: var(--heading-md-font-family);
178
+ font-size: var(--heading-md-font-size);
179
+ font-weight: var(--heading-md-font-weight);
180
+ line-height: var(--heading-md-line-height);
181
+ letter-spacing: var(--heading-md-letter-spacing);
182
+ }
183
+
184
+ .heading-sm {
185
+ font-family: var(--heading-sm-font-family);
186
+ font-size: var(--heading-sm-font-size);
187
+ font-weight: var(--heading-sm-font-weight);
188
+ line-height: var(--heading-sm-line-height);
189
+ letter-spacing: var(--heading-sm-letter-spacing);
190
+ }
191
+
192
+ .body-md {
193
+ font-family: var(--body-md-font-family);
194
+ font-size: var(--body-md-font-size);
195
+ font-weight: var(--body-md-font-weight);
196
+ line-height: var(--body-md-line-height);
197
+ letter-spacing: var(--body-md-letter-spacing);
198
+ }
199
+
200
+ .body-sm {
201
+ font-family: var(--body-sm-font-family);
202
+ font-size: var(--body-sm-font-size);
203
+ font-weight: var(--body-sm-font-weight);
204
+ line-height: var(--body-sm-line-height);
205
+ letter-spacing: var(--body-sm-letter-spacing);
206
+ }
207
+
208
+ .eyebrow {
209
+ font-family: var(--eyebrow-font-family);
210
+ font-size: var(--eyebrow-font-size);
211
+ font-weight: var(--eyebrow-font-weight);
212
+ line-height: var(--eyebrow-line-height);
213
+ letter-spacing: var(--eyebrow-letter-spacing);
214
+ text-transform: var(--eyebrow-text-transform);
215
+ }
@@ -0,0 +1,228 @@
1
+ // Harmony rotates HUE ONLY (Global invariant 6). Each palette keeps its own
2
+ // OKLCH chroma and lightness; only a family's hue moves onto a geometric
3
+ // relationship with the anchor. The anchor is axis 0's stored hue: harmony lives
4
+ // on four fixed axes (Anchor/Secondary/Tertiary/Quaternary), each owning a hue
5
+ // whether or not a color family is bound to it. No chroma clamp or cap lives
6
+ // here; a saturated background is a legitimate choice, not something to correct.
7
+
8
+ import type { Oklch } from './oklch';
9
+ import type { PaletteConfig } from '../themes/themeTypes';
10
+ import { PALETTE_SPECS } from './paletteDerivation';
11
+
12
+ export type HarmonyMode =
13
+ | 'complementary'
14
+ | 'split-complementary'
15
+ | 'triadic'
16
+ | 'tetradic'
17
+ | 'compound'
18
+ | 'square'
19
+ | 'analogous'
20
+ | 'monochromatic'
21
+ | 'custom';
22
+
23
+ /** Families the user may order/include on the harmony axes (dev-declared pool). */
24
+ export const HARMONY_ELIGIBLE: readonly string[] = ['Brand', 'Accent', 'Background', 'Special', 'Neutral', 'Alternate'];
25
+
26
+ export const AXIS_COUNT = 4;
27
+ export const AXIS_ROLES = ['Anchor', 'Secondary', 'Tertiary', 'Quaternary'] as const;
28
+
29
+ export interface HarmonyAxis {
30
+ /** Hue in [0, 360). Always present, bound or not. */
31
+ hue: number;
32
+ /** Bound family label (member of HARMONY_ELIGIBLE), or null when the axis is empty. */
33
+ family: string | null;
34
+ }
35
+
36
+ // Slot 0 is the anchor (Brand), then Accent, Background — the pre-axes default.
37
+ const LEGACY_DEFAULT_ORDER: readonly string[] = ['Brand', 'Accent', 'Background'];
38
+
39
+ /**
40
+ * Coerce an untrusted legacy `harmonyOrder` (theme JSON) into a valid order:
41
+ * keep only eligible entries, first occurrence of each. A non-array, empty, or
42
+ * fully-invalid input falls back to the default order. Private to
43
+ * `axesFromLegacyOrder`, the sole migration entry point.
44
+ */
45
+ function sanitizeLegacyOrder(input: unknown): string[] {
46
+ if (!Array.isArray(input)) return [...LEGACY_DEFAULT_ORDER];
47
+ const seen = new Set<string>();
48
+ const out: string[] = [];
49
+ for (const entry of input) {
50
+ if (typeof entry !== 'string') continue;
51
+ if (!HARMONY_ELIGIBLE.includes(entry)) continue;
52
+ if (seen.has(entry)) continue;
53
+ seen.add(entry);
54
+ out.push(entry);
55
+ }
56
+ return out.length > 0 ? out : [...LEGACY_DEFAULT_ORDER];
57
+ }
58
+
59
+ // In-range hues pass through bit-exact: the mod-360 round trip perturbs the
60
+ // last mantissa bits, which would make a preserved hue compare unequal.
61
+ const norm = (h: number): number => (h >= 0 && h < 360 ? h : ((h % 360) + 360) % 360);
62
+
63
+ /**
64
+ * Slot hues for `mode` from the anchor hue, priority-ordered: slot 1 is the
65
+ * primary harmonic partner. Returns the first `slotCount` slots (1–4). With the
66
+ * default order, dealing slots 0–2 reproduces today's per-family output.
67
+ */
68
+ export function harmonyHues(mode: HarmonyMode, anchorHue: number, slotCount: number): number[] {
69
+ const a = norm(anchorHue);
70
+ const slots = ((): number[] => {
71
+ switch (mode) {
72
+ case 'complementary': return [a, norm(a + 180), a, norm(a + 180)];
73
+ case 'split-complementary': return [a, norm(a + 210), norm(a + 150), a];
74
+ case 'triadic': return [a, norm(a + 240), norm(a + 120), a];
75
+ case 'tetradic': return [a, norm(a + 180), norm(a + 60), norm(a + 240)];
76
+ case 'compound': return [a, norm(a + 180), norm(a + 30), norm(a + 210)];
77
+ case 'square': return [a, norm(a + 180), norm(a + 90), norm(a + 270)];
78
+ case 'analogous': return [a, norm(a + 30), norm(a - 30), a];
79
+ case 'monochromatic': return [a, a, a, a];
80
+ case 'custom': return [a, a, a, a];
81
+ }
82
+ })();
83
+ return slots.slice(0, slotCount);
84
+ }
85
+
86
+ function reHue(config: PaletteConfig, hue: number): Oklch {
87
+ const { l, c } = config.baseColor;
88
+ return { l, c, h: norm(hue) };
89
+ }
90
+
91
+ /** Re-hue Neutral and Alternate to `anchorHue`, own chroma + lightness kept. */
92
+ export function tintNeutralsFromAnchor(
93
+ palettes: Record<string, PaletteConfig>,
94
+ anchorHue: number,
95
+ ): Record<string, Oklch> {
96
+ const out: Record<string, Oklch> = {};
97
+ for (const label of ['Neutral', 'Alternate']) {
98
+ const config = palettes[label];
99
+ if (config) out[label] = reHue(config, anchorHue);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ // Callers only pass HARMONY_ELIGIBLE families, all of which have a spec; a miss
105
+ // is a programming error, so surface it rather than seed a silent hue 0.
106
+ const specInitialHue = (label: string): number =>
107
+ PALETTE_SPECS.find((s) => s.label === label)!.initialColor.h;
108
+
109
+ /**
110
+ * Seed the four axes for a fresh editor: the default trio bound (hues read from
111
+ * `PALETTE_SPECS`, never hardcoded) and Quaternary unbound at anchor + 270 (the
112
+ * square slot-3 offset). Fresh array each call — it seeds mutable state.
113
+ */
114
+ export function defaultHarmonyAxes(): HarmonyAxis[] {
115
+ const anchorHue = specInitialHue('Brand');
116
+ return [
117
+ { hue: anchorHue, family: 'Brand' },
118
+ { hue: specInitialHue('Accent'), family: 'Accent' },
119
+ { hue: specInitialHue('Background'), family: 'Background' },
120
+ { hue: norm(anchorHue + 270), family: null },
121
+ ];
122
+ }
123
+
124
+ /**
125
+ * Per-axis availability in `mode`: an axis participates only when its slot is a
126
+ * distinct position (complementary → 2 axes, triadic → 3, square → 4). Two
127
+ * carve-outs keep every axis active: monochromatic, whose repeats are the point
128
+ * (all axes collapse onto the anchor), and custom, which imposes no geometry.
129
+ */
130
+ export function modeActiveAxes(mode: HarmonyMode): boolean[] {
131
+ if (mode === 'custom' || mode === 'monochromatic') return Array(AXIS_COUNT).fill(true);
132
+ const slots = harmonyHues(mode, 0, AXIS_COUNT);
133
+ return slots.map((h, i) => slots.slice(0, i).every((prev) => prev !== h));
134
+ }
135
+
136
+ /**
137
+ * Re-deal every axis's hue from `mode`'s geometry, anchored on axis 0's hue.
138
+ * Bindings are preserved; `'custom'` imposes no constraint and returns a copy.
139
+ * An axis inactive in `mode` (its slot would be a repeat) is left alone — the
140
+ * UI disables it while unbound.
141
+ */
142
+ export function applyHarmonyToAxes(mode: HarmonyMode, axes: HarmonyAxis[]): HarmonyAxis[] {
143
+ if (mode === 'custom') return axes.map((a) => ({ ...a }));
144
+ const hues = harmonyHues(mode, axes[0].hue, AXIS_COUNT);
145
+ const active = modeActiveAxes(mode);
146
+ return axes.map((a, i) => (active[i] ? { hue: hues[i], family: a.family } : { ...a }));
147
+ }
148
+
149
+ /**
150
+ * New baseColors for the families bound to axes, each re-hued to its axis hue
151
+ * with its own chroma + lightness preserved. Unbound axes and missing configs
152
+ * contribute nothing.
153
+ */
154
+ export function boundColorPatch(
155
+ axes: HarmonyAxis[],
156
+ palettes: Record<string, PaletteConfig>,
157
+ ): Record<string, Oklch> {
158
+ const out: Record<string, Oklch> = {};
159
+ for (const axis of axes) {
160
+ if (axis.family === null) continue;
161
+ const config = palettes[axis.family];
162
+ if (config) out[axis.family] = reHue(config, axis.hue);
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * Coerce untrusted `harmonyAxes` (theme JSON) into exactly `AXIS_COUNT` axes:
169
+ * truncate extras, pad missing indexes from the defaults, drop ineligible or
170
+ * duplicate families to `null`, and replace non-finite hues with the default
171
+ * axis hue. The loaded color is ground truth, so every bound axis whose palette
172
+ * exists finally snaps its hue to that palette's `baseColor.h`.
173
+ */
174
+ export function sanitizeHarmonyAxes(
175
+ input: unknown,
176
+ palettes: Record<string, PaletteConfig>,
177
+ ): HarmonyAxis[] {
178
+ const defaults = defaultHarmonyAxes();
179
+ const source = Array.isArray(input) ? input : defaults;
180
+ const used = new Set<string>();
181
+ const axes: HarmonyAxis[] = [];
182
+ for (let i = 0; i < AXIS_COUNT; i++) {
183
+ const entry = (source[i] ?? defaults[i]) as Partial<HarmonyAxis>;
184
+ const rawFamily = entry.family;
185
+ let family: string | null = null;
186
+ if (typeof rawFamily === 'string' && HARMONY_ELIGIBLE.includes(rawFamily) && !used.has(rawFamily)) {
187
+ family = rawFamily;
188
+ used.add(rawFamily);
189
+ }
190
+ const rawHue = entry.hue;
191
+ const hue = typeof rawHue === 'number' && Number.isFinite(rawHue) ? norm(rawHue) : defaults[i].hue;
192
+ axes.push({ hue, family });
193
+ }
194
+ for (const axis of axes) {
195
+ if (axis.family !== null && palettes[axis.family]) {
196
+ axis.hue = norm(palettes[axis.family].baseColor.h);
197
+ }
198
+ }
199
+ return axes;
200
+ }
201
+
202
+ /**
203
+ * Migrate a legacy `harmonyOrder` into axes: `order[i]` binds to axis `i` (via
204
+ * the same eligibility/dedup rules as the old model), seeded at its palette's
205
+ * `baseColor.h` (or the family's spec hue when no palette loaded). Unfilled axes
206
+ * stay unbound at the default hues, Quaternary offset from the migrated anchor.
207
+ */
208
+ export function axesFromLegacyOrder(
209
+ order: unknown,
210
+ palettes: Record<string, PaletteConfig>,
211
+ ): HarmonyAxis[] {
212
+ const legacy = sanitizeLegacyOrder(order);
213
+ const defaults = defaultHarmonyAxes();
214
+ const hueOf = (family: string): number =>
215
+ norm(palettes[family] ? palettes[family].baseColor.h : specInitialHue(family));
216
+ const axes: HarmonyAxis[] = [];
217
+ for (let i = 0; i < AXIS_COUNT; i++) {
218
+ const family = legacy[i] ?? null;
219
+ if (family) {
220
+ axes.push({ hue: hueOf(family), family });
221
+ } else if (i === AXIS_COUNT - 1) {
222
+ axes.push({ hue: norm(axes[0].hue + 270), family: null });
223
+ } else {
224
+ axes.push({ hue: defaults[i].hue, family: null });
225
+ }
226
+ }
227
+ return axes;
228
+ }
@@ -0,0 +1,128 @@
1
+ // WCAG 2.1 contrast math + an OKLCH-lightness solver.
2
+ //
3
+ // The solver is the engine behind the "Derive accessible text" action: given a
4
+ // surface hex and a target ratio, it finds the lightness (at fixed hue) whose
5
+ // gamut-clamped color clears the ratio. Contrast is monotonic in L only on one
6
+ // side of the surface's own lightness, so `direction` selects the branch.
7
+
8
+ import { hexToOklch, oklchToHex, gamutClamp } from './oklch';
9
+
10
+ export const AA_BODY = 4.5;
11
+ export const AA_LARGE = 3.0;
12
+
13
+ function linearizeChannel(v8: number): number {
14
+ const s = v8 / 255;
15
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
16
+ }
17
+
18
+ export function relativeLuminance(hex: string): number {
19
+ const r = linearizeChannel(parseInt(hex.slice(1, 3), 16));
20
+ const g = linearizeChannel(parseInt(hex.slice(3, 5), 16));
21
+ const b = linearizeChannel(parseInt(hex.slice(5, 7), 16));
22
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
23
+ }
24
+
25
+ export function contrastRatio(a: string, b: string): number {
26
+ const la = relativeLuminance(a);
27
+ const lb = relativeLuminance(b);
28
+ const hi = Math.max(la, lb);
29
+ const lo = Math.min(la, lb);
30
+ return (hi + 0.05) / (lo + 0.05);
31
+ }
32
+
33
+ export interface FindLOptions {
34
+ against: string;
35
+ ratio: number;
36
+ direction: 'lighter' | 'darker';
37
+ c: number;
38
+ h: number;
39
+ margin?: number;
40
+ /** Reachable-L window. The Text derivation multiplies seed L by a curve
41
+ * factor clamped to [0,2], so a family can only reach L ∈ [0, min(1,2·seedL)].
42
+ * The solver passes that ceiling as `lMax` so the returned L is realisable. */
43
+ lMin?: number;
44
+ lMax?: number;
45
+ }
46
+
47
+ export interface FindLResult {
48
+ l: number;
49
+ c: number;
50
+ hex: string;
51
+ }
52
+
53
+ /**
54
+ * Binary-search OKLCH lightness for a color that clears `ratio` against
55
+ * `against`, staying on the `direction` side of the surface (the monotonic
56
+ * branch). If the ratio is unreachable at chroma `c` within the window, decay
57
+ * chroma toward 0 and retry — achromatic extremes reach the full 1..21 range.
58
+ * The result is re-verified with `contrastRatio` after the hex round-trip and
59
+ * nudged one step if 8-bit quantization ate the margin (Global invariant 8).
60
+ */
61
+ export function findLForContrast(opts: FindLOptions): FindLResult {
62
+ const { against, ratio, direction, c, h } = opts;
63
+ const margin = opts.margin ?? 0.05;
64
+ const lMin = opts.lMin ?? 0;
65
+ const lMax = opts.lMax ?? 1;
66
+ const target = ratio + margin;
67
+ const againstL = hexToOklch(against).l;
68
+
69
+ const evalAt = (l: number, cc: number) => {
70
+ const g = gamutClamp(l, cc, h);
71
+ const hex = oklchToHex(g.l, g.c, g.h);
72
+ return { hex, chroma: g.c, ratio: contrastRatio(hex, against) };
73
+ };
74
+
75
+ // Returns the boundary solution at a fixed chroma, or null if `target` is
76
+ // unreachable within the window at that chroma. `extreme` is the window end
77
+ // with the most contrast (top L for lighter, bottom L for darker).
78
+ const solveAtChroma = (cc: number): { l: number; hex: string; chroma: number } | null => {
79
+ const lighter = direction === 'lighter';
80
+ const lo0 = lighter ? Math.max(lMin, againstL) : lMin;
81
+ const hi0 = lighter ? lMax : Math.min(lMax, againstL);
82
+ if (lo0 >= hi0) return null; // no room to move to the requested side within the window
83
+ const extreme = lighter ? hi0 : lo0;
84
+ if (evalAt(extreme, cc).ratio < target) return null;
85
+ let lo = lo0;
86
+ let hi = hi0;
87
+ for (let i = 0; i < 24; i++) {
88
+ const mid = (lo + hi) / 2;
89
+ const meets = evalAt(mid, cc).ratio >= target;
90
+ // Contrast rises toward `extreme`; keep the half that still meets target.
91
+ if (lighter ? meets : !meets) hi = mid;
92
+ else lo = mid;
93
+ }
94
+ const at = lighter ? hi : lo;
95
+ const e = evalAt(at, cc);
96
+ return { l: at, hex: e.hex, chroma: e.chroma };
97
+ };
98
+
99
+ let sol = solveAtChroma(c);
100
+ if (!sol) {
101
+ if (!solveAtChroma(0)) {
102
+ // Even achromatic can't reach the target within the window — return the
103
+ // extreme (best effort); the caller reports it as unmet.
104
+ const extremeL = direction === 'lighter' ? lMax : lMin;
105
+ const e = evalAt(extremeL, 0);
106
+ return { l: extremeL, c: 0, hex: e.hex };
107
+ }
108
+ let lo = 0;
109
+ let hi = c;
110
+ for (let i = 0; i < 24; i++) {
111
+ const mid = (lo + hi) / 2;
112
+ if (solveAtChroma(mid)) lo = mid;
113
+ else hi = mid;
114
+ }
115
+ sol = solveAtChroma(lo)!;
116
+ }
117
+
118
+ let { l, hex, chroma } = sol;
119
+ const step = direction === 'lighter' ? 0.004 : -0.004;
120
+ for (let k = 0; k < 12 && contrastRatio(hex, against) < ratio; k++) {
121
+ l = Math.min(lMax, Math.max(lMin, l + step));
122
+ const g = gamutClamp(l, chroma, h);
123
+ l = g.l;
124
+ chroma = g.c;
125
+ hex = oklchToHex(g.l, g.c, g.h);
126
+ }
127
+ return { l, c: chroma, hex };
128
+ }
@@ -102,6 +102,14 @@ function isInGamut(r: number, g: number, b: number): boolean {
102
102
  return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;
103
103
  }
104
104
 
105
+ /** The clamped sRGB projection: reduce chroma into gamut, then serialize to hex.
106
+ * This is the correct projection for unclamped stored intent — naive
107
+ * `oklchToHex` per-channel clips (shifting hue), this preserves hue and L. */
108
+ export function oklchToHexClamped(l: number, c: number, h: number): string {
109
+ const g = gamutClamp(l, c, h);
110
+ return oklchToHex(g.l, g.c, g.h);
111
+ }
112
+
105
113
  export function gamutClamp(l: number, c: number, h: number): Oklch {
106
114
  // Clamp lightness
107
115
  if (l <= 0) return { l: 0, c: 0, h };