@luntta/swatch 3.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CONTRIBUTING.md +89 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING.md +189 -0
  5. package/README.md +463 -0
  6. package/package.json +57 -0
  7. package/scripts/pack-check.mjs +18 -0
  8. package/src/bootstrap.js +159 -0
  9. package/src/core/registry.js +81 -0
  10. package/src/core/state.js +36 -0
  11. package/src/core/swatch-class.js +524 -0
  12. package/src/data/cvd-matrices.js +179 -0
  13. package/src/data/named-colors.js +157 -0
  14. package/src/format/css.js +256 -0
  15. package/src/operations/accessibility.js +103 -0
  16. package/src/operations/apca.js +80 -0
  17. package/src/operations/blend.js +72 -0
  18. package/src/operations/channels.js +123 -0
  19. package/src/operations/cvd.js +119 -0
  20. package/src/operations/deltaE.js +207 -0
  21. package/src/operations/gamut.js +206 -0
  22. package/src/operations/image.js +192 -0
  23. package/src/operations/manipulation.js +100 -0
  24. package/src/operations/mix.js +129 -0
  25. package/src/operations/naming.js +158 -0
  26. package/src/operations/palette.js +133 -0
  27. package/src/operations/random.js +75 -0
  28. package/src/operations/temperature.js +126 -0
  29. package/src/operations/tint-shade.js +42 -0
  30. package/src/palettes/colorbrewer.js +232 -0
  31. package/src/palettes/index.js +58 -0
  32. package/src/palettes/viridis.js +59 -0
  33. package/src/parse/css.js +241 -0
  34. package/src/parse/hex.js +38 -0
  35. package/src/parse/index.js +43 -0
  36. package/src/parse/legacy.js +88 -0
  37. package/src/parse/named.js +11 -0
  38. package/src/parse/objects.js +125 -0
  39. package/src/scale/index.js +382 -0
  40. package/src/scale/interpolators.js +83 -0
  41. package/src/spaces/a98.js +55 -0
  42. package/src/spaces/cmyk.js +75 -0
  43. package/src/spaces/display-p3.js +50 -0
  44. package/src/spaces/hsl.js +93 -0
  45. package/src/spaces/hsluv.js +211 -0
  46. package/src/spaces/hsv.js +78 -0
  47. package/src/spaces/hwb.js +48 -0
  48. package/src/spaces/lab.js +70 -0
  49. package/src/spaces/lch.js +65 -0
  50. package/src/spaces/oklab.js +79 -0
  51. package/src/spaces/oklch.js +53 -0
  52. package/src/spaces/prophoto.js +72 -0
  53. package/src/spaces/rec2020.js +65 -0
  54. package/src/spaces/srgb.js +85 -0
  55. package/src/spaces/xyz.js +71 -0
  56. package/src/swatch.js +57 -0
  57. package/src/util/math.js +53 -0
  58. package/src/util/matrix.js +92 -0
  59. package/src/util/suggest.js +66 -0
  60. package/types/swatch.d.ts +664 -0
