@motion-proto/live-tokens 0.44.0 → 0.46.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 (41) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/dist-plugin/index.cjs +1 -1
  3. package/dist-plugin/index.js +1 -1
  4. package/package.json +1 -1
  5. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +0 -1
  6. package/src/editor/component-editor/scaffolding/ComponentFileMenu.svelte +1 -1
  7. package/src/editor/core/palettes/colorHarmony.ts +35 -6
  8. package/src/editor/core/palettes/paletteDerivation.ts +2 -3
  9. package/src/editor/core/store/editorTypes.ts +2 -2
  10. package/src/editor/docs/CodeBlock.svelte +0 -2
  11. package/src/editor/docs/Docs.svelte +0 -3
  12. package/src/editor/pages/ComponentEditorPage.svelte +3 -5
  13. package/src/editor/pages/EditorShell.svelte +3 -5
  14. package/src/editor/ui/ColorEditPanel.svelte +19 -22
  15. package/src/editor/ui/EditorViewSwitcher.svelte +2 -3
  16. package/src/editor/ui/FileLoadList.svelte +7 -9
  17. package/src/editor/ui/ManifestFileManager.svelte +1 -3
  18. package/src/editor/ui/ProjectFontsSection.svelte +0 -4
  19. package/src/editor/ui/SurfacesTab.svelte +3 -3
  20. package/src/editor/ui/TextStylesSection.svelte +6 -8
  21. package/src/editor/ui/ThemeFileManager.svelte +1 -3
  22. package/src/editor/ui/UIEasingSelector.svelte +0 -2
  23. package/src/editor/ui/UIFontFamilySelector.svelte +9 -3
  24. package/src/editor/ui/UIMenuButton.svelte +230 -0
  25. package/src/editor/ui/UIPaletteSelector.svelte +0 -1
  26. package/src/editor/ui/VariablesTab.svelte +3 -4
  27. package/src/editor/ui/colors/AxisNumeral.svelte +70 -0
  28. package/src/editor/ui/colors/ColorReadouts.svelte +0 -2
  29. package/src/editor/ui/colors/ColorStory.svelte +24 -77
  30. package/src/editor/ui/colors/ColorWheel.svelte +48 -29
  31. package/src/editor/ui/colors/ColorsTab.svelte +361 -212
  32. package/src/editor/ui/colors/HarmonyAxesList.svelte +264 -141
  33. package/src/editor/ui/colors/LightnessBar.svelte +0 -2
  34. package/src/editor/ui/colors/harmonyDrag.ts +10 -0
  35. package/src/editor/ui/colors/harmonyModeIcons.ts +5 -0
  36. package/src/editor/ui/colors/paletteBaseColor.ts +25 -39
  37. package/src/live-tokens/data/tokens.generated.css +28 -28
  38. package/src/system/components/ImageLightbox.svelte +0 -2
  39. package/src/system/components/SectionDivider.svelte +4 -4
  40. package/src/editor/core/palettes/recommendText.ts +0 -216
  41. package/src/editor/core/palettes/solveTextContrast.ts +0 -254
@@ -19,10 +19,8 @@
19
19
 
20
20
  import { get } from 'svelte/store';
21
21
  import type { Oklch } from '../../core/palettes/oklch';
22
- import { AXIS_ROLES } from '../../core/palettes/colorHarmony';
22
+ import { axisLabel } from '../../core/palettes/colorHarmony';
23
23
  import { PALETTE_SPECS, syncBaseAnchor, type PaletteSpec } from '../../core/palettes/paletteDerivation';
24
- import { solveTextCurves } from '../../core/palettes/solveTextContrast';
25
- import { recommendNeutralText } from '../../core/palettes/recommendText';
26
24
  import { defaultPaletteConfig } from '../palette/paletteMath';
27
25
  import { mutate, transaction, editorState } from '../../core/store/editorStore';
28
26
  import type { EditorState } from '../../core/store/editorTypes';
@@ -108,7 +106,7 @@ export function setAxisHue(index: number, hue: number, familyChroma?: number): v
108
106
  const base = axis.family !== null ? get(editorState).palettes[axis.family]?.baseColor : undefined;
