@rafters/color-utils 0.4.4 → 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 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.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.4"
28
+ "@rafters/shared": "0.4.5"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "24.10.0",
@@ -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.1 minimum contrast ratios, by conformance level and text size. */
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
- * Convert OKLCH to relative luminance for WCAG calculations
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
- const color = new Color('oklch', [oklch.l, oklch.c, oklch.h]);
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.1 contrast ratio between two colors
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
- * Calculate APCA contrast for modern accessibility
89
- * Uses the official APCA algorithm via apca-w3 library
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
- export function calculateAPCAContrast(foreground: OKLCH, background: OKLCH): number {
92
- // Convert OKLCH to RGB arrays for APCA
93
- const foregroundColor = new Color('oklch', [foreground.l, foreground.c, foreground.h]);
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
- // Calculate luminance values
108
- const fgY = sRGBtoY([fgR, fgG, fgB]);
109
- const bgY = sRGBtoY([bgR, bgG, bgB]);
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
- // Use official APCA calculation
112
- const result = APCAcontrast(fgY, bgY);
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[5] ?? value.scale[0];
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
- // High chroma colors appear darker due to the Helmholtz-Kohlrausch effect
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 += color.c * 0.3; // Increase threshold for high chroma
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
- if (hue >= 345 || hue <= 15)
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 { generateHarmony, generateOKLCHScale } from './harmony.js';
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
- // Generate the 11-position scale
86
- const scaleRecord = generateOKLCHScale(oklch);
87
- const scale = SCALE_POSITIONS.map((pos) => scaleRecord[pos]).filter(
88
- (v): v is OKLCH => v !== undefined,
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; APCA is layered on below, builder-only.
96
- const wcagAccessibility = assembleWcagAccessibility(scale, oklch);
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 roundOKLCH().
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 validation = validateScaleGeneration(baseColor);
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
- /** Rank step per state on the AAA ladder. sign: 1 for light (darker), -1 for dark (lighter). */
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
- function foregroundPair(from: string, level: 'AA' | 'AAA'): Pair {
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: { family: auto.family, position: auto.position },
266
- standard: level,
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
- const partner = level === 'AAA' ? (aaa ?? aa) : aa;
276
- if (partner === undefined) {
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
- const fromAAA = level === 'AAA' && aaa !== undefined;
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: fromAAA ? 'AAA' : 'AA',
287
- tier: (fromAAA ? exactAAA : exactAA) !== undefined ? 'pair-exact' : 'pair-nearest',
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
- function statePair(use: StateUse, from: string, dark?: boolean): Pair {
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: { family: precomputed.family, position: String(precomputed.position) },
297
- standard: 'AAA',
468
+ to,
469
+ standard: measuredStandard(from, to),
298
470
  tier: 'reference',
299
471
  };
300
472
  }
301
- if (!aaaPairs || aaaPairs.length === 0) {
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.wcagAAA.normal ladder (color generator must emit accessibility metadata)',
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(aaaPairs);
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 clampedRank = Math.max(0, Math.min(ladder.length - 1, targetRank));
313
- const targetIndex = ladder[clampedRank];
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 ${clampedRank}`);
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: 'AAA',
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 [pairs, standard] of [
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: 'none',
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
- if (request.use === 'foreground') return foregroundPair(request.from, level);
388
- return statePair(request.use, request.from, request.dark);
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) out[use] = statePair(use, from, dark);
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,