@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 +36 -0
- package/package.json +1 -1
- package/src/editor/core/palettes/paletteDerivation.ts +1 -2
- package/src/editor/ui/colors/ColorStory.svelte +24 -77
- package/src/editor/ui/colors/ColorsTab.svelte +2 -42
- package/src/editor/ui/colors/paletteBaseColor.ts +0 -22
- package/src/editor/core/palettes/recommendText.ts +0 -216
- package/src/editor/core/palettes/solveTextContrast.ts +0 -254
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
|
@@ -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
|
|
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
|
|
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
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
182
|
+
.text-steps {
|
|
223
183
|
display: flex;
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
}
|