109
107
  const colorNoop = !base || (base.h === h && (c === undefined || base.c === c));
110
108
  if (axis.hue === h && colorNoop) return;
111
- mutate(`colors: ${AXIS_ROLES[index]} axis hue`, (s) => {
109
+ mutate(`colors: ${axisLabel(index)} hue`, (s) => {
112
110
  applyAxisHue(s, index, h, c);
113
111
  });
114
112
  }
@@ -147,39 +145,47 @@ function applyAxisHue(s: EditorState, index: number, hue: number, chroma: number
147
145
  }
148
146
  }
149
147
 
150
- /** Bind a family to an axis; the family adopts the axis hue (c + L kept).
151
- * Trade-places semantics: whatever occupied the destination takes the source's
152
- * position (another axis, adopting its hue, or Unassigned). One mutate. */
153
- export function bindFamilyToAxis(family: string, index: number): void {
148
+ /** Bind a family to an axis; the axis moves to the family's hue. Trade-places
149
+ * semantics: whatever occupied the destination takes the source's position
150
+ * (another axis, which likewise moves to it, or unassigned). One mutate.
151
+ * Returns whether any axis hue moved, which is what makes an applied harmony
152
+ * mode untrue — the caller drops to Custom on that. */
153
+ export function bindFamilyToAxis(family: string, index: number): boolean {
154
154
  const srcIndex = get(editorState).harmonyAxes.findIndex((a) => a.family === family);
155
- if (srcIndex === index) return;
156
- mutate(`colors: bind ${family}`, (s) => {
155
+ if (srcIndex === index) return false;
156
+ let hueMoved = false;
157
+ mutate(`colors: assign ${family}`, (s) => {
157
158
  const axes = s.harmonyAxes;
158
159
  const occupant = axes[index].family;
159
160
  axes[index].family = family;
160
- adoptAxisHue(s, family, axes[index].hue);
161
- // Source was another axis: the occupant trades into it (adopting its hue).
162
- // Source was Unassigned (-1): the occupant simply floats free, color kept.
161
+ hueMoved = takeSeedHue(s, index, family);
162
+ // Source was another axis: the occupant trades into it, keeping its color
163
+ // and taking that axis to its hue. Source was unassigned (-1): the occupant
164
+ // simply floats free, color kept.
163
165
  if (srcIndex !== -1) {
164
166
  axes[srcIndex].family = occupant;
165
- if (occupant !== null) adoptAxisHue(s, occupant, axes[srcIndex].hue);
167
+ if (occupant !== null) hueMoved = takeSeedHue(s, srcIndex, occupant) || hueMoved;
166
168
  }
167
169
  });
170
+ return hueMoved;
168
171
  }
169
172
 
170
173
  /** Unbind; the family keeps its current color, the axis keeps its hue. */
171
174
  export function unbindFamily(family: string): void {
172
175
  const idx = get(editorState).harmonyAxes.findIndex((a) => a.family === family);
173
176
  if (idx === -1) return;
174
- mutate(`colors: unbind ${family}`, (s) => {
177
+ mutate(`colors: unassign ${family}`, (s) => {
175
178
  s.harmonyAxes[idx].family = null;
176
179
  });
177
180
  }
178
181
 
179
- function adoptAxisHue(s: EditorState, family: string, hue: number): void {
180
- const cfg = ensureConfig(s, family);
181
- cfg.baseColor = { l: cfg.baseColor.l, c: cfg.baseColor.c, h: normHue(hue) };
182
- syncBaseAnchor(cfg);
182
+ /** Assignment must never repaint a seed the user chose, so the axis is what
183
+ * moves. Harmony modes and axis drags stay the steps that move colors. */
184
+ function takeSeedHue(s: EditorState, index: number, family: string): boolean {
185
+ const hue = normHue(ensureConfig(s, family).baseColor.h);
186
+ if (s.harmonyAxes[index].hue === hue) return false;
187
+ s.harmonyAxes[index].hue = hue;
188
+ return true;
183
189
  }
184
190
 
185
191
  /** Set several seed colors in ONE undo entry (harmony apply, global rotate). */
@@ -193,14 +199,6 @@ export function setBaseColors(patch: Record<string, Oklch>, historyLabel = 'colo
193
199
  });
194
200
  }
195
201
 
196
- export function applySolvedTextCurves(s: EditorState): void {
197
- const { patches } = solveTextCurves(s.palettes);
198
- for (const [label, patch] of Object.entries(patches)) {
199
- const cfg = s.palettes[label];
200
- if (cfg) cfg.scaleCurves = { ...cfg.scaleCurves, ...patch.scaleCurves };
201
- }
202
- }
203
-
204
202
  /** Read-only view of every family's config, seeding spec defaults for the ones a
205
203
  * loaded theme leaves unconfigured — previews and recommendations must not go
206
204
  * static on a family the theme never mentions. */
@@ -212,15 +210,3 @@ export function palettesWithDefaults(palettes: Record<string, PaletteConfig>): R
212
210
  }
213
211
  return map;
214
212
  }
215
-
216
- /** Apply the Color Story's suggested neutral text hierarchy (primary anchor,
217
- * two even L drops, AA floors) as the Neutral family's Text curve. */
218
- export function applySuggestedNeutralText(useBlackWhite = false): void {
219
- mutate('colors: suggested text hierarchy', (s) => {
220
- ensureConfig(s, 'Neutral');
221
- const rec = recommendNeutralText(s.palettes, undefined, useBlackWhite);
222
- if (!rec.patch) return;
223
- const cfg = s.palettes['Neutral'];
224
- cfg.scaleCurves = { ...cfg.scaleCurves, Text: rec.patch };
225
- });
226
- }
@@ -125,6 +125,34 @@
125
125
  --text-accent-tertiary: #4d7271;
126
126
  --text-accent-muted: #3c4847;
127
127
  --text-accent-disabled: #313232;
128
+ --color-special-100: #d2c8ff;
129
+ --color-special-200: #b19cff;
130
+ --color-special-300: #9673f5;
131
+ --color-special-400: #7d54db;
132
+ --color-special-500: #6634c4;
133
+ --color-special-600: #5407af;
134
+ --color-special-700: #3f0088;
135
+ --color-special-800: #2d0064;
136
+ --color-special-850: #1d0046;
137
+ --color-special-900: #10002d;
138
+ --color-special-950: #060019;
139
+ --surface-special-lowest: #0f002b;
140
+ --surface-special-lower: #250055;
141
+ --surface-special-low: #3d0084;
142
+ --surface-special: #5700b7;
143
+ --surface-special-high: #6c3bcb;
144
+ --surface-special-higher: #8161d8;
145
+ --surface-special-highest: #9983e4;
146
+ --border-special-faint: #29005c;
147
+ --border-special-subtle: #4b009f;
148
+ --border-special: #6b34ce;
149
+ --border-special-medium: #8c5df8;
150
+ --border-special-strong: #ac93ff;
151
+ --text-special: #baa9ff;
152
+ --text-special-secondary: #8665e0;
153
+ --text-special-tertiary: #5c4e8b;
154
+ --text-special-muted: #3e3a4f;
155
+ --text-special-disabled: #303032;
128
156
  --color-canvas-100: #cecfe5;
129
157
  --color-canvas-200: #abacc2;
130
158
  --color-canvas-300: #8d8da4;
@@ -212,34 +240,6 @@
212
240
  --text-alternate-tertiary: #8a8786;
213
241
  --text-alternate-muted: #545352;
214
242
  --text-alternate-disabled: #3b3b3b;
215
- --color-special-100: #d2c8ff;
216
- --color-special-200: #b19cff;
217
- --color-special-300: #9673f5;
218
- --color-special-400: #7d54db;
219
- --color-special-500: #6634c4;
220
- --color-special-600: #5407af;
221
- --color-special-700: #3f0088;
222
- --color-special-800: #2d0064;
223
- --color-special-850: #1d0046;
224
- --color-special-900: #10002d;
225
- --color-special-950: #060019;
226
- --surface-special-lowest: #0f002b;
227
- --surface-special-lower: #250055;
228
- --surface-special-low: #3d0084;
229
- --surface-special: #5700b7;
230
- --surface-special-high: #6c3bcb;
231
- --surface-special-higher: #8161d8;
232
- --surface-special-highest: #9983e4;
233
- --border-special-faint: #29005c;
234
- --border-special-subtle: #4b009f;
235
- --border-special: #6b34ce;
236
- --border-special-medium: #8c5df8;
237
- --border-special-strong: #ac93ff;
238
- --text-special: #baa9ff;
239
- --text-special-secondary: #8665e0;
240
- --text-special-tertiary: #5c4e8b;
241
- --text-special-muted: #3e3a4f;
242
- --text-special-disabled: #303032;
243
243
  --color-info-100: #9adbff;
244
244
  --color-info-200: #4ebbf1;
245
245
  --color-info-300: #109bd4;
@@ -916,7 +916,6 @@
916
916
  backdrop-filter: blur(var(--blur-md));
917
917
  font-family: var(--font-mono);
918
918
  font-size: var(--font-size-xs);
919
- letter-spacing: var(--letter-spacing-wider);
920
919
  opacity: 0;
921
920
  pointer-events: none;
922
921
  }
@@ -949,7 +948,6 @@
949
948
  text-align: center;
950
949
  font-family: var(--font-mono);
951
950
  font-size: var(--font-size-xs);
952
- letter-spacing: var(--letter-spacing-wider);
953
951
  padding: 0 var(--space-4);
954
952
  }
