@rafters/color-utils 0.5.4 → 0.6.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/README.md CHANGED
@@ -73,7 +73,7 @@ everywhere, `p3` needs a wide-gamut screen, `out` is displayable nowhere.
73
73
 
74
74
  - `getGamutTier(oklch)` — which tier a color falls in.
75
75
  - `isInSRGBGamut(oklch)` / `isInP3Gamut(oklch)` — the two boundary checks.
76
- - `toNearestGamut(oklch)` — snap an out-of-gamut color into sRGB. Returns
76
+ - `toNearestGamut(oklch)` — snap an out-of-gamut color into sRGB, keeping lightness, hue and alpha; only chroma drops. Returns
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rafters/color-utils",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "OKLCH color manipulation utilities for Rafters design system",
5
5
  "homepage": "https://rafters.studio",
6
6
  "license": "MIT",
@@ -22,15 +22,15 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
+ "@rafters/shared": "0.6.0",
25
26
  "apca-w3": "^0.1.9",
26
27
  "colorjs.io": "^0.5.2",
27
- "zod": "^4.1.12",
28
- "@rafters/shared": "0.5.4"
28
+ "zod": "^4.6.5"
29
29
  },
30
30
  "devDependencies": {
31
- "@types/node": "24.10.0",
31
+ "@types/node": "^24",
32
32
  "tsx": "^4.21.0",
33
- "typescript": "5.9.3",
33
+ "typescript": "^7.0.2",
34
34
  "vitest": "^5.0.0",
35
35
  "zocker": "3.0.0"
36
36
  },
@@ -18,6 +18,7 @@ import { APCAcontrast, displayP3toY, sRGBtoY } from 'apca-w3';
18
18
  import Color from 'colorjs.io';
19
19
  import { z } from 'zod';
20
20
  import { getGamutTier, toGamutCeiling } from './gamut.js';
21
+ import { POSITION_TO_INDEX } from './scale-positions.js';
21
22
 
22
23
  /** White reference color for contrast calculations. */
23
24
  export const CONTRAST_WHITE: OKLCH = { l: 1, c: 0, h: 0, alpha: 1 };
@@ -276,6 +277,8 @@ export function assembleWcagAccessibility(
276
277
 
277
278
  /**
278
279
  * Re-derive accessibility metadata for a ColorValue whose scale changed.
280
+ * On-white and on-black are measured on the recorded anchor step when the
281
+ * value has one (createNormalizedScale), else on the 600 step.
279
282
  * A family value supplied without its WCAG pair matrices would starve the
280
283
  * contrast/state/invert selection downstream -- this guard re-bakes them.
281
284
  * No-op for values without a scale. (Moved from the removed CLI set command;
@@ -283,7 +286,9 @@ export function assembleWcagAccessibility(
283
286
  */
284
287
  export function rebakeAccessibility(value: ColorValue): ColorValue {
285
288
  if (!('scale' in value) || !Array.isArray(value.scale)) return value;
286
- const reference = value.scale[REFERENCE_STEP_INDEX] ?? value.scale[0];
289
+ const anchorIndex =
290
+ value.anchor === undefined ? REFERENCE_STEP_INDEX : POSITION_TO_INDEX[value.anchor];
291
+ const reference = value.scale[anchorIndex ?? REFERENCE_STEP_INDEX] ?? value.scale[0];
287
292
  if (!reference) return value;
288
293
  return { ...value, accessibility: assembleWcagAccessibility(value.scale, reference) };
289
294
  }
package/src/builder.ts CHANGED
@@ -120,13 +120,42 @@ export function buildColorValue(oklch: OKLCH, options: BuildColorValueOptions =
120
120
  }
121
121
  : undefined;
122
122
 
123
+ return assembleColorValue(
124
+ oklch,
125
+ { scale, gamut, seedAdjusted, reference: landed ?? oklch },
126
+ options,
127
+ );
128
+ }
129
+
130
+ /** The scale half of a ColorValue, as a builder produced it. */
131
+ export interface BuiltScale {
132
+ scale: OKLCH[];
133
+ gamut: NonNullable<ColorValue['gamut']>;
134
+ seedAdjusted: ColorValue['seedAdjusted'];
135
+ /** The step the on-white and on-black figures are measured on. */
136
+ reference: OKLCH;
137
+ }
138
+
139
+ /**
140
+ * Assemble a ColorValue around a built scale: harmonies, accessibility,
141
+ * analysis, weights, semantic suggestions and the name, all from the
142
+ * requested colour. Shared by buildColorValue and createNormalizedScale so
143
+ * both families carry the same intelligence.
144
+ */
145
+ export function assembleColorValue(
146
+ oklch: OKLCH,
147
+ built: BuiltScale,
148
+ options: BuildColorValueOptions,
149
+ ): ColorValue {
150
+ const { scale, gamut, seedAdjusted, reference } = built;
151
+
123
152
  // Generate harmonies
124
153
  const harmony = generateHarmony(oklch);
125
154
 
126
155
  // WCAG accessibility (pair matrices + on-white/on-black) via the shared
127
156
  // assembler, measured on the step the seed landed on (the same step
128
157
  // rebakeAccessibility uses); APCA is layered on below, builder-only.
129
- const wcagAccessibility = assembleWcagAccessibility(scale, landed ?? oklch);
158
+ const wcagAccessibility = assembleWcagAccessibility(scale, reference);
130
159
  const apcaOnWhite = calculateAPCAContrast(oklch, CONTRAST_WHITE);
131
160
  const apcaOnBlack = calculateAPCAContrast(oklch, CONTRAST_BLACK);
132
161
 
package/src/conversion.ts CHANGED
@@ -90,3 +90,23 @@ export function roundOKLCH(oklch: OKLCH): OKLCH {
90
90
  alpha: oklch.alpha !== undefined ? Math.round(oklch.alpha * 100) / 100 : 1,
91
91
  };
92
92
  }
