px-icon: initial commit

This commit is contained in:
mark
2026-08-08 15:47:25 +02:00
commit c6035b66bf
5 changed files with 561 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.DS_Store
*.swp
+21
View File
@@ -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.
+66
View File
@@ -0,0 +1,66 @@
# px-icon
A tiny, dependency-free `<px-icon>` custom element that rasterizes an inline
`<svg>` onto a shared art-pixel grid. The source `<svg>` 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
<script type="module" src="https://your-cdn.example/px-icon/v1.0.0/px-icon.js"></script>
<px-icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="#4a90d9" d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</px-icon>
```
Or import it directly in your own module and let bundling/copying be
someone else's problem:
```html
<script type="module">
import 'https://your-cdn.example/px-icon/v1.0.0/px-icon.js';
</script>
```
### 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).
+250
View File
@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>px-icon demo</title>
<style>
:root {
--bg: #1b1712;
--panel-bg: #2a241c;
--ink: #e8dcc4;
--accent: #c9a15a;
--ok: #7fae6a;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px;
background: var(--bg);
color: var(--ink);
font-family: ui-monospace, "SF Mono", Consolas, monospace;
font-size: 13px;
}
h1 { font-size: 15px; font-weight: 600; margin: 28px 0 4px; }
h1:first-child { margin-top: 0; }
.sub { color: #948a76; margin: 0 0 16px; line-height: 1.5; max-width: 640px; }
.controls {
display: flex; gap: 24px; flex-wrap: wrap; align-items: center;
margin-bottom: 16px; padding: 12px 16px; background: var(--panel-bg);
border: 1px solid #3a3327;
}
.control { display: flex; align-items: center; gap: 8px; }
.control label { color: #b8ac93; }
input[type="range"] { width: 120px; }
.control .val { color: var(--accent); min-width: 40px; display: inline-block; }
select {
background: var(--bg); color: var(--ink); border: 1px solid #3a3327;
padding: 3px 6px; font-family: inherit;
}
button {
background: var(--bg); color: var(--ink); border: 1px solid var(--accent);
padding: 6px 12px; font-family: inherit; cursor: pointer;
}
button:hover { background: #241f18; }
.icon-row {
display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap;
margin-bottom: 8px; padding: 16px; background: var(--panel-bg);
border: 1px solid #3a3327;
}
.icon-wrap { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.icon-wrap px-icon { width: 64px; height: 64px; }
.icon-label { color: #948a76; font-size: 11px; }
.log {
background: #14110d; border: 1px solid #3a3327; padding: 10px 12px;
max-height: 180px; overflow-y: auto; line-height: 1.6; margin-top: 8px;
}
.log .ok { color: var(--ok); }
.log .t { color: #6b6152; }
code { color: var(--accent); }
</style>
</head>
<body>
<h1>px-icon</h1>
<p class="sub">
Standard &lt;svg&gt; markup, authored inline as normal. The library rasterizes each one onto a shared
art-pixel grid into its shadow DOM, while the original &lt;svg&gt; stays untouched in the light DOM.
</p>
<div class="controls">
<div class="control">
<label for="apSize">art-pixel size (px)</label>
<input type="range" id="apSize" min="2" max="20" value="6">
<span class="val" id="apSizeVal">6</span>
</div>
<div class="control">
<label for="mode">downsample</label>
<select id="mode">
<option value="average">area average</option>
<option value="point">hard point sample</option>
</select>
</div>
</div>
<div class="icon-row" id="mainRow">
<div class="icon-wrap">
<px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<polygon points="60,50 140,50 170,90 100,180 30,90" fill="#8e1f3a" stroke="#3a0d17" stroke-width="4"/>
<polygon points="60,50 140,50 100,80" fill="#c9385c"/>
<polygon points="60,50 30,90 100,80" fill="#a02648"/>
<polygon points="140,50 170,90 100,80" fill="#a02648"/>
<polygon points="30,90 100,80 100,180" fill="#6e1730"/>
<polygon points="170,90 100,80 100,180" fill="#5c1329"/>
</svg></px-icon>
<div class="icon-label">ruby (own set)</div>
</div>
<div class="icon-wrap">
<px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect x="10" y="10" width="180" height="180" rx="14" fill="#7a5a34" stroke="#3f2c18" stroke-width="6"/>
<rect x="26" y="26" width="148" height="148" fill="none" stroke="#c9a15a" stroke-width="3" stroke-dasharray="10 8"/>
</svg></px-icon>
<div class="icon-label">box (own set)</div>
</div>
<div class="icon-wrap">
<px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<defs>
<linearGradient id="pxg1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#e8c887"/>
<stop offset="45%" stop-color="#c9a15a"/>
<stop offset="100%" stop-color="#6b4423"/>
</linearGradient>
</defs>
<circle cx="100" cy="100" r="90" fill="url(#pxg1)"/>
</svg></px-icon>
<div class="icon-label">gradient (own set)</div>
</div>
<div class="icon-wrap">
<px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="#4a90d9" stroke="#1f5b96" stroke-width="1" d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg></px-icon>
<div class="icon-label">layers (pasted-in external set)</div>
</div>
</div>
<h1>datastar morph safety</h1>
<p class="sub">
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 &lt;svg&gt;, never the shadow-dom canvas.
</p>
<div class="icon-row" id="morphRow">
<div class="icon-wrap">
<div id="morphTarget"><px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<polygon points="60,50 140,50 170,90 100,180 30,90" fill="#8e1f3a" stroke="#3a0d17" stroke-width="4"/>
<polygon points="60,50 140,50 100,80" fill="#c9385c"/>
<polygon points="60,50 30,90 100,80" fill="#a02648"/>
<polygon points="140,50 170,90 100,80" fill="#a02648"/>
<polygon points="30,90 100,80 100,180" fill="#6e1730"/>
<polygon points="170,90 100,80 100,180" fill="#5c1329"/>
</svg></px-icon></div>
<div class="icon-label">morph target</div>
</div>
</div>
<div class="controls">
<button id="morphSame">simulate server morph (identical markup)</button>
<button id="morphSwap">simulate server morph (swap to different icon)</button>
</div>
<div class="log" id="morphLog"></div>
<h1>progressive enhancement</h1>
<p class="sub">
With a debug delay set on the render pipeline, the raw &lt;svg&gt; (via the shadow-root &lt;slot&gt;) 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.
</p>
<div class="controls">
<div class="control">
<label for="peDelay">artificial render delay (ms)</label>
<input type="range" id="peDelay" min="0" max="3000" step="250" value="0">
<span class="val" id="peDelayVal">0</span>
</div>
<button id="peRerender">re-mount icon to see the delay</button>
</div>
<div class="icon-row" id="peRow">
<div class="icon-wrap" id="peWrap">
<px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="#4a90d9" stroke="#1f5b96" stroke-width="1" d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg></px-icon>
</div>
</div>
<script type="module">
import '../px-icon.js';
const apSizeInput = document.getElementById('apSize');
const apSizeVal = document.getElementById('apSizeVal');
const modeSelect = document.getElementById('mode');
function applyConfig() {
const ap = apSizeInput.value;
apSizeVal.textContent = ap;
document.documentElement.style.setProperty('--pxicon-ap-size', ap + 'px');
document.documentElement.style.setProperty('--pxicon-mode', modeSelect.value);
}
apSizeInput.addEventListener('input', applyConfig);
modeSelect.addEventListener('change', applyConfig);
applyConfig();
// progressive enhancement delay control
const peDelay = document.getElementById('peDelay');
const peDelayVal = document.getElementById('peDelayVal');
const peWrap = document.getElementById('peWrap');
const peRerender = document.getElementById('peRerender');
const peIconMarkup = peWrap.innerHTML;
peDelay.addEventListener('input', () => {
peDelayVal.textContent = peDelay.value;
document.documentElement.style.setProperty('--pxicon-debug-delay', peDelay.value);
});
peRerender.addEventListener('click', () => {
// re-mount so connectedCallback runs again with the current delay
peWrap.innerHTML = '';
requestAnimationFrame(() => { peWrap.innerHTML = peIconMarkup; });
});
</script>
<script src="https://unpkg.com/idiomorph@0.7.4/dist/idiomorph.min.js"></script>
<script type="module">
const morphTarget = document.getElementById('morphTarget');
const log = document.getElementById('morphLog');
const originalMarkup = morphTarget.innerHTML;
const swappedMarkup = `<px-icon><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="#4a90d9" stroke="#1f5b96" stroke-width="1" d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg></px-icon>`;
function logLine(text, ok) {
const line = document.createElement('div');
const t = new Date().toLocaleTimeString();
line.innerHTML = `<span class="t">${t}</span> ${ok ? '<span class="ok">&#10003;</span> ' : ''}${text}`;
log.prepend(line);
}
function currentCanvas() {
const el = morphTarget.querySelector('px-icon');
return el && el.shadowRoot ? el.shadowRoot.querySelector('canvas') : null;
}
document.getElementById('morphSame').addEventListener('click', () => {
const before = currentCanvas();
Idiomorph.morph(morphTarget, `<div id="morphTarget">${originalMarkup}</div>`);
const after = currentCanvas();
logLine(
before && after === before
? 'identical morph applied, same &lt;canvas&gt; node survived untouched'
: 'canvas node changed (unexpected, check console)',
before && after === before
);
});
document.getElementById('morphSwap').addEventListener('click', () => {
Idiomorph.morph(morphTarget, `<div id="morphTarget">${swappedMarkup}</div>`);
logLine('server sent a different icon, light-dom &lt;svg&gt; morphed, watch it re-rasterize above', true);
});
logLine('ready. click a button to simulate a server-driven morph.', false);
</script>
</body>
</html>
+222
View File
@@ -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 <svg> 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<HTMLCanvasElement> (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<HTMLCanvasElement> 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
// <slot> 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 <slot>
// pointing at it anymore) and shows the raster instead
this._shadowRoot.replaceChild(this._canvas, this._slot);
}
}
}
customElements.define('px-icon', PxIcon);
export { PxIcon, rasterCache };