@motion-proto/live-tokens 0.44.0 → 0.45.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.45.0 — The Color Story shows the tokens you actually have
4
+
5
+ ### Fixed
6
+
7
+ - **The Color Story's text steps read the live tokens.** The primary, secondary
8
+ and tertiary row rendered the values the *Even neutral steps* pill would have
9
+ written, not the current ones, so the same step could report two different
10
+ ratios in the same frame (the Canvas header said 17.9:1 while the row said
11
+ 14.7:1). Each step now resolves `--text-primary` / `--text-secondary` /
12
+ `--text-tertiary` through the same path the functional chips and tone pills
13
+ already used, renders in its own token, and reports its real contrast against
14
+ the canvas.
15
+
16
+ ### Removed
17
+
18
+ - **The Color Story's action pills.** *Raise all text to AA*, *Even neutral
19
+ steps*, and the *Anchor at pure white/black* toggle, added in 0.44.0, are
20
+ gone. The story is a proportional preview, and authoring text steps from it
21
+ put a second palette editor next to the real one. Text steps are edited in
22
+ the Tokens view.
23
+
24
+ - **The text-contrast solvers.** `solveTextContrast.ts` and `recommendText.ts`
25
+ (plus `applySolvedTextCurves` and `applySuggestedNeutralText`) backed only
26
+ those pills and are deleted. Both were internal to `src/editor/core/palettes/`
27
+ and never reachable through the package exports, so no consumer import
28
+ changes. `BW_GUARD_MIN_L` / `BW_GUARD_MAX_L` are unaffected: they live in
29
+ `paletteDerivation.ts` and still guard derived inverted text.
30
+
31
+ ### Changed
32
+
33
+ - **The story's prose lines and its unlabeled header ratio are gone.** The three
34
+ sentences under Canvas ("The dominant surface. Body text sits here." and its
35
+ two siblings) said nothing the labelled steps don't, and the ratio beside the
36
+ Canvas heading was primary's, measured but never named. The stacked steps
37
+ carry all three ratios now.
38
+
3
39
  ## 0.44.0 — Every text step derives to body contrast
4
40
 
5
41
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -104,8 +104,7 @@ export const SCALES: readonly Scale[] = [
104
104
  ];
105
105
 
106
106
  /** Guard band for derived near-black/near-white text: values stay between
107
- * ≈ #111 and ≈ #e8e8e8 unless pure black/white is opted into. The AA floor
108
- * beats the guard (see `recommendText.ts`). */
107
+ * ≈ #111 and ≈ #e8e8e8, the range most designers prefer over pure extremes. */
109
108
  export const BW_GUARD_MIN_L = 0.17;
110
109
  export const BW_GUARD_MAX_L = 0.93;
111
110
 
@@ -2,18 +2,14 @@
2
2
  import { oklchToHexClamped } from '../../core/palettes/oklch';
3
3
  import { palettesToVars } from '../../core/palettes/paletteDerivation';
4
4
  import { contrastRatio } from '../../core/palettes/contrast';
5
- import type { NeutralTextRecommendation } from '../../core/palettes/recommendText';
6
5
  import type { PaletteConfig } from '../../core/themes/themeTypes';
7
6
 
8
7
  interface Props {
9
8
  /** Every family's config, spec defaults filled in (palettesWithDefaults). */
10
9
  full: Record<string, PaletteConfig>;
11
- /** Suggested neutral text hierarchy — read out here, acted on from the
12
- * section's action pills. The story itself stays non-interactive. */
13
- rec: NeutralTextRecommendation;
14
10
  }
15
11
 
16
- let { full, rec }: Props = $props();
12
+ let { full }: Props = $props();
17
13
 
18
14
  // 60-30-10 as an area composition: the background surface is the dominant 60%
19
15
  // field (the page), and Brand/Accent/Special are the tones shown against it.
@@ -24,6 +20,8 @@
24
20
  { label: 'Special', ns: 'special', share: 2 },
25
21
  ] as const;
