mykiwi/mykiwi.blogpublic Fork
⑂ e06433e
Commits
⬇ Clone ▾
git clone https://git.rickub.com/mykiwi/mykiwi.blog.git
git clone ssh://git@rickub.com/mykiwi/mykiwi.blog.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Add hello-world svgUnverified

Romain Gautier committed 2026-09-20T12:05:08+02:00 Browse files
e06433e parent: 69a63a8
modified blog/content/posts/hello-world.md +7 -1
@@ -2,6 +2,12 @@
22 date = '1990-04-13T09:00:00+01:00'
33 draft = false
44 title = 'Hello world'
5+# Hugo derives .Summary from the opening of the *rendered* content, markup and
6+# all, so with the plate as the first block the auto-summary was the figure
7+# itself - SVG, CSS and script - repeated into the homepage excerpt
8+# (layouts/index.html) and the RSS <description>. Stating the summary here
9+# keeps the plate in .Content, where it belongs, and leaves the teaser text.
10+summary = ' '
511 +++
612
7-Hello world
13+{{< hello-world >}}
@@ -2,6 +2,12 @@
2 date = '1990-04-13T09:00:00+01:00'2 date = '1990-04-13T09:00:00+01:00'
3 draft = false3 draft = false
4 title = 'Hello world'4 title = 'Hello world'
5+# Hugo derives .Summary from the opening of the *rendered* content, markup and
6+# all, so with the plate as the first block the auto-summary was the figure
7+# itself - SVG, CSS and script - repeated into the homepage excerpt
8+# (layouts/index.html) and the RSS <description>. Stating the summary here
9+# keeps the plate in .Content, where it belongs, and leaves the teaser text.
10+summary = ' '
5 +++11 +++
6 12
7-Hello world13+{{< hello-world >}}
added blog/layouts/shortcodes/hello-world.html +137 -0
new file mode 100644
@@ -0,0 +1,137 @@
1+{{- /*
2+ The animated "Hello, world" plate for the post of the same name. Kept as a
3+ shortcode rather than pasted into the markdown so the content file stays
4+ readable and every translation (hello-world.fr.md, .ja.md, ...) can reuse
5+ the exact same one-liner instead of carrying its own copy of 8 KB of SVG,
6+ CSS and JS that would then have to be kept in sync five ways.
7+
8+ The <style>/<script> live here rather than in assets/css/style.scss and
9+ assets/js/ on purpose: this is a one-post ornament, and inlining it keeps
10+ the cost off every other page in the site. If a second post ever wants it,
11+ that's the moment to promote it to a fingerprinted asset.
12+
13+ The palette is self-contained too - light-dark() reads the `color-scheme:
14+ light dark` already declared on :root, so the plate flips with the rest of
15+ the site and needs no wiring into the theme variables.
16+
17+ The two fill="#..." attributes on the SVG paths are the no-CSS fallback:
18+ the <style> below outranks them wherever it applies, but anywhere the
19+ figure travels without it - a reader that strips <style>/<script>, a
20+ saved page, a full-content feed - the paths would otherwise take the SVG
21+ default and render a black word on a black plate. With the fills, that
22+ context gets the still version the bare SVG already is.
23+*/ -}}
24+<figure class="hw">
25+<svg viewBox="0 0 102 61" role="img" aria-labelledby="hw-title" shape-rendering="crispEdges">
26+<title id="hw-title">Hello, world</title>
27+<path class="hw-bg" fill="#e9eff5" d="M2 0h98v1h1v1h1v57h-1v1h-1v1h-98v-1h-1v-1h-1v-57h1v-1h1z"/>
28+<path class="hw-word" fill="#0f3460" transform="translate(18 13)" d="M0 0h3v15h-3zM8 0h3v15h-3zM13 0h10v3h-10zM25 0h3v15h-3zM37 0h3v15h-3zM50 0h9v3h-9zM49 1h1v1h-1zM59 1h1v2h-1zM49 2h1v1h-1zM13 3h3v12h-3zM49 3h3v11h-3zM57 3h3v11h-3zM3 6h5v1h-5zM16 6h5v3h-5zM3 7h5v1h-5zM3 8h5v1h-5zM16 12h7v3h-7zM28 12h7v3h-7zM40 12h7v3h-7zM52 12h5v1h-5zM52 13h5v1h-5zM50 14h9v1h-9zM0 20h3v14h-3zM12 20h3v14h-3zM18 20h9v3h-9zM30 20h10v3h-10zM43 20h3v15h-3zM55 20h9v3h-9zM17 21h1v1h-1zM27 21h1v2h-1zM40 21h1v2h-1zM64 21h1v2h-1zM17 22h1v1h-1zM65 22h1v1h-1zM17 23h3v11h-3zM25 23h3v11h-3zM30 23h3v12h-3zM38 23h3v5h-3zM55 23h3v12h-3zM63 23h3v10h-3zM6 24h3v11h-3zM33 26h5v1h-5zM33 27h5v1h-5zM33 28h7v1h-7zM34 29h4v1h-4zM35 30h4v1h-4zM36 31h4v1h-4zM3 32h3v1h-3zM9 32h3v1h-3zM20 32h5v1h-5zM37 32h4v3h-4zM46 32h7v3h-7zM58 32h5v1h-5zM3 33h3v1h-3zM9 33h3v1h-3zM20 33h5v1h-5zM58 33h7v1h-7zM1 34h5v1h-5zM9 34h5v1h-5zM18 34h9v1h-9zM58 34h6v1h-6z"/>
29+</svg>
30+<style>
31+.hw {
32+ color-scheme: light dark; /* follows the reader's OS; pin with style="color-scheme: dark" on the figure */
33+ --hw-bg: light-dark(#e9eff5, #0a2342);
34+ --hw-dim: light-dark(#cddbe9, #143a6b);
35+ --hw-mid: light-dark(#8fb0d1, #2e6aa6);
36+ --hw-lit: light-dark(#0f3460, #d6e9f8);
37+ --hw-hover: light-dark(#f0913f, #ffb454);
38+ --hw-crest: light-dark(#d9480f, #fff1cf);
39+ position: relative; margin: 1.5em 0; aspect-ratio: 102 / 61;
40+ cursor: crosshair; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent;
41+}
42+.hw svg, .hw canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
43+.hw canvas { image-rendering: crisp-edges; image-rendering: pixelated; }
44+.hw-bg { fill: var(--hw-bg); }
45+.hw-word { fill: var(--hw-lit); }
46+.hw-live svg { opacity: 0; } /* still there for screen readers */
47+</style>
48+<script>
49+(() => {
50+ const fig = document.currentScript?.closest('.hw') || document.querySelector('.hw:not(.hw-live)');
51+ // htmx 4 re-runs inline scripts in swapped-in content (it clones each
52+ // <script> into a fresh, executable one), so a boosted navigation back to
53+ // this post runs the whole IIFE a second time. `.hw-live` is the marker set
54+ // at the end of a successful init: bail when the figure already carries it,
55+ // or the plate collects another canvas and another rAF loop per visit.
56+ if (!fig || fig.classList.contains('hw-live')) return;
57+ const svg = fig.querySelector('svg'), wordEl = svg.querySelector('.hw-word');
58+ const [, , W, H] = svg.getAttribute('viewBox').split(' ').map(Number);
59+ const [ox, oy] = wordEl.getAttribute('transform').match(/-?\d+/g).map(Number);
60+ // The SVG path IS the bitmap: every "M x y h w v h" is a rectangle of lit cells.
61+ const word = new Uint8Array(W * H); let wx = 0, wy = 0;
62+ for (const m of wordEl.getAttribute('d').matchAll(/M(\d+) (\d+)h(\d+)v(\d+)/g)) {
63+ const [x, y, w, h] = m.slice(1).map(Number); wx = Math.max(wx, x + w); wy = Math.max(wy, y + h);
64+ for (let r = y; r < y + h; r++) for (let c = x; c < x + w; c++) word[(r + oy) * W + c + ox] = 1;
65+ }
66+ const BAYER = [0,32,8,40,2,34,10,42,48,16,56,24,50,18,58,26,12,44,4,36,14,46,6,38,60,28,52,20,62,30,54,22,3,35,11,43,1,33,9,41,51,19,59,27,49,17,57,25,15,47,7,39,13,45,5,37,63,31,55,23,61,29,53,21];
67+ const smooth = (lo, hi, x) => { const t = Math.min(1, Math.max(0, (x - lo) / (hi - lo))); return t * t * (3 - 2 * t); };
68+ // Tileable value noise: a 64x64 table of randoms, smoothly interpolated.
69+ const T = Float32Array.from({ length: 4096 }, Math.random);
70+ const noise = (x, y) => {
71+ const xi = Math.floor(x), yi = Math.floor(y), fx = x - xi, fy = y - yi;
72+ const u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy);
73+ const a = T[(yi & 63) << 6 | xi & 63], b = T[(yi & 63) << 6 | xi + 1 & 63], c = T[(yi + 1 & 63) << 6 | xi & 63], d = T[(yi + 1 & 63) << 6 | xi + 1 & 63];
74+ return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
75+ };
76+ // Per cell, once: dither threshold, how quiet the clouds are near the word, when a word cell burns in.
77+ const th = new Float32Array(W * H), hush = new Float32Array(W * H), key = new Float32Array(W * H);
78+ for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) {
79+ const i = r * W + c, x = c + .5 - ox, y = r + .5 - oy;
80+ th[i] = (BAYER[(r & 7) << 3 | c & 7] + .5) / 64;
81+ hush[i] = .25 + .75 * smooth(0, 9, Math.hypot(Math.max(-2 - x, 0, x - wx - 2), Math.max(-2 - y, 0, y - wy - 2)));
82+ key[i] = .6 * (c - ox) / wx + .4 * th[i];
83+ if (Math.min(c, W - 1 - c) + Math.min(r, H - 1 - r) < 2) hush[i] = -1; // notched corners
84+ }
85+ // One canvas pixel per cell; CSS scales it up with image-rendering: pixelated.
86+ const cv = document.createElement('canvas'); cv.width = W; cv.height = H; cv.setAttribute('aria-hidden', 'true');
87+ const ctx = cv.getContext('2d'), img = ctx.createImageData(W, H), px = new Uint32Array(img.data.buffer);
88+ // Colours come from the CSS variables, resolved by the browser (so light-dark() and friends work).
89+ const probe = fig.appendChild(document.createElement('i')); probe.hidden = true;
90+ let PAL = [], LIT = 0;
91+ const readPalette = () => {
92+ const rgb = name => { probe.style.color = `var(--hw-${name})`; const [r, g, b] = getComputedStyle(probe).color.match(/[\d.]+/g).map(Number); return (255 << 24 | b << 16 | g << 8 | r) >>> 0; };
93+ PAL = ['bg', 'dim', 'mid', 'hover', 'crest'].map(rgb); LIT = rgb('lit');
94+ };
95+ const still = matchMedia('(prefers-reduced-motion: reduce)');
96+ const ptr = { x: 0, y: 0, tx: 0, ty: 0, on: false, k: 0 }; let rings = [], introAt = Infinity;
97+ function draw(now) {
98+ const t = still.matches ? 40 : now / 1000, p = still.matches ? 1 : Math.min(1, Math.max(0, (now - introAt) / 1500));
99+ const intro = 1 - (1 - p) * (1 - p), swell = still.matches ? 1 : Math.min(1, Math.max(0, (now - introAt) / 700));
100+ ptr.k += ((ptr.on ? 1 : 0) - ptr.k) * .12; ptr.x += (ptr.tx - ptr.x) * .2; ptr.y += (ptr.ty - ptr.y) * .2;
101+ rings = rings.filter(k => now - k.t < 1700);
102+ for (let r = 0, i = 0; r < H; r++) for (let c = 0; c < W; c++, i++) {
103+ if (hush[i] < 0) { px[i] = 0; continue; }
104+ let glow = 0, ring = 0;
105+ if (ptr.k > .01) { const g = 1 - Math.hypot(c + .5 - ptr.x, r + .5 - ptr.y) / 11; if (g > 0) glow = g * Math.sqrt(g) * ptr.k; }
106+ for (const k of rings) { const age = (now - k.t) / 1000, d = Math.hypot(c + .5 - k.x, r + .5 - k.y); ring = Math.max(ring, Math.max(0, 1 - Math.abs(d - age * 26) / 2.5) * (1 - age / 1.7)); }
107+ if (word[i] && p > 0 && intro >= key[i]) {
108+ px[i] = (p < 1 && intro - key[i] < .07) || ring > th[i] || glow > .45 + th[i] * .5 ? PAL[4] : glow * 1.6 > th[i] ? PAL[3] : LIT;
109+ continue;
110+ }
111+ // Two octaves of drifting noise, shaped into clouds, dithered across bg > dim > mid > hover > crest.
112+ const n = .62 * noise(c / 10 + t * .035, r / 10 + t * .02) + .38 * noise(c / 4.5 - t * .05 + 17, r / 4.5 + t * .03 + 9);
113+ const level = smooth(.38, .84, n) * 2 * hush[i] * swell + glow * 3.6 + ring * 3, base = Math.floor(level);
114+ px[i] = PAL[Math.min(4, base + (level - base > th[i] ? 1 : 0))];
115+ }
116+ ctx.putImageData(img, 0, 0);
117+ }
118+ let visible = false, raf = 0, last = 0;
119+ // isConnected: an htmx swap can tear this figure out of the document while
120+ // the loop is running - the IntersectionObserver may never report it - so
121+ // check the node is still in the page rather than animating a detached canvas.
122+ const tick = now => { raf = 0; if (!visible || !fig.isConnected) return; if (now - last > 31) { last = now; draw(now); } if (!still.matches) raf = requestAnimationFrame(tick); };
123+ const kick = () => { if (!raf) { last = 0; raf = requestAnimationFrame(tick); } };
124+ const repaint = () => { readPalette(); kick(); };
125+ const at = e => { const b = cv.getBoundingClientRect(); return { x: (e.clientX - b.left) / b.width * W, y: (e.clientY - b.top) / b.height * H }; };
126+ fig.addEventListener('pointermove', e => { const q = at(e); if (ptr.k < .02) { ptr.x = q.x; ptr.y = q.y; } ptr.tx = q.x; ptr.ty = q.y; ptr.on = !still.matches; });
127+ fig.addEventListener('pointerleave', () => { ptr.on = false; });
128+ fig.addEventListener('pointerup', e => { if (e.pointerType !== 'mouse') ptr.on = false; });
129+ fig.addEventListener('pointerdown', e => { if (still.matches) return; rings.push({ ...at(e), t: performance.now() }); if (rings.length > 4) rings.shift(); });
130+ new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible && introAt === Infinity) introAt = performance.now(); kick(); }).observe(fig);
131+ new MutationObserver(repaint).observe(document.documentElement, { attributes: true });
132+ new MutationObserver(repaint).observe(fig, { attributes: true, attributeFilter: ['style', 'class'] });
133+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', repaint); still.addEventListener('change', kick);
134+ readPalette(); draw(performance.now()); fig.append(cv); fig.classList.add('hw-live');
135+})();
136+</script>
137+</figure>
new file mode 100644
@@ -0,0 +1,137 @@
1+{{- /*
2+ The animated "Hello, world" plate for the post of the same name. Kept as a
3+ shortcode rather than pasted into the markdown so the content file stays
4+ readable and every translation (hello-world.fr.md, .ja.md, ...) can reuse
5+ the exact same one-liner instead of carrying its own copy of 8 KB of SVG,
6+ CSS and JS that would then have to be kept in sync five ways.
7+
8+ The <style>/<script> live here rather than in assets/css/style.scss and
9+ assets/js/ on purpose: this is a one-post ornament, and inlining it keeps
10+ the cost off every other page in the site. If a second post ever wants it,
11+ that's the moment to promote it to a fingerprinted asset.
12+
13+ The palette is self-contained too - light-dark() reads the `color-scheme:
14+ light dark` already declared on :root, so the plate flips with the rest of
15+ the site and needs no wiring into the theme variables.
16+
17+ The two fill="#..." attributes on the SVG paths are the no-CSS fallback:
18+ the <style> below outranks them wherever it applies, but anywhere the
19+ figure travels without it - a reader that strips <style>/<script>, a
20+ saved page, a full-content feed - the paths would otherwise take the SVG
21+ default and render a black word on a black plate. With the fills, that
22+ context gets the still version the bare SVG already is.
23+*/ -}}
24+<figure class="hw">
25+<svg viewBox="0 0 102 61" role="img" aria-labelledby="hw-title" shape-rendering="crispEdges">
26+<title id="hw-title">Hello, world</title>
27+<path class="hw-bg" fill="#e9eff5" d="M2 0h98v1h1v1h1v57h-1v1h-1v1h-98v-1h-1v-1h-1v-57h1v-1h1z"/>
28+<path class="hw-word" fill="#0f3460" transform="translate(18 13)" d="M0 0h3v15h-3zM8 0h3v15h-3zM13 0h10v3h-10zM25 0h3v15h-3zM37 0h3v15h-3zM50 0h9v3h-9zM49 1h1v1h-1zM59 1h1v2h-1zM49 2h1v1h-1zM13 3h3v12h-3zM49 3h3v11h-3zM57 3h3v11h-3zM3 6h5v1h-5zM16 6h5v3h-5zM3 7h5v1h-5zM3 8h5v1h-5zM16 12h7v3h-7zM28 12h7v3h-7zM40 12h7v3h-7zM52 12h5v1h-5zM52 13h5v1h-5zM50 14h9v1h-9zM0 20h3v14h-3zM12 20h3v14h-3zM18 20h9v3h-9zM30 20h10v3h-10zM43 20h3v15h-3zM55 20h9v3h-9zM17 21h1v1h-1zM27 21h1v2h-1zM40 21h1v2h-1zM64 21h1v2h-1zM17 22h1v1h-1zM65 22h1v1h-1zM17 23h3v11h-3zM25 23h3v11h-3zM30 23h3v12h-3zM38 23h3v5h-3zM55 23h3v12h-3zM63 23h3v10h-3zM6 24h3v11h-3zM33 26h5v1h-5zM33 27h5v1h-5zM33 28h7v1h-7zM34 29h4v1h-4zM35 30h4v1h-4zM36 31h4v1h-4zM3 32h3v1h-3zM9 32h3v1h-3zM20 32h5v1h-5zM37 32h4v3h-4zM46 32h7v3h-7zM58 32h5v1h-5zM3 33h3v1h-3zM9 33h3v1h-3zM20 33h5v1h-5zM58 33h7v1h-7zM1 34h5v1h-5zM9 34h5v1h-5zM18 34h9v1h-9zM58 34h6v1h-6z"/>
29+</svg>
30+<style>
31+.hw {
32+ color-scheme: light dark; /* follows the reader's OS; pin with style="color-scheme: dark" on the figure */
33+ --hw-bg: light-dark(#e9eff5, #0a2342);
34+ --hw-dim: light-dark(#cddbe9, #143a6b);
35+ --hw-mid: light-dark(#8fb0d1, #2e6aa6);
36+ --hw-lit: light-dark(#0f3460, #d6e9f8);
37+ --hw-hover: light-dark(#f0913f, #ffb454);
38+ --hw-crest: light-dark(#d9480f, #fff1cf);
39+ position: relative; margin: 1.5em 0; aspect-ratio: 102 / 61;
40+ cursor: crosshair; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent;
41+}
42+.hw svg, .hw canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
43+.hw canvas { image-rendering: crisp-edges; image-rendering: pixelated; }
44+.hw-bg { fill: var(--hw-bg); }
45+.hw-word { fill: var(--hw-lit); }
46+.hw-live svg { opacity: 0; } /* still there for screen readers */
47+</style>
48+<script>
49+(() => {
50+ const fig = document.currentScript?.closest('.hw') || document.querySelector('.hw:not(.hw-live)');
51+ // htmx 4 re-runs inline scripts in swapped-in content (it clones each
52+ // <script> into a fresh, executable one), so a boosted navigation back to
53+ // this post runs the whole IIFE a second time. `.hw-live` is the marker set
54+ // at the end of a successful init: bail when the figure already carries it,
55+ // or the plate collects another canvas and another rAF loop per visit.
56+ if (!fig || fig.classList.contains('hw-live')) return;
57+ const svg = fig.querySelector('svg'), wordEl = svg.querySelector('.hw-word');
58+ const [, , W, H] = svg.getAttribute('viewBox').split(' ').map(Number);
59+ const [ox, oy] = wordEl.getAttribute('transform').match(/-?\d+/g).map(Number);
60+ // The SVG path IS the bitmap: every "M x y h w v h" is a rectangle of lit cells.
61+ const word = new Uint8Array(W * H); let wx = 0, wy = 0;
62+ for (const m of wordEl.getAttribute('d').matchAll(/M(\d+) (\d+)h(\d+)v(\d+)/g)) {
63+ const [x, y, w, h] = m.slice(1).map(Number); wx = Math.max(wx, x + w); wy = Math.max(wy, y + h);
64+ for (let r = y; r < y + h; r++) for (let c = x; c < x + w; c++) word[(r + oy) * W + c + ox] = 1;
65+ }
66+ const BAYER = [0,32,8,40,2,34,10,42,48,16,56,24,50,18,58,26,12,44,4,36,14,46,6,38,60,28,52,20,62,30,54,22,3,35,11,43,1,33,9,41,51,19,59,27,49,17,57,25,15,47,7,39,13,45,5,37,63,31,55,23,61,29,53,21];
67+ const smooth = (lo, hi, x) => { const t = Math.min(1, Math.max(0, (x - lo) / (hi - lo))); return t * t * (3 - 2 * t); };
68+ // Tileable value noise: a 64x64 table of randoms, smoothly interpolated.
69+ const T = Float32Array.from({ length: 4096 }, Math.random);
70+ const noise = (x, y) => {
71+ const xi = Math.floor(x), yi = Math.floor(y), fx = x - xi, fy = y - yi;
72+ const u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy);
73+ const a = T[(yi & 63) << 6 | xi & 63], b = T[(yi & 63) << 6 | xi + 1 & 63], c = T[(yi + 1 & 63) << 6 | xi & 63], d = T[(yi + 1 & 63) << 6 | xi + 1 & 63];
74+ return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
75+ };
76+ // Per cell, once: dither threshold, how quiet the clouds are near the word, when a word cell burns in.
77+ const th = new Float32Array(W * H), hush = new Float32Array(W * H), key = new Float32Array(W * H);
78+ for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) {
79+ const i = r * W + c, x = c + .5 - ox, y = r + .5 - oy;
80+ th[i] = (BAYER[(r & 7) << 3 | c & 7] + .5) / 64;
81+ hush[i] = .25 + .75 * smooth(0, 9, Math.hypot(Math.max(-2 - x, 0, x - wx - 2), Math.max(-2 - y, 0, y - wy - 2)));
82+ key[i] = .6 * (c - ox) / wx + .4 * th[i];
83+ if (Math.min(c, W - 1 - c) + Math.min(r, H - 1 - r) < 2) hush[i] = -1; // notched corners
84+ }
85+ // One canvas pixel per cell; CSS scales it up with image-rendering: pixelated.
86+ const cv = document.createElement('canvas'); cv.width = W; cv.height = H; cv.setAttribute('aria-hidden', 'true');
87+ const ctx = cv.getContext('2d'), img = ctx.createImageData(W, H), px = new Uint32Array(img.data.buffer);
88+ // Colours come from the CSS variables, resolved by the browser (so light-dark() and friends work).
89+ const probe = fig.appendChild(document.createElement('i')); probe.hidden = true;
90+ let PAL = [], LIT = 0;
91+ const readPalette = () => {
92+ const rgb = name => { probe.style.color = `var(--hw-${name})`; const [r, g, b] = getComputedStyle(probe).color.match(/[\d.]+/g).map(Number); return (255 << 24 | b << 16 | g << 8 | r) >>> 0; };
93+ PAL = ['bg', 'dim', 'mid', 'hover', 'crest'].map(rgb); LIT = rgb('lit');
94+ };
95+ const still = matchMedia('(prefers-reduced-motion: reduce)');
96+ const ptr = { x: 0, y: 0, tx: 0, ty: 0, on: false, k: 0 }; let rings = [], introAt = Infinity;
97+ function draw(now) {
98+ const t = still.matches ? 40 : now / 1000, p = still.matches ? 1 : Math.min(1, Math.max(0, (now - introAt) / 1500));
99+ const intro = 1 - (1 - p) * (1 - p), swell = still.matches ? 1 : Math.min(1, Math.max(0, (now - introAt) / 700));
100+ ptr.k += ((ptr.on ? 1 : 0) - ptr.k) * .12; ptr.x += (ptr.tx - ptr.x) * .2; ptr.y += (ptr.ty - ptr.y) * .2;
101+ rings = rings.filter(k => now - k.t < 1700);
102+ for (let r = 0, i = 0; r < H; r++) for (let c = 0; c < W; c++, i++) {
103+ if (hush[i] < 0) { px[i] = 0; continue; }
104+ let glow = 0, ring = 0;
105+ if (ptr.k > .01) { const g = 1 - Math.hypot(c + .5 - ptr.x, r + .5 - ptr.y) / 11; if (g > 0) glow = g * Math.sqrt(g) * ptr.k; }
106+ for (const k of rings) { const age = (now - k.t) / 1000, d = Math.hypot(c + .5 - k.x, r + .5 - k.y); ring = Math.max(ring, Math.max(0, 1 - Math.abs(d - age * 26) / 2.5) * (1 - age / 1.7)); }
107+ if (word[i] && p > 0 && intro >= key[i]) {
108+ px[i] = (p < 1 && intro - key[i] < .07) || ring > th[i] || glow > .45 + th[i] * .5 ? PAL[4] : glow * 1.6 > th[i] ? PAL[3] : LIT;
109+ continue;
110+ }
111+ // Two octaves of drifting noise, shaped into clouds, dithered across bg > dim > mid > hover > crest.
112+ const n = .62 * noise(c / 10 + t * .035, r / 10 + t * .02) + .38 * noise(c / 4.5 - t * .05 + 17, r / 4.5 + t * .03 + 9);
113+ const level = smooth(.38, .84, n) * 2 * hush[i] * swell + glow * 3.6 + ring * 3, base = Math.floor(level);
114+ px[i] = PAL[Math.min(4, base + (level - base > th[i] ? 1 : 0))];
115+ }
116+ ctx.putImageData(img, 0, 0);
117+ }
118+ let visible = false, raf = 0, last = 0;
119+ // isConnected: an htmx swap can tear this figure out of the document while
120+ // the loop is running - the IntersectionObserver may never report it - so
121+ // check the node is still in the page rather than animating a detached canvas.
122+ const tick = now => { raf = 0; if (!visible || !fig.isConnected) return; if (now - last > 31) { last = now; draw(now); } if (!still.matches) raf = requestAnimationFrame(tick); };
123+ const kick = () => { if (!raf) { last = 0; raf = requestAnimationFrame(tick); } };
124+ const repaint = () => { readPalette(); kick(); };
125+ const at = e => { const b = cv.getBoundingClientRect(); return { x: (e.clientX - b.left) / b.width * W, y: (e.clientY - b.top) / b.height * H }; };
126+ fig.addEventListener('pointermove', e => { const q = at(e); if (ptr.k < .02) { ptr.x = q.x; ptr.y = q.y; } ptr.tx = q.x; ptr.ty = q.y; ptr.on = !still.matches; });
127+ fig.addEventListener('pointerleave', () => { ptr.on = false; });
128+ fig.addEventListener('pointerup', e => { if (e.pointerType !== 'mouse') ptr.on = false; });
129+ fig.addEventListener('pointerdown', e => { if (still.matches) return; rings.push({ ...at(e), t: performance.now() }); if (rings.length > 4) rings.shift(); });
130+ new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible && introAt === Infinity) introAt = performance.now(); kick(); }).observe(fig);
131+ new MutationObserver(repaint).observe(document.documentElement, { attributes: true });
132+ new MutationObserver(repaint).observe(fig, { attributes: true, attributeFilter: ['style', 'class'] });
133+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', repaint); still.addEventListener('change', kick);
134+ readPalette(); draw(performance.now()); fig.append(cv); fig.classList.add('hw-live');
135+})();
136+</script>
137+</figure>