955
953
 
@@ -212,7 +212,7 @@
212
212
  --sectiondivider-lg-eyebrow-font-family: var(--font-sans);
213
213
  --sectiondivider-lg-eyebrow-font-weight: var(--font-weight-medium);
214
214
  --sectiondivider-lg-eyebrow-font-size: var(--font-size-md);
215
- --sectiondivider-lg-eyebrow-letter-spacing: var(--letter-spacing-wide);
215
+ --sectiondivider-lg-eyebrow-letter-spacing: var(--letter-spacing-normal);
216
216
  --sectiondivider-lg-padding: var(--space-0);
217
217
  --sectiondivider-lg-title-padding: var(--space-2);
218
218
  --sectiondivider-lg-description-padding: var(--space-0);
@@ -243,7 +243,7 @@
243
243
  --sectiondivider-md-eyebrow-font-family: var(--font-sans);
244
244
  --sectiondivider-md-eyebrow-font-weight: var(--font-weight-medium);
245
245
  --sectiondivider-md-eyebrow-font-size: var(--font-size-sm);
246
- --sectiondivider-md-eyebrow-letter-spacing: var(--letter-spacing-wide);
246
+ --sectiondivider-md-eyebrow-letter-spacing: var(--letter-spacing-normal);
247
247
  --sectiondivider-md-padding: var(--space-0);
