@newtonedev/colors 0.0.1 → 1.0.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
@@ -33,26 +33,25 @@ const hexValues = scale.map(oklchToHex);
33
33
 
34
34
  | Option | Type | Default | Description |
35
35
  |---|---|---|---|
36
+ | `contrast.light` | `number` | `1` | How light the lightest step is (0–1). 0 = near-white, 1 = pure white. |
37
+ | `contrast.dark` | `number` | `1` | How dark the darkest step is (0–1). 0 = near-black, 1 = pure black. |
36
38
  | `hue` | `number` | — | Hue angle in degrees (0–360) |
37
- | `steps` | `number` | | Number of steps (minimum 2) |
38
- | `chroma.value` | `number` | `1` | How much of the gamut to use (0–1). 0 = achromatic, 1 = maximum. |
39
- | `chroma.offset` | `number` | `0.5` | Where in the scale chroma peaks (0 = lightest end, 1 = darkest end). |
40
- | `gamut` | `"srgb" \| "display-p3"` | `"srgb"` | Target gamut for chroma boundary. |
41
- | `lightest` | `number` | `1` | Lightness of the lightest step. |
42
- | `darkest` | `number` | `0` | Lightness of the darkest step. |
39
+ | `chroma.amount` | `number` | `0` | How much of the gamut to use (0–1). 0 = achromatic, 1 = maximum. |
40
+ | `chroma.balance` | `number` | `0.5` | How chroma is distributed along the scale (0 = toward lightest, 1 = toward darkest). |
41
+ | `isP3` | `boolean` | `false` | Use Display P3 gamut instead of sRGB. |
43
42
  | `grading` | `Grading` | — | Global hue grading shared across all palettes. |
44
- | `gradient` | `Gradient` | — | Per-palette one-sided hue grade. |
43
+ | `shift` | `Shift` | — | Per-palette one-sided hue shift. Defaults to the dark end; set `light: true` for the light end. |
45
44
 
46
45
  ---
47
46
 
48
47
  ## Key color workflow
49
48
 
50
- Derive scale parameters from a brand color, then locate it in the generated scale:
49
+ Derive scale parameters from a known color, then locate it in the generated scale:
51
50
 
52
51
  ```ts
53
52
  import { keyColor, generateScale, findNearest, oklchToHex } from "newtone-colors";
54
53
 
55
- const key = keyColor("#3B82F6", { gamut: "srgb", lightest: 0.96, darkest: 0.16 });
54
+ const key = keyColor("#3B82F6", { contrast: { light: 0, dark: 0 } });
56
55
  const scale = generateScale({ ...key, steps: 11 });
57
56
  const { index } = findNearest(key.resolved.oklch, scale);
58
57
 
@@ -60,7 +59,7 @@ const { index } = findNearest(key.resolved.oklch, scale);
60
59
  console.log(oklchToHex(scale[index]));
61
60
  ```
62
61
 
63
- `keyColor` derives `hue` and `chroma` (value + offset) from the color and returns them ready to spread into `ScaleOptions`. The `chroma.offset` is computed from where the color's lightness falls within the scale range, so the chroma peak aligns with the key color's position.
62
+ `keyColor` derives `hue` and `chroma` (amount + balance) from the color and returns them ready to spread into `ScaleOptions`. The `chroma.balance` is computed from where the color's lightness falls within the scale range, so the chroma distribution is centered around the key color's position.
64
63
 
65
64
  The reverse direction — step to hex — is already covered by `oklchToHex(scale[index])`.
66
65
 
@@ -72,22 +71,22 @@ Shift hues at the light or dark end of the scale toward a target hue, using vect
72
71
 
73
72
  ```ts
74
73
  import { generateScale } from "newtone-colors";
75
- import type { Grading, Gradient } from "newtone-colors";
74
+ import type { Grading, Shift } from "newtone-colors";
76
75
 
77
76
  // Global: affects all palettes equally
78
77
  const grading: Grading = {
79
- lightHue: 145, lightValue: 0.15, // light end shifts toward 145°
80
- darkHue: 25, darkValue: 0.15, // dark end shifts toward 25°
78
+ light: { hue: 145, amount: 0.15 }, // light end shifts toward 145°
79
+ dark: { hue: 25, amount: 0.15 }, // dark end shifts toward 25°
81
80
  };
82
81
 
83
- // Per-palette: one-sided, stronger
84
- const gradient: Gradient = {
82
+ // Per-palette: one-sided, stronger. Omit `light` or set false for dark end.
83
+ const shift: Shift = {
85
84
  hue: 200,
86
- value: 0.3,
87
- direction: "dark", // only affects the dark end
85
+ amount: 0.3,
86
+ // light: true uncomment to target the light end instead
88
87
  };
89
88
 
90
- const scale = generateScale({ hue: 264, steps: 11, grading, gradient });
89
+ const scale = generateScale({ hue: 264, grading, shift });
91
90
  ```