93
+
94
+ /**
95
+ * Convert an in-gamut OKLCH colour to sRGB: gamma-encoded components in
96
+ * [0, 1] (clamped, so a colour on the sRGB boundary cannot round outside it)
97
+ * and a six-digit hex. Map an out-of-gamut colour first, with
98
+ * toGamutCeiling(oklch, 'srgb'); alpha is not part of the result.
99
+ */
100
+ export function oklchToSRGB(oklch: OKLCH): { components: [number, number, number]; hex: string } {
101
+ const coords = new Color('oklch', [oklch.l, oklch.c, oklch.h]).to('srgb').coords;
102
+ const channel = (i: number): number => Math.min(1, Math.max(0, Number(coords[i] ?? 0) || 0));
103
+ const components: [number, number, number] = [channel(0), channel(1), channel(2)];
104
+ const hex = `#${components
105
+ .map((v) =>
106
+ Math.round(v * 255)
107
+ .toString(16)
108
+ .padStart(2, '0'),
109
+ )
110
+ .join('')}`;
111
+ return { components, hex };
112
+ }
package/src/gamut.ts CHANGED
@@ -45,33 +45,17 @@ export function getGamutTier(oklch: OKLCH): GamutTier {
45
45
  }
46
46
 
47
47
  /**
48
- * Snap an OKLCH color to the nearest in-gamut color.
49
- * Returns the snapped color and its tier.
48
+ * Snap an OKLCH color into sRGB, keeping lightness, hue and alpha: the
49
+ * largest chroma sRGB holds there, as toGamutCeiling(oklch, 'srgb').
50
50
  */
51
51
  export function toNearestGamut(oklch: OKLCH): { color: OKLCH; tier: GamutTier } {
52
- const color = new Color('oklch', [oklch.l, oklch.c, oklch.h]);
53
-
54
- if (color.inGamut('srgb')) {
55
- return { color: { ...oklch }, tier: 'srgb' };
56
- }
57
-
58
- const srgbMapped = color.toGamut({ space: 'srgb' });
59
- const mappedOklch = srgbMapped.to('oklch');
60
- return {
61
- color: {
62
- l: mappedOklch.coords[0] ?? 0,
63
- c: mappedOklch.coords[1] ?? 0,
64
- h: mappedOklch.coords[2] ?? oklch.h,
65
- alpha: oklch.alpha,
66
- },
67
- tier: 'srgb',
68
- };
52
+ return { color: toGamutCeiling(oklch, 'srgb'), tier: 'srgb' };
69
53
  }
70
54
 
71
55
  /**
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.
56
+ * Whether Display P3 steps are allowed in a family. One constant, off by
57
+ * default, so every family is built in sRGB until a setting exists to turn it
58
+ * on.
75
59
  */
76
60
  export const ALLOW_P3 = false;
77
61
 
