@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.
- package/CHANGELOG.md +56 -0
- package/CONTRIBUTING.md +89 -0
- package/LICENSE +21 -0
- package/MIGRATING.md +189 -0
- package/README.md +463 -0
- package/package.json +57 -0
- package/scripts/pack-check.mjs +18 -0
- package/src/bootstrap.js +159 -0
- package/src/core/registry.js +81 -0
- package/src/core/state.js +36 -0
- package/src/core/swatch-class.js +524 -0
- package/src/data/cvd-matrices.js +179 -0
- package/src/data/named-colors.js +157 -0
- package/src/format/css.js +256 -0
- package/src/operations/accessibility.js +103 -0
- package/src/operations/apca.js +80 -0
- package/src/operations/blend.js +72 -0
- package/src/operations/channels.js +123 -0
- package/src/operations/cvd.js +119 -0
- package/src/operations/deltaE.js +207 -0
- package/src/operations/gamut.js +206 -0
- package/src/operations/image.js +192 -0
- package/src/operations/manipulation.js +100 -0
- package/src/operations/mix.js +129 -0
- package/src/operations/naming.js +158 -0
- package/src/operations/palette.js +133 -0
- package/src/operations/random.js +75 -0
- package/src/operations/temperature.js +126 -0
- package/src/operations/tint-shade.js +42 -0
- package/src/palettes/colorbrewer.js +232 -0
- package/src/palettes/index.js +58 -0
- package/src/palettes/viridis.js +59 -0
- package/src/parse/css.js +241 -0
- package/src/parse/hex.js +38 -0
- package/src/parse/index.js +43 -0
- package/src/parse/legacy.js +88 -0
- package/src/parse/named.js +11 -0
- package/src/parse/objects.js +125 -0
- package/src/scale/index.js +382 -0
- package/src/scale/interpolators.js +83 -0
- package/src/spaces/a98.js +55 -0
- package/src/spaces/cmyk.js +75 -0
- package/src/spaces/display-p3.js +50 -0
- package/src/spaces/hsl.js +93 -0
- package/src/spaces/hsluv.js +211 -0
- package/src/spaces/hsv.js +78 -0
- package/src/spaces/hwb.js +48 -0
- package/src/spaces/lab.js +70 -0
- package/src/spaces/lch.js +65 -0
- package/src/spaces/oklab.js +79 -0
- package/src/spaces/oklch.js +53 -0
- package/src/spaces/prophoto.js +72 -0
- package/src/spaces/rec2020.js +65 -0
- package/src/spaces/srgb.js +85 -0
- package/src/spaces/xyz.js +71 -0
- package/src/swatch.js +57 -0
- package/src/util/math.js +53 -0
- package/src/util/matrix.js +92 -0
- package/src/util/suggest.js +66 -0
- package/types/swatch.d.ts +664 -0
package/README.md
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
# Swatch
|
|
2
|
+
|
|
3
|
+
A color library with **first-class colorblind support** — physically correct Brettel/Viénot simulation, severity continuum, Fidaner daltonization, and palette distinguishability checks — plus the things you expect from a modern color library: CSS Color 4 parsing, wide-gamut spaces, OKLab/OKLCh manipulation, color scales, built-in scientific palettes, blend modes, naming, temperature, WCAG 2.1, and APCA.
|
|
4
|
+
|
|
5
|
+
**Docs & interactive playground:** <https://luntta.github.io/swatch/>
|
|
6
|
+
|
|
7
|
+
> **3.0** is a breaking rewrite. See [MIGRATING.md](./MIGRATING.md) for the v2 → v3 cookbook. The most visible change is that `lighten`/`darken`/`saturate` now operate in OKLCh with amounts in `0..1` instead of HSL with amounts in `0..100`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @luntta/swatch
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import swatch from "@luntta/swatch";
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The factory statics are also available as named exports, so bundlers can
|
|
20
|
+
tree-shake the ones you don't use:
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import { scale, contrast, simulate } from "@luntta/swatch";
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
const blue = swatch("#3b82f6");
|
|
30
|
+
|
|
31
|
+
blue.lighten(0.1).hex(); // perceptual OKLCh lighten
|
|
32
|
+
blue.contrast("#ffffff"); // WCAG 2.1 contrast ratio
|
|
33
|
+
blue.isReadable("#ffffff"); // AA body-text default
|
|
34
|
+
blue.simulate(swatch.cvd.deutan).hex(); // colorblind simulation
|
|
35
|
+
|
|
36
|
+
swatch.scale(["#f00", "#00f"]).colors(5, "hex"); // theme/data-viz ramps
|
|
37
|
+
swatch.try(userInput); // Swatch | null
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Creating a color
|
|
41
|
+
|
|
42
|
+
`swatch(input)` accepts any of the usual CSS forms — including CSS Color 4 — object literals, or another `swatch`.
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
// Legacy CSS.
|
|
46
|
+
swatch("#0000ff");
|
|
47
|
+
swatch("#00f");
|
|
48
|
+
swatch("#0000ff80"); // with alpha
|
|
49
|
+
swatch("rgb(0, 0, 255)");
|
|
50
|
+
swatch("rgba(0, 0, 255, 0.5)");
|
|
51
|
+
swatch("hsl(240, 100%, 50%)");
|
|
52
|
+
swatch("rebeccapurple"); // all 148 CSS named colors
|
|
53
|
+
|
|
54
|
+
// CSS Color 4.
|
|
55
|
+
swatch("rgb(0 0 255 / 0.5)"); // modern slash-alpha
|
|
56
|
+
swatch("hsl(240 100% 50% / 50%)");
|
|
57
|
+
swatch("hwb(240 0% 0%)");
|
|
58
|
+
swatch("lab(52.2% 40 -70)");
|
|
59
|
+
swatch("lch(52.2% 80.5 300)");
|
|
60
|
+
swatch("oklab(0.5 0.1 -0.2)");
|
|
61
|
+
swatch("oklch(0.7 0.15 240)");
|
|
62
|
+
swatch("color(display-p3 1 0 0)"); // wide-gamut
|
|
63
|
+
swatch("color(rec2020 1 0 0)");
|
|
64
|
+
swatch("color(xyz-d65 0.4124 0.2126 0.0193)");
|
|
65
|
+
|
|
66
|
+
// Objects.
|
|
67
|
+
swatch({ r: 0, g: 0, b: 255 });
|
|
68
|
+
swatch({ h: 240, s: 100, l: 50, a: 0.5 });
|
|
69
|
+
swatch({ space: "oklch", coords: [0.7, 0.15, 240] });
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Invalid input throws. For live text inputs and validation, use the safe helpers:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
const c = swatch.try("not a color"); // null
|
|
76
|
+
swatch.isColor("oklch(0.7 0.15 240)"); // true
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Instances are **immutable** — every operation returns a new `swatch`.
|
|
80
|
+
|
|
81
|
+
Common serialization helpers are available for the everyday cases:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
const c = swatch("rgb(51 102 204 / 0.5)");
|
|
85
|
+
|
|
86
|
+
c.hex(); // "#3366cc"
|
|
87
|
+
c.hex({ alpha: true }); // "#3366cc80"
|
|
88
|
+
c.rgb(); // { r: 51, g: 102, b: 204, a: 0.5 }
|
|
89
|
+
c.css({ format: "oklch" });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`hex()` and `rgb()` map wide-gamut colors into sRGB perceptually (CSS Color 4 chroma reduction) before serializing, so they never silently clip chromaticity. Pass `{ gamut: false }` for a raw clamp, or use `css()` to keep the color in its source space losslessly. See [Gamut mapping](#gamut-mapping).
|
|
93
|
+
|
|
94
|
+
## Color spaces
|
|
95
|
+
|
|
96
|
+
Each instance exposes a getter for every registered space. Conversions go through CIE XYZ D65 and are lazily memoized on the instance.
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
const c = swatch("oklch(0.7 0.15 240)");
|
|
100
|
+
|
|
101
|
+
c.srgb; // { r, g, b } — may be out of [0,1] for wide-gamut sources
|
|
102
|
+
c.linearSrgb; // { r, g, b }
|
|
103
|
+
c.hsl; // { h, s, l }
|
|
104
|
+
c.hsv; // { h, s, v }
|
|
105
|
+
c.hwb; // { h, w, b }
|
|
106
|
+
c.cmyk; // { c, m, y, k } — naive, uncalibrated
|
|
107
|
+
c.lab; // { l, a, b } — CIE Lab D65
|
|
108
|
+
c.lch; // { l, c, h }
|
|
109
|
+
c.oklab; // { l, a, b }
|
|
110
|
+
c.oklch; // { l, c, h }
|
|
111
|
+
c.hsluv; // { h, s, l } — human-friendly HSL
|
|
112
|
+
c.luv; // { l, u, v }
|
|
113
|
+
c.xyz; // { x, y, z } — D65
|
|
114
|
+
c.displayP3; // { r, g, b }
|
|
115
|
+
c.rec2020; // { r, g, b }
|
|
116
|
+
c.a98; // { r, g, b }
|
|
117
|
+
c.prophoto; // { r, g, b }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Getters return mathematically correct coordinates — they do not gamut-map. For a wide-gamut source like `color(display-p3 1 0 0)`, `.srgb` will include out-of-range values. Use [`.toGamut()`](#gamut-mapping) if you need an in-gamut sRGB version.
|
|
121
|
+
|
|
122
|
+
Switch the canonical space with `c.to(spaceId)`:
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
swatch("#ff0000").to("oklch");
|
|
126
|
+
// Swatch { space: "oklch", coords: [0.628, 0.258, 29.2], alpha: 1 }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
For autocomplete-friendly call sites, string constants are exposed:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
swatch("#ff0000").to(swatch.spaces.oklch);
|
|
133
|
+
swatch("#ff0000").simulate(swatch.cvd.protan);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Units cheat sheet
|
|
137
|
+
|
|
138
|
+
| API surface | Units |
|
|
139
|
+
| --- | --- |
|
|
140
|
+
| `c.srgb`, `c.displayP3`, `color(display-p3 ...)` | `0..1` RGB channels |
|
|
141
|
+
| `c.rgb()` and `{ r, g, b }` input | `0..255` RGB channels |
|
|
142
|
+
| `hsl`, `hsv`, `hwb`, `hsluv` | hue degrees, percentages for the other channels |
|
|
143
|
+
| `oklab.l`, `oklch.l` | `0..1` perceptual lightness |
|
|
144
|
+
| `lab.l`, `lch.l`, `luv.l` | `0..100` CIE lightness |
|
|
145
|
+
| `lighten` / `darken` | delta in OKLCh `L` (`0..1`) |
|
|
146
|
+
| `saturate` / `desaturate` | delta in OKLCh `C` |
|
|
147
|
+
|
|
148
|
+
## Channels
|
|
149
|
+
|
|
150
|
+
Address any channel with a string path:
|
|
151
|
+
|
|
152
|
+
```js
|
|
153
|
+
const c = swatch("oklch(0.7 0.15 240)");
|
|
154
|
+
c.get("oklch.l"); // 0.7
|
|
155
|
+
c.get("hsl.h"); // 240 (converted from oklch)
|
|
156
|
+
c.get("alpha"); // 1
|
|
157
|
+
|
|
158
|
+
c.set("oklch.h", 120); // returns a new Swatch with hue = 120°
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Channel paths are typed in TypeScript, so typos like `"oklch.lightness"` are caught by the compiler.
|
|
162
|
+
|
|
163
|
+
## Manipulation
|
|
164
|
+
|
|
165
|
+
**Breaking change from v2.** Manipulation now operates in OKLCh (perceptually uniform) instead of HSL. Amounts are in `0..1`, not `0..100`.
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
swatch("#ff0000").lighten(0.1); // +0.1 in OKLCh L
|
|
169
|
+
swatch("#ff0000").darken(0.1);
|
|
170
|
+
swatch("#ff0000").saturate(0.05); // +0.05 in OKLCh C
|
|
171
|
+
swatch("#ff0000").desaturate(0.05);
|
|
172
|
+
swatch("#ff0000").spin(180); // +180° OKLCh hue
|
|
173
|
+
swatch("#ff0000").greyscale(); // OKLCh C = 0
|
|
174
|
+
swatch("#ff0000").complement();
|
|
175
|
+
swatch("#ff0000").invert(); // per-channel sRGB invert
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
OKLCh-based lightening means `#ffff00` and `#0000ff` both become *visually* brighter by the same amount — unlike HSL, where yellow barely changed at all.
|
|
179
|
+
|
|
180
|
+
Results are re-mapped to sRGB after each operation. Pass `{ gamut: false }` to opt out.
|
|
181
|
+
|
|
182
|
+
## Tint, shade, tone
|
|
183
|
+
|
|
184
|
+
Mix toward white / black / mid-grey in OKLab.
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
swatch("#ff0000").tint(0.2); // 20% toward white
|
|
188
|
+
swatch("#ff0000").shade(0.2); // 20% toward black
|
|
189
|
+
swatch("#ff0000").tone(0.2); // 20% toward 50% grey
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Mix and blend
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
const a = swatch("#ff0000");
|
|
196
|
+
const b = swatch("#0000ff");
|
|
197
|
+
|
|
198
|
+
a.mix(b); // oklab, 50/50 (default)
|
|
199
|
+
a.mix(b, 0.25); // weighted 25% toward b
|
|
200
|
+
a.mix(b, 0.5, { space: "lab" });
|
|
201
|
+
a.mix(b, 0.5, { space: "oklch" }); // polar, shortest-arc hue
|
|
202
|
+
a.mix(b, 0.5, { space: "srgb" }); // naive gamma-space blend
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
W3C Compositing and Blending Level 1 blend modes:
|
|
206
|
+
|
|
207
|
+
```js
|
|
208
|
+
swatch("#ff0000").blend("#0000ff", "multiply");
|
|
209
|
+
swatch("#ff0000").blend("#00ff00", "screen");
|
|
210
|
+
// Also: darken, lighten, overlay, color-dodge, color-burn,
|
|
211
|
+
// hard-light, soft-light, difference, exclusion, normal
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Color scales
|
|
215
|
+
|
|
216
|
+
Chroma.js-style scale builder. Interpolates between stops in OKLab by default.
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
const s = swatch.scale(["#ff0000", "#ffff00", "#00ff00"]);
|
|
220
|
+
|
|
221
|
+
s(0); // Swatch at the start
|
|
222
|
+
s(0.5); // midpoint
|
|
223
|
+
s.colors(5); // [Swatch, Swatch, Swatch, Swatch, Swatch]
|
|
224
|
+
s.colors(5, "hex"); // ["#ff0000", "#ffa000", "#ffff00", "#b0ff00", "#00ff00"]
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Shape the curve:
|
|
228
|
+
|
|
229
|
+
```js
|
|
230
|
+
swatch.scale(["#000", "#fff"])
|
|
231
|
+
.domain([0, 100]) // remap the input range
|
|
232
|
+
.padding([0.1, 0.1]) // trim fractional amounts off each end
|
|
233
|
+
.gamma(0.5) // pow the normalized t (emphasis curve)
|
|
234
|
+
.classes(5) // bucket into 5 discrete bins
|
|
235
|
+
.mode("oklch") // interpolation space
|
|
236
|
+
.correctLightness() // resample so the output Lab L is linear
|
|
237
|
+
.cache(true); // memoize samples (default)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Interpolators that feed into `.scale(...)`:
|
|
241
|
+
|
|
242
|
+
```js
|
|
243
|
+
swatch.bezier(["#ff0000", "#ffff00", "#00ff00"]);
|
|
244
|
+
swatch.cubehelix({ start: 300, rotations: -1.5, hue: 1, lightness: [0, 1] });
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Built-in palettes
|
|
248
|
+
|
|
249
|
+
Matplotlib perceptually-uniform colormaps (Smith & van der Walt) and the full ColorBrewer 2.0 set (Cynthia Brewer).
|
|
250
|
+
|
|
251
|
+
```js
|
|
252
|
+
swatch.scale("viridis");
|
|
253
|
+
swatch.scale("magma");
|
|
254
|
+
swatch.scale("plasma");
|
|
255
|
+
swatch.scale("inferno");
|
|
256
|
+
swatch.scale("cividis"); // colorblind-safe by design
|
|
257
|
+
|
|
258
|
+
swatch.scale("Blues");
|
|
259
|
+
swatch.scale("RdBu");
|
|
260
|
+
swatch.scale("Spectral");
|
|
261
|
+
swatch.scale("Set1").classes(9); // qualitative → discrete
|
|
262
|
+
|
|
263
|
+
swatch.palettes(); // all registered palette names
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Gamut mapping
|
|
267
|
+
|
|
268
|
+
```js
|
|
269
|
+
const p3red = swatch("color(display-p3 1 0 0)");
|
|
270
|
+
|
|
271
|
+
p3red.gamut; // "display-p3" — smallest standard gamut that fits
|
|
272
|
+
p3red.inGamut("srgb"); // false
|
|
273
|
+
p3red.toGamut({ space: "srgb" });
|
|
274
|
+
p3red.toGamut({ space: "srgb", method: "clip" }); // naive clipping
|
|
275
|
+
p3red.toGamut({ space: "srgb", method: "css4" }); // CSS Color 4 binary chroma reduction (default)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`.gamut` reports the smallest standard gamut containing the color, walking `srgb ⊂ display-p3 ⊂ rec2020 ⊂ prophoto` (or `null` for imaginary colors). It's a quick way to notice an out-of-sRGB color before serializing.
|
|
279
|
+
|
|
280
|
+
The `.srgb` / `.linearSrgb` / `.hsl` getters return raw conversions, so wide-gamut sources can produce out-of-range values — they give you the exact math, not a display-ready color. The presentation helpers are gamut-aware: `c.hex()` and `c.rgb()` perceptually map into sRGB first (pass `{ gamut: false }` for the old raw clamp). `c.toString({ format: "hex" })` / `c.toCss(...)` still clip naively. Reach for `toGamut` directly when you want to control the target space or method.
|
|
281
|
+
|
|
282
|
+
## Colorblind simulation
|
|
283
|
+
|
|
284
|
+
Physically correct sRGB → linear → LMS projection onto the dichromat confusion plane (Brettel 1997 / Viénot 1999).
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
const red = swatch("#ff0000");
|
|
288
|
+
|
|
289
|
+
red.simulate("protan"); // full protanopia
|
|
290
|
+
red.simulate("deutan", { severity: 0.6 }); // 60% deuteranomaly
|
|
291
|
+
red.simulate("tritan");
|
|
292
|
+
red.simulate("achroma"); // Rec. 709 grayscale
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Accepted aliases: `protanopia`/`protanomaly` → `protan`, `deuteranopia`/`deuteranomaly` → `deutan`, `tritanopia`/`tritanomaly` → `tritan`, `achromatopsia` → `achroma`.
|
|
296
|
+
|
|
297
|
+
Severity `0.0` is identity, `1.0` is the full dichromat, in between is a linear interpolation of the RGB transform matrix.
|
|
298
|
+
|
|
299
|
+
### ImageData simulation
|
|
300
|
+
|
|
301
|
+
For photos and other raster images, use the batch ImageData API instead of
|
|
302
|
+
calling `swatch(pixel).simulate(...)` in a loop. It uses byte/linear-light LUTs
|
|
303
|
+
and computes the CVD matrix once per image.
|
|
304
|
+
|
|
305
|
+
```js
|
|
306
|
+
const ctx = canvas.getContext("2d");
|
|
307
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
308
|
+
|
|
309
|
+
swatch.simulateImageData(imageData, "deutan", { severity: 1 });
|
|
310
|
+
ctx.putImageData(imageData, 0, 0);
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
The transform mutates in place by default for performance. Pass
|
|
314
|
+
`{ inPlace: false }` to clone first. Alpha is preserved.
|
|
315
|
+
|
|
316
|
+
## Daltonization
|
|
317
|
+
|
|
318
|
+
Fidaner error-redistribution: the information lost to a dichromat is shifted into channels they can still see.
|
|
319
|
+
|
|
320
|
+
```js
|
|
321
|
+
swatch("#ff0000").daltonize("deutan");
|
|
322
|
+
swatch("#ff0000").daltonize("protan", { severity: 0.8 });
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
The same batch API is available for raster corrections:
|
|
326
|
+
|
|
327
|
+
```js
|
|
328
|
+
swatch.daltonizeImageData(imageData, "protan");
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## Palette distinguishability
|
|
332
|
+
|
|
333
|
+
```js
|
|
334
|
+
const report = swatch.checkPalette(
|
|
335
|
+
["#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00"],
|
|
336
|
+
{ cvd: "deutan", minDeltaE: 11 }
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
report.safe; // false
|
|
340
|
+
report.minDeltaE; // e.g. 7.2
|
|
341
|
+
report.unsafe; // [{ i, j, deltaE, safe: false }, …]
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Nudge a single color until it clears a threshold against a reference:
|
|
345
|
+
|
|
346
|
+
```js
|
|
347
|
+
swatch.nearestDistinguishable("#ff6666", "#ff0000", {
|
|
348
|
+
cvd: "deutan",
|
|
349
|
+
minDeltaE: 15
|
|
350
|
+
});
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## WCAG 2.1 contrast
|
|
354
|
+
|
|
355
|
+
```js
|
|
356
|
+
swatch.contrast("#ffffff", "#000000"); // 21
|
|
357
|
+
swatch("#767676").isReadable("#ffffff"); // true (AA normal body text)
|
|
358
|
+
swatch("#767676").isReadable("#ffffff", { level: "AAA", size: "normal" });
|
|
359
|
+
swatch("#767676").isReadable("#ffffff", { level: "AA", size: "large" });
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Auto-fix a foreground until it meets a target ratio:
|
|
363
|
+
|
|
364
|
+
```js
|
|
365
|
+
swatch("#888888").ensureContrast("#ffffff", { minRatio: 4.5 });
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Pick the best candidate from a list:
|
|
369
|
+
|
|
370
|
+
```js
|
|
371
|
+
swatch.mostReadable("#ffffff", ["#888", "#555", "#222"]);
|
|
372
|
+
// → Swatch("#222222")
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
## APCA
|
|
376
|
+
|
|
377
|
+
Andrew Somers' SAPC-based contrast. Returns signed Lc on a ~[-108, +106] scale.
|
|
378
|
+
|
|
379
|
+
```js
|
|
380
|
+
swatch.apcaContrast("#000000", "#ffffff"); // 106.04 (BoW)
|
|
381
|
+
swatch.apcaContrast("#ffffff", "#000000"); // -107.88 (WoB)
|
|
382
|
+
swatch.apcaContrast("#767676", "#ffffff"); // 71.57 (fails body-text min)
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Positive = dark text on a light background; negative = light text on a dark background. Typical body-text thresholds: `|Lc| ≥ 75` comfortable, `≥ 60` minimum.
|
|
386
|
+
|
|
387
|
+
## Delta E
|
|
388
|
+
|
|
389
|
+
```js
|
|
390
|
+
const a = swatch("#3366cc");
|
|
391
|
+
a.deltaE("#3366cd"); // ΔE2000 (default)
|
|
392
|
+
a.deltaE("#3366cd", "76"); // ΔE76
|
|
393
|
+
a.deltaE("#3366cd", "94"); // ΔE94 graphic arts
|
|
394
|
+
a.deltaE("#3366cd", "cmc", { l: 2, c: 1 }); // CMC l:c (2:1 acceptability)
|
|
395
|
+
a.deltaE("#3366cd", "hyab"); // HyAB (large differences)
|
|
396
|
+
a.deltaE("#3366cd", "ok"); // ΔE OK
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
## Color naming
|
|
400
|
+
|
|
401
|
+
```js
|
|
402
|
+
swatch("#dc143c").name();
|
|
403
|
+
// { name: "crimson", hex: "#dc143c", deltaE: 0 }
|
|
404
|
+
|
|
405
|
+
swatch("#dc1b3d").toName();
|
|
406
|
+
// "crimson"
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Nearest match from the 148 CSS Color Module 4 named colors, measured in ΔE2000.
|
|
410
|
+
|
|
411
|
+
## Temperature
|
|
412
|
+
|
|
413
|
+
```js
|
|
414
|
+
swatch.temperature(6500); // D65-ish white
|
|
415
|
+
swatch.temperature(2000); // warm candlelight orange
|
|
416
|
+
swatch("#ffb070").temperature();// → ~2870 K (McCamy approximation)
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Krystek 1985 blackbody for kelvin → color; McCamy 1992 for the inverse.
|
|
420
|
+
|
|
421
|
+
## Random
|
|
422
|
+
|
|
423
|
+
```js
|
|
424
|
+
swatch.random();
|
|
425
|
+
|
|
426
|
+
swatch.random({
|
|
427
|
+
space: "oklch",
|
|
428
|
+
lightness: [0.6, 0.8],
|
|
429
|
+
chroma: [0.1, 0.2],
|
|
430
|
+
hue: [180, 240],
|
|
431
|
+
seed: 42
|
|
432
|
+
});
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Pass `seed` for reproducible sequences (xorshift32).
|
|
436
|
+
|
|
437
|
+
## TypeScript
|
|
438
|
+
|
|
439
|
+
Hand-written declarations ship in `types/swatch.d.ts` and are referenced by `package.json`. No configuration required. Channel paths and constants are typed for autocomplete:
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
const c = swatch("#3366cc");
|
|
443
|
+
c.get("oklch.l"); // ok
|
|
444
|
+
c.to(swatch.spaces.oklch); // ok
|
|
445
|
+
// c.get("oklch.lightness"); // TypeScript error
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
## Development
|
|
449
|
+
|
|
450
|
+
```bash
|
|
451
|
+
npm install
|
|
452
|
+
npm test
|
|
453
|
+
npm run docs:dev
|
|
454
|
+
npm run check
|
|
455
|
+
npm run pack:check
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the repo layout, docs workflow,
|
|
459
|
+
and release checklist.
|
|
460
|
+
|
|
461
|
+
## License
|
|
462
|
+
|
|
463
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@luntta/swatch",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "A color library with first-class colorblind support, CSS Color 4 parsing, wide-gamut spaces, OKLCh manipulation, color scales, built-in palettes, blend modes, WCAG 2.1, and APCA.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/swatch.js",
|
|
7
|
+
"module": "./src/swatch.js",
|
|
8
|
+
"types": "./types/swatch.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./types/swatch.d.ts",
|
|
12
|
+
"import": "./src/swatch.js",
|
|
13
|
+
"default": "./src/swatch.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src/**/*.js",
|
|
18
|
+
"!src/_v2-monolith.js",
|
|
19
|
+
"!src/_v2-named-colors.js",
|
|
20
|
+
"types/swatch.d.ts",
|
|
21
|
+
"scripts/pack-check.mjs",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CONTRIBUTING.md",
|
|
24
|
+
"MIGRATING.md",
|
|
25
|
+
"CHANGELOG.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"directories": {
|
|
29
|
+
"test": "tests"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
35
|
+
"docs:dev": "npm --prefix docs run dev",
|
|
36
|
+
"docs:build": "npm --prefix docs run build",
|
|
37
|
+
"check": "npm test && npm run typecheck && npm run docs:build",
|
|
38
|
+
"pack:check": "node scripts/pack-check.mjs"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typescript": "^6.0.3",
|
|
42
|
+
"vitest": "^2.1.0"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/luntta/swatch.git"
|
|
47
|
+
},
|
|
48
|
+
"author": "Raine Luntta",
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/luntta/swatch/issues"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/luntta/swatch#readme"
|
|
57
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
// Use a temporary npm cache so this command works in constrained/sandboxed
|
|
8
|
+
// environments where the default user-level npm cache may be read-only.
|
|
9
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
10
|
+
const cacheDir = join(tmpdir(), "swatch-npm-cache");
|
|
11
|
+
|
|
12
|
+
const result = spawnSync(
|
|
13
|
+
npm,
|
|
14
|
+
["pack", "--dry-run", "--cache", cacheDir],
|
|
15
|
+
{ stdio: "inherit" }
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
process.exit(result.status ?? 1);
|
package/src/bootstrap.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Import side-effect: registers all built-in spaces with the registry
|
|
2
|
+
// and wires the parser dispatcher into the Swatch factory.
|
|
3
|
+
|
|
4
|
+
import "./spaces/xyz.js";
|
|
5
|
+
import "./spaces/srgb.js";
|
|
6
|
+
import "./spaces/display-p3.js";
|
|
7
|
+
import "./spaces/rec2020.js";
|
|
8
|
+
import "./spaces/a98.js";
|
|
9
|
+
import "./spaces/prophoto.js";
|
|
10
|
+
import "./spaces/lab.js";
|
|
11
|
+
import "./spaces/lch.js";
|
|
12
|
+
import "./spaces/oklab.js";
|
|
13
|
+
import "./spaces/oklch.js";
|
|
14
|
+
import "./spaces/hsl.js";
|
|
15
|
+
import "./spaces/hsv.js";
|
|
16
|
+
import "./spaces/hwb.js";
|
|
17
|
+
import "./spaces/cmyk.js";
|
|
18
|
+
import "./spaces/hsluv.js";
|
|
19
|
+
|
|
20
|
+
import "./parse/css.js";
|
|
21
|
+
|
|
22
|
+
import { parseInput } from "./parse/index.js";
|
|
23
|
+
import {
|
|
24
|
+
swatch,
|
|
25
|
+
_bindParseInput,
|
|
26
|
+
_bindChannels,
|
|
27
|
+
_bindGamut,
|
|
28
|
+
_bindManipulation,
|
|
29
|
+
_bindTintShade,
|
|
30
|
+
_bindMix,
|
|
31
|
+
_bindDeltaE,
|
|
32
|
+
_bindNaming,
|
|
33
|
+
_bindTemperature,
|
|
34
|
+
_bindAccessibility,
|
|
35
|
+
_bindApca,
|
|
36
|
+
_bindCvd
|
|
37
|
+
} from "./core/swatch-class.js";
|
|
38
|
+
import { getChannel, setChannel } from "./operations/channels.js";
|
|
39
|
+
import { inGamut, toGamut } from "./operations/gamut.js";
|
|
40
|
+
import * as manipulation from "./operations/manipulation.js";
|
|
41
|
+
import * as tintShade from "./operations/tint-shade.js";
|
|
42
|
+
import { mix, average } from "./operations/mix.js";
|
|
43
|
+
import { blend } from "./operations/blend.js";
|
|
44
|
+
import {
|
|
45
|
+
deltaE,
|
|
46
|
+
deltaE76,
|
|
47
|
+
deltaE94,
|
|
48
|
+
deltaE2000,
|
|
49
|
+
deltaECMC,
|
|
50
|
+
deltaEHyAB,
|
|
51
|
+
deltaEOK
|
|
52
|
+
} from "./operations/deltaE.js";
|
|
53
|
+
import { name, toName, listNamedColors } from "./operations/naming.js";
|
|
54
|
+
import { temperature, kelvin } from "./operations/temperature.js";
|
|
55
|
+
import { random } from "./operations/random.js";
|
|
56
|
+
import * as accessibility from "./operations/accessibility.js";
|
|
57
|
+
import { apcaContrast } from "./operations/apca.js";
|
|
58
|
+
import * as cvd from "./operations/cvd.js";
|
|
59
|
+
import {
|
|
60
|
+
simulateImageData,
|
|
61
|
+
daltonizeImageData
|
|
62
|
+
} from "./operations/image.js";
|
|
63
|
+
import {
|
|
64
|
+
checkPalette,
|
|
65
|
+
nearestDistinguishable,
|
|
66
|
+
mostReadable
|
|
67
|
+
} from "./operations/palette.js";
|
|
68
|
+
import { scale } from "./scale/index.js";
|
|
69
|
+
import { bezier, cubehelix } from "./scale/interpolators.js";
|
|
70
|
+
import { listPalettes } from "./palettes/index.js";
|
|
71
|
+
|
|
72
|
+
_bindParseInput(parseInput);
|
|
73
|
+
_bindChannels(getChannel, setChannel);
|
|
74
|
+
_bindGamut(inGamut, toGamut);
|
|
75
|
+
_bindManipulation(manipulation);
|
|
76
|
+
_bindTintShade(tintShade);
|
|
77
|
+
_bindMix({ mix, average, blend });
|
|
78
|
+
_bindDeltaE({
|
|
79
|
+
deltaE,
|
|
80
|
+
deltaE76,
|
|
81
|
+
deltaE94,
|
|
82
|
+
deltaE2000,
|
|
83
|
+
deltaECMC,
|
|
84
|
+
deltaEHyAB,
|
|
85
|
+
deltaEOK
|
|
86
|
+
});
|
|
87
|
+
_bindNaming({ name, toName, listNamedColors });
|
|
88
|
+
_bindTemperature({ kelvin });
|
|
89
|
+
|
|
90
|
+
_bindAccessibility(accessibility);
|
|
91
|
+
_bindApca({ apcaContrast });
|
|
92
|
+
_bindCvd(cvd);
|
|
93
|
+
|
|
94
|
+
// Statics on the factory function.
|
|
95
|
+
swatch.temperature = temperature;
|
|
96
|
+
swatch.random = random;
|
|
97
|
+
swatch.contrast = accessibility.contrast;
|
|
98
|
+
swatch.isReadable = accessibility.isReadable;
|
|
99
|
+
swatch.ensureContrast = accessibility.ensureContrast;
|
|
100
|
+
swatch.apcaContrast = apcaContrast;
|
|
101
|
+
swatch.simulate = (c, type, opts) => cvd.simulate(c, type, opts);
|
|
102
|
+
swatch.daltonize = (c, type, opts) => cvd.daltonize(c, type, opts);
|
|
103
|
+
swatch.simulateImageData = simulateImageData;
|
|
104
|
+
swatch.daltonizeImageData = daltonizeImageData;
|
|
105
|
+
swatch.image = Object.freeze({
|
|
106
|
+
simulate: simulateImageData,
|
|
107
|
+
daltonize: daltonizeImageData
|
|
108
|
+
});
|
|
109
|
+
swatch.checkPalette = checkPalette;
|
|
110
|
+
swatch.nearestDistinguishable = nearestDistinguishable;
|
|
111
|
+
swatch.mostReadable = mostReadable;
|
|
112
|
+
swatch.scale = scale;
|
|
113
|
+
swatch.bezier = bezier;
|
|
114
|
+
swatch.cubehelix = cubehelix;
|
|
115
|
+
swatch.palettes = listPalettes;
|
|
116
|
+
swatch.try = (input) => {
|
|
117
|
+
try {
|
|
118
|
+
return swatch(input);
|
|
119
|
+
} catch (_err) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
swatch.isColor = (input) => swatch.try(input) !== null;
|
|
124
|
+
swatch.spaces = Object.freeze({
|
|
125
|
+
srgb: "srgb",
|
|
126
|
+
linearSrgb: "srgb-linear",
|
|
127
|
+
displayP3: "display-p3",
|
|
128
|
+
rec2020: "rec2020",
|
|
129
|
+
a98: "a98",
|
|
130
|
+
prophoto: "prophoto",
|
|
131
|
+
xyz: "xyz",
|
|
132
|
+
xyzD65: "xyz-d65",
|
|
133
|
+
xyzD50: "xyz-d50",
|
|
134
|
+
lab: "lab",
|
|
135
|
+
labD50: "lab-d50",
|
|
136
|
+
lch: "lch",
|
|
137
|
+
lchD50: "lch-d50",
|
|
138
|
+
oklab: "oklab",
|
|
139
|
+
oklch: "oklch",
|
|
140
|
+
hsl: "hsl",
|
|
141
|
+
hsv: "hsv",
|
|
142
|
+
hwb: "hwb",
|
|
143
|
+
cmyk: "cmyk",
|
|
144
|
+
luv: "luv",
|
|
145
|
+
hsluv: "hsluv"
|
|
146
|
+
});
|
|
147
|
+
swatch.cvd = Object.freeze({
|
|
148
|
+
protan: "protan",
|
|
149
|
+
protanopia: "protanopia",
|
|
150
|
+
protanomaly: "protanomaly",
|
|
151
|
+
deutan: "deutan",
|
|
152
|
+
deuteranopia: "deuteranopia",
|
|
153
|
+
deuteranomaly: "deuteranomaly",
|
|
154
|
+
tritan: "tritan",
|
|
155
|
+
tritanopia: "tritanopia",
|
|
156
|
+
tritanomaly: "tritanomaly",
|
|
157
|
+
achroma: "achroma",
|
|
158
|
+
achromatopsia: "achromatopsia"
|
|
159
|
+
});
|