92
91
 
93
92
  ---
@@ -99,10 +98,10 @@ Map slider values (0–1) to lightness bounds. The defaults keep the scale away
99
98
  ```ts
100
99
  import { resolveLightest, resolveDarkest } from "newtone-colors";
101
100
 
102
- const lightest = resolveLightest(0); // → 0.96 (MIN_LIGHTEST_L)
103
- const darkest = resolveDarkest(0); // → 0.16 (MAX_DARKEST_L)
101
+ const lightL = resolveLightest(0); // → 0.96 (MIN_LIGHTEST_L)
102
+ const darkL = resolveDarkest(0); // → 0.16 (MAX_DARKEST_L)
104
103
 
105
- const scale = generateScale({ hue: 264, steps: 11, lightest, darkest });
104
+ const scale = generateScale({ contrast: { light: 0, dark: 0 }, hue: 264, steps: 11 });
106
105
  ```
107
106
 
108
107
  See [docs/constants.md](docs/constants.md) for all hardcoded values and their rationale.
@@ -145,9 +144,9 @@ Resolve any hex or OKLCH input to scale-ready parameters, with automatic gamut m
145
144
  ```ts
146
145
  import { resolveColor } from "newtone-colors";
147
146
 
148
- const result = resolveColor("#E74C3C", "srgb");
147
+ const result = resolveColor("#E74C3C");
149
148
  result.hue; // extracted hue
150
- result.chromaRatio; // chroma as fraction of gamut boundary → ScaleOptions.chroma.value
149
+ result.chromaRatio; // chroma as fraction of gamut boundary → ScaleOptions.chroma.amount
151
150
  result.wasRemapped; // true if the color was outside the target gamut
152
151
  result.original; // original OKLCH before mapping
153
152
  ```
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=key-color.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"key-color.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/key-color.test.ts"],"names":[],"mappings":""}
package/dist/config.d.ts CHANGED
@@ -4,6 +4,18 @@
4
4
  * Edit this file to adjust the perceptual behavior of every scale and
5
5
  * grading operation across the library.
6
6
  */
7
+ /**
8
+ * Default number of steps in a generated scale.
9
+ * Fixed at 26 — enough for a full design token set with fine lightness
10
+ * increments without redundancy.
11
+ */
12
+ export declare const DEFAULT_SCALE_STEPS = 26;
13
+ /**
14
+ * Default hue when none is provided.
15
+ * 0° is the start of the OKLCH hue wheel (red region). Arbitrary but
16
+ * deterministic — callers should always supply an explicit hue.
17
+ */
18
+ export declare const DEFAULT_HUE = 0;
7
19
  /**
8
20
  * Lightest step's lightness when the whites slider is at 0.
9
21
  * slider 0 → L = MIN_LIGHTEST_L, slider 1 → L = 1.0 (pure white).
@@ -15,20 +27,20 @@ export declare const MIN_LIGHTEST_L = 0.96;
15
27
  */
16
28
  export declare const MAX_DARKEST_L = 0.16;
17
29
  /**
18
- * How far a global grade reaches into the scale from each end (0–1).
19
- * Light grading fades to zero at t = GRADE_REACH.
20
- * Dark grading fades to zero at t = 1 − GRADE_REACH.
30
+ * How far global grading reaches into the scale from each end (0–1).
31
+ * Light grading fades to zero at t = GRADING_REACH.
32
+ * Dark grading fades to zero at t = 1 − GRADING_REACH.
21
33
  */