@@ -84,18 +68,11 @@ function inCeiling(oklch: OKLCH, ceiling: GamutCeiling): boolean {
84
68
 
85
69
  /**
86
70
  * Map one colour into a gamut ceiling: unchanged when already inside, else
87
- * colorjs.io toGamut into that space.
71
+ * the same lightness and hue at the largest chroma the ceiling holds there.
88
72
  */
89
73
  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
- };
74
+ if (inCeiling(oklch, ceiling)) return { ...oklch };
75
+ return { ...oklch, c: Math.min(oklch.c, findMaxChroma(oklch.l, oklch.h, ceiling)) };
99
76
  }
100
77
 
101
78
  /**
@@ -113,46 +90,17 @@ function roundInside(oklch: OKLCH, ceiling: GamutCeiling): OKLCH {
113
90
  return { ...rounded, c: Math.max(0, c) };
114
91
  }
115
92
 
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
93
  /**
123
- * Map every step of a scale into the ceiling, keeping lightness order.
94
+ * Map every step of a scale into the ceiling, keeping lightness and hue.
124
95
  *
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.
96
+ * A step already inside the ceiling is not moved. A step outside keeps its
97
+ * lightness and hue and loses chroma only, to the ceiling's edge, so the
98
+ * scale's lightness order is the input's.
130
99
  */
131
100
  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
- }
101
+ return scale.map((step) =>
102
+ inCeiling(step, ceiling) ? { ...step } : roundInside(toGamutCeiling(step, ceiling), ceiling),
103
+ );
156
104
  }
157
105
 
158
106
  /**
@@ -172,12 +120,12 @@ export function storedTier(oklch: OKLCH): GamutCeiling {
172
120
  }
173
121
 
174
122
  /**
175
- * Snap an OKLCH into sRGB and round it -- the shape every generated color is
176
- * emitted in. Lives here because both halves are gamut/conversion concerns;
177
- * harmony.ts and semantic.ts are callers, not owners.
123
+ * Snap an OKLCH into sRGB and round it inside sRGB -- the shape every
124
+ * generated color is emitted in. Lives here because both halves are
125
+ * gamut/conversion concerns; harmony.ts and semantic.ts are callers, not owners.
178
126
  */
179
127
  export function clampColor(color: OKLCH): OKLCH {
180
- return roundOKLCH(toNearestGamut(color).color);
128
+ return roundInside(toNearestGamut(color).color, 'srgb');
181
129
  }
182
130
 
183
131
  const DEFAULT_STEPS = 101;
@@ -196,7 +144,7 @@ const TOLERANCE = 0.001;
196
144
  * against 0.0227 at h=145, l=0.05. `inGamut` is the predicate this function
197
145
  * actually needs, and bisecting it is the only way to ask where it flips.
198
146
  */
