@rafters/color-utils 0.4.3 → 0.4.5
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/README.md +10 -0
- package/package.json +2 -2
- package/src/accessibility.ts +56 -44
- package/src/analysis.ts +7 -4
- package/src/builder.ts +42 -7
- package/src/gamut.ts +103 -0
- package/src/harmony.ts +17 -9
- package/src/index.ts +5 -0
- package/src/semantic.ts +202 -29
package/README.md
CHANGED
|
@@ -77,6 +77,16 @@ everywhere, `p3` needs a wide-gamut screen, `out` is displayable nowhere.
|
|
|
77
77
|
`{ color, tier }`: the snapped OKLCH and the tier it landed in.
|
|
78
78
|
- `computeGamutBoundaries(hue, steps?)` — the boundary points a picker draws
|
|
79
79
|
against.
|
|
80
|
+
- `toGamutCeiling(oklch, ceiling)` — map one color into `'srgb'` or `'p3'`;
|
|
81
|
+
unchanged when already inside.
|
|
82
|
+
- `mapScaleToGamut(scale, ceiling)` — map every step of a scale into the
|
|
83
|
+
ceiling, keeping lightness strictly falling.
|
|
84
|
+
- `ALLOW_P3` — whether a family may keep Display P3 steps; `false`.
|
|
85
|
+
|
|
86
|
+
`buildColorValue` builds every family inside a ceiling its seed sets: `'p3'`
|
|
87
|
+
only when the seed is a P3 color and `ALLOW_P3` is on, else `'srgb'`. The
|
|
88
|
+
result records each step's tier in `gamut`, and `seedAdjusted` whenever the
|
|
89
|
+
seed was moved (by the lightness bounds or into the gamut).
|
|
80
90
|
|
|
81
91
|
Boundary checks run through `colorjs.io`, so sRGB and P3 agree with what the
|
|
82
92
|
browser will render.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rafters/color-utils",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "OKLCH color manipulation utilities for Rafters design system",
|
|
5
5
|
"homepage": "https://rafters.studio",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"apca-w3": "^0.1.9",
|
|
26
26
|
"colorjs.io": "^0.5.2",
|
|
27
27
|
"zod": "^4.1.12",
|
|
28
|
-
"@rafters/shared": "0.4.
|
|
28
|
+
"@rafters/shared": "0.4.5"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "24.10.0",
|
package/src/accessibility.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Accessibility contrast calculation and compliance checking functions
|
|
2
|
+
* Accessibility contrast calculation and compliance checking functions.
|
|
3
|
+
*
|
|
4
|
+
* Every colour is measured as stored, in the gamut it is stored in (sRGB or
|
|
5
|
+
* Display P3), never through a clipped sRGB copy.
|
|
6
|
+
*
|
|
7
|
+
* - WCAG 2.2 contrast: relative luminance is the colour's CIE XYZ (D65) Y
|
|
8
|
+
* (colorjs.io 'xyz-d65'). Y exists for any colour, so no gamut mapping is
|
|
9
|
+
* needed, and an sRGB colour's ratio matches the sRGB-channel formula.
|
|
10
|
+
* - APCA contrast (informational): luminance from APCA's own per-space
|
|
11
|
+
* function -- sRGBtoY for an sRGB colour (0-255 channels), displayP3toY for
|
|
12
|
+
* a P3 colour (0-1 channels) -- chosen by getGamutTier. A colour beyond P3
|
|
13
|
+
* is first mapped into P3 with toGamutCeiling(colour, 'p3').
|
|
3
14
|
*/
|
|
4
15
|
|
|
5
16
|
import type { ColorValue, OKLCH } from '@rafters/shared';
|
|
6
|
-
import { APCAcontrast, sRGBtoY } from 'apca-w3';
|
|
17
|
+
import { APCAcontrast, displayP3toY, sRGBtoY } from 'apca-w3';
|
|
7
18
|
import Color from 'colorjs.io';
|
|
8
19
|
import { z } from 'zod';
|
|
20
|
+
import { getGamutTier, toGamutCeiling } from './gamut.js';
|
|
9
21
|
|
|
10
22
|
/** White reference color for contrast calculations. */
|
|
11
23
|
export const CONTRAST_WHITE: OKLCH = { l: 1, c: 0, h: 0, alpha: 1 };
|
|
@@ -13,7 +25,7 @@ export const CONTRAST_WHITE: OKLCH = { l: 1, c: 0, h: 0, alpha: 1 };
|
|
|
13
25
|
/** Black reference color for contrast calculations. */
|
|
14
26
|
export const CONTRAST_BLACK: OKLCH = { l: 0, c: 0, h: 0, alpha: 1 };
|
|
15
27
|
|
|
16
|
-
/** WCAG 2.
|
|
28
|
+
/** WCAG 2.2 minimum contrast ratios, by conformance level and text size. */
|
|
17
29
|
const WCAG_CONTRAST_THRESHOLDS = {
|
|
18
30
|
AA: { normal: 4.5, large: 3.0 },
|
|
19
31
|
AAA: { normal: 7.0, large: 4.5 },
|
|
@@ -50,29 +62,15 @@ export function minFontSizeForAPCA(contrast: number): number {
|
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
/**
|
|
53
|
-
*
|
|
65
|
+
* Relative luminance for WCAG 2.2: the colour's CIE XYZ (D65) Y, read from
|
|
66
|
+
* the colour itself with no sRGB step, so a P3 colour is measured as stored.
|
|
54
67
|
*/
|
|
55
68
|
function getRelativeLuminance(oklch: OKLCH): number {
|
|
56
|
-
|
|
57
|
-
const srgb = color.to('srgb');
|
|
58
|
-
|
|
59
|
-
// WCAG formula for relative luminance
|
|
60
|
-
const toLinear = (val: number): number => {
|
|
61
|
-
if (val <= 0.03928) {
|
|
62
|
-
return val / 12.92;
|
|
63
|
-
}
|
|
64
|
-
return ((val + 0.055) / 1.055) ** 2.4;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const r = toLinear(srgb.r);
|
|
68
|
-
const g = toLinear(srgb.g);
|
|
69
|
-
const b = toLinear(srgb.b);
|
|
70
|
-
|
|
71
|
-
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
69
|
+
return new Color('oklch', [oklch.l, oklch.c, oklch.h]).to('xyz-d65').coords[1] ?? 0;
|
|
72
70
|
}
|
|
73
71
|
|
|
74
72
|
/**
|
|
75
|
-
* Calculate WCAG 2.
|
|
73
|
+
* Calculate WCAG 2.2 contrast ratio between two colors
|
|
76
74
|
*/
|
|
77
75
|
export function calculateWCAGContrast(foreground: OKLCH, background: OKLCH): number {
|
|
78
76
|
const l1 = getRelativeLuminance(foreground);
|
|
@@ -85,31 +83,37 @@ export function calculateWCAGContrast(foreground: OKLCH, background: OKLCH): num
|
|
|
85
83
|
}
|
|
86
84
|
|
|
87
85
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
86
|
+
* A channel of an in-gamut colour, guarded only against float noise: colorjs
|
|
87
|
+
* can return a value a hair outside [0, 1] for a colour inside the gamut, and
|
|
88
|
+
* apca-w3 raises each channel to 2.4, which is NaN for a negative base. This
|
|
89
|
+
* is not a gamut clip -- the colour is already inside the space measured.
|
|
90
90
|
*/
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const backgroundColorObj = new Color('oklch', [background.l, background.c, background.h]);
|
|
95
|
-
|
|
96
|
-
const fgRgb = foregroundColor.to('srgb');
|
|
97
|
-
const bgRgb = backgroundColorObj.to('srgb');
|
|
98
|
-
|
|
99
|
-
// Convert to 0-255 RGB values for apca-w3
|
|
100
|
-
const fgR = Math.max(0, Math.min(255, (fgRgb.r || 0) * 255));
|
|
101
|
-
const fgG = Math.max(0, Math.min(255, (fgRgb.g || 0) * 255));
|
|
102
|
-
const fgB = Math.max(0, Math.min(255, (fgRgb.b || 0) * 255));
|
|
103
|
-
const bgR = Math.max(0, Math.min(255, (bgRgb.r || 0) * 255));
|
|
104
|
-
const bgG = Math.max(0, Math.min(255, (bgRgb.g || 0) * 255));
|
|
105
|
-
const bgB = Math.max(0, Math.min(255, (bgRgb.b || 0) * 255));
|
|
91
|
+
function inGamutChannel(value: number | null | undefined): number {
|
|
92
|
+
return Math.min(1, Math.max(0, value ?? 0));
|
|
93
|
+
}
|
|
106
94
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
95
|
+
/**
|
|
96
|
+
* APCA luminance of a colour in the gamut it is stored in: sRGBtoY (0-255
|
|
97
|
+
* channels) for an sRGB colour, displayP3toY (0-1 channels) for a P3 colour.
|
|
98
|
+
* A colour beyond P3 is mapped into P3 with toGamutCeiling first.
|
|
99
|
+
*/
|
|
100
|
+
function getAPCALuminance(oklch: OKLCH): number {
|
|
101
|
+
const tier = getGamutTier(oklch);
|
|
102
|
+
if (tier === 'srgb') {
|
|
103
|
+
const [r, g, b] = new Color('oklch', [oklch.l, oklch.c, oklch.h]).to('srgb').coords;
|
|
104
|
+
return sRGBtoY([inGamutChannel(r) * 255, inGamutChannel(g) * 255, inGamutChannel(b) * 255]);
|
|
105
|
+
}
|
|
106
|
+
const inP3 = tier === 'out' ? toGamutCeiling(oklch, 'p3') : oklch;
|
|
107
|
+
const [r, g, b] = new Color('oklch', [inP3.l, inP3.c, inP3.h]).to('p3').coords;
|
|
108
|
+
return displayP3toY([inGamutChannel(r), inGamutChannel(g), inGamutChannel(b)]);
|
|
109
|
+
}
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Calculate APCA contrast (informational) via the apca-w3 library, from
|
|
113
|
+
* each colour's luminance in the gamut it is stored in.
|
|
114
|
+
*/
|
|
115
|
+
export function calculateAPCAContrast(foreground: OKLCH, background: OKLCH): number {
|
|
116
|
+
const result = APCAcontrast(getAPCALuminance(foreground), getAPCALuminance(background));
|
|
113
117
|
return typeof result === 'number' ? result : 0;
|
|
114
118
|
}
|
|
115
119
|
|
|
@@ -229,6 +233,14 @@ export function generateAccessibilityMetadata(scale: OKLCH[]): AccessibilityMeta
|
|
|
229
233
|
return metadata;
|
|
230
234
|
}
|
|
231
235
|
|
|
236
|
+
/**
|
|
237
|
+
* The step a family's on-white/on-black figures are measured on: the 600
|
|
238
|
+
* step, where the seed lands (after any seed adjustment). buildColorValue and
|
|
239
|
+
* rebakeAccessibility both read it, so re-baking an unchanged family is a
|
|
240
|
+
* no-op.
|
|
241
|
+
*/
|
|
242
|
+
export const REFERENCE_STEP_INDEX = 6;
|
|
243
|
+
|
|
232
244
|
/**
|
|
233
245
|
* Assemble the WCAG half of ColorValue.accessibility for a scale: pair
|
|
234
246
|
* matrices plus on-white/on-black ratios and pass flags for a reference
|
|
@@ -271,7 +283,7 @@ export function assembleWcagAccessibility(
|
|
|
271
283
|
*/
|
|
272
284
|
export function rebakeAccessibility(value: ColorValue): ColorValue {
|
|
273
285
|
if (!('scale' in value) || !Array.isArray(value.scale)) return value;
|
|
274
|
-
const reference = value.scale[
|
|
286
|
+
const reference = value.scale[REFERENCE_STEP_INDEX] ?? value.scale[0];
|
|
275
287
|
if (!reference) return value;
|
|
276
288
|
return { ...value, accessibility: assembleWcagAccessibility(value.scale, reference) };
|
|
277
289
|
}
|
package/src/analysis.ts
CHANGED
|
@@ -13,10 +13,10 @@ export function isLightColor(color: OKLCH): boolean {
|
|
|
13
13
|
// Base threshold at 50% lightness
|
|
14
14
|
let threshold = 0.5;
|
|
15
15
|
|
|
16
|
-
// Adjust threshold based on chroma
|
|
17
|
-
//
|
|
16
|
+
// Adjust threshold based on chroma. Helmholtz-Kohlrausch: at equal
|
|
17
|
+
// lightness a saturated colour looks lighter, so the threshold drops.
|
|
18
18
|
if (color.c > 0.15) {
|
|
19
|
-
threshold
|
|
19
|
+
threshold -= color.c * 0.3;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// Very low chroma colors are essentially gray, use simple lightness
|
|
@@ -125,7 +125,10 @@ export function calculatePerceptualWeight(color: OKLCH): {
|
|
|
125
125
|
const hue = color.h;
|
|
126
126
|
let hueWeight = 0.5;
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
// An achromatic colour has no hue to weigh (its h is 0, which would read as
|
|
129
|
+
// red); same neutral threshold as getColorTemperature.
|
|
130
|
+
if (color.c < 0.04) hueWeight = 0.5;
|
|
131
|
+
else if (hue >= 345 || hue <= 15)
|
|
129
132
|
hueWeight = 0.9; // Red - heaviest
|
|
130
133
|
else if (hue <= 45)
|
|
131
134
|
hueWeight = 0.8; // Red-Orange
|
package/src/builder.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
CONTRAST_BLACK,
|
|
16
16
|
CONTRAST_WHITE,
|
|
17
17
|
minFontSizeForAPCA,
|
|
18
|
+
REFERENCE_STEP_INDEX,
|
|
18
19
|
} from './accessibility.js';
|
|
19
20
|
import {
|
|
20
21
|
calculateAtmosphericWeight,
|
|
@@ -22,7 +23,15 @@ import {
|
|
|
22
23
|
getColorTemperature,
|
|
23
24
|
isLightColor,
|
|
24
25
|
} from './analysis.js';
|
|
25
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
ceilingForSeed,
|
|
28
|
+
getGamutTier,
|
|
29
|
+
mapScaleToGamut,
|
|
30
|
+
storedTier,
|
|
31
|
+
toGamutCeiling,
|
|
32
|
+
} from './gamut.js';
|
|
33
|
+
import { boundSeedLightness, generateHarmony, generateOKLCHScale } from './harmony.js';
|
|
34
|
+
import { roundOKLCH } from './conversion.js';
|
|
26
35
|
import { generateColorName } from './naming/index.js';
|
|
27
36
|
import { SCALE_POSITIONS } from './scale-positions.js';
|
|
28
37
|
import { generateSemanticColorSuggestions } from './semantic.js';
|
|
@@ -82,18 +91,42 @@ export interface BuildColorValueOptions {
|
|
|
82
91
|
* ```
|
|
83
92
|
*/
|
|
84
93
|
export function buildColorValue(oklch: OKLCH, options: BuildColorValueOptions = {}): ColorValue {
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
94
|
+
// The seed the family is built from: inside the lightness bounds first,
|
|
95
|
+
// then, for a P3 seed above the ceiling (P3 not allowed), mapped into sRGB
|
|
96
|
+
// before the scale is built. Every step, the seed's included, is then mapped
|
|
97
|
+
// into the ceiling. Any move of the seed is recorded below.
|
|
98
|
+
const bounded = boundSeedLightness(oklch);
|
|
99
|
+
const ceiling = ceilingForSeed(bounded);
|
|
100
|
+
const seed = getGamutTier(bounded) === 'p3' ? toGamutCeiling(bounded, ceiling) : bounded;
|
|
101
|
+
|
|
102
|
+
// Generate the 11-position scale and map every step into the ceiling
|
|
103
|
+
const scaleRecord = generateOKLCHScale(seed);
|
|
104
|
+
const scale = mapScaleToGamut(
|
|
105
|
+
SCALE_POSITIONS.map((pos) => scaleRecord[pos]).filter((v): v is OKLCH => v !== undefined),
|
|
106
|
+
ceiling,
|
|
89
107
|
);
|
|
108
|
+
const gamut = scale.map(storedTier);
|
|
109
|
+
|
|
110
|
+
// The seed lands on the 600 step. Any difference from the requested seed
|
|
111
|
+
// (beyond the rounding every step gets) is a move, and is recorded.
|
|
112
|
+
const landed = scale[REFERENCE_STEP_INDEX];
|
|
113
|
+
const requested = roundOKLCH(oklch);
|
|
114
|
+
const seedAdjusted: ColorValue['seedAdjusted'] =
|
|
115
|
+
landed && (landed.l !== requested.l || landed.c !== requested.c || landed.h !== requested.h)
|
|
116
|
+
? {
|
|
117
|
+
from: { ...oklch },
|
|
118
|
+
to: { ...landed },
|
|
119
|
+
reason: bounded !== oklch ? 'lightness-bounds' : 'gamut',
|
|
120
|
+
}
|
|
121
|
+
: undefined;
|
|
90
122
|
|
|
91
123
|
// Generate harmonies
|
|
92
124
|
const harmony = generateHarmony(oklch);
|
|
93
125
|
|
|
94
126
|
// WCAG accessibility (pair matrices + on-white/on-black) via the shared
|
|
95
|
-
// assembler
|
|
96
|
-
|
|
127
|
+
// assembler, measured on the step the seed landed on (the same step
|
|
128
|
+
// rebakeAccessibility uses); APCA is layered on below, builder-only.
|
|
129
|
+
const wcagAccessibility = assembleWcagAccessibility(scale, landed ?? oklch);
|
|
97
130
|
const apcaOnWhite = calculateAPCAContrast(oklch, CONTRAST_WHITE);
|
|
98
131
|
const apcaOnBlack = calculateAPCAContrast(oklch, CONTRAST_BLACK);
|
|
99
132
|
|
|
@@ -115,6 +148,8 @@ export function buildColorValue(oklch: OKLCH, options: BuildColorValueOptions =
|
|
|
115
148
|
const colorValue: ColorValue = {
|
|
116
149
|
name,
|
|
117
150
|
scale,
|
|
151
|
+
gamut,
|
|
152
|
+
...(seedAdjusted && { seedAdjusted }),
|
|
118
153
|
tokenId: `color-${oklch.l.toFixed(3)}-${oklch.c.toFixed(3)}-${Math.round(oklch.h)}`,
|
|
119
154
|
|
|
120
155
|
// Optional metadata from options
|
package/src/gamut.ts
CHANGED
|
@@ -68,6 +68,109 @@ export function toNearestGamut(oklch: OKLCH): { color: OKLCH; tier: GamutTier }
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Whether Display P3 steps are allowed in a family. Current choice (Sean,
|
|
73
|
+
* 2026-10-17; revisable): one constant, off by default, so every family is
|
|
74
|
+
* built in sRGB until a setting exists to turn it on.
|
|
75
|
+
*/
|
|
76
|
+
export const ALLOW_P3 = false;
|
|
77
|
+
|
|
78
|
+
/** The gamut a family is built inside: the space every one of its steps fits in. */
|
|
79
|
+
export type GamutCeiling = 'srgb' | 'p3';
|
|
80
|
+
|
|
81
|
+
function inCeiling(oklch: OKLCH, ceiling: GamutCeiling): boolean {
|
|
82
|
+
return new Color('oklch', [oklch.l, oklch.c, oklch.h]).inGamut(ceiling);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Map one colour into a gamut ceiling: unchanged when already inside, else
|
|
87
|
+
* colorjs.io toGamut into that space.
|
|
88
|
+
*/
|
|
89
|
+
export function toGamutCeiling(oklch: OKLCH, ceiling: GamutCeiling): OKLCH {
|
|
90
|
+
const color = new Color('oklch', [oklch.l, oklch.c, oklch.h]);
|
|
91
|
+
if (color.inGamut(ceiling)) return { ...oklch };
|
|
92
|
+
const [l, c, h] = color.toGamut({ space: ceiling }).to('oklch').coords;
|
|
93
|
+
return {
|
|
94
|
+
l: l ?? oklch.l,
|
|
95
|
+
c: c ?? 0,
|
|
96
|
+
h: h === undefined || Number.isNaN(h) ? oklch.h : h,
|
|
97
|
+
alpha: oklch.alpha,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Round a colour the way stored steps are rounded, then take chroma off in
|
|
103
|
+
* 0.001 steps until the rounded colour is inside the ceiling. Rounding hue to
|
|
104
|
+
* a whole degree and chroma to three places can carry a boundary colour back
|
|
105
|
+
* out; this keeps what is stored inside.
|
|
106
|
+
*/
|
|
107
|
+
function roundInside(oklch: OKLCH, ceiling: GamutCeiling): OKLCH {
|
|
108
|
+
const rounded = roundOKLCH(oklch);
|
|
109
|
+
let c = rounded.c;
|
|
110
|
+
while (c > 0 && !inCeiling({ ...rounded, c }, ceiling)) {
|
|
111
|
+
c = Math.round((c - 0.001) * 1000) / 1000;
|
|
112
|
+
}
|
|
113
|
+
return { ...rounded, c: Math.max(0, c) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Keep the step's lightness and hue; reduce chroma only, to the ceiling's edge. */
|
|
117
|
+
function reduceChromaOnly(oklch: OKLCH, ceiling: GamutCeiling): OKLCH {
|
|
118
|
+
const maxC = findMaxChroma(oklch.l, oklch.h, ceiling);
|
|
119
|
+
return roundInside({ ...oklch, c: Math.min(oklch.c, maxC) }, ceiling);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Map every step of a scale into the ceiling, keeping lightness order.
|
|
124
|
+
*
|
|
125
|
+
* A step already inside the ceiling is not moved. A step outside is mapped
|
|
126
|
+
* with colorjs.io toGamut; where that mapping would break the scale's
|
|
127
|
+
* strictly falling lightness, the step keeps its lightness and loses chroma
|
|
128
|
+
* only. Steps already inside are never re-marked, so the loop ends: at worst
|
|
129
|
+
* every outside step keeps its lightness, and the input order holds.
|
|
130
|
+
*/
|
|
131
|
+
export function mapScaleToGamut(scale: readonly OKLCH[], ceiling: GamutCeiling): OKLCH[] {
|
|
132
|
+
const chromaOnly = new Set<number>();
|
|
133
|
+
const mapStep = (step: OKLCH, i: number): OKLCH => {
|
|
134
|
+
if (inCeiling(step, ceiling)) return { ...step };
|
|
135
|
+
if (chromaOnly.has(i)) return reduceChromaOnly(step, ceiling);
|
|
136
|
+
return roundInside(toGamutCeiling(step, ceiling), ceiling);
|
|
137
|
+
};
|
|
138
|
+
let mapped = scale.map(mapStep);
|
|
139
|
+
for (;;) {
|
|
140
|
+
let marked = false;
|
|
141
|
+
for (let i = 1; i < mapped.length; i++) {
|
|
142
|
+
const prev = mapped[i - 1];
|
|
143
|
+
const cur = mapped[i];
|
|
144
|
+
if (prev === undefined || cur === undefined || cur.l < prev.l) continue;
|
|
145
|
+
for (const j of [i - 1, i]) {
|
|
146
|
+
const original = scale[j];
|
|
147
|
+
if (original && !chromaOnly.has(j) && !inCeiling(original, ceiling)) {
|
|
148
|
+
chromaOnly.add(j);
|
|
149
|
+
marked = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!marked) return mapped;
|
|
154
|
+
mapped = scale.map(mapStep);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The ceiling a family is built inside, set by its seed: 'p3' only when the
|
|
160
|
+
* seed is a Display P3 colour and ALLOW_P3 is on; otherwise 'srgb'.
|
|
161
|
+
*/
|
|
162
|
+
export function ceilingForSeed(seed: OKLCH): GamutCeiling {
|
|
163
|
+
return ALLOW_P3 && getGamutTier(seed) === 'p3' ? 'p3' : 'srgb';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The tier of a step mapScaleToGamut returned. Every such step is inside its
|
|
168
|
+
* ceiling, which is at most Display P3, so a step outside sRGB is a P3 step.
|
|
169
|
+
*/
|
|
170
|
+
export function storedTier(oklch: OKLCH): GamutCeiling {
|
|
171
|
+
return isInSRGBGamut(oklch) ? 'srgb' : 'p3';
|
|
172
|
+
}
|
|
173
|
+
|
|
71
174
|
/**
|
|
72
175
|
* Snap an OKLCH into sRGB and round it -- the shape every generated color is
|
|
73
176
|
* emitted in. Lives here because both halves are gamut/conversion concerns;
|
package/src/harmony.ts
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
* Harmony generation for design systems using pure OKLCH hue rotation.
|
|
3
3
|
*
|
|
4
4
|
* Rule: harmonies are PURE HUE ROTATIONS. L and C are never mutated.
|
|
5
|
-
* Only H changes. Every output goes through toNearestGamut() then
|
|
5
|
+
* Only H changes. Every harmony output goes through toNearestGamut() then
|
|
6
|
+
* roundOKLCH(). The scale is different: generateOKLCHScale returns unmapped
|
|
7
|
+
* steps, and buildColorValue maps them into the family's ceiling with
|
|
8
|
+
* mapScaleToGamut (gamut.ts).
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import type { ColorHarmonies, OKLCH } from '@rafters/shared';
|
|
@@ -111,6 +114,17 @@ function validateScaleGeneration(baseColor: OKLCH): {
|
|
|
111
114
|
return { isValid: true };
|
|
112
115
|
}
|
|
113
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The seed a scale is actually built from: unchanged inside the lightness
|
|
119
|
+
* bounds, else moved to the suggested lightness (0.7 or 0.4). Exposed so
|
|
120
|
+
* buildColorValue can record the move instead of making it in silence.
|
|
121
|
+
*/
|
|
122
|
+
export function boundSeedLightness(baseColor: OKLCH): OKLCH {
|
|
123
|
+
const validation = validateScaleGeneration(baseColor);
|
|
124
|
+
if (validation.isValid || validation.suggestedLightness === undefined) return baseColor;
|
|
125
|
+
return { ...baseColor, l: validation.suggestedLightness };
|
|
126
|
+
}
|
|
127
|
+
|
|
114
128
|
/**
|
|
115
129
|
* Generate lightness progression using mathematical functions.
|
|
116
130
|
* Base color positioned at 600, with 6 lighter steps above + 4 darker + 950.
|
|
@@ -160,14 +174,8 @@ function generateLightnessProgression(baseLightness: number): Record<string, num
|
|
|
160
174
|
* Chroma reduced only at extreme lightness to avoid perceptual artifacts.
|
|
161
175
|
*/
|
|
162
176
|
export function generateOKLCHScale(baseColor: OKLCH): Record<string, OKLCH> {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
if (!validation.isValid) {
|
|
166
|
-
const adjustedColor = validation.suggestedLightness
|
|
167
|
-
? { ...baseColor, l: validation.suggestedLightness }
|
|
168
|
-
: baseColor;
|
|
169
|
-
return generateOKLCHScale(adjustedColor);
|
|
170
|
-
}
|
|
177
|
+
const bounded = boundSeedLightness(baseColor);
|
|
178
|
+
if (bounded !== baseColor) return generateOKLCHScale(bounded);
|
|
171
179
|
|
|
172
180
|
const lightnessSteps = generateLightnessProgression(baseColor.l);
|
|
173
181
|
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export {
|
|
|
9
9
|
calculateAPCAContrast,
|
|
10
10
|
calculateWCAGContrast,
|
|
11
11
|
generateAccessibilityMetadata,
|
|
12
|
+
REFERENCE_STEP_INDEX,
|
|
12
13
|
rebakeAccessibility,
|
|
13
14
|
} from './accessibility.js';
|
|
14
15
|
|
|
@@ -42,12 +43,16 @@ export {
|
|
|
42
43
|
|
|
43
44
|
// Gamut
|
|
44
45
|
export {
|
|
46
|
+
ALLOW_P3,
|
|
45
47
|
computeGamutBoundaries,
|
|
46
48
|
type GamutBoundaryPoint,
|
|
49
|
+
type GamutCeiling,
|
|
47
50
|
type GamutTier,
|
|
48
51
|
getGamutTier,
|
|
49
52
|
isInP3Gamut,
|
|
50
53
|
isInSRGBGamut,
|
|
54
|
+
mapScaleToGamut,
|
|
55
|
+
toGamutCeiling,
|
|
51
56
|
toNearestGamut,
|
|
52
57
|
} from './gamut.js';
|
|
53
58
|
|
package/src/semantic.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
SemanticColorSuggestions,
|
|
27
27
|
SemanticStatusRole,
|
|
28
28
|
} from '@rafters/shared';
|
|
29
|
+
import { calculateWCAGContrast } from './accessibility.js';
|
|
29
30
|
import { roundOKLCH } from './conversion.js';
|
|
30
31
|
import { clampColor } from './gamut.js';
|
|
31
32
|
import { clamp01, normalizeHue } from './internal/math.js';
|
|
@@ -56,6 +57,10 @@ export interface PairRequest {
|
|
|
56
57
|
from: string;
|
|
57
58
|
level?: 'AA' | 'AAA';
|
|
58
59
|
dark?: boolean;
|
|
60
|
+
/** ring only: the colour the ring sits on (the mode's background). */
|
|
61
|
+
surface?: OKLCH;
|
|
62
|
+
/** foreground only: the family to fall back to when no in-family pair meets AA (the neutral family). */
|
|
63
|
+
fallback?: ColorValue;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
export interface SemanticContext {
|
|
@@ -84,7 +89,11 @@ export const STATE_USES: readonly StateUse[] = [
|
|
|
84
89
|
'subtle',
|
|
85
90
|
];
|
|
86
91
|
|
|
87
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Rank step per state on the AA ladder (steps with an in-family partner of at least 4.5:1). sign: 1 for light (darker), -1 for dark (lighter).
|
|
94
|
+
* Without a surface a ring copies its base; with one, statePair searches the
|
|
95
|
+
* whole family instead (see ringOnSurface).
|
|
96
|
+
*/
|
|
88
97
|
const STATE_RANK_STEP: Record<
|
|
89
98
|
StateUse,
|
|
90
99
|
(rank: number, ladder: readonly number[], sign: number) => number
|
|
@@ -98,6 +107,34 @@ const STATE_RANK_STEP: Record<
|
|
|
98
107
|
subtle: (rank, _l, s) => rank - 2 * s,
|
|
99
108
|
};
|
|
100
109
|
|
|
110
|
+
/**
|
|
111
|
+
* States whose target is reflected back into the ladder when it would leave
|
|
112
|
+
* it: the step goes the same distance the other way, so a base at an end of
|
|
113
|
+
* the ladder never collapses onto itself. disabled and ring never step off.
|
|
114
|
+
*/
|
|
115
|
+
const REFLECTED_STATES: ReadonlySet<StateUse> = new Set([
|
|
116
|
+
'hover',
|
|
117
|
+
'active',
|
|
118
|
+
'focus',
|
|
119
|
+
'border',
|
|
120
|
+
'subtle',
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
/** The minimum contrast a focus indicator needs against its surface (WCAG 2.2). */
|
|
124
|
+
const RING_MIN_CONTRAST = 3;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Bring a target rank inside a ladder of `length` rungs: reflect it about the
|
|
128
|
+
* base rank (when the state reflects), then clamp what is still outside.
|
|
129
|
+
*/
|
|
130
|
+
function rankInLadder(use: StateUse, baseRank: number, target: number, length: number): number {
|
|
131
|
+
const last = length - 1;
|
|
132
|
+
const inside = (rank: number): boolean => rank >= 0 && rank <= last;
|
|
133
|
+
const reflected =
|
|
134
|
+
!inside(target) && REFLECTED_STATES.has(use) ? baseRank - (target - baseRank) : target;
|
|
135
|
+
return Math.max(0, Math.min(last, reflected));
|
|
136
|
+
}
|
|
137
|
+
|
|
101
138
|
/** Escape hatches the generators may precompute onto a family. */
|
|
102
139
|
type FamilyWithReferences = ColorValue & {
|
|
103
140
|
foregroundReferences?: { auto?: { family: string; position: string } };
|
|
@@ -106,6 +143,20 @@ type FamilyWithReferences = ColorValue & {
|
|
|
106
143
|
|
|
107
144
|
type PairMatrix = readonly (readonly number[])[];
|
|
108
145
|
|
|
146
|
+
const STANDARD_RANK: Record<PairStandard, number> = { none: 0, AA: 1, AAA: 2 };
|
|
147
|
+
|
|
148
|
+
/** The highest WCAG standard (normal text) a contrast ratio meets. */
|
|
149
|
+
function standardFor(ratio: number): PairStandard {
|
|
150
|
+
if (ratio >= 7) return 'AAA';
|
|
151
|
+
if (ratio >= 4.5) return 'AA';
|
|
152
|
+
return 'none';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The highest WCAG standard (normal text) two colours meet, measured. */
|
|
156
|
+
function standardOf(a: OKLCH, b: OKLCH): PairStandard {
|
|
157
|
+
return standardFor(calculateWCAGContrast(a, b));
|
|
158
|
+
}
|
|
159
|
+
|
|
109
160
|
function partnerForBase(pairs: PairMatrix | undefined, base: number): number | undefined {
|
|
110
161
|
if (!pairs) return undefined;
|
|
111
162
|
for (const [p1, p2] of pairs) {
|
|
@@ -257,13 +308,72 @@ export function semanticFor(family: ColorValue, options?: { name?: string }): Se
|
|
|
257
308
|
const aaaPairs = family.accessibility?.wcagAAA?.normal;
|
|
258
309
|
const aaPairs = family.accessibility?.wcagAA?.normal;
|
|
259
310
|
|
|
260
|
-
|
|
311
|
+
/** Contrast between two steps of this family, by scale index. */
|
|
312
|
+
function contrastOf(a: number, b: number): number {
|
|
313
|
+
const colorA = family.scale[a];
|
|
314
|
+
const colorB = family.scale[b];
|
|
315
|
+
if (!colorA || !colorB) return 1;
|
|
316
|
+
return calculateWCAGContrast(colorA, colorB);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The colour a leg names, when this context holds that family. */
|
|
320
|
+
function colorOf(leg: PairLeg): OKLCH | undefined {
|
|
321
|
+
if (leg.family !== familyName && leg.family !== family.name) return undefined;
|
|
322
|
+
const index = POSITION_TO_INDEX[leg.position];
|
|
323
|
+
return index === undefined ? undefined : family.scale[index];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The highest standard a pair's own two steps meet, measured on the two
|
|
328
|
+
* colours the legs resolve to. A leg whose colour this context does not
|
|
329
|
+
* hold cannot be measured, so it claims no standard.
|
|
330
|
+
*/
|
|
331
|
+
function measuredStandard(fromPosition: string, to: PairLeg): PairStandard {
|
|
332
|
+
const fromColor = colorOf({ family: familyName, position: fromPosition });
|
|
333
|
+
const toColor = colorOf(to);
|
|
334
|
+
if (!fromColor || !toColor) return 'none';
|
|
335
|
+
return standardOf(fromColor, toColor);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The fallback family's step with the highest contrast against this
|
|
340
|
+
* family's step at `base`, labelled by its measured standard.
|
|
341
|
+
*/
|
|
342
|
+
function fallbackForeground(from: string, base: number, fallback: ColorValue): Pair {
|
|
343
|
+
const baseColor = family.scale[base];
|
|
344
|
+
let bestIndex = -1;
|
|
345
|
+
let bestRatio = 0;
|
|
346
|
+
for (let index = 0; index < fallback.scale.length; index++) {
|
|
347
|
+
const candidate = fallback.scale[index];
|
|
348
|
+
if (!baseColor || !candidate) continue;
|
|
349
|
+
const ratio = calculateWCAGContrast(baseColor, candidate);
|
|
350
|
+
if (ratio > bestRatio) {
|
|
351
|
+
bestRatio = ratio;
|
|
352
|
+
bestIndex = index;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (bestIndex === -1) {
|
|
356
|
+
throw new SemanticSelectionError(
|
|
357
|
+
familyName,
|
|
358
|
+
`has no step at ${from}, or fallback "${fallback.name}" has no steps`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
from: { family: familyName, position: from },
|
|
363
|
+
to: { family: fallback.name, position: requirePosition(bestIndex, fallback.name) },
|
|
364
|
+
standard: standardFor(bestRatio),
|
|
365
|
+
tier: 'pair-nearest',
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function foregroundPair(from: string, level: 'AA' | 'AAA', fallback?: ColorValue): Pair {
|
|
261
370
|
const auto = refs.foregroundReferences?.auto;
|
|
262
371
|
if (auto) {
|
|
372
|
+
const to = { family: auto.family, position: auto.position };
|
|
263
373
|
return {
|
|
264
374
|
from: { family: familyName, position: from },
|
|
265
|
-
to
|
|
266
|
-
standard:
|
|
375
|
+
to,
|
|
376
|
+
standard: measuredStandard(from, to),
|
|
267
377
|
tier: 'reference',
|
|
268
378
|
};
|
|
269
379
|
}
|
|
@@ -272,52 +382,115 @@ export function semanticFor(family: ColorValue, options?: { name?: string }): Se
|
|
|
272
382
|
const aaa = exactAAA ?? nearestPartner(aaaPairs, base);
|
|
273
383
|
const exactAA = partnerForBase(aaPairs, base);
|
|
274
384
|
const aa = exactAA ?? nearestPartner(aaPairs, base);
|
|
275
|
-
|
|
276
|
-
|
|
385
|
+
|
|
386
|
+
// Candidates in the order they are tried: the AAA matrix's partner (when
|
|
387
|
+
// AAA is asked for), then the AA matrix's.
|
|
388
|
+
const candidates: { partner: number; exact: boolean }[] = [];
|
|
389
|
+
if (level === 'AAA' && aaa !== undefined) {
|
|
390
|
+
candidates.push({ partner: aaa, exact: exactAAA !== undefined });
|
|
391
|
+
}
|
|
392
|
+
if (aa !== undefined) candidates.push({ partner: aa, exact: exactAA !== undefined });
|
|
393
|
+
if (candidates.length === 0) {
|
|
277
394
|
throw new SemanticSelectionError(
|
|
278
395
|
familyName,
|
|
279
396
|
`has no WCAG pair partner for position ${from} (accessibility metadata required)`,
|
|
280
397
|
);
|
|
281
398
|
}
|
|
282
|
-
|
|
399
|
+
|
|
400
|
+
const measured = candidates.map((candidate) => ({
|
|
401
|
+
...candidate,
|
|
402
|
+
ratio: contrastOf(base, candidate.partner),
|
|
403
|
+
}));
|
|
404
|
+
const targets: readonly PairStandard[] = level === 'AAA' ? ['AAA', 'AA'] : ['AA'];
|
|
405
|
+
const passing = targets
|
|
406
|
+
.map((target) =>
|
|
407
|
+
measured.find((m) => STANDARD_RANK[standardFor(m.ratio)] >= STANDARD_RANK[target]),
|
|
408
|
+
)
|
|
409
|
+
.find((m) => m !== undefined);
|
|
410
|
+
// No in-family pair meets AA: the fallback family (the neutral) serves.
|
|
411
|
+
if (!passing && fallback) return fallbackForeground(from, base, fallback);
|
|
412
|
+
const chosen = passing ?? measured.reduce((best, m) => (m.ratio > best.ratio ? m : best));
|
|
283
413
|
return {
|
|
284
414
|
from: { family: familyName, position: from },
|
|
285
|
-
to: { family: familyName, position: requirePosition(partner, familyName) },
|
|
286
|
-
standard:
|
|
287
|
-
tier:
|
|
415
|
+
to: { family: familyName, position: requirePosition(chosen.partner, familyName) },
|
|
416
|
+
standard: standardFor(chosen.ratio),
|
|
417
|
+
tier: chosen.exact ? 'pair-exact' : 'pair-nearest',
|
|
288
418
|
};
|
|
289
419
|
}
|
|
290
420
|
|
|
291
|
-
|
|
421
|
+
/**
|
|
422
|
+
* A ring that stands out from `surface`: starting at the base and moving
|
|
423
|
+
* away from the surface's lightness, the first step at least 3:1 against
|
|
424
|
+
* the surface; when no step reaches 3:1, the step with the highest contrast.
|
|
425
|
+
*/
|
|
426
|
+
function ringOnSurface(from: string, surface: OKLCH): Pair {
|
|
427
|
+
const base = requireIndex(from, familyName);
|
|
428
|
+
const baseColor = family.scale[base];
|
|
429
|
+
if (!baseColor) {
|
|
430
|
+
throw new SemanticSelectionError(familyName, `has no step at ${from}`);
|
|
431
|
+
}
|
|
432
|
+
// Scale index grows darker: a surface lighter than the base sends the
|
|
433
|
+
// search toward darker steps, otherwise toward lighter ones.
|
|
434
|
+
const step = surface.l > baseColor.l ? 1 : -1;
|
|
435
|
+
const ratioAt = (index: number): number => {
|
|
436
|
+
const color = family.scale[index];
|
|
437
|
+
return color ? calculateWCAGContrast(color, surface) : 0;
|
|
438
|
+
};
|
|
439
|
+
let chosen: number | undefined;
|
|
440
|
+
for (let index = base; index >= 0 && index < family.scale.length; index += step) {
|
|
441
|
+
if (ratioAt(index) >= RING_MIN_CONTRAST) {
|
|
442
|
+
chosen = index;
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (chosen === undefined) {
|
|
447
|
+
let best = base;
|
|
448
|
+
for (let index = 0; index < family.scale.length; index++) {
|
|
449
|
+
if (ratioAt(index) > ratioAt(best)) best = index;
|
|
450
|
+
}
|
|
451
|
+
chosen = best;
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
from: { family: familyName, position: from },
|
|
455
|
+
to: { family: familyName, position: requirePosition(chosen, familyName) },
|
|
456
|
+
standard: standardFor(contrastOf(base, chosen)),
|
|
457
|
+
tier: 'ladder',
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function statePair(request: PairRequest & { use: StateUse }): Pair {
|
|
462
|
+
const { use, from, dark, surface } = request;
|
|
292
463
|
const precomputed = refs.stateReferences?.[use];
|
|
293
464
|
if (precomputed) {
|
|
465
|
+
const to = { family: precomputed.family, position: String(precomputed.position) };
|
|
294
466
|
return {
|
|
295
467
|
from: { family: familyName, position: from },
|
|
296
|
-
to
|
|
297
|
-
standard:
|
|
468
|
+
to,
|
|
469
|
+
standard: measuredStandard(from, to),
|
|
298
470
|
tier: 'reference',
|
|
299
471
|
};
|
|
300
472
|
}
|
|
301
|
-
if (
|
|
473
|
+
if (use === 'ring' && surface) return ringOnSurface(from, surface);
|
|
474
|
+
if (!aaPairs || aaPairs.length === 0) {
|
|
302
475
|
throw new SemanticSelectionError(
|
|
303
476
|
familyName,
|
|
304
|
-
'has no accessibility.
|
|
477
|
+
'has no accessibility.wcagAA.normal ladder (color generator must emit accessibility metadata)',
|
|
305
478
|
);
|
|
306
479
|
}
|
|
307
480
|
const base = requireIndex(from, familyName);
|
|
308
|
-
const ladder = collectLadder(
|
|
481
|
+
const ladder = collectLadder(aaPairs);
|
|
309
482
|
const baseRank = closestRankTo(ladder, base);
|
|
310
483
|
const sign = dark ? -1 : 1;
|
|
311
484
|
const targetRank = STATE_RANK_STEP[use](baseRank, ladder, sign);
|
|
312
|
-
const
|
|
313
|
-
const targetIndex = ladder[
|
|
485
|
+
const rank = rankInLadder(use, baseRank, targetRank, ladder.length);
|
|
486
|
+
const targetIndex = ladder[rank];
|
|
314
487
|
if (targetIndex === undefined) {
|
|
315
|
-
throw new SemanticSelectionError(familyName, `ladder lookup failed at rank ${
|
|
488
|
+
throw new SemanticSelectionError(familyName, `ladder lookup failed at rank ${rank}`);
|
|
316
489
|
}
|
|
317
490
|
return {
|
|
318
491
|
from: { family: familyName, position: from },
|
|
319
492
|
to: { family: familyName, position: requirePosition(targetIndex, familyName) },
|
|
320
|
-
standard:
|
|
493
|
+
standard: standardFor(contrastOf(base, targetIndex)),
|
|
321
494
|
tier: 'ladder',
|
|
322
495
|
};
|
|
323
496
|
}
|
|
@@ -339,10 +512,7 @@ export function semanticFor(family: ColorValue, options?: { name?: string }): Se
|
|
|
339
512
|
// advisory here because the cascade re-derives it as contrast against the
|
|
340
513
|
// dark background. Sum-scoring let a distant fg leg drag the bg to the
|
|
341
514
|
// wrong side of the scale.
|
|
342
|
-
for (const [
|
|
343
|
-
[aaaPairs, 'AAA'],
|
|
344
|
-
[aaPairs, 'AA'],
|
|
345
|
-
] as const) {
|
|
515
|
+
for (const pairs of [aaaPairs, aaPairs]) {
|
|
346
516
|
if (!pairs || pairs.length === 0) continue;
|
|
347
517
|
let best: { from: number; to: number; fromDist: number; toDist: number } | null = null;
|
|
348
518
|
for (const [p1, p2] of pairs) {
|
|
@@ -365,7 +535,7 @@ export function semanticFor(family: ColorValue, options?: { name?: string }): Se
|
|
|
365
535
|
return {
|
|
366
536
|
from: { family: familyName, position: requirePosition(best.from, familyName) },
|
|
367
537
|
to: { family: familyName, position: requirePosition(best.to, familyName) },
|
|
368
|
-
standard,
|
|
538
|
+
standard: standardFor(contrastOf(best.from, best.to)),
|
|
369
539
|
tier: best.fromDist === 0 && best.toDist === 0 ? 'pair-exact' : 'pair-nearest',
|
|
370
540
|
};
|
|
371
541
|
}
|
|
@@ -376,7 +546,7 @@ export function semanticFor(family: ColorValue, options?: { name?: string }): Se
|
|
|
376
546
|
return {
|
|
377
547
|
from: { family: familyName, position: requirePosition(fromTarget, familyName) },
|
|
378
548
|
to: { family: familyName, position: requirePosition(toFallback, familyName) },
|
|
379
|
-
standard:
|
|
549
|
+
standard: standardFor(contrastOf(fromTarget, toFallback)),
|
|
380
550
|
tier: 'inversion',
|
|
381
551
|
};
|
|
382
552
|
}
|
|
@@ -384,12 +554,15 @@ export function semanticFor(family: ColorValue, options?: { name?: string }): Se
|
|
|
384
554
|
return {
|
|
385
555
|
pair(request: PairRequest): Pair {
|
|
386
556
|
const level = request.level ?? 'AAA';
|
|
387
|
-
|
|
388
|
-
|
|
557
|
+
const { use } = request;
|
|
558
|
+
if (use === 'foreground') return foregroundPair(request.from, level, request.fallback);
|
|
559
|
+
return statePair({ ...request, use });
|
|
389
560
|
},
|
|
390
561
|
states(from: string, dark?: boolean): Record<StateUse, Pair> {
|
|
391
562
|
const out = {} as Record<StateUse, Pair>;
|
|
392
|
-
for (const use of STATE_USES)
|
|
563
|
+
for (const use of STATE_USES) {
|
|
564
|
+
out[use] = statePair({ use, from, ...(dark === undefined ? {} : { dark }) });
|
|
565
|
+
}
|
|
393
566
|
return out;
|
|
394
567
|
},
|
|
395
568
|
invert,
|