@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 +22 -23
- package/dist/__tests__/key-color.test.d.ts +2 -0
- package/dist/__tests__/key-color.test.d.ts.map +1 -0
- package/dist/config.d.ts +31 -11
- package/dist/config.d.ts.map +1 -1
- package/dist/contrast/apca.d.ts +16 -0
- package/dist/contrast/apca.d.ts.map +1 -1
- package/dist/gamut/max-chroma.d.ts +8 -3
- package/dist/gamut/max-chroma.d.ts.map +1 -1
- package/dist/index.cjs +135 -83
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +5 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +125 -76
- package/dist/index.js.map +1 -1
- package/dist/scale/generate.d.ts +44 -34
- package/dist/scale/generate.d.ts.map +1 -1
- package/dist/scale/hue-grade.d.ts +47 -29
- package/dist/scale/hue-grade.d.ts.map +1 -1
- package/dist/scale/key-color.d.ts +31 -27
- package/dist/scale/key-color.d.ts.map +1 -1
- package/dist/scale/resolve-color.d.ts +6 -26
- package/dist/scale/resolve-color.d.ts.map +1 -1
- package/package.json +1 -1
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
|
-
| `
|
|
38
|
-
| `chroma.
|
|
39
|
-
| `
|
|
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
|
-
| `
|
|
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
|
|
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", {
|
|
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` (
|
|
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,
|
|
74
|
+
import type { Grading, Shift } from "newtone-colors";
|
|
76
75
|
|
|
77
76
|
// Global: affects all palettes equally
|
|
78
77
|
const grading: Grading = {
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
82
|
+
// Per-palette: one-sided, stronger. Omit `light` or set false for dark end.
|
|
83
|
+
const shift: Shift = {
|
|
85
84
|
hue: 200,
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
amount: 0.3,
|
|
86
|
+
// light: true ← uncomment to target the light end instead
|
|
88
87
|
};
|
|
89
88
|
|
|
90
|
-
const scale = generateScale({ hue: 264,
|
|
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
|
|
103
|
-
const
|
|
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
|
|
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"
|
|
147
|
+
const result = resolveColor("#E74C3C");
|
|
149
148
|
result.hue; // extracted hue
|
|
150
|
-
result.chromaRatio; // chroma as fraction of gamut boundary → ScaleOptions.chroma.
|
|
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 @@
|
|
|
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
|
|
19
|
-
* Light grading fades to zero at t =
|
|
20
|
-
* Dark grading fades to zero at t = 1 −
|
|
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
|
|
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
|
|
27
|
-
* Slightly wider than
|
|
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
|
|
30
|
-
/** Maximum
|
|
31
|
-
export declare const
|
|
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.
|
package/dist/config.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/contrast/apca.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
351
|
-
var
|
|
352
|
-
var
|
|
353
|
-
var
|
|
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 =
|
|
358
|
-
const
|
|
359
|
-
const
|
|
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
|
|
365
|
-
const baseRad = baseHue *
|
|
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 =
|
|
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 =
|
|
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) /
|
|
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,
|
|
394
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
466
|
+
contrast,
|
|
467
|
+
hue = DEFAULT_HUE,
|
|
402
468
|
chroma,
|
|
403
|
-
|
|
404
|
-
lightest = 1,
|
|
405
|
-
darkest = 0,
|
|
469
|
+
isP3 = false,
|
|
406
470
|
grading,
|
|
407
|
-
|
|
471
|
+
shift
|
|
408
472
|
} = options;
|
|
409
|
-
const
|
|
410
|
-
const
|
|
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 =
|
|
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 =
|
|
417
|
-
const darkestL =
|
|
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
|
-
|
|
425
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
544
|
+
const warpedC = maxChroma(Lwarped, hue, gamut) * ratio;
|
|
475
545
|
const boundaryC = maxChroma(L, h, gamut);
|
|
476
|
-
|
|
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,
|
|
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 {
|
|
544
|
-
const resolved = resolveColor(color,
|
|
545
|
-
const
|
|
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: {
|
|
549
|
-
|
|
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.
|
|
554
|
-
exports.
|
|
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.
|
|
557
|
-
exports.
|
|
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;
|