@cssints/gradient-2d 0.0.0-stage → 0.5.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,158 @@
1
- # Temporary Holding Version
1
+ # @cssints/gradient-2d
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
+ `mesh-gradient()` and `freeform-gradient()` for [cssints](../../README.md), as styles of `cssints/plugin` `scope()`.
4
+ Where the browser has the CSS function, the style uses it. Where it does not, a runtime draws the gradient on a canvas and
5
+ sets the drawing as the background. **No browser has either function yet**, so today the runtime draws every gradient; the
6
+ first paint is freeform's stack of radial gradients or mesh's average colour.
7
+
8
+ **Status (2026-10-17).** The CSSWG adopted mesh and freeform gradients into css-images-5 on 2026-10-14, as an explainer
9
+ ([css-images-5/explainers/2d-gradients.md](https://github.com/w3c/csswg-drafts/blob/main/css-images-5/explainers/2d-gradients.md)).
10
+ That is not a spec draft: the syntax can still change. Freeform may be renamed `diffusion-gradient()`, and neither
11
+ function's rendering rule is public, so both drawings here are **this package's algorithm**, not a browser's or
12
+ Illustrator's.
13
+
14
+ ```ts
15
+ import { cn, color } from "cssints" with { type: "cssints" };
16
+ import { freeformGradient, meshGradient } from "@cssints/gradient-2d" with { type: "cssints" };
17
+
18
+ export const hero = cn(meshGradient("red, blue; green, yellow"), color("white"));
19
+ export const panel = freeformGradient("red top left, gold 4 60% 40%, yellow bottom right");
20
+ ```
21
+
22
+ ```css
23
+ @layer _.k {
24
+ ._gradient2d-core {
25
+ background-image: var(--gradient2d-fallback);
26
+ background-repeat: no-repeat;
27
+ background-size: 100% 100%;
28
+ }
29
+ /* only where the browser parses the function */
30
+ @supports (background-image: mesh-gradient(red, blue; green, yellow)) {
31
+ ._gradient2d-core {
32
+ background-image: var(--gradient2d-native);
33
+ }
34
+ }
35
+ ._gradient2d-ff {
36
+ background-image: var(--gradient2d-ff-fallback);
37
+ background-repeat: no-repeat;
38
+ background-size: 100% 100%;
39
+ }
40
+ @supports (background-image: freeform-gradient(red top left, blue bottom right)) {
41
+ ._gradient2d-ff {
42
+ background-image: var(--gradient2d-ff-native);
43
+ }
44
+ }
45
+ }
46
+ @layer _.a {
47
+ ._1gxhgq5 {
48
+ --gradient2d-native: mesh-gradient(red, blue; green, yellow);
49
+ --gradient2d-fallback: linear-gradient(#918d78, #918d78);
50
+ --gradient2d-data: '{"space":"oklab","rows":[[[1,0,0,1],[0,0,1,1]],[[0,0.502,0,1],[1,1,0,1]]]}';
51
+ }
52
+ ._1ps31mi {
53
+ --gradient2d-ff-native: freeform-gradient(red top left, gold 4 60% 40%, yellow bottom right);
54
+ --gradient2d-ff-fallback:
55
+ radial-gradient(circle at 0% 0%, #ff0000, transparent),
56
+ radial-gradient(circle at 60% 40%, #ffd700, transparent),
57
+ radial-gradient(circle at 100% 100%, #ffff00, transparent);
58
+ --gradient2d-ff-data: '{"space":"oklab","points":[[0,0,1,[1,0,0,1]],[0.6,0.4,4,[1,0.8431,0,1]],[1,1,1,[1,1,0,1]]]}';
59
+ }
60
+ }
61
+ ```
62
+
63
+ The native value is the spec's text, as written. The data is the stops or points as sRGB numbers, for the runtime. Mesh's
64
+ fallback is the average colour of its stops, in oklab; freeform's is a radial layer per point.
65
+
66
+ ## Syntax
67
+
68
+ ### `meshGradient(mesh)`
69
+
70
+ - **`meshGradient(mesh)`**, one string: rows of stops, rows separated by `;` from the top of the box down, stops by `,`
71
+ from the left. A stop is a colour: `red`, `#888`, `oklch(...)`, or `var(--token)`.
72
+ - **`in oklab`** or **`in srgb`** may open the text, as the spec has it: `meshGradient("in oklab, red, blue; green, yellow")`.
73
+ A colour after it (`in oklab red, ...`) is the colour outside the mesh. It is checked and kept in the native value, but
74
+ never drawn: the grid covers the whole box.
75
+ - **Build errors**, at the call: rows of different lengths, fewer than 2 rows of 2 stops, a colour that does not parse,
76
+ another colour space than oklab or srgb, and a stop with a position, `by` or `with`.
77
+ - **Not supported yet:** positions (`red 0% 0%`) and the control points (`by`, `with`). The grid is regular: the corners
78
+ sit at the corners of the box and the other stops are spaced evenly. A position needs the grid warped, which the
79
+ runtime does not do yet. Both are build errors, not ignored.
80
+ - **`var(--token)` stops** work. The browser resolves them: the drawing waits for the runtime, and the first paint has no
81
+ fallback (`--gradient2d-fallback: none`).
82
+
83
+ ### `freeformGradient(points)`
84
+
85
+ - **`freeformGradient(points)`**, one string: points separated by `,`, each `<color> <number>? <position>`. The number is
86
+ the point's **spread**, its weight against the others (1 when it is left out). The position is a keyword
87
+ (`top left`, `bottom right`, `center`) or one or two percentages, as `background-position` reads it
88
+ (`gold 4 60% 40%`, `blue right 10%` is 90% across).
89
+ - **`in oklab`** or **`in srgb`** may open the text, as the spec has it: `freeformGradient("in srgb, red 20% 30%, blue")`.
90
+ The function has no colour outside the points, so a colour after the method is a build error.
91
+ - **Build errors**, at the call: a point without a position, fewer than 2 points, two points at the same position (their
92
+ weights are both infinite there), a colour that does not parse, a length (`10px` needs the box), a line (a point with
93
+ more than one position, the spec's future work) and a shape.
94
+ - **Not supported yet:** lines, shapes and lengths, as above. A `var(--token)` point works: the browser resolves it, in
95
+ the fallback too.
96
+
97
+ ## Browsers
98
+
99
+ - **Native**: neither function is in any browser. The installed Chrome of `test:browser` reports no support, so the
100
+ runtime draws. The `@supports` tests are the spec's simplest examples: `mesh-gradient(red, blue; green, yellow)` and
101
+ `freeform-gradient(red top left, blue bottom right)`. The two are decided apart, so a browser with mesh and without
102
+ freeform takes mesh's branch only (`_gradient2d-ff` is a class of its own).
103
+ - **The runtime** is in every build whose browserslist targets include a browser that BCD tracks. The table in
104
+ `@cssints/css-grammar` says every such browser lacks `css.types.gradient.mesh-gradient` and
105
+ `css.types.gradient.freeform-gradient` (BCD has no entry for either). When BCD or the table has a browser with one of
106
+ them, the runtime ships only to the targets that lack it.
107
+ - **The runtime needs** `ResizeObserver`, canvas and `MutationObserver` (where the page has them, the drawing works).
108
+
109
+ ## The runtime
110
+
111
+ - **The algorithm is this package's, not the spec's and not Illustrator's.** Mesh is a bicubic (Catmull-Rom) through the
112
+ 4 × 4 stops around a point; the spec interpolates a tensor-product patch with Bézier control points, which this package
113
+ does not model. Freeform is **inverse-distance weighting**: the colour at a point is the weighted mean of the stops,
114
+ the weight of a stop `spread / d²`, in oklab. The spec suggests a Delaunay triangulation; freeform's rendering rule is
115
+ not public (Josh Tumath, 2026-06-25), so no browser or editor agrees on it yet. IDW was chosen over a Delaunay because
116
+ every pixel of the box then has a weight: a triangulation paints only the points' convex hull and leaves the rest of
117
+ the box undefined (the spike in `docs/research/2d-gradients.md` saw exactly that hard hull edge; this package's own
118
+ reading of an IDW render is soft round blobs, one per stop, with the spread visible). The drawing is a close relative
119
+ of a browser's freeform, not the same surface. Compare by eye.
120
+ - **Resolution.** The canvas is a quarter of the box (64 px at least) and the browser stretches it to the box. A large box
121
+ is soft, not sharp. In Chrome (the installed one, 1000 × 600, median of six resizes between 800 and 1000 px, the canvas
122
+ 250 × 150, the time to the new background, layout and PNG encoding included): the mesh redraws in 49 ms, the freeform in
123
+ 34 ms. The distances of freeform are measured in the box's pixels (height / width), as the fallback's radial layers are,
124
+ so a point's influence is a circle on screen and not an ellipse.
125
+ - **Size.** 3.9 kB minified, 1.9 kB gzipped for both rules (measured with rolldown's minifier; the `dist/runtime.mjs` of
126
+ `vp pack` is not minified). Mesh alone is 2.9 kB / 1.6 kB, as it was before freeform joined it; `@cssints/balance`'s
127
+ script is 552 bytes.
128
+ - **Data and tokens.** The runtime reads `--gradient2d-data` or `--gradient2d-ff-data` (JSON in a CSS string). A `var()`
129
+ stop is read through a hidden child of the element, so it inherits the element's custom properties, and a 1 × 1 canvas
130
+ turns the computed colour into sRGB.
131
+ - **Redraw.** On a size change, and when an element gets the marker class (added to the page or given the class later).
132
+ Both rules share one `ResizeObserver` and one `MutationObserver` (src/canvas.ts). A drawing that is outdated when a newer
133
+ one finishes is dropped. A previous blob URL is revoked.
134
+ - **First paint without JS.** Freeform's fallback is a stack of radial gradients, one layer per point: its colour at the
135
+ point fading to transparent at the farthest corner of the box. It is an approximation with its own blend (alpha, in the
136
+ browser's space, not `spread / d²` in oklab; the spread is not in it), and it holds a `var()` point, which mesh's
137
+ average colour cannot.
138
+ - **CSP.** The fallback is a `blob:` URL. The page needs `img-src blob:` (or `img-src` with `blob:` in it) for the drawing.
139
+ - **SSR.** The runtime does nothing without `document`. The first paint is the kernel's fallback.
140
+
141
+ ## Limits
142
+
143
+ - **Freeform's fallback and its drawing do not agree pixel for pixel.** The stack of radial gradients is a low-fidelity
144
+ first paint, not the IDW rule: its blend is linear in the distance and in the browser's colour space.
145
+ - **A condition can change the data, and the runtime reads it at the draw.** `hover(meshGradient(...))` puts the data
146
+ under `:hover` (checked: the engine prints it); the drawing does not change on hover, only on the next resize.
147
+ - **An element removed from the page keeps its observer and its blob URL** until the page unloads. Fine for pages, not
148
+ for a long single-page app that churns gradients; a fix would observe removals.
149
+ - **Printing** takes the raster fallback. A PDF of a browser's freeform is a patch shading, not a bitmap, and freeform is
150
+ large in PDF.
151
+ - **The syntax can change** until the spec draft, and freeform's name with it (`diffusion-gradient()`); the build error
152
+ messages name the part they reject.
153
+
154
+ ```bash
155
+ cd packages/gradient-2d && bun run test # the maths in Node, the engine (kernels, data, errors, the runtime by targets)
156
+ cd packages/gradient-2d && bun run typecheck # and the type tests
157
+ cd packages/gradient-2d && bun run test:browser # Chrome (installed): both first paints, the pixels against the maths, resize, an added element
158
+ ```
@@ -0,0 +1,41 @@
1
+ //#region src/index.d.ts
2
+ declare const mesh: import("cssints/plugin").Scope<"gradient2d", {
3
+ readonly "#core": {
4
+ readonly css: "&{background-image:var(--_fallback);background-repeat:no-repeat;background-size:100% 100%}@supports (background-image:mesh-gradient(red,blue;green,yellow)){&{background-image:var(--_native)}}";
5
+ };
6
+ readonly "#script": {
7
+ readonly polyfills: ["css.types.gradient.mesh-gradient"];
8
+ readonly script: "@cssints/gradient-2d/runtime";
9
+ };
10
+ readonly "#ff": {
11
+ readonly css: "&{background-image:var(--_ff-fallback);background-repeat:no-repeat;background-size:100% 100%}@supports (background-image:freeform-gradient(red top left, blue bottom right)){&{background-image:var(--_ff-native)}}";
12
+ };
13
+ readonly "#ffScript": {
14
+ readonly polyfills: ["css.types.gradient.freeform-gradient"];
15
+ readonly script: "@cssints/gradient-2d/runtime";
16
+ };
17
+ readonly meshGradient: {
18
+ readonly args: import("cssints/plugin").Typed<string>;
19
+ readonly uses: readonly ["core", "script"];
20
+ };
21
+ readonly freeformGradient: {
22
+ readonly args: import("cssints/plugin").Typed<string>;
23
+ readonly uses: readonly ["ff", "ffScript"];
24
+ };
25
+ }>;
26
+ type Members = ReturnType<typeof mesh.export>;
27
+ /**
28
+ * `meshGradient("red, blue; green, yellow")`: a mesh gradient of a grid of colour stops (rows `;`, stops `,`), with the
29
+ * spec's `in oklab` first if wanted. The native `mesh-gradient()` where the browser has it, else drawn by the runtime.
30
+ * A build error for ragged rows, fewer than 2 by 2, a colour it cannot read, and a position, `by` or `with`.
31
+ */
32
+ export declare const meshGradient: Members["meshGradient"];
33
+ /**
34
+ * `freeformGradient("red top left, gold 4 60% 40%, yellow bottom right")`: a freeform (diffusion) gradient of points,
35
+ * each `<color> <number>? <position>` (the number is the spread), with the spec's `in oklab` first if wanted. The native
36
+ * `freeform-gradient()` where the browser has it; where it does not, a stack of radial gradients paints the first one
37
+ * and the runtime redraws it. A build error for a point without a position, two points at one position, fewer than 2
38
+ * points, a colour it cannot read, a length, a line and a shape.
39
+ */
40
+ export declare const freeformGradient: Members["freeformGradient"];
41
+ //#endregion
package/dist/index.mjs ADDED
@@ -0,0 +1,255 @@
1
+ import { arg, scope } from "cssints/plugin";
2
+ import { formatHex, formatHex8, parse, rgb } from "culori";
3
+ //#region src/grid.ts
4
+ const lin = (c) => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;
5
+ const gam = (c) => c <= .0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - .055;
6
+ const toOklab = ([r, g, b, a]) => {
7
+ const R = lin(r);
8
+ const G = lin(g);
9
+ const B = lin(b);
10
+ const l = Math.cbrt(.4122214708 * R + .5363325363 * G + .0514459929 * B);
11
+ const m = Math.cbrt(.2119034982 * R + .6806995451 * G + .1073969566 * B);
12
+ const s = Math.cbrt(.0883024619 * R + .2817188376 * G + .6299787005 * B);
13
+ return [
14
+ .2104542553 * l + .793617785 * m - .0040720468 * s,
15
+ 1.9779984951 * l - 2.428592205 * m + .4505937099 * s,
16
+ .0259040371 * l + .7827717662 * m - .808675766 * s,
17
+ a
18
+ ];
19
+ };
20
+ const toSrgb = ([L, A, B, a]) => {
21
+ const l = (L + .3963377774 * A + .2158037573 * B) ** 3;
22
+ const m = (L - .1055613458 * A - .0638541728 * B) ** 3;
23
+ const s = (L - .0894841775 * A - 1.291485548 * B) ** 3;
24
+ return [
25
+ gam(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s),
26
+ gam(-1.2684380046 * l + 2.6097574011 * m - .3413193965 * s),
27
+ gam(-.0041960863 * l - .7034186147 * m + 1.707614701 * s),
28
+ a
29
+ ];
30
+ };
31
+ const fromSpace = (space, q) => space === "oklab" ? toSrgb(q) : q;
32
+ const mean = (qs) => [
33
+ 0,
34
+ 1,
35
+ 2,
36
+ 3
37
+ ].map((k) => qs.reduce((s, q) => s + q[k], 0) / qs.length);
38
+ //#endregion
39
+ //#region src/index.ts
40
+ const cut = (text, sep) => {
41
+ const { out, cur } = [...text].reduce((acc, ch) => {
42
+ const depth = acc.depth + Number(ch === "(") - Number(ch === ")");
43
+ return depth === 0 && sep(ch) ? {
44
+ out: [...acc.out, acc.cur.trim()],
45
+ cur: "",
46
+ depth
47
+ } : {
48
+ out: acc.out,
49
+ cur: acc.cur + ch,
50
+ depth
51
+ };
52
+ }, {
53
+ out: [],
54
+ cur: "",
55
+ depth: 0
56
+ });
57
+ return [...out, cur.trim()];
58
+ };
59
+ const colour = (text, call) => {
60
+ if (text.includes("var(")) return text;
61
+ const c = parse(text);
62
+ if (!c) throw new Error(`${call}(): ${JSON.stringify(text)} is not a colour`);
63
+ const { r, g, b, alpha = 1 } = rgb(c);
64
+ return [
65
+ r,
66
+ g,
67
+ b,
68
+ alpha
69
+ ];
70
+ };
71
+ const parseMesh = (text) => {
72
+ const rows = cut(text, (c) => c === ";").map((row) => cut(row, (c) => c === ","));
73
+ const head = /^in\s+(\S+)(?:\s+(.*))?$/i.exec(rows[0]?.[0] ?? "");
74
+ const [, method = "oklab", outside = ""] = head ?? [];
75
+ if (method !== "oklab" && method !== "srgb") throw new Error(`meshGradient(): in ${method} is not supported yet (oklab and srgb)`);
76
+ if (outside) colour(outside, "meshGradient");
77
+ const space = method === "srgb" ? "srgb" : "oklab";
78
+ const stops = (head ? [rows[0].slice(1), ...rows.slice(1)] : rows).map((row) => row.map((p) => {
79
+ if (cut(p, (c) => /\s/.test(c)).length > 1) throw new Error(`meshGradient(): ${JSON.stringify(p)}: a stop is one colour, its position, by and with are not supported yet`);
80
+ return colour(p, "meshGradient");
81
+ }));
82
+ const lengths = stops.map((r) => r.length);
83
+ if (lengths.some((n) => n !== lengths[0])) throw new Error(`meshGradient(): the rows have ${lengths.join(", ")} stops: every row needs the same number`);
84
+ if (stops.length < 2 || lengths[0] < 2) throw new Error(`meshGradient(): a mesh needs at least 2 rows of 2 stops, not ${stops.length} of ${lengths[0] ?? 0}`);
85
+ return {
86
+ space,
87
+ rows: stops
88
+ };
89
+ };
90
+ const clamp = (x) => Math.min(1, Math.max(0, x));
91
+ const hex = ([r, g, b, a]) => {
92
+ const c = {
93
+ mode: "rgb",
94
+ r: clamp(r),
95
+ g: clamp(g),
96
+ b: clamp(b),
97
+ alpha: clamp(a)
98
+ };
99
+ return c.alpha < 1 ? formatHex8(c) : formatHex(c);
100
+ };
101
+ const average = (stops) => {
102
+ const known = stops.filter((s) => typeof s !== "string");
103
+ if (known.length < stops.length) return "none";
104
+ const h = hex(fromSpace("oklab", mean(known.map((q) => toOklab(q)))));
105
+ return `linear-gradient(${h}, ${h})`;
106
+ };
107
+ const KEYWORD = /^(left|right|top|bottom|center)$/;
108
+ const PERCENT = /^(-?\d*\.?\d+)%$/;
109
+ const NUMBER = /^[+-]?(\d+\.?\d*|\.\d+)$/;
110
+ const LENGTH = /^[+-]?(\d+\.?\d*|\.\d+)[a-z]+$/;
111
+ const A_LINE_OR_SHAPE = /^(line|shape|curve)\b/;
112
+ const CALL = "freeformGradient";
113
+ const position = (words) => {
114
+ const pct = (w) => Number(PERCENT.exec(w)[1]) / 100;
115
+ const axis = (w) => w === "left" || w === "right" ? "x" : w === "top" || w === "bottom" ? "y" : "c";
116
+ const edge = (w) => w === "left" || w === "top" ? 0 : 1;
117
+ const [a, b] = words;
118
+ if (b === void 0) {
119
+ if (!KEYWORD.test(a)) return [pct(a), .5];
120
+ if (a === "center") return [.5, .5];
121
+ return axis(a) === "x" ? [edge(a), .5] : [.5, edge(a)];
122
+ }
123
+ const ka = KEYWORD.test(a);
124
+ const kb = KEYWORD.test(b);
125
+ if (!ka && !kb) return [pct(a), pct(b)];
126
+ if (ka && kb) {
127
+ const x = [a, b].find((w) => axis(w) === "x");
128
+ const y = [a, b].find((w) => axis(w) === "y");
129
+ return [x ? edge(x) : .5, y ? edge(y) : .5];
130
+ }
131
+ if (ka) {
132
+ if (axis(a) === "y") return [.5, a === "top" ? pct(b) : 1 - pct(b)];
133
+ if (axis(a) === "x") return [a === "left" ? pct(b) : 1 - pct(b), .5];
134
+ return [.5, pct(b)];
135
+ }
136
+ return axis(b) === "y" ? [pct(a), edge(b)] : [pct(a), .5];
137
+ };
138
+ const point = (text) => {
139
+ const words = cut(text, (c) => /\s/.test(c));
140
+ const c = words[0] ?? "";
141
+ const all = words.slice(1);
142
+ const wants = () => /* @__PURE__ */ new Error(`${CALL}(): ${JSON.stringify(text)}: a point needs a position, \`<color> <number>? <position>\``);
143
+ if (all.length === 0) throw wants();
144
+ const spread = NUMBER.test(all[0]) ? Number(all[0]) : 1;
145
+ const rest = NUMBER.test(all[0]) ? all.slice(1) : all;
146
+ if (rest.length === 0) throw wants();
147
+ const part = rest.find((w) => A_LINE_OR_SHAPE.test(w));
148
+ if (part) throw new Error(`${CALL}(): ${JSON.stringify(part)}: a line or a shape is not supported yet`);
149
+ const len = rest.find((w) => LENGTH.test(w));
150
+ if (len) throw new Error(`${CALL}(): ${JSON.stringify(len)} is a length: a position is a percentage or a keyword`);
151
+ if (rest.length > 2) {
152
+ const bad = rest.find((w) => !KEYWORD.test(w) && !PERCENT.test(w));
153
+ throw new Error(bad ? `${CALL}(): ${JSON.stringify(bad)} is not a position: a keyword (top, left, ...) or a percentage` : `${CALL}(): ${JSON.stringify(text)}: a line (a point with more than one position) is not supported yet`);
154
+ }
155
+ const [u = .5, v = .5] = position(rest);
156
+ return {
157
+ q: colour(c, CALL),
158
+ u,
159
+ v,
160
+ spread
161
+ };
162
+ };
163
+ const parseFreeform = (text) => {
164
+ const items = cut(text, (c) => c === ",");
165
+ const head = /^in\s+(\S+)(?:\s+(.*))?$/i.exec(items[0] ?? "");
166
+ const [, method = "oklab", extra = ""] = head ?? [];
167
+ if (method !== "oklab" && method !== "srgb") throw new Error(`${CALL}(): in ${method} is not supported yet (oklab and srgb)`);
168
+ if (extra) throw new Error(`${CALL}(): ${JSON.stringify(items[0])}: the interpolation method takes no colour (freeform-gradient() has none)`);
169
+ const space = method === "srgb" ? "srgb" : "oklab";
170
+ const body = head ? items.slice(1) : items;
171
+ if (body.length < 2) throw new Error(`${CALL}(): a gradient needs at least 2 points, not ${body.length}`);
172
+ const points = body.map(point);
173
+ const seen = /* @__PURE__ */ new Map();
174
+ for (const [i, p] of points.entries()) {
175
+ const at = `${p.u},${p.v}`;
176
+ const had = seen.get(at);
177
+ if (had !== void 0) throw new Error(`${CALL}(): ${JSON.stringify(had)} and ${JSON.stringify(body[i])} are the same position: every point needs its own`);
178
+ seen.set(at, body[i]);
179
+ }
180
+ return {
181
+ space,
182
+ points
183
+ };
184
+ };
185
+ const pct = (x) => `${Math.round(x * 1e6) / 1e4}%`;
186
+ const radials = (points) => points.map((p) => {
187
+ const c = typeof p.q === "string" ? p.q : hex(p.q);
188
+ return `radial-gradient(circle at ${pct(p.u)} ${pct(p.v)}, ${c}, transparent)`;
189
+ }).join(", ");
190
+ const members = scope("gradient2d", {
191
+ "#core": { css: "&{background-image:var(--_fallback);background-repeat:no-repeat;background-size:100% 100%}@supports (background-image:mesh-gradient(red,blue;green,yellow)){&{background-image:var(--_native)}}" },
192
+ "#script": {
193
+ polyfills: ["css.types.gradient.mesh-gradient"],
194
+ script: "@cssints/gradient-2d/runtime"
195
+ },
196
+ "#ff": { css: "&{background-image:var(--_ff-fallback);background-repeat:no-repeat;background-size:100% 100%}@supports (background-image:freeform-gradient(red top left, blue bottom right)){&{background-image:var(--_ff-native)}}" },
197
+ "#ffScript": {
198
+ polyfills: ["css.types.gradient.freeform-gradient"],
199
+ script: "@cssints/gradient-2d/runtime"
200
+ },
201
+ meshGradient: {
202
+ args: arg(),
203
+ uses: ["core", "script"]
204
+ },
205
+ freeformGradient: {
206
+ args: arg(),
207
+ uses: ["ff", "ffScript"]
208
+ }
209
+ }).build({
210
+ meshGradient: (text) => {
211
+ const { space, rows } = parseMesh(text);
212
+ const data = {
213
+ space,
214
+ rows: rows.map((row) => row.map((s) => typeof s === "string" ? s : s.map((n) => Math.round(n * 1e4) / 1e4)))
215
+ };
216
+ return { decls: [
217
+ ["--_native", `mesh-gradient(${text})`],
218
+ ["--_fallback", average(rows.flat())],
219
+ ["--_data", JSON.stringify(JSON.stringify(data))]
220
+ ] };
221
+ },
222
+ freeformGradient: (text) => {
223
+ const { space, points } = parseFreeform(text);
224
+ const data = {
225
+ space,
226
+ points: points.map((p) => [
227
+ Math.round(p.u * 1e4) / 1e4,
228
+ Math.round(p.v * 1e4) / 1e4,
229
+ p.spread,
230
+ typeof p.q === "string" ? p.q : p.q.map((n) => Math.round(n * 1e4) / 1e4)
231
+ ])
232
+ };
233
+ return { decls: [
234
+ ["--_ff-native", `freeform-gradient(${text})`],
235
+ ["--_ff-fallback", radials(points)],
236
+ ["--_ff-data", JSON.stringify(JSON.stringify(data))]
237
+ ] };
238
+ }
239
+ }).export();
240
+ /**
241
+ * `meshGradient("red, blue; green, yellow")`: a mesh gradient of a grid of colour stops (rows `;`, stops `,`), with the
242
+ * spec's `in oklab` first if wanted. The native `mesh-gradient()` where the browser has it, else drawn by the runtime.
243
+ * A build error for ragged rows, fewer than 2 by 2, a colour it cannot read, and a position, `by` or `with`.
244
+ */
245
+ const meshGradient = members.meshGradient;
246
+ /**
247
+ * `freeformGradient("red top left, gold 4 60% 40%, yellow bottom right")`: a freeform (diffusion) gradient of points,
248
+ * each `<color> <number>? <position>` (the number is the spread), with the spec's `in oklab` first if wanted. The native
249
+ * `freeform-gradient()` where the browser has it; where it does not, a stack of radial gradients paints the first one
250
+ * and the runtime redraws it. A build error for a point without a position, two points at one position, fewer than 2
251
+ * points, a colour it cannot read, a length, a line and a shape.
252
+ */
253
+ const freeformGradient = members.freeformGradient;
254
+ //#endregion
255
+ export { freeformGradient, meshGradient };
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,207 @@
1
+ //#region src/canvas.ts
2
+ const drawn = /* @__PURE__ */ new WeakMap();
3
+ const resolve = (el, text, ctx) => {
4
+ const probe = document.createElement("span");
5
+ probe.hidden = true;
6
+ probe.style.color = text;
7
+ el.append(probe);
8
+ const css = getComputedStyle(probe).color;
9
+ probe.remove();
10
+ ctx.clearRect(0, 0, 1, 1);
11
+ ctx.fillStyle = css;
12
+ ctx.fillRect(0, 0, 1, 1);
13
+ const px = ctx.getImageData(0, 0, 1, 1).data;
14
+ return [
15
+ px[0] / 255,
16
+ px[1] / 255,
17
+ px[2] / 255,
18
+ px[3] / 255
19
+ ];
20
+ };
21
+ const draw = (el, d) => {
22
+ if (!el.clientWidth || !el.clientHeight) return;
23
+ const w = Math.max(64, Math.round(el.clientWidth / 4));
24
+ const h = Math.max(64, Math.round(el.clientHeight / 4));
25
+ const cv = document.createElement("canvas");
26
+ cv.width = w;
27
+ cv.height = h;
28
+ const ctx = cv.getContext("2d");
29
+ if (!ctx) return;
30
+ ctx.putImageData(d.paint(el, ctx, w, h), 0, 0);
31
+ const s = drawn.get(el) ?? { id: 0 };
32
+ const id = ++s.id;
33
+ drawn.set(el, s);
34
+ cv.toBlob((blob) => {
35
+ if (!blob || drawn.get(el)?.id !== id) return;
36
+ if (s.url) URL.revokeObjectURL(s.url);
37
+ s.url = URL.createObjectURL(blob);
38
+ el.style.setProperty(d.fallback, `url("${s.url}")`);
39
+ }, "image/png");
40
+ };
41
+ const run = (drawings) => {
42
+ const live = drawings.filter((d) => !CSS.supports("background-image", d.native));
43
+ if (!live.length) return;
44
+ const sized = new ResizeObserver((es) => {
45
+ for (const e of es) {
46
+ const el = e.target;
47
+ for (const d of live) if (el.classList.contains(d.marker)) draw(el, d);
48
+ }
49
+ });
50
+ const scan = (root) => {
51
+ for (const d of live) for (const el of root.matches(`.${d.marker}`) ? [root] : root.querySelectorAll(`.${d.marker}`)) sized.observe(el);
52
+ };
53
+ new MutationObserver((rs) => {
54
+ for (const r of rs) if (r.type === "attributes") scan(r.target);
55
+ else for (const n of r.addedNodes) if (n.nodeType === 1) scan(n);
56
+ }).observe(document.documentElement, {
57
+ subtree: true,
58
+ childList: true,
59
+ attributes: true,
60
+ attributeFilter: ["class"]
61
+ });
62
+ scan(document.documentElement);
63
+ };
64
+ //#endregion
65
+ //#region src/grid.ts
66
+ const lin = (c) => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;
67
+ const gam = (c) => c <= .0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - .055;
68
+ const toOklab = ([r, g, b, a]) => {
69
+ const R = lin(r);
70
+ const G = lin(g);
71
+ const B = lin(b);
72
+ const l = Math.cbrt(.4122214708 * R + .5363325363 * G + .0514459929 * B);
73
+ const m = Math.cbrt(.2119034982 * R + .6806995451 * G + .1073969566 * B);
74
+ const s = Math.cbrt(.0883024619 * R + .2817188376 * G + .6299787005 * B);
75
+ return [
76
+ .2104542553 * l + .793617785 * m - .0040720468 * s,
77
+ 1.9779984951 * l - 2.428592205 * m + .4505937099 * s,
78
+ .0259040371 * l + .7827717662 * m - .808675766 * s,
79
+ a
80
+ ];
81
+ };
82
+ const toSrgb = ([L, A, B, a]) => {
83
+ const l = (L + .3963377774 * A + .2158037573 * B) ** 3;
84
+ const m = (L - .1055613458 * A - .0638541728 * B) ** 3;
85
+ const s = (L - .0894841775 * A - 1.291485548 * B) ** 3;
86
+ return [
87
+ gam(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s),
88
+ gam(-1.2684380046 * l + 2.6097574011 * m - .3413193965 * s),
89
+ gam(-.0041960863 * l - .7034186147 * m + 1.707614701 * s),
90
+ a
91
+ ];
92
+ };
93
+ const toSpace = (space, q) => space === "oklab" ? toOklab(q) : q;
94
+ const fromSpace = (space, q) => space === "oklab" ? toSrgb(q) : q;
95
+ const cr = (p0, p1, p2, p3, t) => p1 + .5 * t * (p2 - p0 + t * (2 * p0 - 5 * p1 + 4 * p2 - p3 + t * (3 * (p1 - p2) + p3 - p0)));
96
+ const sample = (g, u, v) => {
97
+ const rows = g.length;
98
+ const cols = g[0].length;
99
+ const x = u * (cols - 1);
100
+ const y = v * (rows - 1);
101
+ const i = Math.min(Math.floor(x), cols - 2);
102
+ const j = Math.min(Math.floor(y), rows - 2);
103
+ const tx = x - i;
104
+ const ty = y - j;
105
+ const at = (r, c) => g[Math.min(Math.max(r, 0), rows - 1)][Math.min(Math.max(c, 0), cols - 1)];
106
+ const line = (r) => {
107
+ const p = [
108
+ -1,
109
+ 0,
110
+ 1,
111
+ 2
112
+ ].map((d) => at(r, i + d));
113
+ return [
114
+ 0,
115
+ 1,
116
+ 2,
117
+ 3
118
+ ].map((k) => cr(p[0][k], p[1][k], p[2][k], p[3][k], tx));
119
+ };
120
+ const q = [
121
+ -1,
122
+ 0,
123
+ 1,
124
+ 2
125
+ ].map((d) => line(j + d));
126
+ return [
127
+ 0,
128
+ 1,
129
+ 2,
130
+ 3
131
+ ].map((k) => cr(q[0][k], q[1][k], q[2][k], q[3][k], ty));
132
+ };
133
+ //#endregion
134
+ //#region src/points.ts
135
+ const freeform = (points, u, v, ratio = 1) => {
136
+ const ws = points.map((p) => {
137
+ const du = u - p.u;
138
+ const dv = (v - p.v) * ratio;
139
+ return {
140
+ p,
141
+ d2: du * du + dv * dv
142
+ };
143
+ });
144
+ const hit = ws.find((x) => x.d2 < 1e-12);
145
+ if (hit) return hit.p.q;
146
+ const w = ws.map((x) => Math.max(0, x.p.spread) / x.d2);
147
+ const sum = w.reduce((s, x) => s + x, 0);
148
+ if (!(sum > 0)) return points[0].q;
149
+ return [
150
+ 0,
151
+ 1,
152
+ 2,
153
+ 3
154
+ ].map((k) => ws.reduce((s, x, i) => s + w[i] * x.p.q[k], 0) / sum);
155
+ };
156
+ //#endregion
157
+ //#region src/runtime.ts
158
+ const mesh = {
159
+ marker: "_gradient2d-core",
160
+ fallback: "--gradient2d-fallback",
161
+ native: "mesh-gradient(red, blue; green, yellow)",
162
+ paint: (el, ctx, w, h) => {
163
+ const raw = getComputedStyle(el).getPropertyValue("--gradient2d-data").trim();
164
+ const { space, rows } = JSON.parse(JSON.parse(raw));
165
+ const grid = rows.map((row) => row.map((s) => toSpace(space, typeof s === "string" ? resolve(el, s, ctx) : s)));
166
+ const img = ctx.createImageData(w, h);
167
+ Array.from({ length: w * h }, (_, i) => i).forEach((i) => {
168
+ const q = fromSpace(space, sample(grid, (i % w + .5) / w, (Math.floor(i / w) + .5) / h));
169
+ img.data.set([
170
+ q[0] * 255,
171
+ q[1] * 255,
172
+ q[2] * 255,
173
+ q[3] * 255
174
+ ], 4 * i);
175
+ });
176
+ return img;
177
+ }
178
+ };
179
+ const points = {
180
+ marker: "_gradient2d-ff",
181
+ fallback: "--gradient2d-ff-fallback",
182
+ native: "freeform-gradient(red top left, blue bottom right)",
183
+ paint: (el, ctx, w, h) => {
184
+ const raw = getComputedStyle(el).getPropertyValue("--gradient2d-ff-data").trim();
185
+ const { space, points } = JSON.parse(JSON.parse(raw));
186
+ const ratio = h / w;
187
+ const ps = points.map(([u, v, spread, c]) => ({
188
+ u,
189
+ v,
190
+ spread,
191
+ q: toSpace(space, typeof c === "string" ? resolve(el, c, ctx) : c)
192
+ }));
193
+ const img = ctx.createImageData(w, h);
194
+ Array.from({ length: w * h }, (_, i) => i).forEach((i) => {
195
+ const q = fromSpace(space, freeform(ps, (i % w + .5) / w, (Math.floor(i / w) + .5) / h, ratio));
196
+ img.data.set([
197
+ q[0] * 255,
198
+ q[1] * 255,
199
+ q[2] * 255,
200
+ q[3] * 255
201
+ ], 4 * i);
202
+ });
203
+ return img;
204
+ }
205
+ };
206
+ if (typeof document !== "undefined" && typeof ResizeObserver !== "undefined") run([mesh, points]);
207
+ //#endregion
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@cssints/gradient-2d",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.5.0",
4
+ "description": "mesh-gradient() and freeform-gradient() for cssints: the native function where the browser has it, else a canvas drawn by a runtime",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/alexeiaccio/css-in-ts-compiler.git",
9
+ "directory": "packages/gradient-2d"
10
+ },
11
+ "homepage": "https://cssints.dev",
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "type": "module",
16
+ "sideEffects": [
17
+ "./src/runtime.ts",
18
+ "./dist/runtime.mjs"
19
+ ],
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.mts",
23
+ "default": "./dist/index.mjs"
24
+ },
25
+ "./runtime": {
26
+ "types": "./dist/runtime.d.mts",
27
+ "default": "./dist/runtime.mjs"
28
+ }
29
+ },
30
+ "dependencies": {
31
+ "culori": "^4.0.2"
32
+ },
33
+ "peerDependencies": {
34
+ "cssints": "^0.5.0"
35
+ },
36
+ "engines": {
37
+ "node": ">=22"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }