@cssints/placeholder 0.0.0-stage → 0.2.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,89 @@
1
- # Temporary Holding Version
1
+ # @cssints/placeholder
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
+ Image placeholders for [cssints](../../README.md): `placeholder(path)` reads an image while the build runs and gives an
4
+ `<image>` to paint until the real one loads, either a gradient of the image's colours laid out as the image is
5
+ (unpic-placeholder's radial gradients) or the image's [ThumbHash](https://evanw.github.io/thumbhash/) as a tiny PNG data
6
+ URL. It is one value member of `cssints/plugin` `scope()`, so it goes into `backgroundImage` (or `maskImage`). No runtime.
7
+
8
+ ```ts
9
+ import { backgroundImage, cn, backgroundSize } from "cssints" with { type: "cssints" };
10
+ import { placeholder } from "@cssints/placeholder" with { type: "cssints" };
11
+
12
+ // placeholder(path, { mode? }): the path is relative to the working directory
13
+ export const hero = backgroundImage(placeholder("src/hero.jpg"));
14
+ export const card = cn(
15
+ backgroundImage(placeholder("src/card.png", { mode: "thumbhash" })),
16
+ backgroundSize("cover"),
17
+ );
18
+ ```
19
+
20
+ For a PNG of four quadrants (red, blue / green, white):
21
+
22
+ ```css
23
+ /* mode "gradient" (the default): a 4 × 3 grid, a radial gradient per cell, brightest on top, over the average colour */
24
+ ._11mb38d {
25
+ background-image:
26
+ radial-gradient(at 67% 100%, #ffffff, transparent 50%),
27
+ radial-gradient(at 100% 100%, #ffffff, transparent 50%),
28
+ radial-gradient(at 67% 50%, #bcbcff, transparent 50%),
29
+ /* … one per cell, 12 in all … */ radial-gradient(at 100% 0, #0000ff, transparent 50%),
30
+ linear-gradient(#bc96bc, #bc96bc);
31
+ }
32
+ /* mode "thumbhash": the ThumbHash rendered, 16 px on the long side, about 0.5 kB */
33
+ ._179cifn {
34
+ background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAM…");
35
+ }
36
+ ```
37
+
38
+ ## Formats
39
+
40
+ JPEG (baseline and progressive, through [jpeg-js](https://github.com/jpeg-js/jpeg-js)) and PNG (every colour type and bit
41
+ depth, through [pngjs](https://github.com/pngjs/pngjs)), both plain JavaScript: nothing to compile, no native binary
42
+ (sharp is not used). The format is read from the file's first bytes, not its name. WebP, AVIF, GIF and SVG are build
43
+ errors that name the format (`cssints: placeholder: src/hero.webp: a WebP image: JPEG and PNG are read`): give a JPEG or
44
+ PNG copy of the image (it can be small; only its colours are read).
45
+
46
+ ## Rules
47
+
48
+ - **Arguments.** `path` is a string, relative to the working directory (`process.cwd()`, the project root under Vite),
49
+ as the `fs` provider of `@cssints/icons` reads it. `mode` is `"gradient"` (the default) or `"thumbhash"`. Both are
50
+ checked in the types and at build time.
51
+ - **`gradient`** averages the image to a grid of 4 × 3 cells (3 × 4 for a portrait image), in linear light and weighted
52
+ by alpha, so a transparent part adds no colour and a cell that straddles red and green is the light's mix, not a muddy
53
+ sRGB average. Each cell is a `radial-gradient(at x y, colour, transparent 50%)` at its place in the image (corners at
54
+ `0` and `100%`), sorted by luminance with the brightest on top (as unpic-placeholder does), over one
55
+ `linear-gradient()` of the whole image's average colour that fills what the gradients leave. A cell that is not opaque
56
+ is `#rrggbbaa`. The value is a list of 13 images, about 0.7 kB.
57
+ - **`thumbhash`** encodes the image (averaged to at most 100 px a side first) with ThumbHash, renders the hash, averages
58
+ that to 16 px on the long side and writes it as a deflated PNG: about 0.5 kB, where ThumbHash's own data URL (32 px,
59
+ uncompressed) is about 5 kB. The browser's smooth upscaling does the blur. It keeps the aspect ratio, so pair it with
60
+ `backgroundSize("cover")` or `"100% 100%"`.
61
+ - **Deterministic.** The same file gives the same CSS on every build. Results are cached for the process by the SHA-256
62
+ of the file's content and the mode, so an image used from many modules is decoded once.
63
+ - **Errors**, at the call: a file that cannot be read (`cssints: placeholder: cannot read src/nope.png (paths are
64
+ relative to /app)`), a format it does not read (above), a file that does not decode (`cssints: placeholder:
65
+ src/broken.png: …`, the decoder's message), a mode outside the grammar (`placeholder(2).mode: unexpected "blurhash"
66
+ …`), and a placeholder where an image does not go (`css.color(placeholder(…))`, an error in the types and at build
67
+ time).
68
+
69
+ ## Limits
70
+
71
+ - In dev, an edited image updates the CSS of the modules that use it (`dependsOn` of `cssints/plugin`); a missing image
72
+ that you add after the build error needs an edit of the module or a reload.
73
+ - The `gradient` value is a list of images, so it fits a property that takes a list (`background-image`, `mask-image`);
74
+ a property that takes one `<image>` (`list-style-image`, `border-image-source`) rejects it at build time: use
75
+ `thumbhash` there.
76
+ - No WebP or AVIF decoder (a WASM one would add a few hundred kB to the build); the grid size and the thumbhash size are
77
+ fixed; no blurhash mode.
78
+ - The image is decoded whole: a very large JPEG (tens of megapixels) costs its pixels in memory once per build.
79
+
80
+ ```bash
81
+ cd packages/placeholder && bun run test # colour extraction on synthetic pixels, fixtures through the engine (output, errors)
82
+ cd packages/placeholder && bun run typecheck # and the type test, test/types/placeholder.ts
83
+ ```
84
+
85
+ ## Credits
86
+
87
+ The gradient layout follows [unpic-placeholder](https://github.com/ascorbic/unpic-placeholder) (MIT, Matt Kane).
88
+ The thumbhash mode uses [thumbhash](https://github.com/evanw/thumbhash) (MIT, Evan Wallace), [jpeg-js](https://github.com/jpeg-js/jpeg-js)
89
+ (BSD-3-Clause) and [pngjs](https://github.com/pngjs/pngjs) (MIT), dependencies. No code is copied.
@@ -0,0 +1,21 @@
1
+ import { CSSImageValue } from "cssints";
2
+ //#region src/index.d.ts
3
+ /** How the placeholder is drawn: a gradient of the colours (the default), or the thumbhash as a PNG data URL. */
4
+ export type Mode = "gradient" | "thumbhash";
5
+ declare const placeholders: import("cssints/plugin").Scope<"placeholder", {
6
+ readonly placeholder: {
7
+ args: readonly [import("cssints/plugin").Typed<string>, {
8
+ readonly "mode?": "gradient | thumbhash";
9
+ }];
10
+ label?: undefined;
11
+ value(args: never): CSSImageValue;
12
+ };
13
+ }>;
14
+ /**
15
+ * `placeholder(path, { mode? })`: a placeholder of the image at `path` (relative to the working directory), read at build
16
+ * time. `mode: "gradient"` (the default) is a list of radial gradients of the image's colours, each where its part of the
17
+ * image is; `mode: "thumbhash"` is the image's thumbhash as a tiny PNG `url()`. An `<image>` for `backgroundImage`. JPEG
18
+ * and PNG; another format or a missing file is a build error at the call.
19
+ */
20
+ export declare const placeholder: ReturnType<typeof placeholders.export>["placeholder"];
21
+ //#endregion
package/dist/index.mjs ADDED
@@ -0,0 +1,194 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { CSSImageValue } from "cssints";
5
+ import { arg, dependsOn, fn, scope } from "cssints/plugin";
6
+ import jpeg from "jpeg-js";
7
+ import { PNG } from "pngjs";
8
+ import { rgbaToThumbHash, thumbHashToRGBA } from "thumbhash";
9
+ //#region src/pixels.ts
10
+ const starts = (bytes, sig, at = 0) => sig.every((b, i) => bytes[at + i] === b);
11
+ const ascii = (s) => [...s].map((c) => c.charCodeAt(0));
12
+ const kind = (bytes) => starts(bytes, [
13
+ 137,
14
+ 80,
15
+ 78,
16
+ 71,
17
+ 13,
18
+ 10,
19
+ 26,
20
+ 10
21
+ ]) ? "png" : starts(bytes, [
22
+ 255,
23
+ 216,
24
+ 255
25
+ ]) ? "jpeg" : starts(bytes, ascii("RIFF")) && starts(bytes, ascii("WEBP"), 8) ? "a WebP image" : starts(bytes, ascii("ftyp"), 4) && /^(avif|avis)$/.test(String.fromCharCode(...bytes.slice(8, 12))) ? "an AVIF image" : starts(bytes, ascii("GIF8")) ? "a GIF image" : "not an image it knows";
26
+ /**
27
+ * The RGBA pixels of a JPEG or PNG; anything else throws, naming what it is.
28
+ * @param {Uint8Array} bytes the file
29
+ * @returns {Pixels} its pixels
30
+ */
31
+ const decode = (bytes) => {
32
+ const k = kind(bytes);
33
+ if (k === "png") {
34
+ const png = PNG.sync.read(Buffer.from(bytes));
35
+ return {
36
+ width: png.width,
37
+ height: png.height,
38
+ data: png.data
39
+ };
40
+ }
41
+ if (k === "jpeg") {
42
+ const img = jpeg.decode(bytes, {
43
+ useTArray: true,
44
+ formatAsRGBA: true
45
+ });
46
+ return {
47
+ width: img.width,
48
+ height: img.height,
49
+ data: img.data
50
+ };
51
+ }
52
+ throw new Error(`${k}: JPEG and PNG are read`);
53
+ };
54
+ const LINEAR = Array.from({ length: 256 }, (_, i) => {
55
+ const c = i / 255;
56
+ return c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;
57
+ });
58
+ const toSrgb = (l) => {
59
+ const c = l <= .0031308 ? l * 12.92 : 1.055 * l ** (1 / 2.4) - .055;
60
+ return Math.round(Math.min(1, Math.max(0, c)) * 255);
61
+ };
62
+ const span = (i, n, size) => {
63
+ const from = Math.min(size - 1, Math.floor(i * size / n));
64
+ return [from, Math.max(from + 1, Math.floor((i + 1) * size / n))];
65
+ };
66
+ /**
67
+ * `img` averaged to `w` × `h` (a box filter over the pixels each cell covers), in linear light and weighted by alpha, so
68
+ * a transparent pixel gives no colour.
69
+ * @param {Pixels} img the image
70
+ * @param {number} w the width of the result
71
+ * @param {number} h the height of the result
72
+ * @returns {Pixels} `w` × `h` cells
73
+ */
74
+ const downsample = (img, w, h) => {
75
+ const data = new Uint8Array(w * h * 4);
76
+ for (let cy = 0; cy < h; cy++) {
77
+ const [y0, y1] = span(cy, h, img.height);
78
+ for (const mx of Array(w).keys()) {
79
+ const [x0, x1] = span(mx, w, img.width);
80
+ let [r, g, b, a] = [
81
+ 0,
82
+ 0,
83
+ 0,
84
+ 0
85
+ ];
86
+ for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
87
+ const i = (y * img.width + x) * 4;
88
+ const alpha = img.data[i + 3] / 255;
89
+ r += LINEAR[img.data[i]] * alpha;
90
+ g += LINEAR[img.data[i + 1]] * alpha;
91
+ b += LINEAR[img.data[i + 2]] * alpha;
92
+ a += alpha;
93
+ }
94
+ const c = (cy * w + mx) * 4;
95
+ if (a) data.set([
96
+ toSrgb(r / a),
97
+ toSrgb(g / a),
98
+ toSrgb(b / a)
99
+ ], c);
100
+ data[c + 3] = Math.round(a / ((y1 - y0) * (x1 - x0)) * 255);
101
+ }
102
+ }
103
+ return {
104
+ width: w,
105
+ height: h,
106
+ data
107
+ };
108
+ };
109
+ const hex = (d, i) => `#${[...d.subarray(i, i + (d[i + 3] === 255 ? 3 : 4))].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
110
+ const pct = (n) => n ? `${Math.round(n * 100)}%` : "0";
111
+ /**
112
+ * unpic-placeholder's gradient: a radial gradient per cell of a `columns` × `rows` grid, at the cell's place and fading out
113
+ * at 50%, brightest first (on top), over the image's average colour.
114
+ * @param {Pixels} img the image
115
+ * @param {number} columns the cells across
116
+ * @param {number} rows the cells down
117
+ * @returns {string} the layers, comma-separated (a `background-image` value)
118
+ */
119
+ const gradients = (img, columns, rows) => {
120
+ const { data } = downsample(img, columns, rows);
121
+ const cells = Array.from({ length: columns * rows }, (_, c) => c);
122
+ const luma = (c) => .299 * data[c * 4] + .587 * data[c * 4 + 1] + .114 * data[c * 4 + 2];
123
+ cells.sort((a, b) => luma(b) - luma(a));
124
+ const layers = cells.map((c) => `radial-gradient(at ${pct(c % columns / (columns - 1))} ${pct(Math.floor(c / columns) / (rows - 1))}, ${hex(data, c * 4)}, transparent 50%)`);
125
+ const average = hex(downsample(img, 1, 1).data, 0);
126
+ return [...layers, `linear-gradient(${average}, ${average})`].join(", ");
127
+ };
128
+ /**
129
+ * The image's thumbhash (from a copy at most 100 px a side), rendered by thumbhash (32 px on the long side), averaged to
130
+ * 16 px and written as a deflated PNG data URL: about 0.5 kB, where thumbhash's own `thumbHashToDataURL` (32 px, stored
131
+ * uncompressed) is about 5 kB. The browser's smooth upscaling is the blur.
132
+ * @param {Pixels} img the image
133
+ * @returns {string} a `data:image/png;base64,…` URL
134
+ */
135
+ const thumbhash = (img) => {
136
+ const fit = (p, side) => {
137
+ const scale = Math.min(1, side / Math.max(p.width, p.height));
138
+ return downsample(p, Math.max(1, Math.round(p.width * scale)), Math.max(1, Math.round(p.height * scale)));
139
+ };
140
+ const small = fit(img, 100);
141
+ const { w, h, rgba } = thumbHashToRGBA(rgbaToThumbHash(small.width, small.height, small.data));
142
+ const out = fit({
143
+ width: w,
144
+ height: h,
145
+ data: rgba
146
+ }, 16);
147
+ const png = new PNG({
148
+ width: out.width,
149
+ height: out.height
150
+ });
151
+ png.data = Buffer.from(out.data);
152
+ return `data:image/png;base64,${PNG.sync.write(png, { deflateLevel: 9 }).toString("base64")}`;
153
+ };
154
+ //#endregion
155
+ //#region src/index.ts
156
+ const g = globalThis;
157
+ const fail = (message, cause) => {
158
+ throw new Error(`cssints: placeholder: ${message}`, { cause });
159
+ };
160
+ const compute = (path, mode) => {
161
+ let bytes;
162
+ dependsOn(path);
163
+ try {
164
+ bytes = readFileSync(resolve(path));
165
+ } catch (e) {
166
+ return fail(`cannot read ${path} (paths are relative to ${process.cwd()})`, e);
167
+ }
168
+ const key = `${createHash("sha256").update(bytes).digest("hex")} ${mode}`;
169
+ const cache = g.__cssintsPlaceholder ??= /* @__PURE__ */ new Map();
170
+ const hit = cache.get(key);
171
+ if (hit !== void 0) return hit;
172
+ let css;
173
+ try {
174
+ const img = decode(bytes);
175
+ const portrait = img.height > img.width;
176
+ css = mode === "thumbhash" ? `url("${thumbhash(img)}")` : gradients(img, portrait ? 3 : 4, portrait ? 4 : 3);
177
+ } catch (e) {
178
+ return fail(`${path}: ${e instanceof Error ? e.message : String(e)}`, e);
179
+ }
180
+ cache.set(key, css);
181
+ return css;
182
+ };
183
+ /**
184
+ * `placeholder(path, { mode? })`: a placeholder of the image at `path` (relative to the working directory), read at build
185
+ * time. `mode: "gradient"` (the default) is a list of radial gradients of the image's colours, each where its part of the
186
+ * image is; `mode: "thumbhash"` is the image's thumbhash as a tiny PNG `url()`. An `<image>` for `backgroundImage`. JPEG
187
+ * and PNG; another format or a missing file is a build error at the call.
188
+ */
189
+ const placeholder = scope("placeholder", { placeholder: fn({
190
+ args: [arg(), { "mode?": "gradient | thumbhash" }],
191
+ value: ([path, { mode = "gradient" }]) => new CSSImageValue(compute(path, mode))
192
+ }) }).export().placeholder;
193
+ //#endregion
194
+ export { placeholder };
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@cssints/placeholder",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Image placeholders for cssints: a gradient of the dominant colours, or a thumbhash data URL, read from the image at build time",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/alexeiaccio/css-in-ts-compiler.git",
9
+ "directory": "packages/placeholder"
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
+ "jpeg-js": "^0.4.4",
25
+ "pngjs": "^7.0.0",
26
+ "thumbhash": "^0.1.1"
27
+ },
28
+ "peerDependencies": {
29
+ "cssints": "^0.2.0"
30
+ },
31
+ "engines": {
32
+ "node": ">=22"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ }
37
+ }