199
- function findMaxChroma(l: number, h: number, gamutSpace: string): number {
147
+ export function findMaxChroma(l: number, h: number, gamutSpace: string): number {
200
148
  let lo = 0;
201
149
  let hi = MAX_CHROMA;
202
150
  if (l <= 0 || l >= 1) return 0;
package/src/harmony.ts CHANGED
@@ -2,10 +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 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).
5
+ * Only H changes. Every harmony output goes through clampColor(), which
6
+ * drops chroma into sRGB and rounds inside it. The scale is different:
7
+ * generateOKLCHScale returns unmapped steps, and buildColorValue maps them
8
+ * into the family's ceiling with mapScaleToGamut (gamut.ts).
9
9
  */
10
10
 
11
11
  import type { ColorHarmonies, OKLCH } from '@rafters/shared';
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export {
37
37
  hexToOKLCH,
38
38
  type OklchToCSSOptions,
39
39
  oklchToCSS,
40
+ oklchToSRGB,
40
41
  roundOKLCH,
41
42
  tryParseColor,
42
43
  } from './conversion.js';
@@ -59,6 +60,15 @@ export {
59
60
  // Harmony
60
61
  export { generateHarmony, generateOKLCHScale } from './harmony.js';
61
62
 
63
+ // Normalized scale (#2872)
64
+ export { createNormalizedScale } from './normalized-scale.js';
65
+ export {
66
+ CHROMA_SHARE,
67
+ HUE_DRIFT_BANDS,
68
+ HUE_DRIFT_SAMPLES,
69
+ LIGHTNESS_LADDERS,
70
+ } from './normalized-scale-constants.js';
71
+
62
72
  // Manipulation
63
73
  export { adjustHue } from './manipulation.js';
64
74
  // Naming
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Measured constants for createNormalizedScale.
3
+ *
4
+ * Source: the hand-tuned palettes in platform's colour seed
5
+ * (apps/api/seed/colors.json) -- Tailwind, Material, Open Color, Ant and
6
+ * Chakra -- 66 chromatic palettes, each interpolated to 11 positions, median
7
+ * across palettes, measured 2026-10-26. Radix is excluded: its light and dark
8
+ * scales are mixed in the data. Every array runs lightest (50) to darkest (950).
9
+ */
10
+
11
+ /**
12
+ * Step lightness, by ladder.
13
+ * deep: Tailwind, Ant and Chakra, 38 palettes (17, 12 and 9).
14
+ * compact: Material and Open Color, 28 palettes (16 and 12).
15
+ */
16
+ export const LIGHTNESS_LADDERS = {
17
+ deep: [0.978, 0.942, 0.896, 0.827, 0.74, 0.67, 0.593, 0.52, 0.451, 0.392, 0.292],
18
+ compact: [0.964, 0.925, 0.874, 0.811, 0.762, 0.719, 0.677, 0.644, 0.6, 0.543, 0.49],
19
+ } as const;
20
+
21
+ /**
22
+ * Step chroma as a share of the sRGB chroma maximum at the step's lightness
23
+ * and hue.
24
+ * gamut: the maximum itself, by definition.
25
+ * measured: all five systems, 66 palettes.
26
+ */
27
+ export const CHROMA_SHARE = {
28
+ gamut: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
29
+ measured: [0.97, 0.95, 0.92, 0.9, 0.94, 0.95, 0.97, 0.97, 0.97, 0.92, 0.94],
30
+ } as const;
31
+
32
+ /**
33
+ * Hue drift in degrees, relative to the palette's middle step (500), keyed by
34
+ * band centre hue (0 = red, 60 = amber, 90 = yellow, ... 330). All five
35
+ * systems, 66 palettes; the count per band is HUE_DRIFT_SAMPLES.
36
+ */
37
+ export const HUE_DRIFT_BANDS = {
38
+ 0: [-10.0, -9.1, -6.6, -4.6, -2.8, 0.0, -0.8, -2.2, -3.4, -4.6, -7.0],
39
+ 30: [-7.0, -4.0, -5.4, -3.8, -2.4, 0.0, 0.0, -0.4, -1.3, -2.1, -2.5],
40
+ 60: [25.5, 25.6, 22.4, 17.8, 10.2, 3.2, -2.4, -7.6, -11.1, -13.8, -16.0],
41
+ 90: [11.0, 10.1, 10.0, 8.6, 6.0, 1.5, -3.6, -10.3, -17.6, -24.8, -32.0],
42
+ 120: [-9.0, -7.2, -6.2, -4.0, -2.0, -0.5, 0.4, 1.0, 2.0, 2.0, 2.0],
43
+ 150: [4.0, 1.6, 1.6, 1.2, 0.7, 0.0, 0.2, 0.6, 1.5, 2.5, 2.0],
44
+ 180: [3.5, 2.6, 1.2, 1.2, 0.9, 0.5, 0.4, 1.0, 1.9, 1.5, 4.5],
45
+ 210: [-10.0, -9.3, -8.4, -6.6, -3.4, -1.5, 0.4, 0.4, 1.2, 2.2, 4.0],
46
+ 240: [-10.0, -8.1, -6.4, -7.0, -4.0, -1.0, 0.8, 2.6, 4.4, 6.1, 7.0],
47
+ 270: [-4.0, -3.5, -2.2, -0.8, 0.5, 0.2, 0.2, 0.4, 0.0, 1.0, 2.0],
48
+ 300: [7.0, 7.0, 6.2, 4.6, 2.6, 0.5, -1.2, -2.3, -2.5, -2.5, -2.5],
49
+ 330: [-2.0, -0.4, 0.0, 0.7, 1.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0],
50
+ } as const;
51
+
52
+ /** Palettes per hue band behind HUE_DRIFT_BANDS (66 in all); 330 (pink) rests on 3. */
53
+ export const HUE_DRIFT_SAMPLES = {
54
+ 0: 5,
55
+ 30: 8,
56
+ 60: 6,
57
+ 90: 7,
58
+ 120: 5,
59
+ 150: 6,
60
+ 180: 4,
61
+ 210: 5,
62
+ 240: 5,
63
+ 270: 6,
64
+ 300: 6,
65
+ 330: 3,
66
+ } as const;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Normalized scale builder.
3
+ *
4
+ * A second way to build a colour family, beside buildColorValue: step
5
+ * lightness from a measured ladder bent through the source, step chroma at
6
+ * the source's own share of what sRGB holds, and step hue drifting toward
7
+ * both ends the way hand-tuned systems drift it. The constants are in normalized-scale-constants.ts.
8
+ */
9
+
10
+ import type { ColorValue, OKLCH } from '@rafters/shared';
11
+ import { assembleColorValue } from './builder.js';
12
+ import { findMaxChroma, isInSRGBGamut, storedTier } from './gamut.js';
13
+ import { normalizeHue } from './internal/math.js';
14
+ import { CHROMA_SHARE, HUE_DRIFT_BANDS, LIGHTNESS_LADDERS } from './normalized-scale-constants.js';
15
+ import { SCALE_POSITIONS } from './scale-positions.js';
16
+
17
+ /** An option createNormalizedScale cannot build from. */
18
+ export class NormalizedScaleError extends Error {
19
+ constructor(
20
+ public readonly option: 'lightness' | 'chroma' | 'hue',
21
+ message: string,
22
+ ) {
23
+ super(`createNormalizedScale: ${option} ${message}`);
24
+ this.name = 'NormalizedScaleError';
25
+ }
26
+ }
27
+
28
+ const STEP_COUNT = SCALE_POSITIONS.length;
29
+ const BAND_WIDTH = 30;
30
+
31
+ function checkLength(option: 'lightness' | 'chroma' | 'hue', values: readonly number[]): void {
32
+ if (values.length !== STEP_COUNT) {
33
+ throw new NormalizedScaleError(option, `must have ${STEP_COUNT} entries, got ${values.length}`);
34
+ }
35
+ }
36
+
37
+ function resolveLadder(lightness: number[] | 'deep' | 'compact'): readonly number[] {
38
+ if (typeof lightness === 'string') return LIGHTNESS_LADDERS[lightness];
39
+ checkLength('lightness', lightness);
40
+ for (let i = 1; i < lightness.length; i++) {
41
+ const prev = lightness[i - 1];
42
+ const cur = lightness[i];
43
+ if (prev === undefined || cur === undefined || cur >= prev) {
44
+ throw new NormalizedScaleError('lightness', 'must be strictly decreasing');
45
+ }
46
+ }
47
+ return lightness;
48
+ }
49
+
50
+ function resolveShare(chroma: number[] | 'gamut' | 'measured'): readonly number[] {
51
+ if (typeof chroma === 'string') return CHROMA_SHARE[chroma];
52
+ checkLength('chroma', chroma);
53
+ return chroma;
54
+ }
55
+
56
+ const DRIFT_BY_BAND: Readonly<Record<number, readonly number[]>> = HUE_DRIFT_BANDS;
57
+
58
+ function bandDrift(band: number): readonly number[] {
59
+ return DRIFT_BY_BAND[normalizeHue(band)] ?? HUE_DRIFT_BANDS[0];
60
+ }
61
+
62
+ /** The measured drift for a hue, interpolated between the two nearest bands. */
63
+ function measuredDrift(hue: number): number[] {
64
+ const h = normalizeHue(hue);
65
+ const lower = Math.floor(h / BAND_WIDTH) * BAND_WIDTH;
66
+ const t = (h - lower) / BAND_WIDTH;
67
+ const lo = bandDrift(lower);
68
+ const hi = bandDrift(lower + BAND_WIDTH);
69
+ return lo.map((d, i) => d * (1 - t) + (hi[i] ?? d) * t);
70
+ }
71
+
72
+ function resolveDrift(hue: number[] | 'measured' | 'none', sourceHue: number): readonly number[] {
73
+ if (hue === 'none') return Array.from({ length: STEP_COUNT }, () => 0);
74
+ if (hue === 'measured') return measuredDrift(sourceHue);
75
+ checkLength('hue', hue);
76
+ return hue;
77
+ }
78
+
79
+ /** The step whose ladder lightness is nearest the source's own. */
80
+ function nearestStep(ladder: readonly number[], l: number): number {
81
+ let best = 0;
82
+ for (let i = 1; i < ladder.length; i++) {
83
+ const cur = ladder[i];
84
+ const bestL = ladder[best];
85
+ if (cur !== undefined && bestL !== undefined && Math.abs(cur - l) < Math.abs(bestL - l)) {
86
+ best = i;
87
+ }
88
+ }
89
+ return best;
90
+ }
91
+
92
+ /**
93
+ * Step lightness: the ladder bent toward the pin. Steps lighter than the
94
+ * anchor are re-spaced between the ladder's top and the pin, steps darker
95
+ * between the pin and the ladder's bottom, so the run has no gap.
96
+ */
97
+ function bendLadder(ladder: readonly number[], anchor: number, pin: number): number[] {
98
+ const top = ladder[0] ?? pin;
99
+ const bottom = ladder[ladder.length - 1] ?? pin;
100
+ const at = ladder[anchor] ?? pin;
101
+ return ladder.map((lad, k) => {
102
+ if (k === anchor) return pin;
103
+ if (k < anchor) return top + ((pin - top) * (lad - top)) / (at - top);
104
+ return pin + ((bottom - pin) * (lad - at)) / (bottom - at);
105
+ });
106
+ }
107
+
108
+ /** Chroma floored to the stored precision, so a step at the sRGB edge stays inside. */
109
+ function floorChroma(c: number): number {
110
+ return Math.floor(c * 1000) / 1000;
111
+ }
112
+
113
+ /**
114
+ * Chroma reduction into sRGB: a step outside keeps its lightness and hue
115
+ * exactly and takes the sRGB chroma maximum there.
116
+ */
117
+ function reduceIntoSRGB(step: OKLCH): OKLCH {
118
+ if (isInSRGBGamut(step)) return step;
119
+ return { ...step, c: floorChroma(findMaxChroma(step.l, step.h, 'srgb')) };
120
+ }
121
+
122
+ /** Lightness at which every hue has room, for the source's chroma share. */
123
+ const SHARE_REFERENCE_L = 0.6;
124
+
125
+ /**
126
+ * Build a ColorValue on a normalized 11-step scale.
127
+ *
128
+ * The source is the pin, exactly as typed, at the step whose ladder lightness
129
+ * is nearest its own (the anchor). The other steps take the ladder bent
130
+ * toward the pin, the source's share of the sRGB chroma maximum (at L 0.6)
131
+ * times the chroma curve times the maximum at the step, and the source hue
132
+ * plus the drift relative to the anchor. A step outside sRGB, the anchor
133
+ * included, loses chroma only. Harmonies, accessibility, analysis and naming
134
+ * are buildColorValue's.
135
+ *
136
+ * @throws NormalizedScaleError when an array option does not have 11 entries,
137
+ * or a lightness array is not strictly decreasing.
138
+ */
139
+ export function createNormalizedScale(
140
+ source: OKLCH,
141
+ options: {
142
+ lightness?: number[] | 'deep' | 'compact';
143
+ chroma?: number[] | 'gamut' | 'measured';
144
+ hue?: number[] | 'measured' | 'none';
145
+ } = {},
146
+ ): ColorValue {
147
+ const lightness = options.lightness ?? 'deep';
148
+ const ladder = resolveLadder(lightness);
149
+ const curve = resolveShare(options.chroma ?? 'measured');
150
+ const drift = resolveDrift(options.hue ?? 'measured', source.h);
151
+
152
+ const anchor = nearestStep(ladder, source.l);
153
+ const anchorDrift = drift[anchor] ?? 0;
154
+ const stepL = bendLadder(ladder, anchor, source.l);
155
+ const referenceMax = findMaxChroma(SHARE_REFERENCE_L, source.h, 'srgb');
156
+ const seedShare = referenceMax > 0 ? Math.min(1, source.c / referenceMax) : 0;
157
+
158
+ const scale = stepL.map((rawL, i): OKLCH => {
159
+ if (i === anchor) return reduceIntoSRGB({ ...source });
160
+ const l = Math.round(rawL * 1000) / 1000;
161
+ const h = Math.round(normalizeHue(source.h + (drift[i] ?? 0) - anchorDrift));
162
+ const c = floorChroma(seedShare * (curve[i] ?? 0) * findMaxChroma(l, h, 'srgb'));
163
+ return reduceIntoSRGB({ l, c, h, alpha: source.alpha ?? 1 });
164
+ });
165
+ const gamut = scale.map(storedTier);
166
+
167
+ const landed = scale[anchor] ?? source;
168
+ const seedAdjusted: ColorValue['seedAdjusted'] =
169
+ landed.c !== source.c ? { from: { ...source }, to: { ...landed }, reason: 'gamut' } : undefined;
170
+
171
+ return {
172
+ ...assembleColorValue(source, { scale, gamut, seedAdjusted, reference: landed }, {}),
173
+ scaleStyle: typeof lightness === 'string' ? lightness : 'custom',
174
+ anchor: SCALE_POSITIONS[anchor],
175
+ };
176
+ }