@motion-proto/live-tokens 0.43.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 +91 -0
- package/package.json +6 -1
- package/src/editor/core/palettes/paletteDerivation.ts +1 -2
- package/src/editor/core/routing/ownedRoutes.ts +1 -0
- package/src/editor/overlay/LiveEditorOverlay.svelte +9 -4
- package/src/editor/overlay/LiveTokensRouter.svelte +29 -17
- package/src/editor/pages/ColorsPage.svelte +117 -0
- package/src/editor/pages/ColorsPage.svelte.d.ts +2 -0
- package/src/editor/ui/EditorViewSwitcher.svelte +12 -8
- package/src/editor/ui/UIPillButton.svelte +15 -0
- package/src/editor/ui/colors/ColorStory.svelte +51 -159
- package/src/editor/ui/colors/ColorWheel.svelte +25 -7
- package/src/editor/ui/colors/ColorsTab.svelte +31 -38
- package/src/editor/ui/colors/HarmonyAxesList.svelte +32 -4
- package/src/editor/ui/colors/paletteBaseColor.ts +9 -19
- package/src/editor/core/palettes/recommendText.ts +0 -216
- package/src/editor/core/palettes/solveTextContrast.ts +0 -251
|
@@ -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,22 +191,14 @@ export function setBaseColors(patch: Record<string, Oklch>, historyLabel = 'colo
|
|
|
193
191
|
});
|
|
194
192
|
}
|
|
195
193
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
194
|
+
/** Read-only view of every family's config, seeding spec defaults for the ones a
|
|
195
|
+
* loaded theme leaves unconfigured — previews and recommendations must not go
|
|
196
|
+
* static on a family the theme never mentions. */
|
|
197
|
+
export function palettesWithDefaults(palettes: Record<string, PaletteConfig>): Record<string, PaletteConfig> {
|
|
198
|
+
const map: Record<string, PaletteConfig> = {};
|
|
199
|
+
for (const spec of PALETTE_SPECS) {
|
|
200
|
+
map[spec.label] =
|
|
201
|
+
palettes[spec.label] ?? defaultPaletteConfig({ baseColor: spec.initialColor, neutral: spec.neutral ?? false });
|
|
201
202
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
/** Apply the Color Story's suggested neutral text hierarchy (primary anchor,
|
|
205
|
-
* two even L drops, AA floors) as the Neutral family's Text curve. */
|
|
206
|
-
export function applySuggestedNeutralText(useBlackWhite = false): void {
|
|
207
|
-
mutate('colors: suggested text hierarchy', (s) => {
|
|
208
|
-
ensureConfig(s, 'Neutral');
|
|
209
|
-
const rec = recommendNeutralText(s.palettes, undefined, useBlackWhite);
|
|
210
|
-
if (!rec.patch) return;
|
|
211
|
-
const cfg = s.palettes['Neutral'];
|
|
212
|
-
cfg.scaleCurves = { ...cfg.scaleCurves, Text: rec.patch };
|
|
213
|
-
});
|
|
203
|
+
return map;
|
|
214
204
|
}
|
|
@@ -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,251 +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
|
-
const NEUTRAL_PLAN: Record<string, StepPlan> = {
|
|
69
|
-
primary: { ratio: 7.0, guaranteed: true, target: 'band' },
|
|
70
|
-
secondary: { ratio: 4.5, guaranteed: true, target: 'band' },
|
|
71
|
-
tertiary: { ratio: 4.5, guaranteed: true, target: 'default' },
|
|
72
|
-
muted: { ratio: 3.0, guaranteed: true, target: 'default' },
|
|
73
|
-
disabled: { ratio: 2.0, guaranteed: false, target: 'default' },
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
// Chromatic families: primary must clear the worse of surface-default / page-bg
|
|
77
|
-
// (chroma decay allowed), secondary the default surface; the rest grade toward
|
|
78
|
-
// the surface (non-AA).
|
|
79
|
-
const CHROMATIC_PLAN: Record<string, StepPlan> = {
|
|
80
|
-
primary: { ratio: 4.5, guaranteed: true, target: 'worst' },
|
|
81
|
-
secondary: { ratio: 4.5, guaranteed: true, target: 'default' },
|
|
82
|
-
tertiary: { ratio: 3.0, guaranteed: false, target: 'default' },
|
|
83
|
-
muted: { ratio: 2.2, guaranteed: false, target: 'default' },
|
|
84
|
-
disabled: { ratio: 1.6, guaranteed: false, target: 'default' },
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
interface Surface {
|
|
88
|
-
var: string;
|
|
89
|
-
l: number;
|
|
90
|
-
c: number;
|
|
91
|
-
h: number;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function colorSurface(varName: string, value: DerivedValue | undefined): Surface | null {
|
|
95
|
-
return value?.kind === 'color' ? { var: varName, l: value.l, c: value.c, h: value.h } : null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// The solver terminates in sRGB (WCAG luminance is sRGB-defined); convert the
|
|
99
|
-
// numeric surface to hex only at this boundary. Clamp so contrast math sees the
|
|
100
|
-
// same chroma-reduced projection the CSS ships (unclamped override surfaces).
|
|
101
|
-
function surfaceHex(s: Surface): string {
|
|
102
|
-
return oklchToHexClamped(s.l, s.c, s.h);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// The hardest surface to clear: for lighter text the highest-luminance surface,
|
|
106
|
-
// for darker text the lowest.
|
|
107
|
-
function pickAdverse(surfaces: Surface[], direction: 'lighter' | 'darker'): Surface {
|
|
108
|
-
let best = surfaces[0];
|
|
109
|
-
let bestL = best.l;
|
|
110
|
-
for (const s of surfaces.slice(1)) {
|
|
111
|
-
if (direction === 'lighter' ? s.l > bestL : s.l < bestL) {
|
|
112
|
-
best = s;
|
|
113
|
-
bestL = s.l;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return best;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function solveTextCurves(
|
|
120
|
-
palettes: Record<string, PaletteConfig>,
|
|
121
|
-
scheme?: SchemeDirection,
|
|
122
|
-
): TextSolveResult {
|
|
123
|
-
const values = palettesToValues(palettes);
|
|
124
|
-
const surfaceDefault = colorSurface('--surface-neutral', values['--surface-neutral'])!;
|
|
125
|
-
const rawPageBg = values['--page-bg'];
|
|
126
|
-
const pageBg: Surface = rawPageBg?.kind === 'color'
|
|
127
|
-
? colorSurface('--page-bg', rawPageBg)!
|
|
128
|
-
: surfaceDefault;
|
|
129
|
-
const band: Surface[] = NEUTRAL_BAND
|
|
130
|
-
.map((v) => colorSurface(v, values[v]))
|
|
131
|
-
.filter((s): s is Surface => s !== null);
|
|
132
|
-
|
|
133
|
-
const resolvedScheme: SchemeDirection =
|
|
134
|
-
scheme ?? (pageBg.l >= 0.5 ? 'light' : 'dark');
|
|
135
|
-
const direction: 'lighter' | 'darker' = resolvedScheme === 'light' ? 'darker' : 'lighter';
|
|
136
|
-
|
|
137
|
-
const bandAdverse = pickAdverse(band, direction);
|
|
138
|
-
const worstAdverse = pickAdverse([surfaceDefault, pageBg], direction);
|
|
139
|
-
|
|
140
|
-
const patches: Record<string, Pick<PaletteConfig, 'scaleCurves'>> = {};
|
|
141
|
-
const report: ContrastPairing[] = [];
|
|
142
|
-
|
|
143
|
-
for (const spec of PALETTE_SPECS) {
|
|
144
|
-
const config = palettes[spec.label];
|
|
145
|
-
if (!config) continue;
|
|
146
|
-
|
|
147
|
-
const seed = config.baseColor;
|
|
148
|
-
const seedL = Math.max(seed.l, 1e-4);
|
|
149
|
-
const baseC = Math.max(seed.c, 1e-9);
|
|
150
|
-
const baseH = seed.h;
|
|
151
|
-
const lMax = Math.min(1, 2 * seedL);
|
|
152
|
-
|
|
153
|
-
const curveOffset = config.curveOffset ?? {};
|
|
154
|
-
const lOff = curveOffset['Text-lightness'] ?? 0;
|
|
155
|
-
const sOff = curveOffset['Text-saturation'] ?? 0;
|
|
156
|
-
const satCurve = config.scaleCurves?.Text?.saturation ?? defaultScaleCurves.Text.saturation();
|
|
157
|
-
|
|
158
|
-
const isNeutral = spec.label === 'Neutral';
|
|
159
|
-
const plan = isNeutral ? NEUTRAL_PLAN : CHROMATIC_PLAN;
|
|
160
|
-
|
|
161
|
-
const lightAnchors: CurveAnchor[] = [];
|
|
162
|
-
const satAnchors: CurveAnchor[] = [];
|
|
163
|
-
|
|
164
|
-
for (const step of TEXT_STEPS) {
|
|
165
|
-
const stepPlan = plan[step.name];
|
|
166
|
-
const against =
|
|
167
|
-
stepPlan.target === 'band' ? bandAdverse :
|
|
168
|
-
stepPlan.target === 'worst' ? worstAdverse : surfaceDefault;
|
|
169
|
-
|
|
170
|
-
const origSatMul = Math.max(0, Math.min(2, (sampleCurve(satCurve, step.x) + sOff) / 100));
|
|
171
|
-
const stepChroma = baseC * origSatMul;
|
|
172
|
-
|
|
173
|
-
const solved = findLForContrast({
|
|
174
|
-
against: surfaceHex(against),
|
|
175
|
-
ratio: stepPlan.ratio,
|
|
176
|
-
direction,
|
|
177
|
-
c: stepChroma,
|
|
178
|
-
h: baseH,
|
|
179
|
-
lMin: 0,
|
|
180
|
-
lMax,
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
const lightY = clamp(0, 200, (100 * solved.l) / seedL - lOff);
|
|
184
|
-
const satY = clamp(0, 200, (100 * solved.c) / baseC - sOff);
|
|
185
|
-
lightAnchors.push(makeAnchor(step.x, lightY));
|
|
186
|
-
satAnchors.push(makeAnchor(step.x, satY));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
patches[spec.label] = {
|
|
190
|
-
scaleCurves: { Text: { lightness: lightAnchors, saturation: satAnchors } },
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Report against the actually-derived text hexes (post-round-trip verification
|
|
195
|
-
// at the solver level, mirroring Global invariant 8).
|
|
196
|
-
const derivedValues = palettesToValues(applyPatches(palettes, patches));
|
|
197
|
-
for (const spec of PALETTE_SPECS) {
|
|
198
|
-
if (!patches[spec.label]) continue;
|
|
199
|
-
const isNeutral = spec.label === 'Neutral';
|
|
200
|
-
const plan = isNeutral ? NEUTRAL_PLAN : CHROMATIC_PLAN;
|
|
201
|
-
for (const step of TEXT_STEPS) {
|
|
202
|
-
const stepPlan = plan[step.name];
|
|
203
|
-
const textVar = scaleToCssVar('Text', step.name, spec.cssNamespace);
|
|
204
|
-
if (!textVar) continue;
|
|
205
|
-
const against =
|
|
206
|
-
stepPlan.target === 'band' ? bandAdverse :
|
|
207
|
-
stepPlan.target === 'worst' ? worstAdverse : surfaceDefault;
|
|
208
|
-
const textValue = derivedValues[textVar];
|
|
209
|
-
const againstValue = derivedValues[against.var];
|
|
210
|
-
const againstHex = againstValue?.kind === 'color' ? serializeDerivedValue(againstValue) : surfaceHex(against);
|
|
211
|
-
const achieved = textValue?.kind === 'color' ? contrastRatio(serializeDerivedValue(textValue), againstHex) : 1;
|
|
212
|
-
report.push({
|
|
213
|
-
family: spec.label,
|
|
214
|
-
step: step.name,
|
|
215
|
-
textVar,
|
|
216
|
-
againstVar: against.var,
|
|
217
|
-
targetRatio: stepPlan.ratio,
|
|
218
|
-
achievedRatio: achieved,
|
|
219
|
-
meets: achieved >= minGuarantee(step.name, stepPlan),
|
|
220
|
-
guaranteed: stepPlan.guaranteed,
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return { patches, report };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// The AA floor a guaranteed step must clear (primary/secondary/tertiary = body
|
|
229
|
-
// 4.5; neutral muted = large 3.0). Graded steps floor at 1 (informational).
|
|
230
|
-
function minGuarantee(stepName: string, plan: StepPlan): number {
|
|
231
|
-
if (!plan.guaranteed) return 1;
|
|
232
|
-
return stepName === 'muted' ? 3.0 : 4.5;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function clamp(lo: number, hi: number, v: number): number {
|
|
236
|
-
return Math.max(lo, Math.min(hi, v));
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function applyPatches(
|
|
240
|
-
palettes: Record<string, PaletteConfig>,
|
|
241
|
-
patches: Record<string, Pick<PaletteConfig, 'scaleCurves'>>,
|
|
242
|
-
): Record<string, PaletteConfig> {
|
|
243
|
-
const out: Record<string, PaletteConfig> = {};
|
|
244
|
-
for (const [label, config] of Object.entries(palettes)) {
|
|
245
|
-
const patch = patches[label];
|
|
246
|
-
out[label] = patch
|
|
247
|
-
? { ...config, scaleCurves: { ...config.scaleCurves, ...patch.scaleCurves } }
|
|
248
|
-
: config;
|
|
249
|
-
}
|
|
250
|
-
return out;
|
|
251
|
-
}
|