@cssints/gamut 0.0.0-stage → 0.33.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexei Accio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,186 @@
1
- # Temporary Holding Version
1
+ # @cssints/gamut
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Gamut and contrast colour maths for [cssints](../../README.md), computed at build time. `fit` returns the nearest colour a
4
+ gamut can show, `readable` a text colour that reaches a WCAG 2 contrast ratio on a background, and `tones` a lightness
5
+ scale of one colour as DTCG data for `createTokens`. The package has no runtime, no DOM and no `scope()` member: like
6
+ [@cssints/ramp](../ramp/README.md), its values are computed when the module is evaluated and are data after that, so they
7
+ go under any token group and get `@property` rules, themes and `check.contrast` from cssints.
8
+
9
+ The idea is [gamutant](https://github.com/noscripting/gamutant)'s: a gamut and contrast library that needs no JavaScript.
10
+ This is the same set of decisions, made once for a design's colours instead of per element in CSS, with the colour maths
11
+ of [culori](https://culorijs.org).
12
+
13
+ ```ts
14
+ // tokens.ts
15
+ import { bg, check, cn, color, createTokens } from "cssints" with { type: "cssints" };
16
+ import { fit, readable, tones } from "@cssints/gamut";
17
+
18
+ export const t = createTokens({
19
+ bg: { $type: "color", $value: "#ffffff" },
20
+ ink: { $type: "color", $value: readable("#ffffff") }, // #767676, 4.54:1
21
+ blue: tones("#2563eb", 9),
22
+ vivid: { $type: "color", $value: fit("oklch(0.7 0.4 150)") }, // #00be58: inside sRGB
23
+ });
24
+
25
+ check.contrast(t.ink, t.bg, { min: 4.5 });
26
+ export const text = cn(color(t.ink), bg(t.bg));
27
+ ```
28
+
29
+ The sheet of that file:
30
+
31
+ ```css
32
+ @layer _.k, _.a, _.b, _.c, _.d, _.e;
33
+ @property --bg {
34
+ syntax: "<color>";
35
+ inherits: true;
36
+ initial-value: #ffffff;
37
+ }
38
+ @property --blue-1 {
39
+ syntax: "<color>";
40
+ inherits: true;
41
+ initial-value: #e7effd;
42
+ }
43
+ /* … --blue-2 to --blue-8 … */
44
+ @property --blue-9 {
45
+ syntax: "<color>";
46
+ inherits: true;
47
+ initial-value: #000211;
48
+ }
49
+ @property --ink {
50
+ syntax: "<color>";
51
+ inherits: true;
52
+ initial-value: #767676;
53
+ }
54
+ @property --vivid {
55
+ syntax: "<color>";
56
+ inherits: true;
57
+ initial-value: #00be58;
58
+ }
59
+ @layer _.a {
60
+ ._od79qs {
61
+ color: var(--ink);
62
+ }
63
+ ._q77new {
64
+ background-color: var(--bg);
65
+ }
66
+ }
67
+ ```
68
+
69
+ ## The maths
70
+
71
+ Every colour is read and written in OKLCH, a perceptual space: lightness `l` from 0 to 1, chroma `c` from 0 (grey) up,
72
+ hue `h` in degrees. Two operations on it do the work:
73
+
74
+ - **The most chroma sRGB holds** at a lightness and hue, `maxChroma(l, h)`: a bisection of 24 steps, the lower bound, so
75
+ the colour it yields is inside the gamut and never a clipped one. A colour's _relative chroma_ is its chroma over that
76
+ maximum: `1` is as vivid as sRGB goes at that lightness and hue, and a grey is `0`.
77
+ - **The WCAG 2 contrast ratio** of two colours: culori's `wcagLuminance` of each clipped to sRGB, then
78
+ `(L1 + 0.05) / (L2 + 0.05)` for the lighter and the darker, from 1 to 21, not rounded. It is the maths of
79
+ `check.contrast`.
80
+
81
+ sRGB results are hex, the 8-bit rounding of an in-gamut colour. A Display P3 result is `color(display-p3 r g b)`: a hex
82
+ cannot hold a colour sRGB cannot show.
83
+
84
+ ## `fit(color, options?)`
85
+
86
+ The nearest colour the target gamut can show, in the target's syntax.
87
+
88
+ ```ts
89
+ fit("oklch(0.7 0.4 150)"); // #00be58: sRGB by default
90
+ fit("oklch(0.7 0.4 150)", { target: "display-p3" }); // color(display-p3 0 0.7683 0.2674)
91
+ fit("oklch(0.7 0.4 150)", { method: "clip" }); // #00d600: the channels clipped, not the chroma
92
+ fit("#2563eb"); // #2563eb: already in sRGB, unchanged
93
+ fit("color(display-p3 1 0 0)"); // #ff3428: the nearest sRGB red
94
+ ```
95
+
96
+ - **`target`**: `"srgb"` (default) or `"display-p3"`.
97
+ - **`method: "chroma"`** (default): hold the lightness and the hue, take the largest chroma the target holds at them
98
+ (bisection, 24 steps). The colour is the most saturated one of that hue the gamut can show at that lightness, so a blue
99
+ that P3 holds stays vivid instead of turning muddy.
100
+ - **`method: "clip"`**: keep the RGB channels and hold each to 0..1. Closer to how a browser shows an out-of-gamut colour,
101
+ and further from the original in hue and lightness.
102
+ - **A no-op inside the gamut**: a colour the target already holds comes back as the same colour, in the target's syntax
103
+ (a hex for sRGB, `color(display-p3 …)` for P3), whatever the method.
104
+
105
+ ## `readable(bg, options?)`
106
+
107
+ A text colour with at least `ratio` WCAG 2 contrast on `bg` (4.5 by default, body text): the background's hue and its
108
+ chroma relative to the sRGB maximum, with the lightness nearest the background that passes, searched by bisection on the
109
+ hex it outputs (32 steps). A colour with no hue (white, grey) gives a neutral text colour.
110
+
111
+ | Background | `toward` | Result | Ratio |
112
+ | ---------- | -------- | --------- | ----- |
113
+ | `#ffffff` | `black` | `#767676` | 4.54 |
114
+ | `#111111` | `white` | `#7c7c7c` | 4.52 |
115
+ | `#2563eb` | `white` | `#e8f0fd` | 4.51 |
116
+ | `#f5f5f4` | `black` | `#545353` | 7.03 |
117
+ | `#808080` | `black` | `#242424` | 3.93 |
118
+ | `#808080` | `white` | `#fefefe` | 3.92 |
119
+
120
+ - **`ratio`**: from 1 to 21; default 4.5 (`3` is large text and borders, `7` is AAA).
121
+ - **`toward`**: `"black"` (darker than the background), `"white"` (lighter), or `"auto"` (default: the side with more
122
+ room, which is darker on a light background and lighter on a dark one). The result is always a hex colour.
123
+ - **By little**: the colour nearest the background that passes, within an 8-bit step, so the ratio is not far above the
124
+ target. It is measured on the hex it outputs, so `check.contrast(readable(bg), bg, { min: ratio })` passes.
125
+
126
+ ## `tones(color, count)`
127
+
128
+ `count` tones of one colour as a DTCG colour group for `createTokens`: OKLCH lightness from 0.95 down to 0.10, evenly,
129
+ lightest first, steps `"1"` to `"N"`. The hue is held and the chroma keeps the colour's share of the sRGB maximum at each
130
+ lightness, so every tone is in sRGB by construction.
131
+
132
+ ```ts
133
+ tones("#2563eb", 9);
134
+ ```
135
+
136
+ | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
137
+ | --------- | --------- | --------- | --------- | --------- | --------- | --------- | --------- | --------- |
138
+ | `#e7effd` | `#b5ccf8` | `#83a9f3` | `#5084ef` | `#1a5ae8` | `#1041ac` | `#072973` | `#02123f` | `#000211` |
139
+
140
+ `tones("#eab308", 5)`: `#ffedc6`, `#d4a206`, `#866502`, `#3f2e00`, `#050300`. `tones(c, 1)` is the `0.95` tone alone.
141
+ The steps are read as `t.blue._1` … `t.blue._9` in code (`_`, as cssints reads a key that starts with a digit), and as
142
+ `--blue-1` … `--blue-9` in CSS.
143
+
144
+ ## Browser support
145
+
146
+ The package runs at build time, in Node (22 and later): no browser, no DOM, nothing shipped to the client. What it writes
147
+ is plain colour values:
148
+
149
+ - **Hex** is understood by every browser, and `readable` and `tones` give only hex.
150
+ - **`color(display-p3 r g b)`** (a `fit` result for the P3 target) needs the `color()` function: Safari 10+, Chrome 111+,
151
+ Firefox 113+. On an sRGB display it is shown clipped to sRGB; a wide-gamut display shows the colour the maths chose.
152
+ - **`@property` and `check.contrast`** see a P3 colour as its sRGB clip, as WCAG 2 is defined on sRGB, so a P3 pair is
153
+ checked at the sRGB colour, like cssints's own contrast maths.
154
+
155
+ ## Limits
156
+
157
+ - **Errors are thrown** when the module is evaluated, so they are build errors: a colour culori cannot read
158
+ (`@cssints/gamut: the colour "banana" is not a colour`) or a translucent one (`… is translucent`), a count that is not
159
+ a whole number from 1, a ratio outside 1..21, and a ratio no lightness reaches on that background
160
+ (`ratio 21 is out of reach on #808080: at most 5.32 (black)`). A mid-grey background leaves little room on either side;
161
+ ratio 21 needs white or black.
162
+ - **Hex is 8-bit around the hue.** The tone and the text colour keep the hue in the maths, and the hex rounds it: within a
163
+ degree where the chroma is at least about 0.1, and by more where the chroma is thinner (at 0.02 chroma an 8-bit colour
164
+ is a handful of RGB levels, so a dark yellow at lightness 0.10 is about 4° off). The lightness of a tone stays within
165
+ an 8-bit step of its target.
166
+ - **Specificity of the ratio**: a `readable` colour meets its ratio against that background only. On another background
167
+ or in a theme, call `readable` with that one, under the same condition, as ramp's contrast mode does.
168
+ - **WCAG 2 only**, not APCA. A WCAG 2 pass is not an APCA pass, especially for light text on dark.
169
+ - **sRGB for `tones` and `readable`.** P3 is a `fit` target only: a P3 token would be checked by `check.contrast` as its
170
+ sRGB clip, and a `color()` value walks the colour grammar in the types where a hex is cheap. A base outside sRGB is read
171
+ with its share of chroma capped at the gamut.
172
+ - **The editor shows `#${string}`.** `readable` and a tone's value are computed when the module runs, so their type is
173
+ `` `#${string}` ``, not the literal hex. The steps of `tones` are known, though: `t.blue._10` of a nine-tone scale is a
174
+ type error, and the build checks the values.
175
+
176
+ ```bash
177
+ cd packages/gamut && bun run test # the maths, then a token file through the engine (@property rules, check.contrast)
178
+ cd packages/gamut && bun run typecheck # the type test (test/types/gamut.ts)
179
+ ```
180
+
181
+ ## Credits
182
+
183
+ The idea, a gamut and contrast library that needs no JavaScript, is [gamutant](https://github.com/noscripting/gamutant)'s
184
+ (MIT). The chroma relative to the sRGB gamut is [nutelch](https://github.com/meodai/nutelch)'s idea (MIT, David Aerne),
185
+ as in [@cssints/ramp](../ramp/README.md). The colour maths is [culori](https://culorijs.org) (MIT, Dan Burzo), a
186
+ dependency. No code is copied.
@@ -0,0 +1,47 @@
1
+ /** A hex colour: cheap in the types (createTokens checks it without the walk of every colour syntax). */
2
+ export type Hex = `#${string}`;
3
+ /** The step names of an N-tone scale: `"1"` to `"N"` (`t.blue._1` in code, `--blue-1` in CSS). */
4
+ export type Keys<N extends number, A extends string[] = []> = number extends N ? `${number}` : A["length"] extends N ? A[number] : Keys<N, [...A, `${[...A, 0]["length"]}`]>;
5
+ /** A tone scale: a DTCG colour group whose tokens are the steps, lightest first. */
6
+ export type Tones<N extends number> = {
7
+ readonly $type: "color";
8
+ } & { readonly [P in Keys<N>]: {
9
+ readonly $value: Hex;
10
+ }; };
11
+ /** The options of `fit`. */
12
+ export interface FitOptions {
13
+ /** The gamut to fit into. Default "srgb". */
14
+ readonly target?: "srgb" | "display-p3" | undefined;
15
+ /** "chroma" (default) reduces OKLCH chroma at constant lightness and hue; "clip" clips the RGB channels. */
16
+ readonly method?: "chroma" | "clip" | undefined;
17
+ }
18
+ /** The options of `readable`. */
19
+ export interface ReadableOptions {
20
+ /** The WCAG 2 ratio to reach, from 1 to 21; default 4.5. */
21
+ readonly ratio?: number | undefined;
22
+ /** "black" or "white" for that direction; "auto" (default): darker on a light background, lighter on a dark one. */
23
+ readonly toward?: "black" | "white" | "auto" | undefined;
24
+ }
25
+ /**
26
+ * The nearest colour the target gamut can show.
27
+ * @param {string} color an opaque CSS colour
28
+ * @param {FitOptions} [options] the gamut (`"srgb"` by default) and the method (`"chroma"` by default)
29
+ * @returns {string} a hex colour for sRGB, `color(display-p3 r g b)` for Display P3
30
+ */
31
+ export declare const fit: (color: string, { target, method }?: FitOptions) => string;
32
+ /**
33
+ * A text colour with at least `ratio` WCAG 2 contrast on `bg`: the background's hue, its chroma relative to the sRGB
34
+ * maximum, with the lightness nearest the background that passes.
35
+ * @param {string} bg an opaque CSS colour
36
+ * @param {ReadableOptions} [options] the ratio (4.5 by default) and the direction ("auto" by default)
37
+ * @returns {Hex} a hex colour that reaches the ratio on `bg`
38
+ */
39
+ export declare const readable: (bg: string, { ratio: want, toward }?: ReadableOptions) => Hex;
40
+ /**
41
+ * `count` tones of one colour as DTCG data for createTokens: OKLCH lightness from 0.95 down to 0.10, evenly, hue held,
42
+ * chroma relative to the sRGB cusp at that lightness, all in sRGB by construction. Steps `"1"` to `"N"`, lightest first.
43
+ * @param {string} color an opaque CSS colour
44
+ * @param {number} count how many tones, from 1
45
+ * @returns {Tones<N>} a DTCG colour group, steps `"1"` to `"N"`
46
+ */
47
+ export declare const tones: <const N extends number>(color: string, count: N) => Tones<N>;
package/dist/index.mjs ADDED
@@ -0,0 +1,128 @@
1
+ import { clampRgb, converter, formatHex, inGamut, parse, wcagLuminance } from "culori";
2
+ // src/index.ts
3
+ const toOklch = converter("oklch");
4
+ const toRgb = converter("rgb");
5
+ const toP3 = converter("p3");
6
+ const inTarget = {
7
+ srgb: inGamut("rgb"),
8
+ "display-p3": inGamut("p3")
9
+ };
10
+ const fail = (why) => {
11
+ throw new Error(`@cssints/gamut: ${why}`);
12
+ };
13
+ const clamp01 = (x) => Math.min(1, Math.max(0, x));
14
+ const maxChroma = (l, h) => {
15
+ if (l <= 0 || l >= 1) return 0;
16
+ let lo = 0;
17
+ let hi = .5;
18
+ for (let i = 0; i < 24; i++) {
19
+ const mid = (lo + hi) / 2;
20
+ if (inTarget.srgb({
21
+ mode: "oklch",
22
+ l,
23
+ c: mid,
24
+ h
25
+ })) lo = mid;
26
+ else hi = mid;
27
+ }
28
+ return lo;
29
+ };
30
+ const opaque = (text, what) => {
31
+ const c = parse(text);
32
+ if (!c) return fail(`${what} "${text}" is not a colour`);
33
+ if ((c.alpha ?? 1) < 1) return fail(`${what} "${text}" is translucent`);
34
+ return c;
35
+ };
36
+ const shapeOf = (c) => {
37
+ const o = toOklch(c);
38
+ const h = o.h ?? 0;
39
+ const max = maxChroma(clamp01(o.l), h);
40
+ return {
41
+ h,
42
+ rel: max > 0 ? Math.min(1, o.c / max) : 0
43
+ };
44
+ };
45
+ const at = (l, { h, rel }) => formatHex(clampRgb(toRgb({
46
+ mode: "oklch",
47
+ l,
48
+ c: rel * maxChroma(l, h),
49
+ h
50
+ })));
51
+ const channel = (x) => String(Math.round(clamp01(x) * 1e4) / 1e4);
52
+ const p3String = (c) => {
53
+ const { r, g, b } = toP3(c);
54
+ return `color(display-p3 ${channel(r)} ${channel(g)} ${channel(b)})`;
55
+ };
56
+ const ratio = (a, b) => (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
57
+ /**
58
+ * The nearest colour the target gamut can show.
59
+ * @param {string} color an opaque CSS colour
60
+ * @param {FitOptions} [options] the gamut (`"srgb"` by default) and the method (`"chroma"` by default)
61
+ * @returns {string} a hex colour for sRGB, `color(display-p3 r g b)` for Display P3
62
+ */
63
+ const fit = (color, { target = "srgb", method = "chroma" } = {}) => {
64
+ const c = opaque(color, "the colour");
65
+ if (inTarget[target](c)) return target === "srgb" ? formatHex(clampRgb(c)) : p3String(c);
66
+ if (method === "clip") return target === "srgb" ? formatHex(clampRgb(c)) : p3String(c);
67
+ const o = toOklch(c);
68
+ const l = clamp01(o.l);
69
+ const h = o.h ?? 0;
70
+ let lo = 0;
71
+ let hi = o.c;
72
+ for (let i = 0; i < 24; i++) {
73
+ const mid = (lo + hi) / 2;
74
+ if (inTarget[target]({
75
+ mode: "oklch",
76
+ l,
77
+ c: mid,
78
+ h
79
+ })) lo = mid;
80
+ else hi = mid;
81
+ }
82
+ const fitted = {
83
+ mode: "oklch",
84
+ l,
85
+ c: lo,
86
+ h
87
+ };
88
+ return target === "srgb" ? formatHex(clampRgb(toRgb(fitted))) : p3String(fitted);
89
+ };
90
+ /**
91
+ * A text colour with at least `ratio` WCAG 2 contrast on `bg`: the background's hue, its chroma relative to the sRGB
92
+ * maximum, with the lightness nearest the background that passes.
93
+ * @param {string} bg an opaque CSS colour
94
+ * @param {ReadableOptions} [options] the ratio (4.5 by default) and the direction ("auto" by default)
95
+ * @returns {Hex} a hex colour that reaches the ratio on `bg`
96
+ */
97
+ const readable = (bg, { ratio: want = 4.5, toward = "auto" } = {}) => {
98
+ if (!(want >= 1 && want <= 21)) fail(`a ratio is from 1 to 21, got ${want}`);
99
+ const background = opaque(bg, "the background");
100
+ const bgLum = wcagLuminance(clampRgb(background));
101
+ const darker = toward === "auto" ? ratio(0, bgLum) >= ratio(1, bgLum) : toward === "black";
102
+ const far = darker ? 0 : 1;
103
+ const farRatio = ratio(far, bgLum);
104
+ if (farRatio < want) fail(`ratio ${want} is out of reach on ${bg}: at most ${farRatio.toFixed(2)} (${darker ? "black" : "white"})`);
105
+ const s = shapeOf(background);
106
+ let ok = far;
107
+ let no = clamp01(toOklch(background).l);
108
+ for (let i = 0; i < 32; i++) {
109
+ const mid = (ok + no) / 2;
110
+ if (ratio(wcagLuminance(parse(at(mid, s))), bgLum) >= want) ok = mid;
111
+ else no = mid;
112
+ }
113
+ return at(ok, s);
114
+ };
115
+ /**
116
+ * `count` tones of one colour as DTCG data for createTokens: OKLCH lightness from 0.95 down to 0.10, evenly, hue held,
117
+ * chroma relative to the sRGB cusp at that lightness, all in sRGB by construction. Steps `"1"` to `"N"`, lightest first.
118
+ * @param {string} color an opaque CSS colour
119
+ * @param {number} count how many tones, from 1
120
+ * @returns {Tones<N>} a DTCG colour group, steps `"1"` to `"N"`
121
+ */
122
+ const tones = (color, count) => {
123
+ if (!Number.isInteger(count) || count < 1) fail(`count is a whole number from 1, got ${count}`);
124
+ const s = shapeOf(opaque(color, "the colour"));
125
+ const steps = Array.from({ length: count }, (_, i) => at(count === 1 ? .95 : .95 - .85 * i / (count - 1), s));
126
+ return Object.fromEntries([["$type", "color"], ...steps.map((hex, i) => [String(i + 1), { $value: hex }])]);
127
+ };
128
+ export { fit, readable, tones };
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@cssints/gamut",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.33.0",
4
+ "description": "Gamut and contrast colour maths for cssints at build time: fit a colour into sRGB or Display P3, a text colour that meets a WCAG 2 ratio, and tones as DTCG data",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/alexeiaccio/css-in-ts-compiler.git",
9
+ "directory": "packages/gamut"
10
+ },
11
+ "homepage": "https://cssints.dev",
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.mts",
20
+ "default": "./dist/index.mjs"
21
+ }
22
+ },
23
+ "dependencies": {
24
+ "culori": "^4.0.2"
25
+ },
26
+ "peerDependencies": {
27
+ "cssints": "^0.33.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }