From c6035b66bfd6f86a8ae02075002ef362756b6067 Mon Sep 17 00:00:00 2001 From: mark Date: Sat, 8 Aug 2026 15:47:25 +0200 Subject: [PATCH] px-icon: initial commit --- .gitignore | 2 + LICENSE | 21 ++++ README.md | 66 ++++++++++++ examples/index.html | 250 ++++++++++++++++++++++++++++++++++++++++++++ px-icon.js | 222 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 561 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/index.html create mode 100644 px-icon.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f31b3e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +*.swp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d5efc64 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Mark + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..efd1f53 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# px-icon + +A tiny, dependency-free `` custom element that rasterizes an inline +`` onto a shared art-pixel grid. The source `` stays untouched in +light DOM (so server-driven morphs, e.g. Idiomorph/Datastar, can freely +update it); the raster is rendered into shadow DOM. + +No build step, no npm, no dependencies. It's one ES module: `px-icon.js`. + +## Usage + +Serve `px-icon.js` from wherever you host static files (your CDN, gitea raw +URL, whatever) and import it as a module: + +```html + + + + + + + +``` + +Or import it directly in your own module and let bundling/copying be +someone else's problem: + +```html + +``` + +### Config + +Set via CSS custom properties, so they cascade normally: + +| Property | Default | Values | +|-------------------------|-----------|----------------------------| +| `--pxicon-ap-size` | `8` (px) | art-pixel grid cell size | +| `--pxicon-mode` | `average` | `average` \| `point` | +| `--pxicon-debug-delay` | `0` (ms) | artificial render delay, for testing progressive enhancement | + +```css +:root { + --pxicon-ap-size: 6px; + --pxicon-mode: point; +} +``` + +## Demo + +See [`examples/index.html`](examples/index.html) — open it directly in a +browser (served over http, not `file://`, since it uses ES modules and +`Blob` URLs) or point your dev server at the repo root. + +## Versioning + +No package.json, no registry. Releases are plain git tags (`v1.0.0`, ...) +on the file as it exists at that commit. Point your CDN/proxy at a tag +(or commit SHA) for a pinned, immutable URL; point at `main` only if you +want to live dangerously. + +## License + +MIT, see [LICENSE](LICENSE). diff --git a/examples/index.html b/examples/index.html new file mode 100644 index 0000000..2776d40 --- /dev/null +++ b/examples/index.html @@ -0,0 +1,250 @@ + + + + +px-icon demo + + + + +

px-icon

+

+ Standard <svg> markup, authored inline as normal. The library rasterizes each one onto a shared + art-pixel grid into its shadow DOM, while the original <svg> stays untouched in the light DOM. +

+ +
+
+ + + 6 +
+
+ + +
+
+ +
+
+ + + + + + + + +
ruby (own set)
+
+
+ + + + +
box (own set)
+
+
+ + + + + + + + + + +
gradient (own set)
+
+
+ + + +
layers (pasted-in external set)
+
+
+ +

datastar morph safety

+

+ This icon lives in a container Idiomorph can morph, the same engine Datastar uses for server-driven + DOM patches. The buttons below simulate the server re-sending HTML for this fragment: once identical, + once with a different icon. The raster survives both without flashing back to raw SVG, because the + morph only ever touches the light-dom <svg>, never the shadow-dom canvas. +

+
+
+
+ + + + + + +
+
morph target
+
+
+
+ + +
+
+ +

progressive enhancement

+

+ With a debug delay set on the render pipeline, the raw <svg> (via the shadow-root <slot>) stays + visible the whole time rasterization is genuinely in flight, then the canvas swaps in when it's ready. + No JS, or a slow one, degrades to a plain icon rather than nothing. +