248
248
  --sectiondivider-md-title-padding: var(--space-2);
249
249
  --sectiondivider-md-description-padding: var(--space-0);
@@ -274,7 +274,7 @@
274
274
  --sectiondivider-sm-eyebrow-font-family: var(--font-sans);
275
275
  --sectiondivider-sm-eyebrow-font-weight: var(--font-weight-medium);
276
276
  --sectiondivider-sm-eyebrow-font-size: var(--font-size-xs);
277
- --sectiondivider-sm-eyebrow-letter-spacing: var(--letter-spacing-wide);
277
+ --sectiondivider-sm-eyebrow-letter-spacing: var(--letter-spacing-normal);
278
278
  --sectiondivider-sm-padding: var(--space-0);
279
279
  --sectiondivider-sm-title-padding: var(--space-2);
280
280
  --sectiondivider-sm-description-padding: var(--space-0);
@@ -546,7 +546,7 @@
546
546
  display: inline-flex;
547
547
  }
548
548
 
549
- /* Eyebrow visibility + uppercase flow from per-variant CSS vars. */
549
+ /* Eyebrow visibility + text transform flow from per-variant CSS vars. */
550
550
  .divider-eyebrow {
551
551
  display: var(--_divider-eyebrow-display);
552
552
  font-family: var(--_divider-eyebrow-font-family);
@@ -1,216 +0,0 @@
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
- }