22
- export declare const GRADE_REACH: number;
23
- /** Maximum blend intensity for global grading. Inputs above this are clamped. */
24
- export declare const MAX_GRADE_INTENSITY = 0.25;
34
+ export declare const GRADING_REACH: number;
35
+ /** Maximum amount for global grading. Inputs above this are clamped. */
36
+ export declare const MAX_GRADING_AMOUNT = 0.25;
25
37
  /**
26
- * How far a local (per-palette) grade reaches into the scale from each end (0–1).
27
- * Slightly wider than GRADE_REACH to give per-palette grading more room.
38
+ * How far per-palette hue shift reaches into the scale from each end (0–1).
39
+ * Slightly wider than GRADING_REACH to give per-palette shifts more room.
28
40
  */
29
- export declare const LOCAL_GRADE_REACH: number;
30
- /** Maximum blend intensity for local grading. Inputs above this are clamped. */
31
- export declare const MAX_LOCAL_GRADE_INTENSITY = 0.5;
41
+ export declare const SHIFT_REACH: number;
42
+ /** Maximum amount for per-palette hue shift. Inputs above this are clamped. */
43
+ export declare const MAX_SHIFT_AMOUNT = 0.5;
32
44
  /**
33
45
  * Just-noticeable difference threshold (deltaEOK).
34
46
  * Colors with distance below this are considered perceptually identical.
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH;;;GAGG;AACH,eAAO,MAAM,cAAc,OAAO,CAAC;AAEnC;;;GAGG;AACH,eAAO,MAAM,aAAa,OAAO,CAAC;AAIlC;;;;GAIG;AACH,eAAO,MAAM,WAAW,QAAQ,CAAC;AAEjC,iFAAiF;AACjF,eAAO,MAAM,mBAAmB,OAAO,CAAC;AAExC;;;GAGG;AACH,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC,gFAAgF;AAChF,eAAO,MAAM,yBAAyB,MAAM,CAAC;AAI7C;;;;GAIG;AACH,eAAO,MAAM,cAAc,OAAO,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC;;;;GAIG;AACH,eAAO,MAAM,WAAW,IAAI,CAAC;AAI7B;;;GAGG;AACH,eAAO,MAAM,cAAc,OAAO,CAAC;AAEnC;;;GAGG;AACH,eAAO,MAAM,aAAa,OAAO,CAAC;AAIlC;;;;GAIG;AACH,eAAO,MAAM,aAAa,QAAQ,CAAC;AAEnC,wEAAwE;AACxE,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC;;;GAGG;AACH,eAAO,MAAM,WAAW,QAAQ,CAAC;AAEjC,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAIpC;;;;GAIG;AACH,eAAO,MAAM,cAAc,OAAO,CAAC"}
@@ -10,5 +10,21 @@ import type { Srgb } from "../types.js";
10
10
  * IMPORTANT: Uses its own linearization (simple 2.4 gamma),
11
11
  * NOT the IEC piecewise sRGB transfer function.
12
12
  */
13
+ /**
14
+ * Practical maximum APCA Lc magnitude used for normalization.
15
+ * BoW tops out at ~+106, WoB at ~-108; we use 108 as a symmetric ceiling.
16
+ */
17
+ export declare const APCA_LC_MAX = 108;
18
+ /**
19
+ * Normalize a raw APCA Lc value to the range [-1, +1].
20
+ * +1 = maximum dark-on-light contrast (BoW)
21
+ * -1 = maximum light-on-dark contrast (WoB)
22
+ * 0 = no meaningful contrast
23
+ */
24
+ export declare function apcaToNormalized(lc: number): number;
25
+ /**
26
+ * Convert a normalized contrast value [-1, +1] back to a raw APCA Lc value.
27
+ */
28
+ export declare function normalizedToApca(normalized: number): number;
13
29
  export declare function apcaContrast(textColor: Srgb, bgColor: Srgb): number;
14
30
  //# sourceMappingURL=apca.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"apca.d.ts","sourceRoot":"","sources":["../../src/contrast/apca.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAoBxC;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,GAAG,MAAM,CAmCnE"}
