@cssints/ramp 0.0.0-stage → 0.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/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,155 @@
1
- # Temporary Holding Version
1
+ # @cssints/ramp
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
+ OKLCH colour ramps for [cssints](../../README.md), computed at build time. `ramp(base, options)` returns plain DTCG data
4
+ (a colour group, steps `"1"` to `"N"`, each a hex colour in sRGB) that you pass to `createTokens` yourself, and
5
+ `overrides(ramp)` turns a ramp into the override object of `createGlobalTheme`. The package has no runtime and no
6
+ `scope()`: like the sets of [@cssints/tokens](../tokens/README.md), a ramp is data, so it goes under any group name, sits
7
+ beside other tokens in one call, and its steps get `@property` rules, themes and `check.contrast` from cssints.
8
+
9
+ ```ts
10
+ // tokens.ts
11
+ import { createGlobalTheme, createTokens, media } from "cssints" with { type: "cssints" };
12
+ import { overrides, ramp } from "@cssints/ramp";
13
+
14
+ const ratios = [1.2, 1.5, 3, 4.5, 7] as const;
15
+
16
+ export const t = createTokens({
17
+ bg: { $type: "color", $value: "#ffffff" },
18
+ blue: ramp("#2563eb", { mode: "contrast", ratios, background: "#ffffff" }),
19
+ gray: ramp("#6b7280", { mode: "lightness", steps: 9 }),
20
+ });
21
+
22
+ // the same ratios on the dark background, under the same condition as it
23
+ createGlobalTheme(t, media("(prefers-color-scheme: dark)"), {
24
+ bg: "#111111",
25
+ blue: overrides(ramp("#2563eb", { mode: "contrast", ratios, background: "#111111" })),
26
+ });
27
+ ```
28
+
29
+ ```ts
30
+ // text.ts
31
+ import { bg, check, cn, color } from "cssints" with { type: "cssints" };
32
+
33
+ import { t } from "./tokens.ts";
34
+
35
+ check.contrast(t.blue._3, t.bg, { min: 3 }); // borders, large text
36
+ check.contrast(t.blue._4, t.bg, { min: 4.5 }); // body text, light and dark
37
+ export const text = cn(color(t.blue._4), bg(t.bg));
38
+ ```
39
+
40
+ The sheet of `text.ts`:
41
+
42
+ ```css
43
+ @property --bg {
44
+ syntax: "<color>";
45
+ inherits: true;
46
+ initial-value: #ffffff;
47
+ }
48
+ @property --blue-4 {
49
+ syntax: "<color>";
50
+ inherits: true;
51
+ initial-value: #356fec;
52
+ }
53
+ @layer _.k {
54
+ @media (prefers-color-scheme: dark) {
55
+ :root {
56
+ --bg: #111111;
57
+ --blue-1: #041e59;
58
+ --blue-2: #092d7c;
59
+ --blue-3: #1855db;
60
+ --blue-4: #3d76ed;
61
+ --blue-5: #719df2;
62
+ }
63
+ }
64
+ }
65
+ @layer _.a {
66
+ ._1od4w7v {
67
+ color: var(--blue-4);
68
+ }
69
+ ._q77new {
70
+ background-color: var(--bg);
71
+ }
72
+ }
73
+ ```
74
+
75
+ ## Modes
76
+
77
+ Every step keeps the base's OKLCH hue and its chroma _relative to the sRGB gamut_ (nutelch's idea): the base's chroma
78
+ over the most chroma sRGB holds at the base's lightness and hue. A step at another lightness takes the same share of the
79
+ most sRGB holds there, so a vivid base stays vivid near its hue's cusp, thins out towards white and black, and every step
80
+ is in sRGB by construction (the hex is the 8-bit rounding of an in-gamut colour, never a clipped one). A grey base gives a
81
+ neutral ramp. The base itself need not be a step.
82
+
83
+ - **`{ mode: "lightness", steps, lightness? }`**: `steps` colours spaced evenly in OKLCH lightness, from
84
+ `lightness[0]` to `lightness[1]` (default `[0.97, 0.25]`, lightest first). `#2563eb` with 9 steps: `#f1f5fe`,
85
+ `#c6d8f9`, `#9cbbf5`, `#719df2`, `#467cee`, `#1a59e6`, `#1143b2`, `#092f81`, `#041b53`.
86
+ - **`{ mode: "contrast", ratios, background }`** (Leonardo's idea): one step per ratio, in the order given. A step's
87
+ lightness is searched (bisection, on the hex it outputs) for the colour nearest the background whose WCAG 2 ratio
88
+ against it is at or above the target. The steps go darker on a light background and lighter on a dark one (the side
89
+ with more room). `#2563eb` with `[1.2, 1.5, 3, 4.5, 7]`:
90
+
91
+ | Background | 1 | 2 | 3 | 4 | 5 |
92
+ | ---------- | -------------- | -------------- | -------------- | -------------- | -------------- |
93
+ | `#ffffff` | `#e1ebfc` 1.20 | `#bfd3f9` 1.51 | `#6593f0` 3.00 | `#356fec` 4.52 | `#164ecc` 7.00 |
94
+ | `#111111` | `#041e59` 1.20 | `#092d7c` 1.50 | `#1855db` 3.02 | `#3d76ed` 4.50 | `#719df2` 7.02 |
95
+
96
+ The numbers are deterministic: the same base and options give the same hex on every machine (no randomness, a fixed
97
+ number of bisection steps).
98
+
99
+ ## Contrast
100
+
101
+ What a contrast ramp promises, exactly:
102
+
103
+ - **Each step `i` has `ratio(step, background) >= ratios[i]`**, with the ratio computed as `check.contrast` computes it:
104
+ culori's `wcagLuminance` of each colour clipped to sRGB, `(L1 + 0.05) / (L2 + 0.05)`, not rounded. It is measured on
105
+ the hex the step outputs, so `check.contrast(t.blue._4, t.bg, { min: 4.5 })` passes when `t.bg` holds the same
106
+ `background`. The search only keeps colours that pass, so this does not rest on the search converging.
107
+ - **By little.** The step is the passing colour nearest the background along the ramp, within an 8-bit step: in the tests
108
+ every step of 8 bases on 4 backgrounds is below `ratio * 1.02`. So a check held to more than the step was made for
109
+ fails: `check.contrast(t.blue._4, t.bg, { min: 4.6 })` is
110
+ `contrast 4.52 is below 4.6: blue.4 #356fec on bg #ffffff, in the default values`, and the same in the dark theme
111
+ (4.50). Check each step at its own ratio.
112
+ - **Against that background only.** A ratio holds for the `background` the ramp was made for. On another background
113
+ (a theme), make a ramp for it with the same ratios and set both under the same condition or selector, as above;
114
+ `check.contrast` then checks each step against the theme's background.
115
+ - **Lightness ramps promise no ratio**; check the pairs you use.
116
+ - **WCAG 2 only**, not APCA. A WCAG 2 pass is not an APCA pass, especially for light text on dark.
117
+
118
+ ## Limits
119
+
120
+ - **Errors are thrown** when the module is evaluated, so they are build errors: a base or background that is not a
121
+ colour (`@cssints/ramp: the base "banana" is not a colour`) or is translucent, `steps` that is not a whole number from
122
+ 1, a lightness outside 0..1, and a ratio out of 1..21 or out of reach on the background
123
+ (`ratio 21 is out of reach on #111: at most 18.88 (white)`). A mid-grey background leaves little room on either side.
124
+ - **Monotone for sorted ratios.** Ascending ratios give steps moving away from the background, but the ramp does not sort
125
+ them for you. The lightness is OKLCH lightness, not a luminance: two hues at one lightness have different ratios.
126
+ - **sRGB only.** A P3 variant (`color(display-p3 …)`) is not shipped: `check.contrast` clips a colour to sRGB, so a P3
127
+ step would be checked as another colour than the one shown, and a `color()` value walks the colour grammar in the types
128
+ (thousands of instantiations a token) where a hex is cheap. A base outside sRGB is read with its share of chroma capped
129
+ at the gamut.
130
+ - **The editor shows `#${string}`.** A step's value is computed when the module runs, so its type is `` `#${string}` ``,
131
+ not the literal hex (hover shows ``Token<"color", true, `#${string}`>``). Its type is still a colour token, the steps are
132
+ known (`t.blue._6` of a five-ratio ramp is a type error), and the build checks the values.
133
+ - **Type cost** (tsgo, one file per row):
134
+
135
+ | Call | Instantiations | Check time |
136
+ | ------------------------------------------------ | -------------- | ---------- |
137
+ | `color("red")` (the baseline) | 0.16 M | 0.09 s |
138
+ | 10 ramps of 10 steps (100 tokens) | 0.15 M | 0.09 s |
139
+ | 100 ramps of 10 steps (1,000 tokens) | 0.16 M | 0.10 s |
140
+ | the same 1,000 tokens and a theme of all of them | 0.24 M | 0.13 s |
141
+
142
+ A step costs about 10 instantiations as a token (every value has the one type `` `#${string}` ``), about 80 as a theme
143
+ override, so a ramp is as cheap as the hex sets of @cssints/tokens.
144
+
145
+ ```bash
146
+ cd packages/ramp && bun run test # the ramp maths, then a fixture through the engine (check.contrast passes, a tight pair fails)
147
+ cd packages/ramp && bun run typecheck # the type test (test/types/ramp.ts)
148
+ ```
149
+
150
+ ## Credits
151
+
152
+ Two ideas, written here: the chroma relative to the sRGB gamut from [nutelch](https://github.com/meodai/nutelch) (MIT,
153
+ David Aerne), and the search for a lightness that meets a contrast ratio from [Leonardo](https://github.com/adobe/leonardo)
154
+ (Apache-2.0, Adobe). The colour maths is [culori](https://culorijs.org) (MIT, Dan Burzo), a dependency. No code is
155
+ copied.
@@ -0,0 +1,56 @@
1
+ //#region src/index.d.ts
2
+ /** A hex colour: cheap in the types (createTokens checks it without the walk of every colour syntax). */
3
+ export type Hex = `#${string}`;
4
+ /** The step names of an N-step ramp: `"1"` to `"N"` (`t.blue._1` in code, `--blue-1` in CSS). */
5
+ 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"]}`]>;
6
+ /** A ramp: a DTCG colour group whose tokens are the steps, lightest first for a lightness ramp. */
7
+ export type Ramp<K extends string> = {
8
+ readonly $type: "color";
9
+ } & { readonly [P in K]: {
10
+ readonly $value: Hex;
11
+ }; };
12
+ /** The overrides of a ramp for createGlobalTheme: a step `"1"` is `_1`, as cssints reads a key that starts with a digit. */
13
+ export type RampOverrides<K extends string> = { readonly [P in K as `_${P}`]: Hex; };
14
+ /** A lightness ramp: `steps` colours from the first lightness to the second, evenly in OKLCH lightness. */
15
+ export interface LightnessOptions<N extends number> {
16
+ readonly mode: "lightness";
17
+ readonly steps: N;
18
+ /** OKLCH lightness of the first and the last step, from 0 to 1; default `[0.97, 0.25]`. */
19
+ readonly lightness?: readonly [number, number];
20
+ }
21
+ /** A contrast ramp: one step per ratio, each the lowest WCAG 2 ratio at or above it against `background`. */
22
+ export interface ContrastOptions<R extends readonly number[]> {
23
+ readonly mode: "contrast";
24
+ /** WCAG 2 contrast ratios against the background, from 1 to 21: `[1.2, 1.5, 3, 4.5, 7]`. */
25
+ readonly ratios: R;
26
+ /** An opaque CSS colour; the steps go darker on a light one and lighter on a dark one. */
27
+ readonly background: string;
28
+ }
29
+ /**
30
+ * The relative luminance of a colour as css.check.contrast reads it (clipped to sRGB).
31
+ * @param {string} text an opaque CSS colour
32
+ * @returns {number} the WCAG 2 relative luminance, from 0 to 1
33
+ */
34
+ export declare const luminance: (text: string) => number;
35
+ /**
36
+ * The WCAG 2 contrast ratio of two relative luminances, as css.check.contrast computes it (not rounded).
37
+ * @param {number} a a relative luminance
38
+ * @param {number} b the other
39
+ * @returns {number} the ratio, from 1 to 21
40
+ */
41
+ export declare const ratio: (a: number, b: number) => number;
42
+ /**
43
+ * An OKLCH ramp of `base` as a DTCG colour group for createTokens, steps `"1"` to `"N"`, each a hex colour in sRGB.
44
+ * `mode: "lightness"`: `steps` colours spaced evenly in OKLCH lightness. `mode: "contrast"`: one step per ratio, each the
45
+ * colour of the base's hue with the lowest WCAG 2 ratio at or above it against `background`, so
46
+ * `check.contrast(step, background, { min: ratio })` passes. Throws on a colour it cannot read or a ratio out of reach.
47
+ */
48
+ export declare function ramp<const N extends number>(base: string, options: LightnessOptions<N>): Ramp<Keys<N>>;
49
+ export declare function ramp<const R extends readonly number[]>(base: string, options: ContrastOptions<R>): Ramp<Keys<R["length"]>>;
50
+ /**
51
+ * A ramp as the overrides of a theme: `createGlobalTheme(t.blue, dark, overrides(ramp(…)))`.
52
+ * @param {Ramp<K>} r a ramp
53
+ * @returns {RampOverrides<K>} its steps as `{ _1: hex, … }`
54
+ */
55
+ export declare const overrides: <K extends string>(r: Ramp<K>) => RampOverrides<K>;
56
+ //#endregion
package/dist/index.mjs ADDED
@@ -0,0 +1,96 @@
1
+ import { clampRgb, converter, formatHex, inGamut, parse, wcagLuminance } from "culori";
2
+ //#region src/index.ts
3
+ const toOklch = converter("oklch");
4
+ const toRgb = converter("rgb");
5
+ const inSrgb = inGamut("rgb");
6
+ const fail = (why) => {
7
+ throw new Error(`@cssints/ramp: ${why}`);
8
+ };
9
+ const maxChroma = (l, h) => {
10
+ if (l <= 0 || l >= 1) return 0;
11
+ let lo = 0;
12
+ let hi = .5;
13
+ for (let i = 0; i < 24; i++) {
14
+ const mid = (lo + hi) / 2;
15
+ if (inSrgb({
16
+ mode: "oklch",
17
+ l,
18
+ c: mid,
19
+ h
20
+ })) lo = mid;
21
+ else hi = mid;
22
+ }
23
+ return lo;
24
+ };
25
+ const opaque = (text, what) => {
26
+ const c = parse(text);
27
+ if (!c) return fail(`${what} "${text}" is not a colour`);
28
+ if ((c.alpha ?? 1) < 1) return fail(`${what} "${text}" is translucent`);
29
+ return c;
30
+ };
31
+ const shape = (base) => {
32
+ const o = toOklch(opaque(base, "the base"));
33
+ const h = o.h ?? 0;
34
+ const max = maxChroma(o.l, h);
35
+ return {
36
+ h,
37
+ rel: max > 0 ? Math.min(1, o.c / max) : 0
38
+ };
39
+ };
40
+ const at = (l, { h, rel }) => formatHex(clampRgb(toRgb({
41
+ mode: "oklch",
42
+ l,
43
+ c: rel * maxChroma(l, h),
44
+ h
45
+ })));
46
+ /**
47
+ * The relative luminance of a colour as css.check.contrast reads it (clipped to sRGB).
48
+ * @param {string} text an opaque CSS colour
49
+ * @returns {number} the WCAG 2 relative luminance, from 0 to 1
50
+ */
51
+ const luminance = (text) => wcagLuminance(clampRgb(opaque(text, "the colour")));
52
+ /**
53
+ * The WCAG 2 contrast ratio of two relative luminances, as css.check.contrast computes it (not rounded).
54
+ * @param {number} a a relative luminance
55
+ * @param {number} b the other
56
+ * @returns {number} the ratio, from 1 to 21
57
+ */
58
+ const ratio = (a, b) => (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
59
+ const group = (steps) => Object.fromEntries([["$type", "color"], ...steps.map((hex, i) => [String(i + 1), { $value: hex }])]);
60
+ const lightnessRamp = (base, { steps, lightness = [.97, .25] }) => {
61
+ if (!Number.isInteger(steps) || steps < 1) fail(`steps is a whole number from 1, got ${steps}`);
62
+ const [from, to] = lightness;
63
+ if (!(from >= 0 && from <= 1 && to >= 0 && to <= 1)) fail(`lightness is two numbers from 0 to 1, got [${from}, ${to}]`);
64
+ const s = shape(base);
65
+ return Array.from({ length: steps }, (_, i) => at(steps === 1 ? from : from + (to - from) * i / (steps - 1), s));
66
+ };
67
+ const contrastRamp = (base, { ratios, background }) => {
68
+ const bgL = toOklch(opaque(background, "the background")).l;
69
+ const bg = luminance(background);
70
+ const darker = ratio(0, bg) >= ratio(1, bg);
71
+ const s = shape(base);
72
+ return ratios.map((r) => {
73
+ if (!(r >= 1 && r <= 21)) fail(`a ratio is from 1 to 21, got ${r}`);
74
+ const passes = (l) => ratio(luminance(at(l, s)), bg) >= r;
75
+ let ok = darker ? 0 : 1;
76
+ let no = bgL;
77
+ if (!passes(ok)) fail(`ratio ${r} is out of reach on ${background}: at most ${ratio(darker ? 0 : 1, bg).toFixed(2)} (${darker ? "black" : "white"})`);
78
+ for (let i = 0; i < 32; i++) {
79
+ const mid = (ok + no) / 2;
80
+ if (passes(mid)) ok = mid;
81
+ else no = mid;
82
+ }
83
+ return at(ok, s);
84
+ });
85
+ };
86
+ function ramp(base, options) {
87
+ return group(options.mode === "contrast" ? contrastRamp(base, options) : lightnessRamp(base, options));
88
+ }
89
+ /**
90
+ * A ramp as the overrides of a theme: `createGlobalTheme(t.blue, dark, overrides(ramp(…)))`.
91
+ * @param {Ramp<K>} r a ramp
92
+ * @returns {RampOverrides<K>} its steps as `{ _1: hex, … }`
93
+ */
94
+ const overrides = (r) => Object.fromEntries(Object.entries(r).flatMap(([k, v]) => k === "$type" ? [] : [[`_${k}`, v.$value]]));
95
+ //#endregion
96
+ export { luminance, overrides, ramp, ratio };
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@cssints/ramp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "OKLCH colour ramps for cssints as DTCG data: a lightness ramp, or steps picked by WCAG 2 contrast against a background",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/alexeiaccio/css-in-ts-compiler.git",
9
+ "directory": "packages/ramp"
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.1.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }