@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
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,182 @@
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', 'Canvas', '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
+ // In-range hues pass through bit-exact: the mod-360 round trip perturbs the
37
+ // last mantissa bits, which would make a preserved hue compare unequal.
38
+ const norm = (h: number): number => (h >= 0 && h < 360 ? h : ((h % 360) + 360) % 360);
39
+
40
+ /**
41
+ * Slot hues for `mode` from the anchor hue, priority-ordered: slot 1 is the
42
+ * primary harmonic partner. Returns the first `slotCount` slots (1–4). With the
43
+ * default order, dealing slots 0–2 reproduces today's per-family output.
44
+ */
45
+ export function harmonyHues(mode: HarmonyMode, anchorHue: number, slotCount: number): number[] {
46
+ const a = norm(anchorHue);
47
+ const slots = ((): number[] => {
48
+ switch (mode) {
49
+ case 'complementary': return [a, norm(a + 180), a, norm(a + 180)];
50
+ case 'split-complementary': return [a, norm(a + 210), norm(a + 150), a];
51
+ case 'triadic': return [a, norm(a + 240), norm(a + 120), a];
52
+ case 'tetradic': return [a, norm(a + 180), norm(a + 60), norm(a + 240)];
53
+ case 'compound': return [a, norm(a + 180), norm(a + 30), norm(a + 210)];
54
+ case 'square': return [a, norm(a + 180), norm(a + 90), norm(a + 270)];
55
+ case 'analogous': return [a, norm(a + 30), norm(a - 30), a];
56
+ case 'monochromatic': return [a, a, a, a];
57
+ case 'custom': return [a, a, a, a];
58
+ }
59
+ })();
60
+ return slots.slice(0, slotCount);
61
+ }
62
+
63
+ function reHue(config: PaletteConfig, hue: number): Oklch {
64
+ const { l, c } = config.baseColor;
65
+ return { l, c, h: norm(hue) };
66
+ }
67
+
68
+ /** Re-hue Neutral and Alternate to `anchorHue`, own chroma + lightness kept. */
69
+ export function tintNeutralsFromAnchor(
70
+ palettes: Record<string, PaletteConfig>,
71
+ anchorHue: number,
72
+ ): Record<string, Oklch> {
73
+ const out: Record<string, Oklch> = {};
74
+ for (const label of ['Neutral', 'Alternate']) {
75
+ const config = palettes[label];
76
+ if (config) out[label] = reHue(config, anchorHue);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ // Callers only pass HARMONY_ELIGIBLE families, all of which have a spec; a miss
82
+ // is a programming error, so surface it rather than seed a silent hue 0.
83
+ const specInitialHue = (label: string): number =>
84
+ PALETTE_SPECS.find((s) => s.label === label)!.initialColor.h;
85
+
86
+ /**
87
+ * Seed the four axes for a fresh editor: the default trio bound (hues read from
88
+ * `PALETTE_SPECS`, never hardcoded) and Quaternary unbound at anchor + 270 (the
89
+ * square slot-3 offset). Fresh array each call — it seeds mutable state.
90
+ */
91
+ export function defaultHarmonyAxes(): HarmonyAxis[] {
92
+ const anchorHue = specInitialHue('Brand');
93
+ return [
94
+ { hue: anchorHue, family: 'Brand' },
95
+ { hue: specInitialHue('Accent'), family: 'Accent' },
96
+ { hue: specInitialHue('Canvas'), family: 'Canvas' },
97
+ { hue: norm(anchorHue + 270), family: null },
98
+ ];
99
+ }
100
+
101
+ /**
102
+ * Per-axis availability in `mode`: an axis participates only when its slot is a
103
+ * distinct position (complementary → 2 axes, triadic → 3, square → 4). Two
104
+ * carve-outs keep every axis active: monochromatic, whose repeats are the point
105
+ * (all axes collapse onto the anchor), and custom, which imposes no geometry.
106
+ */
107
+ export function modeActiveAxes(mode: HarmonyMode): boolean[] {
108
+ if (mode === 'custom' || mode === 'monochromatic') return Array(AXIS_COUNT).fill(true);
109
+ const slots = harmonyHues(mode, 0, AXIS_COUNT);
110
+ return slots.map((h, i) => slots.slice(0, i).every((prev) => prev !== h));
111
+ }
112
+
113
+ /**
114
+ * Re-deal every axis's hue from `mode`'s geometry, anchored on axis 0's hue.
115
+ * Bindings are preserved; `'custom'` imposes no constraint and returns a copy.
116
+ * An axis inactive in `mode` (its slot would be a repeat) is left alone — the
117
+ * UI disables it while unbound.
118
+ */
119
+ export function applyHarmonyToAxes(mode: HarmonyMode, axes: HarmonyAxis[]): HarmonyAxis[] {
120
+ if (mode === 'custom') return axes.map((a) => ({ ...a }));
121
+ const hues = harmonyHues(mode, axes[0].hue, AXIS_COUNT);
122
+ const active = modeActiveAxes(mode);
123
+ return axes.map((a, i) => (active[i] ? { hue: hues[i], family: a.family } : { ...a }));
124
+ }
125
+
126
+ /**
127
+ * New baseColors for the families bound to axes, each re-hued to its axis hue
128
+ * with its own chroma + lightness preserved. Unbound axes and missing configs
129
+ * contribute nothing.
130
+ */
131
+ export function boundColorPatch(
132
+ axes: HarmonyAxis[],
133
+ palettes: Record<string, PaletteConfig>,
134
+ ): Record<string, Oklch> {
135
+ const out: Record<string, Oklch> = {};
136
+ for (const axis of axes) {
137
+ if (axis.family === null) continue;
138
+ const config = palettes[axis.family];
139
+ if (config) out[axis.family] = reHue(config, axis.hue);
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * Coerce untrusted `harmonyAxes` (theme JSON) into exactly `AXIS_COUNT` axes:
146
+ * truncate extras, pad missing indexes from the defaults, drop ineligible or
147
+ * duplicate families to `null`, and replace non-finite hues with the default
148
+ * axis hue. The loaded color is ground truth, so every bound axis whose palette
149
+ * exists finally snaps its hue to that palette's `baseColor.h`.
150
+ */
151
+ export function sanitizeHarmonyAxes(
152
+ input: unknown,
153
+ palettes: Record<string, PaletteConfig>,
154
+ ): HarmonyAxis[] {
155
+ const defaults = defaultHarmonyAxes();
156
+ const source = Array.isArray(input) ? input : defaults;
157
+ const used = new Set<string>();
158
+ const axes: HarmonyAxis[] = [];
159
+ for (let i = 0; i < AXIS_COUNT; i++) {
160
+ const entry = (source[i] ?? defaults[i]) as Partial<HarmonyAxis>;
161
+ const rawFamily = entry.family;
162
+ let family: string | null = null;
163
+ if (typeof rawFamily === 'string' && HARMONY_ELIGIBLE.includes(rawFamily) && !used.has(rawFamily)) {
164
+ family = rawFamily;
165
+ used.add(rawFamily);
166
+ }
167
+ const rawHue = entry.hue;
168
+ const hue = typeof rawHue === 'number' && Number.isFinite(rawHue) ? norm(rawHue) : defaults[i].hue;
169
+ axes.push({ hue, family });
170
+ }
171
+ for (const axis of axes) {
172
+ if (axis.family !== null && palettes[axis.family]) {
173
+ axis.hue = norm(palettes[axis.family].baseColor.h);
174
+ }
175
+ }
176
+ // With no stored axes (a theme predating them), the default Quaternary hue is
177
+ // an offset from the anchor, so it follows the anchor's palette-seeded hue
178
+ // rather than the spec hue the defaults were built from.
179
+ const last = axes[AXIS_COUNT - 1];
180
+ if (!Array.isArray(input) && last.family === null) last.hue = norm(axes[0].hue + 270);
181
+ return axes;
182
+ }
@@ -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 };