1
+ {"version":3,"file":"apca.d.ts","sourceRoot":"","sources":["../../src/contrast/apca.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAoBxC;;;;;;;;;;GAUG;AACH;;;GAGG;AACH,eAAO,MAAM,WAAW,MAAM,CAAC;AAE/B;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,GAAG,MAAM,CAmCnE"}
@@ -1,8 +1,13 @@
1
1
  import type { Gamut } from "../types.js";
2
2
  /**
3
- * Find the maximum gamut chroma for a given OKLCH lightness and hue.
4
- * Binary search on chroma [0, upperBound].
5
- * @param gamut Target gamut (default: 'srgb'). P3 searches up to 0.5.
3
+ * Find the maximum in-gamut chroma for a given OKLCH lightness and hue.
4
+ *
5
+ * Binary search over [0, upperBound], converging to 1e-6 precision.
6
+ * Upper bounds are empirically safe ceilings — no in-gamut color at any
7
+ * lightness or hue exceeds C = 0.4 in sRGB or C = 0.5 in Display P3.
8
+ * Searching beyond these would waste iterations with no benefit.
9
+ *
10
+ * Returns 0 for L ≤ 0 or L ≥ 1 (pure black and white carry no chroma).
6
11
  */
7
12
  export declare function maxChroma(L: number, h: number, gamut?: Gamut): number;
8
13
  //# sourceMappingURL=max-chroma.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"max-chroma.d.ts","sourceRoot":"","sources":["../../src/gamut/max-chroma.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAS,MAAM,aAAa,CAAC;AAKhD;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,KAAK,GAAE,KAAc,GAAG,MAAM,CAsB7E"}
1
+ {"version":3,"file":"max-chroma.d.ts","sourceRoot":"","sources":["../../src/gamut/max-chroma.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAS,MAAM,aAAa,CAAC;AAKhD;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,KAAK,GAAE,KAAc,GAAG,MAAM,CAsB7E"}
package/dist/index.cjs CHANGED
@@ -304,6 +304,13 @@ function contrastTextHex(background) {
304
304
  }
305
305
 
306
306
  // src/contrast/apca.ts
307
+ var APCA_LC_MAX = 108;
308
+ function apcaToNormalized(lc) {
309
+ return Math.max(-1, Math.min(1, lc / APCA_LC_MAX));
310
+ }
311
+ function normalizedToApca(normalized) {
312
+ return normalized * APCA_LC_MAX;
313
+ }
307
314
  function apcaContrast(textColor, bgColor) {
308
315
  let txtY = APCA_SRGB_R * textColor.r ** APCA_MAIN_TRC + APCA_SRGB_G * textColor.g ** APCA_MAIN_TRC + APCA_SRGB_B * textColor.b ** APCA_MAIN_TRC;
309
316
  let bgY = APCA_SRGB_R * bgColor.r ** APCA_MAIN_TRC + APCA_SRGB_G * bgColor.g ** APCA_MAIN_TRC + APCA_SRGB_B * bgColor.b ** APCA_MAIN_TRC;
@@ -345,19 +352,20 @@ function mix(a, b, t) {
345
352
  }
346
353
 
347
354
  // src/config.ts
355
+ var DEFAULT_SCALE_STEPS = 26;
356
+ var DEFAULT_HUE = 0;
348
357
  var MIN_LIGHTEST_L = 0.96;
349
358
  var MAX_DARKEST_L = 0.16;
350
- var GRADE_REACH = 3 / 5;
351
- var MAX_GRADE_INTENSITY = 0.25;
352
- var LOCAL_GRADE_REACH = 2 / 3;
353
- var MAX_LOCAL_GRADE_INTENSITY = 0.5;
359
+ var GRADING_REACH = 3 / 5;
360
+ var MAX_GRADING_AMOUNT = 0.25;
361
+ var SHIFT_REACH = 2 / 3;
362
+ var MAX_SHIFT_AMOUNT = 0.5;
354
363
  var PERCEPTUAL_JND = 0.02;
355
364
 
356
365
  // src/scale/hue-grade.ts
357
- function gradeHue(baseHue, t, grade, reach = GRADE_REACH, maxIntensity = MAX_GRADE_INTENSITY) {
358
- const { lightHue, lightValue, darkHue, darkValue } = grade;
359
- const li = Math.max(0, Math.min(maxIntensity, lightValue));
360
- const di = Math.max(0, Math.min(maxIntensity, darkValue));
366
+ function gradeHue(baseHue, t, grade, reach = GRADING_REACH, maxIntensity = MAX_GRADING_AMOUNT) {
367
+ const li = Math.max(0, Math.min(maxIntensity, grade.light?.amount ?? 0));
368
+ const di = Math.max(0, Math.min(maxIntensity, grade.dark?.amount ?? 0));
361
369
  if (li === 0 && di === 0) return baseHue;
362
370
  const lightInfluence = t <= reach ? 0.5 * (1 + Math.cos(Math.PI * t / reach)) : 0;
363
371
  const darkInfluence = t >= 1 - reach ? 0.5 * (1 + Math.cos(Math.PI * (1 - t) / reach)) : 0;
@@ -369,13 +377,13 @@ function gradeHue(baseHue, t, grade, reach = GRADE_REACH, maxIntensity = MAX_GRA
369
377
  let dy = 0;
370
378
  if (lightInfluence > 0 && li > 0) {
371
379
  const blend = lightInfluence * li;
372
- const lRad = lightHue * toRad;
380
+ const lRad = grade.light.hue * toRad;
373
381
  dx += blend * (Math.cos(lRad) - bx);
374
382
  dy += blend * (Math.sin(lRad) - by);
375
383
  }
376
384
  if (darkInfluence > 0 && di > 0) {
377
385
  const blend = darkInfluence * di;
378
- const dRad = darkHue * toRad;
386
+ const dRad = grade.dark.hue * toRad;
379
387
  dx += blend * (Math.cos(dRad) - bx);
380
388
  dy += blend * (Math.sin(dRad) - by);
381
389
  }
@@ -386,35 +394,49 @@ function gradeHue(baseHue, t, grade, reach = GRADE_REACH, maxIntensity = MAX_GRA
386
394
  }
387
395
  function resolveGradedHue(baseHue, t, globalGrade, localGrade) {
388
396
  let h = baseHue;
397
+ if (localGrade) h = gradeHue(h, t, localGrade, SHIFT_REACH, MAX_SHIFT_AMOUNT);
389
398
  if (globalGrade) h = gradeHue(h, t, globalGrade);
390
- if (localGrade) h = gradeHue(h, t, localGrade, LOCAL_GRADE_REACH, MAX_LOCAL_GRADE_INTENSITY);
391
399
  return h;
392
400
  }
393
- function buildOneSidedGrade(hue, value, side) {
394
- return side === "light" ? { lightHue: hue, lightValue: value, darkHue: 0, darkValue: 0 } : { lightHue: 0, lightValue: 0, darkHue: hue, darkValue: value };
401
+ function buildOneSidedGrade(hue, amount, light = false) {
402
+ return light ? { light: { hue, amount } } : { dark: { hue, amount } };
403
+ }
404
+
405
+ // src/scale/dynamic-range.ts
406
+ function resolveLightest(slider) {
407
+ const s = Math.max(0, Math.min(1, slider));
408
+ return MIN_LIGHTEST_L + s * (1 - MIN_LIGHTEST_L);
409
+ }
410
+ function resolveDarkest(slider) {
411
+ const s = Math.max(0, Math.min(1, slider));
412
+ return MAX_DARKEST_L * (1 - s);
413
+ }
414
+ function lightnessToScaleT(L, lightestL, darkestL) {
415
+ const range = lightestL - darkestL;
416
+ if (range <= 0) return 0.5;
417
+ return Math.max(0, Math.min(1, (lightestL - L) / range));
395
418
  }
396
419
 
397
420
  // src/scale/generate.ts
398
- function generateScale(options) {
421
+ function generateScale(options = {}) {
399
422
  const {
400
- hue,
401
- steps,
423
+ contrast,
424
+ hue = DEFAULT_HUE,
402
425
  chroma,
403
- gamut = "srgb",
404
- lightest = 1,
405
- darkest = 0,
426
+ isP3 = false,
406
427
  grading,
407
- gradient
428
+ shift
408
429
  } = options;
409
- const chromaRatio = chroma?.value ?? 1;
410
- const chromaPeak = chroma?.offset ?? 0.5;
430
+ const gamut = isP3 ? "display-p3" : "srgb";
431
+ const steps = DEFAULT_SCALE_STEPS;
432
+ const chromaRatio = chroma?.amount ?? 0;
433
+ const chromaPeak = chroma?.balance ?? 0.5;
411
434
  const hueGrade = grading;
412
- const localHueGrade = gradient ? buildOneSidedGrade(gradient.hue, gradient.value, gradient.direction) : void 0;
413
- if (steps < 2) return [];
435
+ const localHueGrade = shift ? buildOneSidedGrade(shift.hue, shift.amount, shift.light) : void 0;
414
436
  const ratio = Math.max(0, Math.min(1, chromaRatio));
415
437
  const peak = Math.max(0, Math.min(1, chromaPeak));
416
- const lightestL = Math.max(0, Math.min(1, lightest));
417
- const darkestL = Math.max(0, Math.min(1, darkest));
438
+ const lightestL = resolveLightest(contrast?.light ?? 1);
439
+ const darkestL = resolveDarkest(contrast?.dark ?? 1);
418
440
  const hueAt = (t) => resolveGradedHue(hue, t, hueGrade, localHueGrade);
419
441
  if (peak === 0.5 || ratio === 0 || ratio >= 1) {
420
442
  const scale2 = [];
@@ -422,7 +444,7 @@ function generateScale(options) {
422
444
  const t = i / (steps - 1);
423
445
  const L = lightestL - t * (lightestL - darkestL);
424
446
  const h = hueAt(t);
425
- const C = maxChroma(L, h, gamut) * ratio;
447
+ const C = Math.min(maxChroma(L, hue, gamut) * ratio, maxChroma(L, h, gamut));
426
448
  scale2.push({ L, C, h });
427
449
  }
428
450
  return scale2;
@@ -434,7 +456,7 @@ function generateScale(options) {
434
456
  for (let i = 0; i <= N; i++) {
435
457
  const t = i / N;
436
458
  const L = lightestL - t * (lightestL - darkestL);
437
- const C = maxChroma(L, hueAt(t), gamut);
459
+ const C = maxChroma(L, hue, gamut);
438
460
  boundarySamples.push({ t, C });
439
461
  if (C > peakBoundaryC) {
440
462
  peakBoundaryC = C;
@@ -471,7 +493,7 @@ function generateScale(options) {
471
493
  tWarped = peakT + (t - targetT) * ((1 - peakT) / (1 - targetT));
472
494
  }
473
495
  const Lwarped = lightestL - tWarped * (lightestL - darkestL);
474
- const warpedC = maxChroma(Lwarped, hueAt(tWarped), gamut) * ratio;
496
+ const warpedC = maxChroma(Lwarped, hue, gamut) * ratio;
475
497
  const boundaryC = maxChroma(L, h, gamut);
476
498
  const C = Math.min(warpedC, boundaryC);
477
499
  scale.push({ L, C, h });
@@ -480,7 +502,8 @@ function generateScale(options) {
480
502
  }
481
503
 
482
504
  // src/scale/resolve-color.ts
483
- function resolveColor(input, gamut = "srgb") {
505
+ function resolveColor(input, isP3 = false) {
506
+ const gamut = isP3 ? "display-p3" : "srgb";
484
507
  const isHex = typeof input === "string";
485
508
  const original = isHex ? hexToOklch(input) : input;
486
509
  let inGamut;
@@ -503,67 +526,43 @@ function resolveColor(input, gamut = "srgb") {
503
526
  chromaRatio: Math.min(1, chromaRatio)
504
527
  };
505
528
  }
506
- function findNearest(target, scale) {
507
- if (scale.length === 0) {
508
- throw new Error("findNearest: scale must not be empty");
509
- }
510
- let bestIndex = 0;
511
- let bestDistance = Infinity;
512
- for (let i = 0; i < scale.length; i++) {
513
- const d = deltaEOK(target, scale[i]);
514
- if (d < bestDistance) {
515
- bestDistance = d;
516
- bestIndex = i;
517
- }
518
- }
519
- return {
520
- index: bestIndex,
521
- color: scale[bestIndex],
522
- distance: bestDistance
523
- };
524
- }
525
-
526
- // src/scale/dynamic-range.ts
527
- function resolveLightest(slider) {
528
- const s = Math.max(0, Math.min(1, slider));
529
- return MIN_LIGHTEST_L + s * (1 - MIN_LIGHTEST_L);
530
- }
531
- function resolveDarkest(slider) {
532
- const s = Math.max(0, Math.min(1, slider));
533
- return MAX_DARKEST_L * (1 - s);
534
- }
535
- function lightnessToScaleT(L, lightestL, darkestL) {
536
- const range = lightestL - darkestL;
537
- if (range <= 0) return 0.5;
538
- return Math.max(0, Math.min(1, (lightestL - L) / range));
539
- }
540
529
 
541
530
  // src/scale/key-color.ts
542
531
  function keyColor(color, options) {
543
- const { gamut = "srgb", lightest = 1, darkest = 0 } = options ?? {};
544
- const resolved = resolveColor(color, gamut);
545
- const offset = lightnessToScaleT(resolved.oklch.L, lightest, darkest);
532
+ const { isP3 = false, contrast, steps = DEFAULT_SCALE_STEPS } = options ?? {};
533
+ const resolved = resolveColor(color, isP3);
534
+ const lightestL = resolveLightest(contrast?.light ?? 1);
535
+ const darkestL = resolveDarkest(contrast?.dark ?? 1);
536
+ const balance = lightnessToScaleT(resolved.oklch.L, lightestL, darkestL);
537
+ const stepIndex = Math.round(balance * Math.max(0, steps - 1));
546
538
  return {
547
539
  hue: resolved.hue,
548
- chroma: { value: resolved.chromaRatio, offset },
549
- resolved
540
+ chroma: { amount: resolved.chromaRatio, balance },
541
+ stepIndex,
542
+ hex: resolved.hex,
543
+ oklch: resolved.oklch,
544
+ wasRemapped: resolved.wasRemapped,
545
+ original: resolved.original
550
546
  };
551
547
  }
552
548
 
553
- exports.GRADE_REACH = GRADE_REACH;
554
- exports.LOCAL_GRADE_REACH = LOCAL_GRADE_REACH;
549
+ exports.APCA_LC_MAX = APCA_LC_MAX;
550
+ exports.DEFAULT_HUE = DEFAULT_HUE;
551
+ exports.DEFAULT_SCALE_STEPS = DEFAULT_SCALE_STEPS;
552
+ exports.GRADING_REACH = GRADING_REACH;
555
553
  exports.MAX_DARKEST_L = MAX_DARKEST_L;
556
- exports.MAX_GRADE_INTENSITY = MAX_GRADE_INTENSITY;
557
- exports.MAX_LOCAL_GRADE_INTENSITY = MAX_LOCAL_GRADE_INTENSITY;
554
+ exports.MAX_GRADING_AMOUNT = MAX_GRADING_AMOUNT;
555
+ exports.MAX_SHIFT_AMOUNT = MAX_SHIFT_AMOUNT;
558
556
  exports.MIN_LIGHTEST_L = MIN_LIGHTEST_L;
559
557
  exports.PERCEPTUAL_JND = PERCEPTUAL_JND;
558
+ exports.SHIFT_REACH = SHIFT_REACH;
560
559
  exports.apcaContrast = apcaContrast;
560
+ exports.apcaToNormalized = apcaToNormalized;
561
561
  exports.buildOneSidedGrade = buildOneSidedGrade;
562
562
  exports.clampSrgb = clampSrgb;
563
563
  exports.contrastTextHex = contrastTextHex;
564
564
  exports.deltaEOK = deltaEOK;
565
565
  exports.deltaEOKLab = deltaEOKLab;
566
- exports.findNearest = findNearest;
567
566
  exports.gamutMap = gamutMap;
568
567
  exports.generateScale = generateScale;
569
568
  exports.gradeHue = gradeHue;
@@ -578,6 +577,7 @@ exports.linearSrgbToOklab = linearSrgbToOklab;
578
577
  exports.linearSrgbToSrgb = linearSrgbToSrgb;
579
578
  exports.maxChroma = maxChroma;
580
579
  exports.mix = mix;
580
+ exports.normalizedToApca = normalizedToApca;
581
581
  exports.oklabToLinearP3 = oklabToLinearP3;
582
582
  exports.oklabToLinearSrgb = oklabToLinearSrgb;
583
583
  exports.oklabToOklch = oklabToOklch;
@@ -587,10 +587,7 @@ exports.oklchToOklab = oklchToOklab;
587
587
  exports.oklchToP3 = oklchToP3;
588
588
  exports.oklchToSrgb = oklchToSrgb;
589
589
  exports.p3ToOklch = p3ToOklch;
590
- exports.resolveColor = resolveColor;
591
- exports.resolveDarkest = resolveDarkest;
592
590
  exports.resolveGradedHue = resolveGradedHue;
593
- exports.resolveLightest = resolveLightest;
594
591
  exports.srgbChannelToLinear = srgbChannelToLinear;
595
592
  exports.srgbToHex = srgbToHex;
596
593
  exports.srgbToLinearSrgb = srgbToLinearSrgb;