@newtonedev/colors 0.0.1 → 1.1.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,28 @@ 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.
33
+ */
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;
37
+ /**
38
+ * Fraction of the gamut boundary injected as chroma when global grading
39
+ * is active. Ensures grading has a visible tinting effect even on
40
+ * achromatic (chroma.amount = 0) palettes.
41
+ *
42
+ * Only global grading injects chroma — per-palette shift does not.
21
43
  */
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;
44
+ export declare const GRADING_CHROMA_RATIO = 0.5;
25
45
  /**
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.
46
+ * How far per-palette hue shift reaches into the scale from each end (0–1).
47
+ * Slightly wider than GRADING_REACH to give per-palette shifts more room.
28
48
  */
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;
49
+ export declare const SHIFT_REACH: number;
50
+ /** Maximum amount for per-palette hue shift. Inputs above this are clamped. */
51
+ export declare const MAX_SHIFT_AMOUNT = 0.5;
32
52
  /**
33
53
  * Just-noticeable difference threshold (deltaEOK).
34
54
  * 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;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC;;;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,84 +352,147 @@ 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 GRADING_CHROMA_RATIO = 0.5;
362
+ var SHIFT_REACH = 2 / 3;
363
+ var MAX_SHIFT_AMOUNT = 0.5;
354
364
  var PERCEPTUAL_JND = 0.02;
355
365
 
356
366
  // 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));
367
+ function gradeHue(baseHue, t, grade, reach = GRADING_REACH, maxIntensity = MAX_GRADING_AMOUNT) {
368
+ const li = Math.max(0, Math.min(maxIntensity, grade.light?.amount ?? 0));
369
+ const di = Math.max(0, Math.min(maxIntensity, grade.dark?.amount ?? 0));
361
370
  if (li === 0 && di === 0) return baseHue;
362
371
  const lightInfluence = t <= reach ? 0.5 * (1 + Math.cos(Math.PI * t / reach)) : 0;
363
372
  const darkInfluence = t >= 1 - reach ? 0.5 * (1 + Math.cos(Math.PI * (1 - t) / reach)) : 0;
364
- const toRad = Math.PI / 180;
365
- const baseRad = baseHue * toRad;
373
+ const toRad2 = Math.PI / 180;
374
+ const baseRad = baseHue * toRad2;
366
375
  const bx = Math.cos(baseRad);
367
376
  const by = Math.sin(baseRad);
368
377
  let dx = 0;
369
378
  let dy = 0;
370
379
  if (lightInfluence > 0 && li > 0) {
371
380
  const blend = lightInfluence * li;
372
- const lRad = lightHue * toRad;
381
+ const lRad = grade.light.hue * toRad2;
373
382
  dx += blend * (Math.cos(lRad) - bx);
374
383
  dy += blend * (Math.sin(lRad) - by);
375
384
  }
376
385
  if (darkInfluence > 0 && di > 0) {
377
386
  const blend = darkInfluence * di;
378
- const dRad = darkHue * toRad;
387
+ const dRad = grade.dark.hue * toRad2;
379
388
  dx += blend * (Math.cos(dRad) - bx);
380
389
  dy += blend * (Math.sin(dRad) - by);
381
390
  }
382
391
  const rx = bx + dx;
383
392
  const ry = by + dy;
384
393
  if (rx * rx + ry * ry < 1e-20) return baseHue;
385
- return (Math.atan2(ry, rx) / toRad + 360) % 360;
394
+ return (Math.atan2(ry, rx) / toRad2 + 360) % 360;
395
+ }
396
+ function gradingInfluence(t, grading) {
397
+ const li = Math.max(0, Math.min(MAX_GRADING_AMOUNT, grading.light?.amount ?? 0));
398
+ const di = Math.max(0, Math.min(MAX_GRADING_AMOUNT, grading.dark?.amount ?? 0));
399
+ if (li === 0 && di === 0) return 0;
400
+ const lightInfluence = t <= GRADING_REACH ? 0.5 * (1 + Math.cos(Math.PI * t / GRADING_REACH)) : 0;
401
+ const darkInfluence = t >= 1 - GRADING_REACH ? 0.5 * (1 + Math.cos(Math.PI * (1 - t) / GRADING_REACH)) : 0;
402
+ const lightBlend = lightInfluence * (li / MAX_GRADING_AMOUNT);
403
+ const darkBlend = darkInfluence * (di / MAX_GRADING_AMOUNT);
404
+ return Math.max(lightBlend, darkBlend);
386
405
  }
387
406
  function resolveGradedHue(baseHue, t, globalGrade, localGrade) {
388
407
  let h = baseHue;
408
+ if (localGrade) h = gradeHue(h, t, localGrade, SHIFT_REACH, MAX_SHIFT_AMOUNT);
389
409
  if (globalGrade) h = gradeHue(h, t, globalGrade);
390
- if (localGrade) h = gradeHue(h, t, localGrade, LOCAL_GRADE_REACH, MAX_LOCAL_GRADE_INTENSITY);
391
410
  return h;
392
411
  }
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 };
412
+ function buildOneSidedGrade(hue, amount, light = false) {
413
+ return light ? { light: { hue, amount } } : { dark: { hue, amount } };
414
+ }
415
+
416
+ // src/scale/dynamic-range.ts
417
+ function resolveLightest(slider) {
418
+ const s = Math.max(0, Math.min(1, slider));
419
+ return MIN_LIGHTEST_L + s * (1 - MIN_LIGHTEST_L);
420
+ }
421
+ function resolveDarkest(slider) {
422
+ const s = Math.max(0, Math.min(1, slider));
423
+ return MAX_DARKEST_L * (1 - s);
424
+ }
425
+ function lightnessToScaleT(L, lightestL, darkestL) {
426
+ const range = lightestL - darkestL;
427
+ if (range <= 0) return 0.5;
428
+ return Math.max(0, Math.min(1, (lightestL - L) / range));
395
429
  }
396
430
 
397
431
  // src/scale/generate.ts
398
- function generateScale(options) {
432
+ var toRad = Math.PI / 180;
433
+ function applyGradingOverlay(C, h, L, t, grading, gamut) {
434
+ const li = Math.max(0, Math.min(MAX_GRADING_AMOUNT, grading.light?.amount ?? 0));
435
+ const di = Math.max(0, Math.min(MAX_GRADING_AMOUNT, grading.dark?.amount ?? 0));
436
+ if (li === 0 && di === 0) return { C, h };
437
+ const lightFade = t <= GRADING_REACH ? 0.5 * (1 + Math.cos(Math.PI * t / GRADING_REACH)) : 0;
438
+ const darkFade = t >= 1 - GRADING_REACH ? 0.5 * (1 + Math.cos(Math.PI * (1 - t) / GRADING_REACH)) : 0;
439
+ const lightBlend = lightFade * (li / MAX_GRADING_AMOUNT);
440
+ const darkBlend = darkFade * (di / MAX_GRADING_AMOUNT);
441
+ if (lightBlend === 0 && darkBlend === 0) return { C, h };
442
+ const hRad = h * toRad;
443
+ let a = C * Math.cos(hRad);
444
+ let b = C * Math.sin(hRad);
445
+ if (lightBlend > 0) {
446
+ const lh = grading.light.hue;
447
+ const lRad = lh * toRad;
448
+ const lC = maxChroma(L, lh, gamut) * GRADING_CHROMA_RATIO * lightBlend;
449
+ a += lC * Math.cos(lRad);
450
+ b += lC * Math.sin(lRad);
451
+ }
452
+ if (darkBlend > 0) {
453
+ const dh = grading.dark.hue;
454
+ const dRad = dh * toRad;
455
+ const dC = maxChroma(L, dh, gamut) * GRADING_CHROMA_RATIO * darkBlend;
456
+ a += dC * Math.cos(dRad);
457
+ b += dC * Math.sin(dRad);
458
+ }
459
+ let newC = Math.sqrt(a * a + b * b);
460
+ let newH = (Math.atan2(b, a) / toRad + 360) % 360;
461
+ newC = Math.min(newC, maxChroma(L, newH, gamut));
462
+ return { C: newC, h: newH };
463
+ }
464
+ function generateScale(options = {}) {
399
465
  const {
400
- hue,
401
- steps,
466
+ contrast,
467
+ hue = DEFAULT_HUE,
402
468
  chroma,
403
- gamut = "srgb",
404
- lightest = 1,
405
- darkest = 0,
469
+ isP3 = false,
406
470
  grading,
407
- gradient
471
+ shift
408
472
  } = options;
409
- const chromaRatio = chroma?.value ?? 1;
410
- const chromaPeak = chroma?.offset ?? 0.5;
473
+ const gamut = isP3 ? "display-p3" : "srgb";
474
+ const steps = DEFAULT_SCALE_STEPS;
475
+ const chromaRatio = chroma?.amount ?? 0;
476
+ const chromaPeak = chroma?.balance ?? 0.5;
411
477
  const hueGrade = grading;
412
- const localHueGrade = gradient ? buildOneSidedGrade(gradient.hue, gradient.value, gradient.direction) : void 0;
413
- if (steps < 2) return [];
478
+ const localHueGrade = shift ? buildOneSidedGrade(shift.hue, shift.amount, shift.light) : void 0;
414
479
  const ratio = Math.max(0, Math.min(1, chromaRatio));
415
480
  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));
481
+ const lightestL = resolveLightest(contrast?.light ?? 1);
482
+ const darkestL = resolveDarkest(contrast?.dark ?? 1);
418
483
  const hueAt = (t) => resolveGradedHue(hue, t, hueGrade, localHueGrade);
419
484
  if (peak === 0.5 || ratio === 0 || ratio >= 1) {
420
485
  const scale2 = [];
421
486
  for (let i = 0; i < steps; i++) {
422
487
  const t = i / (steps - 1);
423
488
  const L = lightestL - t * (lightestL - darkestL);
424
- const h = hueAt(t);
425
- const C = maxChroma(L, h, gamut) * ratio;
489
+ let h = hueAt(t);
490
+ let C = Math.min(maxChroma(L, hue, gamut) * ratio, maxChroma(L, h, gamut));
491
+ if (hueGrade) {
492
+ const overlay = applyGradingOverlay(C, h, L, t, hueGrade, gamut);
493
+ C = overlay.C;
494
+ h = overlay.h;
495
+ }
426
496
  scale2.push({ L, C, h });
427
497
  }
428
498
  return scale2;
@@ -434,7 +504,7 @@ function generateScale(options) {
434
504
  for (let i = 0; i <= N; i++) {
435
505
  const t = i / N;
436
506
  const L = lightestL - t * (lightestL - darkestL);
437
- const C = maxChroma(L, hueAt(t), gamut);
507
+ const C = maxChroma(L, hue, gamut);
438
508
  boundarySamples.push({ t, C });
439
509
  if (C > peakBoundaryC) {
440
510
  peakBoundaryC = C;
@@ -463,7 +533,7 @@ function generateScale(options) {
463
533
  for (let i = 0; i < steps; i++) {
464
534
  const t = i / (steps - 1);
465
535
  const L = lightestL - t * (lightestL - darkestL);
466
- const h = hueAt(t);
536
+ let h = hueAt(t);
467
537
  let tWarped;
468
538
  if (t <= targetT) {
469
539
  tWarped = t * (peakT / targetT);
@@ -471,16 +541,22 @@ function generateScale(options) {
471
541
  tWarped = peakT + (t - targetT) * ((1 - peakT) / (1 - targetT));
472
542
  }
473
543
  const Lwarped = lightestL - tWarped * (lightestL - darkestL);
474
- const warpedC = maxChroma(Lwarped, hueAt(tWarped), gamut) * ratio;
544
+ const warpedC = maxChroma(Lwarped, hue, gamut) * ratio;
475
545
  const boundaryC = maxChroma(L, h, gamut);
476
- const C = Math.min(warpedC, boundaryC);
546
+ let C = Math.min(warpedC, boundaryC);
547
+ if (hueGrade) {
548
+ const overlay = applyGradingOverlay(C, h, L, t, hueGrade, gamut);
549
+ C = overlay.C;
550
+ h = overlay.h;
551
+ }
477
552
  scale.push({ L, C, h });
478
553
  }
479
554
  return scale;
480
555
  }
481
556
 
482
557
  // src/scale/resolve-color.ts
483
- function resolveColor(input, gamut = "srgb") {
558
+ function resolveColor(input, isP3 = false) {
559
+ const gamut = isP3 ? "display-p3" : "srgb";
484
560
  const isHex = typeof input === "string";
485
561
  const original = isHex ? hexToOklch(input) : input;
486
562
  let inGamut;
@@ -503,70 +579,48 @@ function resolveColor(input, gamut = "srgb") {
503
579
  chromaRatio: Math.min(1, chromaRatio)
504
580
  };
505
581
  }
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
582
 
541
583
  // src/scale/key-color.ts
542
584
  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);
585
+ const { isP3 = false, contrast, steps = DEFAULT_SCALE_STEPS } = options ?? {};
586
+ const resolved = resolveColor(color, isP3);
587
+ const lightestL = resolveLightest(contrast?.light ?? 1);
588
+ const darkestL = resolveDarkest(contrast?.dark ?? 1);
589
+ const balance = lightnessToScaleT(resolved.oklch.L, lightestL, darkestL);
590
+ const stepIndex = Math.round(balance * Math.max(0, steps - 1));
546
591
  return {
547
592
  hue: resolved.hue,
548
- chroma: { value: resolved.chromaRatio, offset },
549
- resolved
593
+ chroma: { amount: resolved.chromaRatio, balance },
594
+ stepIndex,
595
+ hex: resolved.hex,
596
+ oklch: resolved.oklch,
597
+ wasRemapped: resolved.wasRemapped,
598
+ original: resolved.original
550
599
  };
551
600
  }
552
601
 
553
- exports.GRADE_REACH = GRADE_REACH;
554
- exports.LOCAL_GRADE_REACH = LOCAL_GRADE_REACH;
602
+ exports.APCA_LC_MAX = APCA_LC_MAX;
603
+ exports.DEFAULT_HUE = DEFAULT_HUE;
604
+ exports.DEFAULT_SCALE_STEPS = DEFAULT_SCALE_STEPS;
605
+ exports.GRADING_CHROMA_RATIO = GRADING_CHROMA_RATIO;
606
+ exports.GRADING_REACH = GRADING_REACH;
555
607
  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;
608
+ exports.MAX_GRADING_AMOUNT = MAX_GRADING_AMOUNT;
609
+ exports.MAX_SHIFT_AMOUNT = MAX_SHIFT_AMOUNT;
558
610
  exports.MIN_LIGHTEST_L = MIN_LIGHTEST_L;
559
611
  exports.PERCEPTUAL_JND = PERCEPTUAL_JND;
612
+ exports.SHIFT_REACH = SHIFT_REACH;
560
613
  exports.apcaContrast = apcaContrast;
614
+ exports.apcaToNormalized = apcaToNormalized;
561
615
  exports.buildOneSidedGrade = buildOneSidedGrade;
562
616
  exports.clampSrgb = clampSrgb;
563
617
  exports.contrastTextHex = contrastTextHex;
564
618
  exports.deltaEOK = deltaEOK;
565
619
  exports.deltaEOKLab = deltaEOKLab;
566
- exports.findNearest = findNearest;
567
620
  exports.gamutMap = gamutMap;
568
621
  exports.generateScale = generateScale;
569
622
  exports.gradeHue = gradeHue;
623
+ exports.gradingInfluence = gradingInfluence;
570
624
  exports.hexToOklch = hexToOklch;
571
625
  exports.hexToSrgb = hexToSrgb;
572
626
  exports.isInGamut = isInGamut;
@@ -578,6 +632,7 @@ exports.linearSrgbToOklab = linearSrgbToOklab;
578
632
  exports.linearSrgbToSrgb = linearSrgbToSrgb;
579
633
  exports.maxChroma = maxChroma;
580
634
  exports.mix = mix;
635
+ exports.normalizedToApca = normalizedToApca;
581
636
  exports.oklabToLinearP3 = oklabToLinearP3;
582
637
  exports.oklabToLinearSrgb = oklabToLinearSrgb;
583
638
  exports.oklabToOklch = oklabToOklch;
@@ -587,10 +642,7 @@ exports.oklchToOklab = oklchToOklab;
587
642
  exports.oklchToP3 = oklchToP3;
588
643
  exports.oklchToSrgb = oklchToSrgb;
589
644
  exports.p3ToOklch = p3ToOklch;
590
- exports.resolveColor = resolveColor;
591
- exports.resolveDarkest = resolveDarkest;
592
645
  exports.resolveGradedHue = resolveGradedHue;
593
- exports.resolveLightest = resolveLightest;
594
646
  exports.srgbChannelToLinear = srgbChannelToLinear;
595
647
  exports.srgbToHex = srgbToHex;
596
648
  exports.srgbToLinearSrgb = srgbToLinearSrgb;