26
22
 
23
+ const TEXT_STEPS = ['primary', 'secondary', 'tertiary'] as const;
24
+
27
25
  const FUNCTIONAL = [
28
26
  { label: 'Info', ns: 'info' },
29
27
  { label: 'Success', ns: 'success' },
@@ -69,8 +67,6 @@
69
67
  return r === null ? null : `${(Math.floor(r * 10) / 10).toFixed(1)}:1`;
70
68
  }
71
69
 
72
- let bgRatio = $derived(ratioOnBg('--text-primary'));
73
-
74
70
  // Inverted is measured against the fill it sits on (primary), not the page:
75
71
  // the pill below IS that pairing, so the number describes what is shown. A
76
72
  // mid-lightness primary makes this pairing inherently low-contrast — the
@@ -86,19 +82,20 @@
86
82
 
87
83
  <div class="story" style={storyVars}>
88
84
  <div class="field">
89
- <div class="field-head">
90
- <span class="field-label">Canvas</span>
91
- {#if fmt(bgRatio)}<span class="ratio">{fmt(bgRatio)}</span>{/if}
85
+ <span class="field-label">Canvas</span>
86
+
87
+ <!-- Each step renders in its own token, so the word IS the sample and the
88
+ ratio beside it measures exactly what you are reading. -->
89
+ <div class="text-steps">
90
+ {#each TEXT_STEPS as step (step)}
91
+ {@const r = fmt(ratioOnBg(`--text-${step}`))}
92
+ <p class="text-step" style:color="var(--text-{step})">
93
+ {step}
94
+ {#if r}<span class="ratio">{r}</span>{/if}
95
+ </p>
96
+ {/each}
92
97
  </div>
93
- <p class="body">The dominant surface. Body text sits here.</p>
94
- <p class="secondary">
95
- Secondary text follows the same field.
96
- {#if fmt(ratioOnBg('--text-secondary'))}<span class="ratio">{fmt(ratioOnBg('--text-secondary'))}</span>{/if}
97
- </p>
98
- <p class="tertiary">
99
- Tertiary text steps back once more.
100
- {#if fmt(ratioOnBg('--text-tertiary'))}<span class="ratio">{fmt(ratioOnBg('--text-tertiary'))}</span>{/if}
101
- </p>
98
+
102
99
  <p class="inverted-row">
103
100
  <span class="inverted-pill">
104
101
  Inverted text flips onto primary.
@@ -106,18 +103,6 @@
106
103
  </span>
107
104
  </p>
108
105
 
109
- <!-- Readout only: applied from the section's actions, not from here. -->
110
- <div class="suggest-row" title="What “Even neutral steps” would set these three text steps to">
111
- <span class="suggest-label">Suggested steps</span>
112
- {#each rec.suggestions as s (s.step)}
113
- <span class="suggest-entry" class:dim={!s.differs}>
114
- <span class="fn-dot" style:--dot={s.suggested.hex}></span>
115
- <span class="suggest-step">{s.step}</span>
116
- <span class="ratio">{fmt(contrastRatio(s.suggested.hex, seedHex('Canvas')))}</span>
117
- </span>
118
- {/each}
119
- </div>
120
-
121
106
  <div class="functional-row">
122
107
  {#each FUNCTIONAL as fn (fn.ns)}
123
108
  {@const r = fmt(ratioOnBg(`--text-${fn.ns}`))}
@@ -172,36 +157,11 @@
172
157
  color: var(--text-primary);
173
158
  }
174
159
 
175
- .field-head {
176
- display: flex;
177
- align-items: baseline;
178
- justify-content: space-between;
179
- gap: var(--ui-space-8);
180
- }
181
-
182
160
  .field-label {
183
161
  font-size: var(--ui-font-size-md);
184
162
  font-weight: var(--ui-font-weight-semibold);
185
163
  }
186
164
 
187
- .body {
188
- margin: 0;
189
- font-size: var(--ui-font-size-md);
190
- color: var(--text-primary);
191
- }
192
-
193
- .secondary {
194
- margin: 0;
195
- font-size: var(--ui-font-size-md);
196
- color: var(--text-secondary);
197
- }
198
-
199
- .tertiary {
200
- margin: 0;
201
- font-size: var(--ui-font-size-md);
202
- color: var(--text-tertiary);
203
- }
204
-
205
165
  .inverted-row {
206
166
  margin: var(--ui-space-8) 0 0;
207
167
  }
@@ -219,31 +179,18 @@
219
179
  font-size: var(--ui-font-size-md);
220
180
  }
221
181
 
222
- .suggest-row {
182
+ .text-steps {
223
183
  display: flex;
224
- align-items: center;
225
- flex-wrap: wrap;
226
- gap: var(--ui-space-8);
227
- margin-top: var(--ui-space-12);
228
- }
229
-
230
- .suggest-label {
231
- font-size: var(--ui-font-size-md);
232
- font-weight: var(--ui-font-weight-semibold);
233
- opacity: 0.6;
234
- }
235
-
236
- .suggest-entry {
237
- display: flex;
238
- align-items: center;
184
+ flex-direction: column;
185
+ align-items: flex-start;
239
186
  gap: var(--ui-space-6);
240
187
  }
241
188
 
242
- .suggest-entry.dim {
243
- opacity: 0.45;
244
- }
245
-
246
- .suggest-step {
189
+ .text-step {
190
+ display: flex;
191
+ align-items: baseline;
192
+ gap: var(--ui-space-8);
193
+ margin: 0;
247
194
  font-size: var(--ui-font-size-md);
248
195
  }
249
196
 
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { oklchToHexClamped } from '../../core/palettes/oklch';
3
3
  import { PALETTE_SPECS } from '../../core/palettes/paletteDerivation';
4
- import { editorState, beginSliderGesture, transaction } from '../../core/store/editorStore';
4
+ import { editorState, beginSliderGesture } from '../../core/store/editorStore';
5
5
  import { applyHarmonyToAxes, modeActiveAxes, tintNeutralsFromAnchor, type HarmonyMode } from '../../core/palettes/colorHarmony';
6
6
  import ColorEditPanel from '../ColorEditPanel.svelte';
7
7
  import ColorWheel from './ColorWheel.svelte';
@@ -11,13 +11,10 @@
11
11
  import PaletteStepStrip from './PaletteStepStrip.svelte';
12
12
  import HarmonyAxesList from './HarmonyAxesList.svelte';
13
13
  import UIPillButton from '../UIPillButton.svelte';
14
- import { recommendNeutralText } from '../../core/palettes/recommendText';
15
14
  import {
16
15
  setBaseColor,
17
16
  setBaseColors,
18
17
  setAxisHues,
19
- applySolvedTextCurves,
20
- applySuggestedNeutralText,
21
18
  palettesWithDefaults,
22
19
  } from './paletteBaseColor';
23
20
  import { HARMONY_MODE_BUTTONS } from './harmonyModeIcons';
@@ -67,17 +64,7 @@
67
64
  if (Object.keys(patch).length) setBaseColors(patch, 'colors: tint neutrals from anchor');
68
65
  }
69
66
 
70
- function deriveAccessibleText() {
71
- transaction('derive accessible text', applySolvedTextCurves);
72
- }
73
-
74
- // Color Story model: the story is a pure readout, so its palette map and text
75
- // recommendation are owned here alongside the actions that write them.
76
- let useBlackWhite = $state(false);
77
67
  let fullPalettes = $derived(palettesWithDefaults($editorState.palettes));
78
- let textRec = $derived(recommendNeutralText(fullPalettes, undefined, useBlackWhite));
79
-
80
- let pureExtreme = $derived(textRec.scheme === 'light' ? 'pure black' : 'pure white');
81
68
 
82
69
  let swatches = $derived.by(() => {
83
70
  // A family bound to an inactive axis edits in dot mode, so it is not "on the wheel".
@@ -240,28 +227,7 @@
240
227
  <span class="eyebrow">Proportional preview</span>
241
228
  <h2 class="title">Color Story</h2>
242
229
  </header>
243
- <div class="story-actions">
244
- <UIPillButton
245
- icon="fa-universal-access"
246
- title="Every family: solve its Text lightness curve to the value that clears WCAG AA (4.5:1) against its own surfaces. Contrast-first, so secondary and tertiary can land close together. One undo."
247
- onclick={deriveAccessibleText}
248
- >Raise all text to AA</UIPillButton>
249
- <UIPillButton
250
- icon="fa-arrow-down-short-wide"
251
- disabled={!textRec.anyDiffers}
252
- title={textRec.anyDiffers
253
- ? 'Neutral text: keep primary where it is and step secondary and tertiary down in even lightness drops, floored at WCAG AA (4.5:1). Muted and disabled stay put. One undo.'
254
- : 'The neutral text steps already match the suggestion shown below.'}
255
- onclick={() => applySuggestedNeutralText(useBlackWhite)}
256
- >Even neutral steps</UIPillButton>
257
- <UIPillButton
258
- icon="fa-circle-half-stroke"
259
- ariaPressed={useBlackWhite}
260
- title={`Start the suggested steps from ${pureExtreme} instead of the softer extreme most designers prefer. Changes the suggestion shown below.`}
261
- onclick={() => (useBlackWhite = !useBlackWhite)}
262
- >Anchor at {pureExtreme}</UIPillButton>
263
- </div>
264
- <ColorStory full={fullPalettes} rec={textRec} />
230
+ <ColorStory full={fullPalettes} />
265
231
  </section>
266
232
 
267
233
  <section id="colors-axes" class="block">
@@ -465,12 +431,6 @@
465
431
  gap: var(--ui-space-8);
466
432
  }
467
433
 
468
- .story-actions {
469
- display: flex;
470
- flex-wrap: wrap;
471
- gap: var(--ui-space-8);
472
- }
473
-
474
434
  .swatch-rows {
475
435
  display: flex;
476
436
  flex-direction: column;
@@ -21,8 +21,6 @@ import { get } from 'svelte/store';
21
21
  import type { Oklch } from '../../core/palettes/oklch';
22
22
  import { AXIS_ROLES } 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';
@@ -193,14 +191,6 @@ export function setBaseColors(patch: Record<string, Oklch>, historyLabel = 'colo
193
191
  });
194
192
  }
195
193
 
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
194
  /** Read-only view of every family's config, seeding spec defaults for the ones a
205
195
  * loaded theme leaves unconfigured — previews and recommendations must not go
206
196
  * static on a family the theme never mentions. */
@@ -212,15 +202,3 @@ export function palettesWithDefaults(palettes: Record<string, PaletteConfig>): R
212
202
  }
213
203
  return map;
214
204
  }
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
- }
@@ -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
- }
@@ -1,254 +0,0 @@
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
- //
69
- // Muted held a large-text 3.0 floor until 0.44.0. Nothing in the name marked it
70
- // large-only, so consumers reached for it as a quiet body colour and shipped
71
- // text below AA; disabled is now the sole sub-AA step, which WCAG exempts.
72
- const NEUTRAL_PLAN: Record<string, StepPlan> = {
73
- primary: { ratio: 7.0, guaranteed: true, target: 'band' },
74
- secondary: { ratio: 4.5, guaranteed: true, target: 'band' },
75
- tertiary: { ratio: 4.5, guaranteed: true, target: 'default' },
76
- muted: { ratio: 4.5, guaranteed: true, target: 'default' },
77
- disabled: { ratio: 2.0, guaranteed: false, target: 'default' },
78
- };
79
-
80
- // Chromatic families: primary must clear the worse of surface-default / page-bg
81
- // (chroma decay allowed), secondary the default surface; the rest grade toward
82
- // the surface (non-AA).
83
- const CHROMATIC_PLAN: Record<string, StepPlan> = {
84
- primary: { ratio: 4.5, guaranteed: true, target: 'worst' },
85
- secondary: { ratio: 4.5, guaranteed: true, target: 'default' },
86
- tertiary: { ratio: 3.0, guaranteed: false, target: 'default' },
87
- muted: { ratio: 2.2, guaranteed: false, target: 'default' },
88
- disabled: { ratio: 1.6, guaranteed: false, target: 'default' },
89
- };
90
-
91
- interface Surface {
92
- var: string;
93
- l: number;
94
- c: number;
95
- h: number;
96
- }
97
-
98
- function colorSurface(varName: string, value: DerivedValue | undefined): Surface | null {
99
- return value?.kind === 'color' ? { var: varName, l: value.l, c: value.c, h: value.h } : null;
100
- }
101
-
102
- // The solver terminates in sRGB (WCAG luminance is sRGB-defined); convert the
103
- // numeric surface to hex only at this boundary. Clamp so contrast math sees the
104
- // same chroma-reduced projection the CSS ships (unclamped override surfaces).
105
- function surfaceHex(s: Surface): string {
106
- return oklchToHexClamped(s.l, s.c, s.h);
107
- }
108
-
109
- // The hardest surface to clear: for lighter text the highest-luminance surface,
110
- // for darker text the lowest.
111
- function pickAdverse(surfaces: Surface[], direction: 'lighter' | 'darker'): Surface {
112
- let best = surfaces[0];
113
- let bestL = best.l;
114
- for (const s of surfaces.slice(1)) {
115
- if (direction === 'lighter' ? s.l > bestL : s.l < bestL) {
116
- best = s;
117
- bestL = s.l;
118
- }
119
- }
120
- return best;
121
- }
122
-
123
- export function solveTextCurves(
124
- palettes: Record<string, PaletteConfig>,
125
- scheme?: SchemeDirection,
126
- ): TextSolveResult {
127
- const values = palettesToValues(palettes);
128
- const surfaceDefault = colorSurface('--surface-neutral', values['--surface-neutral'])!;
129
- const rawPageBg = values['--page-bg'];
130
- const pageBg: Surface = rawPageBg?.kind === 'color'
131
- ? colorSurface('--page-bg', rawPageBg)!
132
- : surfaceDefault;
133
- const band: Surface[] = NEUTRAL_BAND
134
- .map((v) => colorSurface(v, values[v]))
135
- .filter((s): s is Surface => s !== null);
136
-
137
- const resolvedScheme: SchemeDirection =
138
- scheme ?? (pageBg.l >= 0.5 ? 'light' : 'dark');
139
- const direction: 'lighter' | 'darker' = resolvedScheme === 'light' ? 'darker' : 'lighter';
140
-
141
- const bandAdverse = pickAdverse(band, direction);
142
- const worstAdverse = pickAdverse([surfaceDefault, pageBg], direction);
143
-
144
- const patches: Record<string, Pick<PaletteConfig, 'scaleCurves'>> = {};
145
- const report: ContrastPairing[] = [];
146
-
147
- for (const spec of PALETTE_SPECS) {
148
- const config = palettes[spec.label];
149
- if (!config) continue;
150
-
151
- const seed = config.baseColor;
152
- const seedL = Math.max(seed.l, 1e-4);
153
- const baseC = Math.max(seed.c, 1e-9);
154
- const baseH = seed.h;
155
- const lMax = Math.min(1, 2 * seedL);
156
-
157
- const curveOffset = config.curveOffset ?? {};
158
- const lOff = curveOffset['Text-lightness'] ?? 0;
159
- const sOff = curveOffset['Text-saturation'] ?? 0;
160
- const satCurve = config.scaleCurves?.Text?.saturation ?? defaultScaleCurves.Text.saturation();
161
-
162
- const isNeutral = spec.label === 'Neutral';
163
- const plan = isNeutral ? NEUTRAL_PLAN : CHROMATIC_PLAN;
164
-
165
- const lightAnchors: CurveAnchor[] = [];
166
- const satAnchors: CurveAnchor[] = [];
167
-
168
- for (const step of TEXT_STEPS) {
169
- const stepPlan = plan[step.name];
170
- const against =
171
- stepPlan.target === 'band' ? bandAdverse :
172
- stepPlan.target === 'worst' ? worstAdverse : surfaceDefault;
173
-
174
- const origSatMul = Math.max(0, Math.min(2, (sampleCurve(satCurve, step.x) + sOff) / 100));
175
- const stepChroma = baseC * origSatMul;
176
-
177
- const solved = findLForContrast({
178
- against: surfaceHex(against),
179
- ratio: stepPlan.ratio,
180
- direction,
181
- c: stepChroma,
182
- h: baseH,
183
- lMin: 0,
184
- lMax,
185
- });
186
-
187
- const lightY = clamp(0, 200, (100 * solved.l) / seedL - lOff);
188
- const satY = clamp(0, 200, (100 * solved.c) / baseC - sOff);
189
- lightAnchors.push(makeAnchor(step.x, lightY));
190
- satAnchors.push(makeAnchor(step.x, satY));
191
- }
192
-
193
- patches[spec.label] = {
194
- scaleCurves: { Text: { lightness: lightAnchors, saturation: satAnchors } },
195
- };
196
- }
197
-
198
- // Report against the actually-derived text hexes (post-round-trip verification
199
- // at the solver level, mirroring Global invariant 8).
200
- const derivedValues = palettesToValues(applyPatches(palettes, patches));
201
- for (const spec of PALETTE_SPECS) {
202
- if (!patches[spec.label]) continue;
203
- const isNeutral = spec.label === 'Neutral';
204
- const plan = isNeutral ? NEUTRAL_PLAN : CHROMATIC_PLAN;
205
- for (const step of TEXT_STEPS) {
206
- const stepPlan = plan[step.name];
207
- const textVar = scaleToCssVar('Text', step.name, spec.cssNamespace);
208
- if (!textVar) continue;
209
- const against =
210
- stepPlan.target === 'band' ? bandAdverse :
211
- stepPlan.target === 'worst' ? worstAdverse : surfaceDefault;
212
- const textValue = derivedValues[textVar];
213
- const againstValue = derivedValues[against.var];
214
- const againstHex = againstValue?.kind === 'color' ? serializeDerivedValue(againstValue) : surfaceHex(against);
215
- const achieved = textValue?.kind === 'color' ? contrastRatio(serializeDerivedValue(textValue), againstHex) : 1;
216
- report.push({
217
- family: spec.label,
218
- step: step.name,
219
- textVar,
220
- againstVar: against.var,
221
- targetRatio: stepPlan.ratio,
222
- achievedRatio: achieved,
223
- meets: achieved >= minGuarantee(stepPlan),
224
- guaranteed: stepPlan.guaranteed,
225
- });
226
- }
227
- }
228
-
229
- return { patches, report };
230
- }
231
-
232
- // Every guaranteed step clears the body-text AA floor. Graded steps floor at 1
233
- // (informational).
234
- function minGuarantee(plan: StepPlan): number {
235
- return plan.guaranteed ? 4.5 : 1;
236
- }
237
-
238
- function clamp(lo: number, hi: number, v: number): number {
239
- return Math.max(lo, Math.min(hi, v));
240
- }
241
-
242
- function applyPatches(
243
- palettes: Record<string, PaletteConfig>,
244
- patches: Record<string, Pick<PaletteConfig, 'scaleCurves'>>,
245
- ): Record<string, PaletteConfig> {
246
- const out: Record<string, PaletteConfig> = {};
247
- for (const [label, config] of Object.entries(palettes)) {
248
- const patch = patches[label];
249
- out[label] = patch
250
- ? { ...config, scaleCurves: { ...config.scaleCurves, ...patch.scaleCurves } }
251
- : config;
252
- }
253
- return out;
254
- }