+
+
+ + + 0 +
+ +
+
+
+ + + +
+
+ + + + + + + + diff --git a/px-icon.js b/px-icon.js new file mode 100644 index 0000000..bb4d269 --- /dev/null +++ b/px-icon.js @@ -0,0 +1,222 @@ +// px-icon: rasterizes an inline SVG (light DOM, source of truth) onto a +// shared art-pixel grid, rendered into shadow DOM (generated output). +// +// Why shadow DOM: idiomorph/Datastar morphs diff the live DOM against +// server HTML by walking childNodes, which does not include shadow trees. +// Keeping the raw in light DOM and the raster in shadow DOM means +// server-driven morphs can freely update/preserve the source icon without +// ever touching or being fought by the generated output. No ignore-morph +// markers needed. +// +// Config (ap size, downsample mode) comes from CSS custom properties so it +// cascades normally: --pxicon-ap-size, --pxicon-mode ('average' | 'point'). + +const DEFAULT_AP_SIZE = 8; +const DEFAULT_MODE = 'average'; +const SUPERSAMPLE = 8; // per grid cell, for point-sample mode + +// key -> Promise (the small pre-upscale grid raster) +const rasterCache = new Map(); + +function hashString(str) { + // fast non-crypto hash (FNV-1a), fine for a cache key over short svg markup + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(36); +} + +function snapToGrid(px, apPx) { + return Math.max(apPx, Math.round(px / apPx) * apPx); +} + +function svgToImage(svgMarkup) { + return new Promise((resolve, reject) => { + const blob = new Blob([svgMarkup], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; + img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); }; + img.src = url; + }); +} + +function areaAverageDownsample(img, gridW, gridH) { + const canvas = document.createElement('canvas'); + canvas.width = gridW; + canvas.height = gridH; + const ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(img, 0, 0, gridW, gridH); + return canvas; +} + +function pointSampleDownsample(img, gridW, gridH) { + const bufW = gridW * SUPERSAMPLE; + const bufH = gridH * SUPERSAMPLE; + + const buf = document.createElement('canvas'); + buf.width = bufW; + buf.height = bufH; + const bctx = buf.getContext('2d', { willReadFrequently: true }); + bctx.imageSmoothingEnabled = true; + bctx.imageSmoothingQuality = 'high'; + bctx.drawImage(img, 0, 0, bufW, bufH); + + let srcData; + try { + srcData = bctx.getImageData(0, 0, bufW, bufH); + } catch (e) { + // tainted canvas (cross-origin source without CORS headers); caller + // falls back to area-average + throw e; + } + + const gridCanvas = document.createElement('canvas'); + gridCanvas.width = gridW; + gridCanvas.height = gridH; + const gctx = gridCanvas.getContext('2d'); + const out = gctx.createImageData(gridW, gridH); + + for (let gy = 0; gy < gridH; gy++) { + for (let gx = 0; gx < gridW; gx++) { + const sx = Math.min(bufW - 1, Math.floor((gx + 0.5) * SUPERSAMPLE)); + const sy = Math.min(bufH - 1, Math.floor((gy + 0.5) * SUPERSAMPLE)); + const si = (sy * bufW + sx) * 4; + const di = (gy * gridW + gx) * 4; + out.data[di] = srcData.data[si]; + out.data[di + 1] = srcData.data[si + 1]; + out.data[di + 2] = srcData.data[si + 2]; + out.data[di + 3] = srcData.data[si + 3]; + } + } + gctx.putImageData(out, 0, 0); + return gridCanvas; +} + +// Returns a cached Promise for the small grid raster, +// coalescing concurrent requests for the same key. +function getGridRaster(svgMarkup, mode, gridW, gridH) { + const key = `${hashString(svgMarkup)}|${mode}|${gridW}x${gridH}`; + let entry = rasterCache.get(key); + if (entry) return entry; + + entry = svgToImage(svgMarkup).then((img) => { + let raster; + if (mode === 'point') { + try { + raster = pointSampleDownsample(img, gridW, gridH); + } catch (e) { + raster = areaAverageDownsample(img, gridW, gridH); + } + } else { + raster = areaAverageDownsample(img, gridW, gridH); + } + return raster; + }); + rasterCache.set(key, entry); + return entry; +} + +class PxIcon extends HTMLElement { + connectedCallback() { + if (!this._upgraded) { + this._upgraded = true; + this._upgrade(); + } + // (Re)start watching the light-dom source for server-driven changes, + // e.g. a morph swapping in a different icon. + this._observer ??= new MutationObserver(() => this._scheduleRender()); + this._observer.observe(this, { childList: true, subtree: true, attributes: true, characterData: true }); + this._scheduleRender(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this._resizeObserver?.disconnect(); + } + + _upgrade() { + const root = this.attachShadow({ mode: 'open' }); + const style = document.createElement('style'); + style.textContent = ` + :host { display: inline-block; width: 1.5em; height: 1.5em; line-height: 0; } + canvas { display: block; width: 100%; height: 100%; image-rendering: pixelated; } + `; + root.appendChild(style); + + // Progressive enhancement: keep the light-dom svg visible through a + // until the first raster is ready, so there is no flash of + // empty content while the rasterization is in flight. + const slot = document.createElement('slot'); + root.appendChild(slot); + + this._canvas = document.createElement('canvas'); + this._shadowRoot = root; + this._slot = slot; + + this._resizeObserver = new ResizeObserver(() => this._scheduleRender()); + this._resizeObserver.observe(this); + } + + _scheduleRender() { + // Batch bursts of mutations/resizes into a single render on the next + // frame, rather than re-rasterizing once per mutation record. + if (this._renderQueued) return; + this._renderQueued = true; + requestAnimationFrame(() => { + this._renderQueued = false; + this._render(); + }); + } + + async _render() { + const svgEl = this.querySelector('svg'); + if (!svgEl) return; + + const cs = getComputedStyle(this); + const apSize = parseFloat(cs.getPropertyValue('--pxicon-ap-size')) || DEFAULT_AP_SIZE; + const mode = (cs.getPropertyValue('--pxicon-mode') || DEFAULT_MODE).trim(); + + const displaySize = Math.max(this.clientWidth, this.clientHeight) || 24; + const snapped = snapToGrid(displaySize, apSize); + const gridW = Math.max(1, Math.round(snapped / apSize)); + const gridH = gridW; + + const svgMarkup = svgEl.outerHTML; + const key = `${svgMarkup}|${mode}|${gridW}x${gridH}`; + if (this._lastKey === key) return; // nothing actually changed + this._lastKey = key; + + // debug-only: --pxicon-debug-delay lets demos/tests prove the + // slot stays visible while rasterization is genuinely in flight, + // rather than faking it with a CSS transition + const debugDelay = parseFloat(cs.getPropertyValue('--pxicon-debug-delay')) || 0; + if (debugDelay > 0) await new Promise((r) => setTimeout(r, debugDelay)); + + const gridCanvas = await getGridRaster(svgMarkup, mode, gridW, gridH); + + const dpr = window.devicePixelRatio || 1; + const backingSize = Math.round(snapped * dpr); + this._canvas.width = backingSize; + this._canvas.height = backingSize; + const ctx = this._canvas.getContext('2d'); + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, backingSize, backingSize); + ctx.drawImage(gridCanvas, 0, 0, backingSize, backingSize); + + if (this._slot.isConnected) { + // first successful raster: swap the placeholder slot for the + // canvas, which stops rendering the light-dom svg (no + // pointing at it anymore) and shows the raster instead + this._shadowRoot.replaceChild(this._canvas, this._slot); + } + } +} + +customElements.define('px-icon', PxIcon); + +export { PxIcon, rasterCache };