@@ -0,0 +1,158 @@
1
+ // Color naming — nearest named color by ΔE2000.
2
+ //
3
+ // Uses the CSS Color Module Level 4 named-color list from
4
+ // src/data/named-colors.js. At module load time we precompute Lab
5
+ // D65 coordinates for each entry so that `name()` only needs to
6
+ // convert the query color once per call.
7
+ //
8
+ // Because several CSS names are synonyms (aqua ↔ cyan, grey ↔ gray,
9
+ // magenta ↔ fuchsia), we deduplicate by hex — an exact match to any
10
+ // of these returns one canonical name but both names remain valid
11
+ // matches via the dedup table.
12
+
13
+ import { Swatch, swatch } from "../core/swatch-class.js";
14
+ import { convert } from "../core/registry.js";
15
+ import { deltaE2000 } from "./deltaE.js";
16
+ import namedColors from "../data/named-colors.js";
17
+
18
+ // Canonical list: deduplicated by hex, preferring the first name in
19
+ // iteration order. `transparent` is excluded (it has no chromatic
20
+ // meaning for naming purposes).
21
+ //
22
+ // We build the table at module-load time without going through the
23
+ // swatch() factory, because the bootstrap sequence imports this module
24
+ // *before* it installs the parser — using swatch() here would crash
25
+ // with "parser not initialized".
26
+ const ENTRIES = [];
27
+ const SEEN_HEX = new Map();
28
+
29
+ function hexToSrgb(hex) {
30
+ const r = parseInt(hex.slice(0, 2), 16) / 255;
31
+ const g = parseInt(hex.slice(2, 4), 16) / 255;
32
+ const b = parseInt(hex.slice(4, 6), 16) / 255;
33
+ return [r, g, b];
34
+ }
35
+
36
+ for (const [colorName, hex] of Object.entries(namedColors)) {
37
+ if (colorName === "transparent") continue;
38
+ if (SEEN_HEX.has(hex)) continue;
39
+ SEEN_HEX.set(hex, colorName);
40
+ const srgb = hexToSrgb(hex);
41
+ const lab = convert(srgb, "srgb", "lab");
42
+ ENTRIES.push({ name: colorName, hex: "#" + hex, lab });
43
+ }
44
+
45
+ function lab2000FromLab(lab1, lab2) {
46
+ // Inlined ΔE2000 so we avoid constructing a Swatch for every
47
+ // precomputed entry on every lookup.
48
+ const deg = Math.PI / 180;
49
+ const [L1, a1, b1] = lab1;
50
+ const [L2, a2, b2] = lab2;
51
+
52
+ const C1 = Math.hypot(a1, b1);
53
+ const C2 = Math.hypot(a2, b2);
54
+ const Cbar = (C1 + C2) / 2;
55
+
56
+ const Cbar7 = Math.pow(Cbar, 7);
57
+ const G = 0.5 * (1 - Math.sqrt(Cbar7 / (Cbar7 + Math.pow(25, 7))));
58
+
59
+ const a1p = (1 + G) * a1;
60
+ const a2p = (1 + G) * a2;
61
+
62
+ const C1p = Math.hypot(a1p, b1);
63
+ const C2p = Math.hypot(a2p, b2);
64
+
65
+ let h1p = (Math.atan2(b1, a1p) * 180) / Math.PI;
66
+ if (h1p < 0) h1p += 360;
67
+ let h2p = (Math.atan2(b2, a2p) * 180) / Math.PI;
68
+ if (h2p < 0) h2p += 360;
69
+
70
+ const dLp = L2 - L1;
71
+ const dCp = C2p - C1p;
72
+
73
+ let dhp;
74
+ if (C1p * C2p === 0) {
75
+ dhp = 0;
76
+ } else if (Math.abs(h2p - h1p) <= 180) {
77
+ dhp = h2p - h1p;
78
+ } else if (h2p - h1p > 180) {
79
+ dhp = h2p - h1p - 360;
80
+ } else {
81
+ dhp = h2p - h1p + 360;
82
+ }
83
+ const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp / 2) * deg);
84
+
85
+ const LbarP = (L1 + L2) / 2;
86
+ const CbarP = (C1p + C2p) / 2;
87
+
88
+ let hbarP;
89
+ if (C1p * C2p === 0) {
90
+ hbarP = h1p + h2p;
91
+ } else if (Math.abs(h1p - h2p) <= 180) {
92
+ hbarP = (h1p + h2p) / 2;
93
+ } else if (h1p + h2p < 360) {
94
+ hbarP = (h1p + h2p + 360) / 2;
95
+ } else {
96
+ hbarP = (h1p + h2p - 360) / 2;
97
+ }
98
+
99
+ const T =
100
+ 1 -
101
+ 0.17 * Math.cos((hbarP - 30) * deg) +
102
+ 0.24 * Math.cos(2 * hbarP * deg) +
103
+ 0.32 * Math.cos((3 * hbarP + 6) * deg) -
104
+ 0.2 * Math.cos((4 * hbarP - 63) * deg);
105
+
106
+ const dTheta = 30 * Math.exp(-Math.pow((hbarP - 275) / 25, 2));
107
+
108
+ const CbarP7 = Math.pow(CbarP, 7);
109
+ const Rc = 2 * Math.sqrt(CbarP7 / (CbarP7 + Math.pow(25, 7)));
110
+
111
+ const Sl =
112
+ 1 +
113
+ (0.015 * Math.pow(LbarP - 50, 2)) /
114
+ Math.sqrt(20 + Math.pow(LbarP - 50, 2));
115
+ const Sc = 1 + 0.045 * CbarP;
116
+ const Sh = 1 + 0.015 * CbarP * T;
117
+
118
+ const Rt = -Math.sin(2 * dTheta * deg) * Rc;
119
+
120
+ const termL = dLp / Sl;
121
+ const termC = dCp / Sc;
122
+ const termH = dHp / Sh;
123
+
124
+ return Math.sqrt(
125
+ termL * termL +
126
+ termC * termC +
127
+ termH * termH +
128
+ Rt * termC * termH
129
+ );
130
+ }
131
+
132
+ function toSwatch(input) {
133
+ return input instanceof Swatch ? input : swatch(input);
134
+ }
135
+
136
+ export function name(input, _opts = {}) {
137
+ const s = toSwatch(input);
138
+ const queryLab = s._getCoordsIn("lab");
139
+ let best = null;
140
+ let bestD = Infinity;
141
+ for (const entry of ENTRIES) {
142
+ const d = lab2000FromLab(queryLab, entry.lab);
143
+ if (d < bestD) {
144
+ bestD = d;
145
+ best = entry;
146
+ }
147
+ }
148
+ return { name: best.name, hex: best.hex, deltaE: bestD };
149
+ }
150
+
151
+ export function toName(input) {
152
+ return name(input).name;
153
+ }
154
+
155
+ // Useful for tests and users who want the raw data table.
156
+ export function listNamedColors() {
157
+ return ENTRIES.map((e) => ({ name: e.name, hex: e.hex }));
158
+ }
@@ -0,0 +1,133 @@
1
+ // Palette helpers.
2
+ //
3
+ // checkPalette(palette, { cvd, severity, minDeltaE, mode })
4
+ // Pairwise ΔE scan (under an optional CVD simulation) reporting
5
+ // the smallest distance and any pairs that fall under the threshold.
6
+ //
7
+ // nearestDistinguishable(target, against, { cvd, severity, minDeltaE, mode, step })
8
+ // Walk target's HSL lightness until it is ≥ minDeltaE away from
9
+ // `against` under the chosen CVD simulation.
10
+ //
11
+ // mostReadable(background, candidates, { level, size, includeFallback })
12
+ // Pick the highest-contrast foreground that passes WCAG. Falls
13
+ // back to black/white if none pass (unless includeFallback=false).
14
+
15
+ import { Swatch, swatch } from "../core/swatch-class.js";
16
+ import { simulate } from "./cvd.js";
17
+ import { deltaE as defaultDeltaE } from "./deltaE.js";
18
+ import { contrast, isReadable } from "./accessibility.js";
19
+
20
+ function toSwatch(input) {
21
+ return input instanceof Swatch ? input : swatch(input);
22
+ }
23
+
24
+ export function checkPalette(palette, opts = {}) {
25
+ const {
26
+ cvd = null,
27
+ severity = 1,
28
+ minDeltaE = 11,
29
+ mode = "2000"
30
+ } = opts;
31
+
32
+ const colors = palette.map(toSwatch);
33
+ const view = cvd
34
+ ? colors.map((c) => simulate(c, cvd, { severity }))
35
+ : colors;
36
+
37
+ const pairs = [];
38
+ const unsafe = [];
39
+ let minDE = Infinity;
40
+
41
+ for (let i = 0; i < view.length; i++) {
42
+ for (let j = i + 1; j < view.length; j++) {
43
+ const de = defaultDeltaE(view[i], view[j], mode);
44
+ const safe = de >= minDeltaE;
45
+ const entry = { i, j, deltaE: de, safe };
46
+ pairs.push(entry);
47
+ if (!safe) unsafe.push(entry);
48
+ if (de < minDE) minDE = de;
49
+ }
50
+ }
51
+
52
+ return {
53
+ pairs,
54
+ unsafe,
55
+ minDeltaE: minDE === Infinity ? 0 : minDE,
56
+ safe: unsafe.length === 0
57
+ };
58
+ }
59
+
60
+ export function nearestDistinguishable(target, against, opts = {}) {
61
+ const {
62
+ cvd = null,
63
+ severity = 1,
64
+ minDeltaE = 11,
65
+ mode = "2000",
66
+ step = 2
67
+ } = opts;
68
+
69
+ const targetT = toSwatch(target);
70
+ const againstT = toSwatch(against);
71
+
72
+ function evalDE(t) {
73
+ const a = cvd ? simulate(t, cvd, { severity }) : t;
74
+ const b = cvd ? simulate(againstT, cvd, { severity }) : againstT;
75
+ return defaultDeltaE(a, b, mode);
76
+ }
77
+
78
+ if (evalDE(targetT) >= minDeltaE) return targetT;
79
+
80
+ const baseHsl = targetT.hsl;
81
+ let best = targetT;
82
+ let bestDE = evalDE(targetT);
83
+
84
+ for (let d = step; d <= 100; d += step) {
85
+ for (const sign of [-1, 1]) {
86
+ const newL = baseHsl.l + sign * d;
87
+ if (newL < 0 || newL > 100) continue;
88
+ const candidate = swatch({
89
+ space: "hsl",
90
+ coords: [baseHsl.h, baseHsl.s, newL],
91
+ alpha: targetT.alpha
92
+ });
93
+ const de = evalDE(candidate);
94
+ if (de > bestDE) {
95
+ bestDE = de;
96
+ best = candidate;
97
+ }
98
+ if (de >= minDeltaE) return candidate;
99
+ }
100
+ }
101
+ return best;
102
+ }
103
+
104
+ export function mostReadable(background, candidates, opts = {}) {
105
+ const bg = toSwatch(background);
106
+ const cands = candidates.map(toSwatch);
107
+
108
+ let bestPass = null;
109
+ let bestPassRatio = -Infinity;
110
+ let bestAny = null;
111
+ let bestAnyRatio = -Infinity;
112
+
113
+ for (const c of cands) {
114
+ const ratio = contrast(c, bg);
115
+ if (isReadable(c, bg, opts)) {
116
+ if (ratio > bestPassRatio) {
117
+ bestPassRatio = ratio;
118
+ bestPass = c;
119
+ }
120
+ }
121
+ if (ratio > bestAnyRatio) {
122
+ bestAnyRatio = ratio;
123
+ bestAny = c;
124
+ }
125
+ }
126
+
127
+ if (bestPass) return bestPass;
128
+ if (opts.includeFallback === false) return bestAny;
129
+
130
+ const black = swatch("#000000");
131
+ const white = swatch("#ffffff");
132
+ return contrast(black, bg) >= contrast(white, bg) ? black : white;
133
+ }
@@ -0,0 +1,75 @@
1
+ // Random color generation.
2
+ //
3
+ // swatch.random()
4
+ // Uniform random sRGB color.
5
+ //
6
+ // swatch.random({ space, hue, lightness, chroma, saturation, seed })
7
+ // Constrained random color in the chosen space ('oklch' or 'hsl').
8
+ // Each of `hue`, `lightness`, `chroma`, `saturation` is either a
9
+ // single number (fixed) or a [min, max] tuple (uniform range). If
10
+ // `seed` is given, an xorshift32 PRNG is used for reproducibility;
11
+ // otherwise Math.random() is used.
12
+ //
13
+ // Unknown constraint keys for the current space are ignored.
14
+
15
+ import { Swatch } from "../core/swatch-class.js";
16
+
17
+ function xorshift32(seed) {
18
+ // Classic xorshift32 PRNG. Seed must be a non-zero 32-bit integer;
19
+ // if the caller passes 0 we nudge it to 1.
20
+ let s = seed | 0;
21
+ if (s === 0) s = 1;
22
+ return function next() {
23
+ s ^= s << 13;
24
+ s ^= s >>> 17;
25
+ s ^= s << 5;
26
+ // Map to [0, 1). Shift into positive and divide by 2^32.
27
+ return (s >>> 0) / 0x100000000;
28
+ };
29
+ }
30
+
31
+ function pick(rng, value, defaultRange) {
32
+ if (value == null) {
33
+ const [lo, hi] = defaultRange;
34
+ return lo + rng() * (hi - lo);
35
+ }
36
+ if (typeof value === "number") return value;
37
+ if (Array.isArray(value) && value.length === 2) {
38
+ const [lo, hi] = value;
39
+ if (lo === hi) return lo;
40
+ return lo + rng() * (hi - lo);
41
+ }
42
+ throw new Error("random: constraints must be a number or [min, max]");
43
+ }
44
+
45
+ export function random(opts = {}) {
46
+ const rng = opts.seed != null ? xorshift32(opts.seed) : Math.random;
47
+ const space = opts.space;
48
+
49
+ if (!space) {
50
+ // Uniform sRGB.
51
+ const r = rng();
52
+ const g = rng();
53
+ const b = rng();
54
+ return new Swatch({ space: "srgb", coords: [r, g, b], alpha: 1 });
55
+ }
56
+
57
+ if (space === "oklch") {
58
+ const l = pick(rng, opts.lightness, [0, 1]);
59
+ const c = pick(rng, opts.chroma, [0, 0.4]);
60
+ const h = pick(rng, opts.hue, [0, 360]);
61
+ return new Swatch({ space: "oklch", coords: [l, c, h], alpha: 1 });
62
+ }
63
+
64
+ if (space === "hsl") {
65
+ // HSL stores S/L as percent (0..100) per CSS convention.
66
+ // Callers can pass either percent ([0, 100]) or any tuple
67
+ // consistent with the HSL registration.
68
+ const h = pick(rng, opts.hue, [0, 360]);
69
+ const s = pick(rng, opts.saturation, [0, 100]);
70
+ const l = pick(rng, opts.lightness, [0, 100]);
71
+ return new Swatch({ space: "hsl", coords: [h, s, l], alpha: 1 });
72
+ }
73
+
74
+ throw new Error(`random: unsupported space "${space}"`);
75
+ }
@@ -0,0 +1,126 @@
1
+ // Correlated color temperature (CCT) ↔ color.
2
+ //
3
+ // Forward: Krystek 1985 piecewise polynomial maps blackbody CCT
4
+ // (Kelvin) onto CIE 1931 xy chromaticity, then to XYZ, then to a
5
+ // scaled sRGB triple normalized so the brightest channel equals 1.
6
+ //
7
+ // Inverse: McCamy 1992 cubic approximation from xy to CCT. This is
8
+ // a *fit*, not an exact inversion — documented as approximate. The
9
+ // fit is accurate to within a few kelvin for daylight illuminants
10
+ // but degrades at the extremes of the Planckian locus.
11
+ //
12
+ // Supported range: 1000 K – 40000 K. Outside this range the
13
+ // polynomial is not defined and we throw.
14
+
15
+ import { Swatch } from "../core/swatch-class.js";
16
+ import { convert } from "../core/registry.js";
17
+
18
+ const MIN_K = 1000;
19
+ const MAX_K = 40000;
20
+
21
+ // Krystek 1985 (also published as CIE D illuminant series refinement).
22
+ // Reference: Krystek, M. (1985) "An algorithm to calculate correlated
23
+ // colour temperature", Color Research & Application 10, 38–40.
24
+ //
25
+ // Implementation here uses the classic piecewise-cubic fit
26
+ // (Kim et al. 2002, also reproduced in Wikipedia's
27
+ // "Planckian locus" article) which matches the blackbody locus to
28
+ // within a few ΔE across the working range.
29
+ function kelvinToXy(k) {
30
+ if (k < MIN_K || k > MAX_K) {
31
+ throw new Error(
32
+ `temperature: ${k}K is out of range [${MIN_K}, ${MAX_K}]`
33
+ );
34
+ }
35
+ const T = k;
36
+ let x;
37
+ if (T <= 4000) {
38
+ // 1667 K ≤ T ≤ 4000 K
39
+ x =
40
+ -0.2661239 * (1e9 / (T * T * T)) -
41
+ 0.2343589 * (1e6 / (T * T)) +
42
+ 0.8776956 * (1e3 / T) +
43
+ 0.17991;
44
+ } else {
45
+ // 4000 K ≤ T ≤ 25000 K — also used as an extrapolation up to 40000 K.
46
+ x =
47
+ -3.0258469 * (1e9 / (T * T * T)) +
48
+ 2.1070379 * (1e6 / (T * T)) +
49
+ 0.2226347 * (1e3 / T) +
50
+ 0.24039;
51
+ }
52
+ let y;
53
+ if (T <= 2222) {
54
+ y =
55
+ -1.1063814 * (x * x * x) -
56
+ 1.3481102 * (x * x) +
57
+ 2.18555832 * x -
58
+ 0.20219683;
59
+ } else if (T <= 4000) {
60
+ y =
61
+ -0.9549476 * (x * x * x) -
62
+ 1.37418593 * (x * x) +
63
+ 2.09137015 * x -
64
+ 0.16748867;
65
+ } else {
66
+ y =
67
+ 3.081758 * (x * x * x) -
68
+ 5.8733867 * (x * x) +
69
+ 3.75112997 * x -
70
+ 0.37001483;
71
+ }
72
+ return [x, y];
73
+ }
74
+
75
+ function xyToXyz(x, y) {
76
+ // Y normalized to 1; XYZ scaled by Y/y.
77
+ if (y === 0) return [0, 0, 0];
78
+ const Y = 1;
79
+ const X = (x * Y) / y;
80
+ const Z = ((1 - x - y) * Y) / y;
81
+ return [X, Y, Z];
82
+ }
83
+
84
+ // Build a Swatch for the blackbody radiator at `kelvin`. The returned
85
+ // color is in sRGB, gamut-clipped and normalized so the brightest
86
+ // channel is 1 (which is how temperature-picker widgets conventionally
87
+ // present these colors).
88
+ export function temperature(kelvin) {
89
+ const [x, y] = kelvinToXy(kelvin);
90
+ const xyz = xyToXyz(x, y);
91
+ const srgbLinear = convert(xyz, "xyz", "srgb-linear");
92
+ // Normalize so the max linear channel is 1 (preserve chromaticity).
93
+ let m = Math.max(srgbLinear[0], srgbLinear[1], srgbLinear[2]);
94
+ if (m <= 0) m = 1;
95
+ const scaled = [
96
+ srgbLinear[0] / m,
97
+ srgbLinear[1] / m,
98
+ srgbLinear[2] / m
99
+ ];
100
+ // Clip any residual negatives (blackbody below ~1900K can still
101
+ // produce small negative B values even after chroma scaling).
102
+ for (let i = 0; i < 3; i++) {
103
+ if (scaled[i] < 0) scaled[i] = 0;
104
+ if (scaled[i] > 1) scaled[i] = 1;
105
+ }
106
+ const srgb = convert(scaled, "srgb-linear", "srgb");
107
+ return new Swatch({ space: "srgb", coords: srgb, alpha: 1 });
108
+ }
109
+
110
+ // McCamy 1992: xy chromaticity → approximate CCT in Kelvin.
111
+ // n = (x − 0.3320) / (0.1858 − y)
112
+ // CCT ≈ 449 n³ + 3525 n² + 6823.3 n + 5520.33
113
+ //
114
+ // Accurate for daylight-ish illuminants; degrades on extreme locus
115
+ // points. Documented as an approximation.
116
+ export function kelvin(input) {
117
+ // `input` must be a Swatch — wrapping is the caller's job, and
118
+ // temperature() exposes this only via the prototype method below.
119
+ const [X, Y, Z] = input._getCoordsIn("xyz");
120
+ const sum = X + Y + Z;
121
+ if (sum === 0) return NaN;
122
+ const x = X / sum;
123
+ const y = Y / sum;
124
+ const n = (x - 0.332) / (0.1858 - y);
125
+ return 449 * n * n * n + 3525 * n * n + 6823.3 * n + 5520.33;
126
+ }
@@ -0,0 +1,42 @@
1
+ // Tint / shade / tone helpers.
2
+ //
3
+ // All three operations are OKLab linear interpolations between the
4
+ // source and a reference color:
5
+ //
6
+ // tint(c, t) mixes toward white — OKLab (1, 0, 0)
7
+ // shade(c, t) mixes toward black — OKLab (0, 0, 0)
8
+ // tone(c, t) mixes toward mid-gray — OKLab (0.5, 0, 0)
9
+ //
10
+ // Amount is a 0..1 fraction: 0 is identity, 1 is fully at the reference.
11
+ //
12
+ // OKLab is used instead of sRGB so tints/shades stay on a visually
13
+ // straight line and mid-gray is perceptually mid.
14
+
15
+ import { Swatch } from "../core/swatch-class.js";
16
+
17
+ function lerpOklab(swatch, targetLab, amount) {
18
+ const src = swatch._getCoordsIn("oklab");
19
+ const t = amount;
20
+ const lerp = (a, b) => a + (b - a) * t;
21
+ return new Swatch({
22
+ space: "oklab",
23
+ coords: [
24
+ lerp(src[0], targetLab[0]),
25
+ lerp(src[1], targetLab[1]),
26
+ lerp(src[2], targetLab[2])
27
+ ],
28
+ alpha: swatch.alpha
29
+ });
30
+ }
31
+
32
+ export function tint(swatch, amount = 0.1) {
33
+ return lerpOklab(swatch, [1, 0, 0], amount);
34
+ }
35
+
36
+ export function shade(swatch, amount = 0.1) {
37
+ return lerpOklab(swatch, [0, 0, 0], amount);
38
+ }
39
+
40
+ export function tone(swatch, amount = 0.1) {
41
+ return lerpOklab(swatch, [0.5, 0, 0], amount);
42
+ }