@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.
- package/CHANGELOG.md +103 -0
- package/dist-plugin/index.cjs +1 -1
- package/dist-plugin/index.js +1 -1
- package/package.json +1 -1
- package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +0 -1
- package/src/editor/component-editor/scaffolding/ComponentFileMenu.svelte +1 -1
- package/src/editor/core/palettes/colorHarmony.ts +35 -6
- package/src/editor/core/palettes/paletteDerivation.ts +2 -3
- package/src/editor/core/store/editorTypes.ts +2 -2
- package/src/editor/docs/CodeBlock.svelte +0 -2
- package/src/editor/docs/Docs.svelte +0 -3
- package/src/editor/pages/ComponentEditorPage.svelte +3 -5
- package/src/editor/pages/EditorShell.svelte +3 -5
- package/src/editor/ui/ColorEditPanel.svelte +19 -22
- package/src/editor/ui/EditorViewSwitcher.svelte +2 -3
- package/src/editor/ui/FileLoadList.svelte +7 -9
- package/src/editor/ui/ManifestFileManager.svelte +1 -3
- package/src/editor/ui/ProjectFontsSection.svelte +0 -4
- package/src/editor/ui/SurfacesTab.svelte +3 -3
- package/src/editor/ui/TextStylesSection.svelte +6 -8
- package/src/editor/ui/ThemeFileManager.svelte +1 -3
- package/src/editor/ui/UIEasingSelector.svelte +0 -2
- package/src/editor/ui/UIFontFamilySelector.svelte +9 -3
- package/src/editor/ui/UIMenuButton.svelte +230 -0
- package/src/editor/ui/UIPaletteSelector.svelte +0 -1
- package/src/editor/ui/VariablesTab.svelte +3 -4
- package/src/editor/ui/colors/AxisNumeral.svelte +70 -0
- package/src/editor/ui/colors/ColorReadouts.svelte +0 -2
- package/src/editor/ui/colors/ColorStory.svelte +24 -77
- package/src/editor/ui/colors/ColorWheel.svelte +48 -29
- package/src/editor/ui/colors/ColorsTab.svelte +361 -212
- package/src/editor/ui/colors/HarmonyAxesList.svelte +264 -141
- package/src/editor/ui/colors/LightnessBar.svelte +0 -2
- package/src/editor/ui/colors/harmonyDrag.ts +10 -0
- package/src/editor/ui/colors/harmonyModeIcons.ts +5 -0
- package/src/editor/ui/colors/paletteBaseColor.ts +25 -39
- package/src/live-tokens/data/tokens.generated.css +28 -28
- package/src/system/components/ImageLightbox.svelte +0 -2
- package/src/system/components/SectionDivider.svelte +4 -4
- package/src/editor/core/palettes/recommendText.ts +0 -216
- 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 {
|
|
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: ${
|
|
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
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
// Source was another axis: the occupant trades into it
|
|
162
|
-
// Source was
|
|
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)
|
|
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:
|
|
177
|
+
mutate(`colors: unassign ${family}`, (s) => {
|
|
175
178
|
s.harmonyAxes[idx].family = null;
|
|
176
179
|
});
|
|
177
180
|
}
|
|
178
181
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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 +
|
|
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
|
-
}
|