commit 90f4c6dcb6101f8e246f1990d08ecf99b5d4110e
parent 96e0be24ec19e82d3e776f287244df61e0af5d89
Author: DAEMON <zer0sec.xp@icloud.com>
Date: Wed, 7 Oct 2026 13:34:23 +0100
Bring over daemon-sec's dot field, cursor pill and softer entrances
The same effects as daemon-sec.xyz, as framework-free modules in
src/scripts/dmn (shaders and timing unchanged):
- a dot field behind the top of every page that leans toward the pointer,
toned down here because pages open into dense text; the fuzz headers stay
- a cursor that trails the pointer and becomes a pill naming the action:
Read over tool pages, Browse over collections, Open PDF over PDF links
- scroll reveals on daemon-sec's longer, softer curve
The Reduce motion switch now works on every page: its CSS used to load only
with /catalog, and several scripts only checked the OS setting. The footer's
duplicate 06 is renumbered. Removes three unused components and a GitHub Pages
leftover.
Tested: astro check (0 errors), npm test (20/20), npm run build, and
Playwright on the preview: field live on home, a collection, a tool page and
the catalog; pills on collection cards, hero rows, tool cards and catalog
rows, including after a view-transition navigation; search dialog hides it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat:
16 files changed, 1678 insertions(+), 159 deletions(-)
diff --git a/public/.nojekyll b/public/.nojekyll
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
@@ -40,8 +40,8 @@ const NAV = [
<span class="n">{item.n}</span>{item.label}
</a>
))}
- <a href={MAIN_SITE} rel="noopener" style="--acc: var(--color-night-foam);"><span class="n">06</span>Main site ↗</a>
- <a href={GITHUB} target="_blank" rel="noopener" style="--acc: var(--color-night-gold);"><span class="n">07</span>GitHub ↗</a>
+ <a href={MAIN_SITE} rel="noopener" style="--acc: var(--color-night-foam);"><span class="n">07</span>Main site ↗</a>
+ <a href={GITHUB} target="_blank" rel="noopener" style="--acc: var(--color-night-gold);"><span class="n">08</span>GitHub ↗</a>
</nav>
<div class="dfooter__meta">
diff --git a/src/components/Operator.astro b/src/components/Operator.astro
@@ -1,71 +0,0 @@
----
-interface Props {
- sheets: number;
- domains: number;
- mirrors: number;
- updated: string;
-}
-const { sheets, domains, mirrors, updated } = Astro.props;
-
-/**
- * The operator band — a dark plate with a grid texture, capability chips, a
- * terminal `--status` readout and an action rail, ported from the main site's
- * operator section. The rail's search field opens the Pagefind modal
- * (`[data-search-open]`, bound in SearchModal); the stats and CTAs bind the
- * real vault counts.
- */
-const pad2 = (n: number) => String(n).padStart(2, '0');
----
-<section class="operator bleed" aria-labelledby="op-title">
- <div class="operator__grid" aria-hidden="true"></div>
- <div class="archive operator__inner">
- <div class="sec-head">
- <span class="sec-head__eyebrow" style="--tone: var(--color-night-iris);">Index / operator</span>
- <h2 id="op-title" class="sec-head__title" style="--tone: var(--color-night-iris);">Start here.</h2>
- <p class="sec-head__intro" style="--intro: var(--color-signal-meta);">
- {sheets} sheets across {domains} shelves, plus two full upstream mirrors. The shelves are
- below; the index itself runs under them.
- </p>
- </div>
-
- <div class="operator__cols">
- <div>
- <div class="op-chips">
- <span class="op-chip" style="--chip: var(--color-night-iris);"><span class="pip dmn-anim-pulse"></span>Offline search</span>
- <span class="op-chip" style="--chip: var(--color-night-foam);">Copy-ready</span>
- <span class="op-chip" style="--chip: var(--color-night-love);">Zero fluff</span>
- </div>
- <div class="op-readout">
- <div class="op-readout__cmd">
- <span class="u">daemon@ref</span><span>~</span><span>%</span><span class="c">vault --status</span><span class="dmn-blink"></span>
- </div>
- <dl>
- <dt>sheets</dt><dd>{sheets} · curated</dd>
- <dt>shelves</dt><dd>{domains} · domains</dd>
- <dt>search</dt><dd><span class="on">offline</span> · fuzzy · no tracking</dd>
- <dt>updated</dt><dd>{updated}</dd>
- </dl>
- </div>
- </div>
-
- <div class="op-rail">
- <div class="op-rail__eyebrow">Quick search / 2026</div>
- <button class="op-search" data-search-open type="button" aria-label="Search the vault">
- <span class="slash">/</span>
- <span class="ph">search the vault</span>
- <kbd>⌘K</kbd>
- </button>
- <dl class="op-stats">
- <dd><span style="color: var(--color-night-iris);">{pad2(domains)}</span> shelves</dd>
- <dd><span style="color: var(--color-night-love);">{sheets}</span> sheets</dd>
- <dd><span style="color: var(--color-night-gold);">{pad2(mirrors)}</span> mirrors</dd>
- </dl>
- <div class="op-cta-list">
- <a class="op-cta op-cta--pine" href="#index"><span>Open the index</span><span class="count">{sheets} sheets</span><span>↓</span></a>
- <a class="op-cta op-cta--text" href="#shelves"><span>Pick a shelf</span><span class="count">{pad2(domains)} shelves</span><span>↓</span></a>
- <a class="op-cta op-cta--love" href="#mirrors"><span>Mirrored trees</span><span class="count">{pad2(mirrors)} upstream</span><span>↓</span></a>
- </div>
- </div>
- </div>
- </div>
-</section>
diff --git a/src/components/RecordRow.astro b/src/components/RecordRow.astro
@@ -1,48 +0,0 @@
----
-import Icon from './Icon.astro';
-
-interface Props {
- href: string;
- /** Zero-padded index, supplied by the list so a row never has to know
- * where it sits. */
- n: string;
- title: string;
- /** The kind mark — the domain tag for a sheet, the topic for a payload. */
- kind: string;
- kindIcon?: string;
- accent?: string;
- /** Trailing slot: date / page count. Dropped below md. */
- meta?: string;
- /** Disclosure detail, revealed on hover/focus. */
- excerpt?: string;
- tagline?: string;
- /** Client-side filter bucket (index page). */
- bucket?: string;
-}
-const { href, n, title, kind, kindIcon = 'doc', accent = 'foam', meta, excerpt, tagline, bucket } = Astro.props;
-
-/**
- * One record in an index — the site's core object, ported from the main
- * site's RecordRow. One line, not a card: the title is NOT a display heading,
- * so a list of fifty stays scannable.
- *
- * Rule #6 of the redesign handoff: the hover is a PAPER wash (`--overlay`),
- * not the solid ink wipe — the title must stay ≥4.5:1 on hover — so nothing
- * here re-colours the text on hover. See `.rec` in daemon.css.
- */
----
-<a class="rec" href={href} style={`--acc: var(--${accent});`} data-bucket={bucket}>
- <span class="rec__grid">
- <span class="rec__n">{n}</span>
- <span class="rec__kind"><Icon name={kindIcon} /> {kind}</span>
- <span class="rec__title">{title}</span>
- <span class="rec__date">{meta}</span>
- <span class="rec__arrow"><Icon name="chevron" /></span>
- {(excerpt || tagline) && (
- <span class="rec__reveal"><span>
- {excerpt && <span class="rec__excerpt">{excerpt}</span>}
- {tagline && <span class="rec__tagline">{tagline}</span>}
- </span></span>
- )}
- </span>
-</a>
diff --git a/src/components/SectionHeader.astro b/src/components/SectionHeader.astro
@@ -1,26 +0,0 @@
----
-interface Props {
- eyebrow: string;
- title: string;
- intro?: string;
- /** Heading + eyebrow colour — each section is keyed to a different accent. */
- tone?: 'ink' | 'pine' | 'iris' | 'love' | 'gold' | 'foam' | 'rose';
- class?: string;
-}
-const { eyebrow, title, intro, tone = 'ink', class: cls } = Astro.props;
-
-/**
- * The three-column header every archive section opens with — mono eyebrow,
- * oversized display heading, short intro — ported from the main site's
- * SectionHeader. The eyebrow takes the section's own accent rather than a
- * neutral; the heading tone is keyed to the same. Stacks below the lg break.
- */
-// The eyebrow always uses an accent (never bare ink); the title follows tone.
-const eyebrowVar = tone === 'ink' ? 'var(--pine)' : `var(--${tone})`;
-const titleVar = tone === 'ink' ? 'var(--text)' : `var(--${tone})`;
----
-<div class:list={['sec-head', cls]}>
- <span class="sec-head__eyebrow" style={`--tone: ${eyebrowVar};`}>{eyebrow}</span>
- <h2 class="sec-head__title" style={`--tone: ${titleVar};`}>{title}</h2>
- {intro && <p class="sec-head__intro">{intro}</p>}
-</div>
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
@@ -180,6 +180,8 @@ const ogImage = new URL('og.png', Astro.site).href;
reading as stock rather than as a swatch, and it stands in for the
texture the old glass build got from blur. -->
<span class="grain" aria-hidden="true"></span>
+ <!-- The dot field behind the top of the page (src/scripts/dmn). -->
+ <div class="dmn-page-field" aria-hidden="true"><canvas data-dots></canvas></div>
<Header />
<main id="main">
<slot />
diff --git a/src/scripts/app.ts b/src/scripts/app.ts
@@ -2,6 +2,16 @@
import { initFuzz } from './fuzz';
import { initHero } from './hero';
+import { initDmn } from './dmn/init';
+
+/* The OS setting or the site's own switch (`<html data-motion="reduce">`,
+ set from the catalog's display panel) both count as reduced motion. */
+function reducedMotion(): boolean {
+ return (
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches ||
+ document.documentElement.dataset.motion === 'reduce'
+ );
+}
function applyTheme(next: 'light' | 'dark'): void {
document.documentElement.setAttribute('data-theme', next);
@@ -26,7 +36,7 @@ function initTheme(): void {
const startVT = (document as unknown as {
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
}).startViewTransition;
- const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const reduce = reducedMotion();
if (!startVT || reduce) { applyTheme(next); return; }
// Origin + radius for the circular reveal: expand from the toggle's centre
@@ -102,7 +112,7 @@ function initUnscramble(): void {
});
const nodes = document.querySelectorAll<HTMLElement>('[data-unscramble]:not([data-unscramble-bound])');
- const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const reduced = reducedMotion();
const resolve = (node: HTMLElement, finalText: string): void => {
let step = 0;
@@ -150,7 +160,7 @@ function initUnscramble(): void {
* the stylesheet, so every word remains visible if JavaScript is unavailable.
*/
function initScrollReveal(): void {
- const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const reduced = reducedMotion();
const nodes = document.querySelectorAll<HTMLElement>([
'[data-reveal]:not([data-reveal-bound])',
'.subcat:not([data-reveal-bound])',
@@ -170,7 +180,7 @@ function initScrollReveal(): void {
nodes.forEach((node, index) => {
node.dataset.revealBound = '1';
// Only nearby siblings cascade; long indexes should not accumulate delay.
- node.style.setProperty('--reveal-delay', `${(index % 6) * 34}ms`);
+ node.style.setProperty('--reveal-delay', `${(index % 6) * 70}ms`);
if (reduced) node.classList.add('is-revealed');
else node.classList.add('scroll-reveal');
});
@@ -205,7 +215,7 @@ function initScrollReveal(): void {
function initCounters(): void {
const nodes = document.querySelectorAll<HTMLElement>('[data-count]:not([data-count-bound])');
if (!nodes.length) return;
- const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const reduced = reducedMotion();
const run = (el: HTMLElement): void => {
const target = parseInt(el.dataset.count || '0', 10) || 0;
@@ -445,6 +455,7 @@ function init(): void {
initScrollReveal();
initScrollProgress();
initTOC();
+ initDmn();
}
document.addEventListener('astro:page-load', init);
diff --git a/src/scripts/dmn/cursor.ts b/src/scripts/dmn/cursor.ts
@@ -0,0 +1,256 @@
+/**
+ * The dot cursor and its pill, framework-free. Ported from daemon-sec's
+ * client/src/components/daemon/cursor.tsx so the sibling sites behave
+ * the same way.
+ *
+ * A small disc trails the pointer and opens into a ring over anything
+ * clickable. Over something with one clear action it becomes a pill
+ * that says what a click will do:
+ * - an element with `data-cursor-label` (optional `data-cursor-icon`:
+ * view | play | pdf | download | read)
+ * - a video marked `data-pill`: Play / Pause (a click on the video body
+ * toggles it; the native control strip keeps its own clicks)
+ * - an image marked `data-zoomable`: View image / View GIF
+ * - a link to a PDF: Open PDF, or Download PDF if it downloads
+ * - a link the site counts as an article (`isArticle`): Read
+ *
+ * Fine pointers only; it never mounts on touch or under reduced motion,
+ * so those visitors keep the native cursor. The trailing loop stops as
+ * soon as the dot catches up, so an idle page costs nothing.
+ *
+ * Usage:
+ * mountCursor({ isArticle: (href) => href.startsWith("/sheets/") });
+ */
+
+export interface CursorOptions {
+ /** Which internal links read as articles and get the "Read" pill. */
+ isArticle?: (href: string) => boolean;
+ /** Finer control: the pill text for a link, or null for none. Takes
+ * precedence over `isArticle`. */
+ linkLabel?: (href: string, link: HTMLAnchorElement) => string | null;
+ /** Ancestors that are dark grounds, so the cursor re-tints over them. */
+ darkSelector?: string;
+ /** Finer control than `darkSelector`, for sites that nest light panels
+ * inside dark bands. */
+ isDark?: (target: HTMLElement) => boolean;
+ /** Override the reduced-motion gate. */
+ enabled?: boolean;
+}
+
+type Mode = "view" | "play" | "read" | "pdf" | "label" | null;
+type IconName = "view" | "play" | "pause" | "pdf" | "download" | "read";
+
+/* lucide path data (ISC/MIT), so the pill needs no icon font. */
+const ICONS: Record<IconName, string> = {
+ view: '<path d="M15 3h6v6"/><path d="m21 3-7 7"/><path d="m3 21 7-7"/><path d="M9 21H3v-6"/>',
+ play: '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>',
+ pause: '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>',
+ pdf: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
+ download: '<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>',
+ read: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
+};
+
+const EASE = 0.28;
+const PILL_EASE = 0.16;
+const SETTLED = 0.1;
+const CONTROLS_STRIP = 48;
+const PDF = /\.pdf($|[?#])/i;
+const INTERACTIVE =
+ "a, button, input, textarea, select, summary, label, [role='button'], [role='switch'], [role='tab']";
+
+/* Only ever fixed markup from ICONS: an unknown name falls back to the
+ arrow rather than reaching the DOM. */
+function svg(name: IconName) {
+ const body = ICONS[name] ?? ICONS.read;
+ return `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
+}
+
+export function mountCursor(options: CursorOptions = {}): () => void {
+ /* A site-level motion switch (`<html data-motion="reduce">`) counts the
+ same as the OS setting. */
+ const enabled =
+ options.enabled ??
+ (window.matchMedia("(pointer: fine)").matches &&
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches &&
+ document.documentElement.dataset.motion !== "reduce");
+ if (!enabled) return () => {};
+ const isArticle = options.isArticle ?? (() => false);
+ const linkLabel =
+ options.linkLabel ?? ((href: string) => (isArticle(href) ? "Read" : null));
+ const darkSelector = options.darkSelector ?? "[data-ground='dark']";
+ const isDark = options.isDark ?? ((t: HTMLElement) => Boolean(t.closest(darkSelector)));
+
+ const el = document.createElement("div");
+ el.className = "dmn-cursor";
+ el.setAttribute("aria-hidden", "true");
+ el.innerHTML = '<span class="dmn-cursor-pill"><span class="dmn-cursor-icon"></span><span class="dmn-cursor-label"></span></span>';
+ const iconEl = el.querySelector<HTMLElement>(".dmn-cursor-icon")!;
+ const labelEl = el.querySelector<HTMLElement>(".dmn-cursor-label")!;
+ document.body.appendChild(el);
+ const root = document.documentElement;
+ root.classList.add("has-dmn-cursor");
+
+ let x = window.innerWidth / 2;
+ let y = window.innerHeight / 2;
+ let tx = x;
+ let ty = y;
+ let raf = 0;
+ let seen = false;
+ let mode: Mode = null;
+ let mediaEl: HTMLElement | null = null;
+
+ const place = () => {
+ el.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%)`;
+ };
+ const step = () => {
+ const k = mode ? PILL_EASE : EASE;
+ x += (tx - x) * k;
+ y += (ty - y) * k;
+ if (Math.abs(tx - x) < SETTLED && Math.abs(ty - y) < SETTLED) {
+ x = tx;
+ y = ty;
+ place();
+ raf = 0;
+ return;
+ }
+ place();
+ raf = requestAnimationFrame(step);
+ };
+ const wake = () => {
+ if (!raf) raf = requestAnimationFrame(step);
+ };
+
+ const under = (target: HTMLElement): { mode: Mode; el: HTMLElement } | null => {
+ const labelled = target.closest<HTMLElement>("[data-cursor-label]");
+ if (labelled) return { mode: "label", el: labelled };
+ const video = target.closest<HTMLVideoElement>("video[data-pill]");
+ if (video) return { mode: "play", el: video };
+ const img =
+ target.closest<HTMLImageElement>("img[data-zoomable]") ??
+ target.closest("button")?.querySelector<HTMLImageElement>("img[data-zoomable]") ??
+ null;
+ if (img) return { mode: "view", el: img };
+ const link = target.closest<HTMLAnchorElement>("a[href]");
+ if (link) {
+ const href = link.getAttribute("href") ?? "";
+ if (PDF.test(href)) return { mode: "pdf", el: link };
+ if (linkLabel(href, link)) return { mode: "read", el: link };
+ }
+ return null;
+ };
+
+ const labelFor = (m: Mode, t: HTMLElement): { icon: IconName; text: string } => {
+ if (m === "label") {
+ return { icon: (t.dataset.cursorIcon as IconName) || "read", text: t.dataset.cursorLabel ?? "" };
+ }
+ if (m === "play") {
+ const paused = (t as HTMLVideoElement).paused;
+ return { icon: paused ? "play" : "pause", text: paused ? "Play" : "Pause" };
+ }
+ if (m === "pdf") {
+ return t.hasAttribute("download")
+ ? { icon: "download", text: "Download PDF" }
+ : { icon: "pdf", text: "Open PDF" };
+ }
+ if (m === "read") {
+ const a = t as HTMLAnchorElement;
+ return { icon: "read", text: linkLabel(a.getAttribute("href") ?? "", a) ?? "Read" };
+ }
+ const src = (t as HTMLImageElement).currentSrc || (t as HTMLImageElement).src || "";
+ return { icon: "view", text: /\.gif($|\?)/i.test(src) ? "View GIF" : "View image" };
+ };
+
+ const syncLabel = () => {
+ if (!mode || !mediaEl) return;
+ const { icon, text } = labelFor(mode, mediaEl);
+ iconEl.innerHTML = svg(icon);
+ labelEl.textContent = text;
+ };
+
+ const setMode = (next: Mode, target: HTMLElement | null) => {
+ if (mediaEl && mediaEl !== target) {
+ mediaEl.removeEventListener("play", syncLabel);
+ mediaEl.removeEventListener("pause", syncLabel);
+ }
+ mode = next;
+ mediaEl = target;
+ if (mode && mediaEl) {
+ el.dataset.mode = mode;
+ if (mode === "play") {
+ mediaEl.addEventListener("play", syncLabel);
+ mediaEl.addEventListener("pause", syncLabel);
+ }
+ syncLabel();
+ } else {
+ delete el.dataset.mode;
+ }
+ };
+
+ /* A viewer or modal on screen hides the pill. Sites often keep their
+ search dialog in the DOM while closed, so only a visible one counts. */
+ const dialogOpen = () =>
+ Array.from(document.querySelectorAll<HTMLElement>("dialog[open], [role='dialog'][aria-modal='true']")).some(
+ (d) => (d.checkVisibility ? d.checkVisibility() : d.offsetParent !== null),
+ );
+
+ const applyTarget = (target: HTMLElement | null) => {
+ if (!target?.closest) return;
+ el.classList.toggle("hot", Boolean(target.closest(INTERACTIVE)));
+ el.classList.toggle("on-dark", isDark(target));
+ const media = dialogOpen() ? null : under(target);
+ setMode(media?.mode ?? null, media?.el ?? null);
+ };
+
+ const onMove = (e: MouseEvent) => {
+ tx = e.clientX;
+ ty = e.clientY;
+ seen = true;
+ wake();
+ };
+ const onOver = (e: MouseEvent) => applyTarget(e.target as HTMLElement | null);
+ const onLeave = () => (el.style.opacity = "0");
+ const onEnter = () => (el.style.opacity = "1");
+
+ /* Content can move under a pointer that has not; look again while the
+ page scrolls, once per frame. */
+ let scrollRaf = 0;
+ const onScroll = () => {
+ if (!seen || scrollRaf) return;
+ scrollRaf = requestAnimationFrame(() => {
+ scrollRaf = 0;
+ applyTarget(document.elementFromPoint(tx, ty) as HTMLElement | null);
+ });
+ };
+
+ const onClick = (e: MouseEvent) => {
+ const video = (e.target as HTMLElement | null)?.closest?.<HTMLVideoElement>("video[data-pill]");
+ if (!video) return;
+ const r = video.getBoundingClientRect();
+ if (e.clientY > r.bottom - CONTROLS_STRIP) return;
+ e.preventDefault();
+ if (video.paused) void video.play().catch(() => {});
+ else video.pause();
+ };
+
+ window.addEventListener("mousemove", onMove, { passive: true });
+ window.addEventListener("mouseover", onOver, { passive: true });
+ document.documentElement.addEventListener("mouseleave", onLeave);
+ document.documentElement.addEventListener("mouseenter", onEnter);
+ window.addEventListener("scroll", onScroll, { passive: true });
+ window.addEventListener("click", onClick);
+ place();
+
+ return () => {
+ if (raf) cancelAnimationFrame(raf);
+ if (scrollRaf) cancelAnimationFrame(scrollRaf);
+ setMode(null, null);
+ window.removeEventListener("mousemove", onMove);
+ window.removeEventListener("mouseover", onOver);
+ document.documentElement.removeEventListener("mouseleave", onLeave);
+ document.documentElement.removeEventListener("mouseenter", onEnter);
+ window.removeEventListener("scroll", onScroll);
+ window.removeEventListener("click", onClick);
+ root.classList.remove("has-dmn-cursor");
+ el.remove();
+ };
+}
diff --git a/src/scripts/dmn/icon-targets.ts b/src/scripts/dmn/icon-targets.ts
@@ -0,0 +1,96 @@
+/**
+ * From an icon's outline to a landing spot for every particle.
+ *
+ * The icon arrives as SVG markup (a lucide component's `outerHTML`),
+ * is drawn once into a small offscreen canvas, and its alpha becomes a
+ * grid of "ink here" cells. Each particle then needs the nearest inked
+ * cell to its own resting place: a multi-source flood fill from every
+ * inked cell gives that for the whole grid at once, in a millisecond,
+ * which is near enough to true nearest at this resolution. The spot is
+ * jittered inside its cell so a stroke does not read as a row of beads.
+ *
+ * Coordinates are icon-local: `[-0.5, 0.5]` on both axes, y up. The
+ * field places and scales them per frame.
+ */
+
+export const GRID = 128;
+const PAD = 8;
+const INK = 96;
+
+/** Draw the markup and read its alpha. Async: image decode. */
+export async function rasterise(svg: string, canvas: HTMLCanvasElement): Promise<Uint8Array> {
+ const img = new Image();
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+ await img.decode();
+ canvas.width = GRID;
+ canvas.height = GRID;
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
+ if (!ctx) throw new Error("no 2d context");
+ ctx.clearRect(0, 0, GRID, GRID);
+ ctx.drawImage(img, PAD, PAD, GRID - PAD * 2, GRID - PAD * 2);
+ const { data } = ctx.getImageData(0, 0, GRID, GRID);
+ const out = new Uint8Array(GRID * GRID);
+ for (let i = 0; i < out.length; i++) out[i] = data[i * 4 + 3] > INK ? 1 : 0;
+ return out;
+}
+
+/** For every cell, the index of the nearest inked cell (8-connected BFS). */
+export function nearestInked(ink: Uint8Array): Int32Array {
+ const n = GRID * GRID;
+ const src = new Int32Array(n).fill(-1);
+ const queue = new Int32Array(n);
+ let head = 0;
+ let tail = 0;
+ for (let i = 0; i < n; i++) {
+ if (ink[i]) {
+ src[i] = i;
+ queue[tail++] = i;
+ }
+ }
+ while (head < tail) {
+ const c = queue[head++];
+ const cx = c % GRID;
+ const cy = (c - cx) / GRID;
+ for (let dy = -1; dy <= 1; dy++) {
+ const y = cy + dy;
+ if (y < 0 || y >= GRID) continue;
+ for (let dx = -1; dx <= 1; dx++) {
+ const x = cx + dx;
+ if (x < 0 || x >= GRID) continue;
+ const i = y * GRID + x;
+ if (src[i] >= 0) continue;
+ src[i] = src[c];
+ queue[tail++] = i;
+ }
+ }
+ }
+ return src;
+}
+
+/**
+ * Landing spots for `rest` (icon-local pairs), one per particle, from
+ * the nearest-ink map. Returns icon-local pairs, y up.
+ */
+export function targetsFor(rest: Float32Array, nearest: Int32Array, hash: (i: number) => number): Float32Array {
+ const count = rest.length / 2;
+ const out = new Float32Array(rest.length);
+ for (let i = 0; i < count; i++) {
+ const rx = rest[i * 2];
+ const ry = rest[i * 2 + 1];
+ const cx = Math.min(GRID - 1, Math.max(0, Math.floor((rx + 0.5) * GRID)));
+ const cy = Math.min(GRID - 1, Math.max(0, Math.floor((0.5 - ry) * GRID)));
+ const s = nearest[cy * GRID + cx];
+ if (s < 0) {
+ out[i * 2] = rx;
+ out[i * 2 + 1] = ry;
+ continue;
+ }
+ const sx = s % GRID;
+ const sy = (s - sx) / GRID;
+ const jx = hash(i * 2) - 0.5;
+ const jy = hash(i * 2 + 1) - 0.5;
+ out[i * 2] = (sx + 0.5 + jx * 0.9) / GRID - 0.5;
+ out[i * 2 + 1] = 0.5 - (sy + 0.5 + jy * 0.9) / GRID;
+ }
+ return out;
+}
diff --git a/src/scripts/dmn/init.ts b/src/scripts/dmn/init.ts
@@ -0,0 +1,50 @@
+/**
+ * Wires the shared daemon-sec effects into this site: the dot field
+ * behind the top of each page and the cursor with its action pill.
+ *
+ * Astro's ClientRouter swaps the body on navigation, which removes the
+ * cursor element and the canvas, so both are re-bound on every
+ * `astro:page-load` and torn down on `astro:before-swap`.
+ */
+import { mountParticleField } from './particle-field';
+import { mountCursor } from './cursor';
+
+const DECKS = 'gtfobins|lolbas|wadcoms|loobins|daemon';
+const TOOL = new RegExp(`^/(?:${DECKS})/[^/?#]+`);
+const DECK = new RegExp(`^/(?:${DECKS}|catalog)/?(?:[?#].*)?$`);
+
+let destroyCursor: (() => void) | null = null;
+const fields = new Set<() => void>();
+
+function bindFields(): void {
+ document.querySelectorAll<HTMLCanvasElement>('canvas[data-dots]:not([data-bound])').forEach((cv) => {
+ cv.dataset.bound = '';
+ const destroy = mountParticleField(cv, {
+ /* This site's mode-flipping tokens; a canvas inside a .plate band
+ reads that band's values. */
+ vars: { ink: '--text', base: '--base', iris: '--iris', love: '--love' },
+ onFail: () => cv.closest<HTMLElement>('.dmn-page-field')?.remove(),
+ });
+ fields.add(destroy);
+ });
+}
+
+function bindCursor(): void {
+ if (document.querySelector('.dmn-cursor')) return;
+ destroyCursor?.();
+ destroyCursor = mountCursor({
+ linkLabel: (href) => (TOOL.test(href) ? 'Read' : DECK.test(href) ? 'Browse' : null),
+ /* Dark bands are `.plate`; `.plate-dawn` panels inside them are light. */
+ isDark: (t) => t.closest('.plate, .plate-dawn')?.classList.contains('plate') ?? false,
+ });
+}
+
+export function initDmn(): void {
+ bindFields();
+ bindCursor();
+}
+
+document.addEventListener('astro:before-swap', () => {
+ fields.forEach((d) => d());
+ fields.clear();
+});
diff --git a/src/scripts/dmn/particle-field.ts b/src/scripts/dmn/particle-field.ts
@@ -0,0 +1,1036 @@
+/**
+ * The dot field, framework-free. Generated from daemon-sec's
+ * client/src/components/daemon/particle-field.tsx (same shaders, same
+ * timing) so the sibling sites share one look without React.
+ *
+ * Two layers. The cloud: blue-noise dashes that drift and lean toward a
+ * ring lagging the pointer, thinning under any `[data-index-zone]` box.
+ * The icon layer (opt-in): dots that gather into the lit shelf's icon,
+ * placed on a `[data-field-icon]` box inside the canvas's
+ * `[data-field-host]` ancestor. On a light ground dots get a minimum size
+ * and more ink. Colours are read off the canvas's computed style and
+ * re-read whenever <html> changes its theme attributes. Pauses off-screen
+ * and in hidden tabs; one still frame under reduced motion (OS setting or
+ * `<html data-motion="reduce">`); `onFail` if WebGL is unavailable.
+ */
+import { minDistFor, poisson, rng } from "./poisson";
+import { nearestInked, rasterise, targetsFor } from "./icon-targets";
+
+export type Accent = string;
+export interface LitShelf {
+ index: number | null;
+ accent: Accent | null;
+ /** True while a pointer holds the row (vs. a tour lighting it). */
+ held: boolean;
+}
+export interface ShelfSource {
+ get(): LitShelf;
+ subscribe(f: (s: LitShelf) => void): () => void;
+}
+
+export interface FieldOptions {
+ /** Custom-property names for colours, resolved on the canvas. */
+ vars?: { ink: string; base: string; iris: string; love: string };
+ /** Accent colour variable for a shelf accent name. */
+ accentVar?: (accent: string) => string;
+ pageFamily?: string;
+ plateFamily?: string;
+ tone?: "page" | "plate";
+ /** Draw the drifting cloud (default true). */
+ cloud?: boolean;
+ /** SVG markup per shelf, in shelf order; enables the icon layer. */
+ icons?: string[];
+ /** Which shelf is lit; required with `icons`. */
+ shelf?: ShelfSource;
+ /** Icon dots rest spread across the canvas and stay visible. */
+ restFill?: boolean;
+ /** Also gather for a shelf a tour lights, not only a held one. */
+ tour?: boolean;
+ /** Override motion; defaults to off under reduced motion. */
+ live?: boolean;
+ onFail?: () => void;
+}
+
+const NO_SHELF: ShelfSource = {
+ get: () => ({ index: null, accent: null, held: false }),
+ subscribe: () => () => {},
+};
+
+type Rgb = [number, number, number];
+
+function hexToRgb(hex: string, fallback: Rgb): Rgb {
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
+ if (!m) return fallback;
+ const n = parseInt(m[1], 16);
+ return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
+}
+
+function readVar(el: HTMLElement, name: string, fallback: Rgb): Rgb {
+ return hexToRgb(getComputedStyle(el).getPropertyValue(name), fallback);
+}
+
+const NOISE = `
+vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
+vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
+vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
+vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
+float snoise(vec3 v) {
+ const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
+ const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
+ vec3 i = floor(v + dot(v, C.yyy));
+ vec3 x0 = v - i + dot(i, C.xxx);
+ vec3 g = step(x0.yzx, x0.xyz);
+ vec3 l = 1.0 - g;
+ vec3 i1 = min(g.xyz, l.zxy);
+ vec3 i2 = max(g.xyz, l.zxy);
+ vec3 x1 = x0 - i1 + C.xxx;
+ vec3 x2 = x0 - i2 + C.yyy;
+ vec3 x3 = x0 - D.yyy;
+ i = mod289(i);
+ vec4 p = permute(permute(permute(
+ i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ + i.y + vec4(0.0, i1.y, i2.y, 1.0))
+ + i.x + vec4(0.0, i1.x, i2.x, 1.0));
+ float n_ = 0.142857142857;
+ vec3 ns = n_ * D.wyz - D.xzx;
+ vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
+ vec4 x_ = floor(j * ns.z);
+ vec4 y_ = floor(j - 7.0 * x_);
+ vec4 x = x_ * ns.x + ns.yyyy;
+ vec4 y = y_ * ns.x + ns.yyyy;
+ vec4 h = 1.0 - abs(x) - abs(y);
+ vec4 b0 = vec4(x.xy, y.xy);
+ vec4 b1 = vec4(x.zw, y.zw);
+ vec4 s0 = floor(b0) * 2.0 + 1.0;
+ vec4 s1 = floor(b1) * 2.0 + 1.0;
+ vec4 sh = -step(h, vec4(0.0));
+ vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
+ vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
+ vec3 p0 = vec3(a0.xy, h.x);
+ vec3 p1 = vec3(a0.zw, h.y);
+ vec3 p2 = vec3(a1.xy, h.z);
+ vec3 p3 = vec3(a1.zw, h.w);
+ vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
+ p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
+ vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
+ m = m * m;
+ return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
+}
+`;
+
+/* Field units: y runs -1..1 over the canvas height, x is ±aspect. */
+const VERT = `
+precision highp float;
+attribute vec2 a_rest;
+attribute vec4 a_seed;
+attribute vec2 a_from;
+attribute vec2 a_to;
+
+uniform float u_time;
+uniform vec2 u_extent;
+uniform float u_pr;
+uniform float u_pscale;
+uniform float u_maxPoint;
+uniform float u_layer;
+uniform float u_hover;
+uniform float u_morph;
+uniform float u_pulse;
+uniform float u_iconReady;
+uniform float u_fill;
+uniform float u_light;
+uniform float u_ringR;
+uniform float u_ringW;
+uniform float u_ringW2;
+uniform float u_ringDisp;
+uniform vec2 u_ring;
+uniform vec2 u_ringLag;
+uniform vec4 u_zone;
+uniform vec4 u_icon;
+
+varying float v_scale;
+varying float v_near;
+varying float v_col;
+varying vec2 v_dir;
+
+${NOISE}
+
+/* The cloud thins under the shelf list so the rows stay readable, but
+ only partly and on a wide, rounded falloff: a hard cut-out read as a
+ box drawn round the list. Measured as a rounded rectangle with a
+ radius as big as its short side, so there is no corner to see.
+ No list, no thinning. */
+float zoneFade(vec2 p, float margin) {
+ if (u_zone.z <= u_zone.x) return 1.0;
+ vec2 c = 0.5 * (u_zone.xy + u_zone.zw);
+ vec2 h = 0.5 * (u_zone.zw - u_zone.xy);
+ float r = min(h.x, h.y) * 0.9;
+ vec2 q = abs(p - c) - h + r;
+ float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
+ return mix(0.45, 1.0, smoothstep(-0.25, margin, sd));
+}
+
+void main() {
+ float t = u_time * 0.5;
+ vec2 p;
+ float scale;
+ float near = 0.0;
+
+ if (u_layer < 0.5) {
+ vec2 rest = a_rest;
+ /* each dot sees the ring a little behind where it is, by its own
+ amount, which spreads the settle across the field */
+ vec2 ring = mix(u_ringLag, u_ring, a_seed.w);
+ float dist = distance(rest, ring);
+ float noise0 = snoise(vec3(rest * 0.2 + vec2(18.4924, 72.9744), t * 0.5));
+ float dist1 = distance(rest + noise0 * 0.005, ring);
+ float R = u_ringR;
+ float t1 = smoothstep(R - u_ringW * 2.0, R, dist) - smoothstep(R, R + u_ringW, dist1);
+ float t2 = smoothstep(R - u_ringW2 * 2.0, R, dist) - smoothstep(R, R + u_ringW2, dist1);
+ float t3 = smoothstep(R + u_ringW2, R, dist);
+ t1 = t1 * t1;
+ t2 = t2 * t2 * t2;
+ float s = t1 + t2 * 3.0 + t3 * 0.4;
+ s += snoise(vec3(rest * 30.0 + vec2(11.4924, 12.9744), t * 0.5)) * t3 * 0.5;
+ float nS = snoise(vec3(rest * 2.0 + vec2(18.4924, 72.9744), t * 0.5));
+ s += pow((nS + 1.5) * 0.5, 2.0) * 0.6;
+
+ float n1 = snoise(vec3(rest * 4.0 + vec2(88.494, 32.4397), t * 0.35));
+ float n2 = snoise(vec3(rest * 4.0 + vec2(50.904, 120.947), t * 0.35));
+ float n3 = snoise(vec3(rest * 20.0 + vec2(18.4924, 72.9744), t * 0.5));
+ float n4 = snoise(vec3(rest * 20.0 + vec2(50.904, 120.947), t * 0.5));
+ vec2 disp = vec2(n1, n2) * 0.03 + vec2(n3, n4) * 0.005;
+ float reach = clamp(dist, 0.0, 1.0);
+ disp.x += sin(rest.x * 20.0 + t * 4.0) * 0.02 * reach;
+ disp.y += cos(rest.y * 20.0 + t * 3.0) * 0.02 * reach;
+
+ vec2 cur = rest + disp;
+ /* the rim is pushed off the ring */
+ p = cur - (ring - cur) * pow(max(t2, 0.0), 0.75) * u_ringDisp * 0.3;
+ scale = (s + 0.2) * zoneFade(p, 0.25);
+
+ float nA = snoise(vec3(rest * 10.0 + vec2(18.4924, 72.9744), u_time * 0.85));
+ float ang = atan(p.y - ring.y, p.x - ring.x) + nA * 0.5;
+ v_dir = vec2(cos(ang), sin(ang));
+ v_col = (snoise(vec3(rest * 2.0 + vec2(74.664, 91.556), u_time * 0.5)) + 1.0) * 0.5;
+ } else {
+ vec2 iconLocal = mix(a_from, a_to, smoothstep(0.0, 1.0, u_morph));
+
+ /* The icon as a volume, not a stencil. Each dot has its own depth
+ through the figure (a stable hash of its seed), and the figure
+ turns a little: a slow idle sway, plus a lean toward the pointer
+ so it follows the hand the way the cloud does. With depth, the
+ turn makes the front of the figure travel further than the back
+ and the dots nearest the eye draw larger and brighter (below), so
+ a gathered shelf icon reads as a small object made of dots
+ rather than a flat outline — the same outline, with a thickness.
+ The rotation is small enough that the glyph never stops reading
+ as itself. */
+ float depth = fract(a_seed.y * 13.37 + a_seed.z * 0.71) - 0.5;
+ vec2 lean = clamp((u_ringLag - u_icon.xy) / max(u_icon.z * 3.0, 0.001), -1.0, 1.0);
+ float ay = sin(u_time * 0.55) * 0.28 + lean.x * 0.3;
+ float ax = cos(u_time * 0.41) * 0.18 - lean.y * 0.22;
+ vec3 q = vec3(iconLocal, depth * 0.34);
+ q = vec3(q.x * cos(ay) + q.z * sin(ay), q.y, -q.x * sin(ay) + q.z * cos(ay));
+ q = vec3(q.x, q.y * cos(ax) - q.z * sin(ax), q.y * sin(ax) + q.z * cos(ax));
+ /* a touch of perspective: what is nearer is a little bigger */
+ float nearness = q.z / 0.34;
+ vec2 iconPos = u_icon.xy + q.xy * (1.0 + nearness * 0.09) * u_icon.zw;
+ /* Where a dot sets out from: a soft disc round the icon (the square
+ sample, folded to polar), so nothing square is ever drawn. */
+ float ang0 = atan(a_rest.y, a_rest.x);
+ float rad0 = max(abs(a_rest.x), abs(a_rest.y)) * 2.0;
+ vec2 restDisc = u_icon.xy + vec2(cos(ang0), sin(ang0)) * sqrt(rad0) * u_icon.z * 0.95;
+ /* or, on the plate, spread over the whole surface: the plate's own
+ dots are the ones that gather */
+ vec2 restFill = a_rest * 2.0 * u_extent * 0.94;
+ vec2 restW = mix(restDisc, restFill, u_fill);
+ restW += 0.02 * vec2(
+ snoise(vec3(a_rest * 3.0, t * 0.2)),
+ snoise(vec3(a_rest * 3.0 + 31.7, t * 0.2)));
+
+ /* arrival, staggered per dot and eased like power3.out */
+ float h = clamp((u_hover - a_seed.x * 0.35) / 0.65, 0.0, 1.0);
+ h = 1.0 - pow(1.0 - h, 3.0);
+ h *= u_iconReady;
+ /* On the plate a share of the dots stays behind, so the card keeps
+ its texture round the icon instead of emptying out. */
+ h *= mix(1.0, step(0.3, a_seed.w), u_fill);
+
+ /* twinkle: a life cycle from the seed, invisible at its wrap */
+ float life = fract(u_time / a_seed.z + a_seed.y);
+ float env = smoothstep(0.0, 0.2, life) * (1.0 - smoothstep(0.65, 1.0, life));
+
+ p = mix(restW, iconPos, h);
+ float dist = distance(p, iconPos) / max(u_icon.z, 0.001);
+ near = h * smoothstep(0.08, 0.0, dist);
+
+ /* a ring expanding from the icon's centre after a change, nudging
+ the dots outward as it passes */
+ if (u_pulse >= 0.0) {
+ vec2 fromC = p - u_icon.xy;
+ float ringD = abs(length(fromC) - u_pulse * u_icon.z * 0.9);
+ float k = ringD / max(u_icon.z, 0.001);
+ p += normalize(fromC + 0.0001) * 0.025 * u_icon.z * exp(-k * k * 60.0) * (1.0 - u_pulse);
+ }
+
+ /* Unseen at rest: the icon's dots only exist while a shelf is
+ hovered, appearing as they set out and fading as they return. */
+ float shown = mix(smoothstep(0.0, 0.3, h), 1.0, u_fill);
+ scale = (env * (0.45 + 0.55 * a_seed.w) * 0.7 * (1.0 - h) + 1.6 * near + 0.3 * env * h) * shown;
+ /* the front of the figure draws a size up, the back a size down;
+ only once the dot has arrived, so the journey is unchanged */
+ scale *= 1.0 + nearness * 0.22 * near;
+ v_col = near;
+ /* the depth rides out on v_dir, which the icon layer never reads
+ for direction: x is nearness in -1..1 */
+ v_dir = vec2(nearness, 0.0);
+ }
+
+ v_near = near;
+ v_scale = scale;
+ if (scale < 0.1) {
+ gl_Position = vec4(2.0, 2.0, 2.0, 1.0);
+ gl_PointSize = 1.0;
+ return;
+ }
+ /* The light-ground minimum is for the page cloud; the plate's dots
+ are already drawn a size up, and doubling it made them chunky. */
+ float px = scale * 7.0 * u_pr * 0.5 * u_pscale + u_light * 1.1 * u_pr * (1.0 - u_layer);
+ /* The plate's dots are its whole surface, not a wash behind a
+ page, so they are drawn a size up. */
+ if (u_layer > 0.5) px = (px * 1.25 + 0.6 * u_pr) * (1.0 + 0.7 * u_fill);
+ gl_PointSize = min(u_maxPoint, px);
+ gl_Position = vec4(p.x / u_extent.x, p.y / u_extent.y, 0.0, 1.0);
+}
+`;
+
+const FRAG = `
+precision highp float;
+uniform float u_layer;
+uniform float u_inkAlpha;
+uniform float u_iconAlpha;
+uniform vec3 u_c1;
+uniform vec3 u_c2;
+uniform vec3 u_c3;
+uniform vec3 u_ink;
+uniform vec3 u_accent;
+uniform float u_light;
+
+varying float v_scale;
+varying float v_near;
+varying float v_col;
+varying vec2 v_dir;
+
+float sdRoundBox(vec2 p, vec2 b, float r) {
+ vec2 q = abs(p) - b + r;
+ return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
+}
+
+void main() {
+ vec2 uv = gl_PointCoord - 0.5;
+ uv.y = -uv.y;
+ float a;
+ vec3 c;
+ if (u_layer < 0.5) {
+ /* a short dash, turned to face the ring */
+ vec2 q = vec2(uv.x * v_dir.x + uv.y * v_dir.y, -uv.x * v_dir.y + uv.y * v_dir.x);
+ float sd = sdRoundBox(q, vec2(0.5, mix(0.2, 0.3, u_light)), 0.25);
+ float shape = smoothstep(0.1, 0.0, sd);
+ a = shape * smoothstep(0.1, 0.2, v_scale) * u_inkAlpha;
+ float h = 0.8;
+ c = v_col < h ? mix(u_c1, u_c2, v_col / h) : mix(u_c2, u_c3, (v_col - h) / (1.0 - h));
+ } else {
+ float disc = smoothstep(0.5, 0.4, length(uv));
+ a = disc * smoothstep(0.1, 0.2, v_scale) * mix(u_inkAlpha, u_iconAlpha, v_near);
+ /* the back of the figure sits a little further into the ground
+ than the front: the depth cue the size change alone lacks */
+ a *= 1.0 - 0.28 * v_near * clamp(-v_dir.x, 0.0, 1.0);
+ c = mix(u_ink, u_accent, smoothstep(0.05, 0.6, v_near));
+ }
+ if (a < 0.01) discard;
+ gl_FragColor = vec4(c * a, a);
+}
+`;
+
+function compile(gl: WebGLRenderingContext, type: number, src: string) {
+ const sh = gl.createShader(type);
+ if (!sh) return null;
+ gl.shaderSource(sh, src);
+ gl.compileShader(sh);
+ if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
+ if (import.meta.env.DEV) console.error("particle-field shader:", gl.getShaderInfoLog(sh));
+ gl.deleteShader(sh);
+ return null;
+ }
+ return sh;
+}
+
+/* ── Tweens, the GSAP way: retarget from wherever the value is now ── */
+const easeOutCubic = (x: number) => 1 - Math.pow(1 - x, 3);
+
+class Tween {
+ value: number;
+ private from: number;
+ private to: number;
+ private t0 = 0;
+ private dur = 1;
+ constructor(v: number) {
+ this.value = v;
+ this.from = v;
+ this.to = v;
+ }
+ go(to: number, dur: number, now: number) {
+ if (to === this.to && dur === this.dur) return;
+ this.from = this.value;
+ this.to = to;
+ this.dur = Math.max(1, dur);
+ this.t0 = now;
+ }
+ set(v: number) {
+ this.value = this.from = this.to = v;
+ }
+ tick(now: number) {
+ const x = Math.min(1, (now - this.t0) / this.dur);
+ this.value = this.from + (this.to - this.from) * easeOutCubic(x);
+ return this.value;
+ }
+}
+
+/**
+ * Pixels per field unit. The hero is a unit per half its height; a
+ * shorter band keeps roughly the hero's scale instead of squeezing the
+ * same cloud into less height, so dot spacing, the ring and the drift
+ * read the same on every page.
+ */
+function unitPx(w: number, h: number) {
+ return Math.max(h / 2, w * 0.26, 1);
+}
+
+/** The phase the still frame is drawn at. */
+const HELD_TIME = 31.0;
+/** How long the pointer may rest before the ring starts to wander. */
+const IDLE_MS = 2800;
+
+
+export function mountParticleField(cv: HTMLCanvasElement, options: FieldOptions = {}): () => void {
+ const fam0 = options.tone === "plate" ? options.plateFamily ?? "night" : options.pageFamily ?? "dawn";
+ const opts = {
+ pageFamily: "dawn",
+ plateFamily: "night",
+ accentVar: (a: string) => `--color-${fam0}-${a}`,
+ ...options,
+ };
+ const tone = opts.tone ?? "page";
+ const withIcons = Boolean(opts.icons && opts.icons.length);
+ const withCloud = opts.cloud ?? true;
+ const restFill = opts.restFill ?? false;
+ const tour = opts.tour ?? false;
+ const heroShelf = opts.shelf ?? NO_SHELF;
+ const live =
+ opts.live ??
+ (!window.matchMedia("(prefers-reduced-motion: reduce)").matches &&
+ document.documentElement.dataset.motion !== "reduce");
+ const coarse = window.matchMedia("(pointer: coarse)").matches;
+ const setFailed = (_f: boolean) => opts.onFail?.();
+
+ const attrs: WebGLContextAttributes = {
+ alpha: true,
+ premultipliedAlpha: true,
+ antialias: false,
+ depth: false,
+ stencil: false,
+ powerPreference: "low-power",
+ };
+ const gl =
+ cv.getContext("webgl", attrs) ||
+ (cv.getContext("experimental-webgl", attrs) as WebGLRenderingContext | null);
+ if (!gl) {
+ if (import.meta.env.DEV) console.error("particle-field: no WebGL context");
+ setFailed(true);
+ return () => {};
+ }
+ const vs = compile(gl, gl.VERTEX_SHADER, VERT);
+ const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
+ const prog = gl.createProgram();
+ if (!vs || !fs || !prog) {
+ setFailed(true);
+ return () => {};
+ }
+ gl.attachShader(prog, vs);
+ gl.attachShader(prog, fs);
+ gl.linkProgram(prog);
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
+ if (import.meta.env.DEV) console.error("particle-field link:", gl.getProgramInfoLog(prog));
+ setFailed(true);
+ return () => {};
+ }
+ gl.useProgram(prog);
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
+ gl.clearColor(0, 0, 0, 0);
+
+ /* ── Geometry ── */
+ /* Dots per square field unit: the hero's 8,000 over its 8.8. */
+ const perUnit = coarse ? 400 : 900;
+ const morphCount = coarse ? 1600 : 2400;
+ /* Half-extents of the cloud in field units, regrown when the canvas
+ outgrows them. */
+ const domain = { w: 0, h: 0 };
+ const hash = rng(7);
+
+ const seeds = (n: number) => {
+ const s = new Float32Array(n * 4);
+ for (let i = 0; i < n; i++) {
+ s[i * 4] = hash(); // arrival delay
+ s[i * 4 + 1] = hash(); // life phase
+ s[i * 4 + 2] = 2 + hash() * 2; // life length, seconds
+ s[i * 4 + 3] = hash(); // general purpose
+ }
+ return s;
+ };
+
+ const buf = {
+ cloudRest: gl.createBuffer(),
+ cloudSeed: gl.createBuffer(),
+ morphRest: gl.createBuffer(),
+ morphSeed: gl.createBuffer(),
+ morphFrom: gl.createBuffer(),
+ morphTo: gl.createBuffer(),
+ };
+ let cloudN = 0;
+ const buildCloud = () => {
+ const area = domain.w * 2 * domain.h * 2;
+ const count = Math.min(16000, Math.round(perUnit * area));
+ const pts = poisson({
+ halfW: domain.w,
+ halfH: domain.h,
+ r: minDistFor(count, domain.w * 2, domain.h * 2),
+ seed: 11,
+ });
+ cloudN = pts.length / 2;
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf.cloudRest);
+ gl.bufferData(gl.ARRAY_BUFFER, pts, gl.STATIC_DRAW);
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf.cloudSeed);
+ gl.bufferData(gl.ARRAY_BUFFER, seeds(cloudN), gl.STATIC_DRAW);
+ };
+
+ const morphRest = poisson({ halfW: 0.5, halfH: 0.5, r: minDistFor(morphCount, 1, 1), seed: 23 });
+ const morphN = morphRest.length / 2;
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf.morphRest);
+ gl.bufferData(gl.ARRAY_BUFFER, morphRest, gl.STATIC_DRAW);
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf.morphSeed);
+ gl.bufferData(gl.ARRAY_BUFFER, seeds(morphN), gl.STATIC_DRAW);
+ const fromArr = new Float32Array(morphRest);
+ const toArr = new Float32Array(morphRest);
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf.morphFrom);
+ gl.bufferData(gl.ARRAY_BUFFER, fromArr, gl.DYNAMIC_DRAW);
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf.morphTo);
+ gl.bufferData(gl.ARRAY_BUFFER, toArr, gl.DYNAMIC_DRAW);
+
+ const loc = {
+ rest: gl.getAttribLocation(prog, "a_rest"),
+ seed: gl.getAttribLocation(prog, "a_seed"),
+ from: gl.getAttribLocation(prog, "a_from"),
+ to: gl.getAttribLocation(prog, "a_to"),
+ };
+ gl.enableVertexAttribArray(loc.rest);
+ gl.enableVertexAttribArray(loc.seed);
+ gl.enableVertexAttribArray(loc.from);
+ gl.enableVertexAttribArray(loc.to);
+
+ const bindLayer = (layer: 0 | 1) => {
+ gl.bindBuffer(gl.ARRAY_BUFFER, layer ? buf.morphRest : buf.cloudRest);
+ gl.vertexAttribPointer(loc.rest, 2, gl.FLOAT, false, 0, 0);
+ gl.bindBuffer(gl.ARRAY_BUFFER, layer ? buf.morphSeed : buf.cloudSeed);
+ gl.vertexAttribPointer(loc.seed, 4, gl.FLOAT, false, 0, 0);
+ /* the cloud never reads these; point them at something valid */
+ gl.bindBuffer(gl.ARRAY_BUFFER, layer ? buf.morphFrom : buf.cloudRest);
+ gl.vertexAttribPointer(loc.from, 2, gl.FLOAT, false, 0, 0);
+ gl.bindBuffer(gl.ARRAY_BUFFER, layer ? buf.morphTo : buf.cloudRest);
+ gl.vertexAttribPointer(loc.to, 2, gl.FLOAT, false, 0, 0);
+ };
+
+ /* ── Uniforms ── */
+ const u = (name: string) => gl.getUniformLocation(prog, name);
+ const U = {
+ time: u("u_time"),
+ extent: u("u_extent"),
+ pr: u("u_pr"),
+ pscale: u("u_pscale"),
+ maxPoint: u("u_maxPoint"),
+ layer: u("u_layer"),
+ hover: u("u_hover"),
+ morph: u("u_morph"),
+ pulse: u("u_pulse"),
+ iconReady: u("u_iconReady"),
+ fill: u("u_fill"),
+ light: u("u_light"),
+ ringR: u("u_ringR"),
+ ringW: u("u_ringW"),
+ ringW2: u("u_ringW2"),
+ ringDisp: u("u_ringDisp"),
+ ring: u("u_ring"),
+ ringLag: u("u_ringLag"),
+ zone: u("u_zone"),
+ icon: u("u_icon"),
+ inkAlpha: u("u_inkAlpha"),
+ iconAlpha: u("u_iconAlpha"),
+ c1: u("u_c1"),
+ c2: u("u_c2"),
+ c3: u("u_c3"),
+ ink: u("u_ink"),
+ accent: u("u_accent"),
+ };
+ const range = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) as Float32Array;
+ gl.uniform1f(U.maxPoint, range ? range[1] : 64);
+ gl.uniform1f(U.ringW, 0.03);
+ gl.uniform1f(U.ringW2, 0.1);
+ gl.uniform1f(U.ringDisp, 0.5);
+ gl.uniform1f(U.inkAlpha, 0.7);
+ gl.uniform1f(U.iconAlpha, 0.6);
+ gl.uniform4f(U.zone, 0, 0, -1, -1);
+ gl.uniform4f(U.icon, 0, 0, 0.5, 0.5);
+ gl.uniform1f(U.iconReady, 0);
+ gl.uniform1f(U.fill, restFill ? 1 : 0);
+
+ /* ── Colour ── */
+ const INK: Rgb = [0.34, 0.32, 0.47];
+ let accentName: Accent | null = null;
+ const fam = tone === "plate" ? opts.plateFamily : opts.pageFamily;
+ /* The reading weights, not the -soft washes: the pastels sank into
+ the cream ground and read as white specks. These tokens invert
+ with the mode, so the dots stay dark on light and light on dark. */
+ const pushPalette = () => {
+ /* Is the ground behind this field light? The page's dawn base, or
+ the plate's night base, after the mode has inverted them. */
+ const V = opts.vars ?? {
+ ink: tone === "plate" ? `--color-${fam}-text` : `--color-${fam}-ink`,
+ base: `--color-${fam}-base`,
+ iris: `--color-${fam}-iris`,
+ love: `--color-${fam}-love`,
+ };
+ const [br, bg, bb] = readVar(cv, V.base, [0.98, 0.96, 0.93]);
+ const light = 0.2126 * br + 0.7152 * bg + 0.0722 * bb > 0.5;
+ gl.uniform1f(U.light, light ? 1 : 0);
+ gl.uniform1f(U.inkAlpha, light ? 0.9 : 0.7);
+ const ink = readVar(cv, V.ink, INK);
+ gl.uniform3fv(U.ink, ink);
+ gl.uniform3fv(U.c1, ink);
+ gl.uniform3fv(
+ U.c2,
+ readVar(cv, V.iris, [0.47, 0.35, 0.64]),
+ );
+ gl.uniform3fv(
+ U.c3,
+ readVar(cv, V.love, [0.69, 0.25, 0.42]),
+ );
+ gl.uniform3fv(
+ U.accent,
+ accentName ? readVar(cv, opts.accentVar(accentName), ink) : ink,
+ );
+ };
+ pushPalette();
+
+ /* ── State the frame reads ── */
+ const box = { left: 0, pageTop: 0, width: 1, height: 1 };
+ let unit = unitPx(cv.clientWidth, cv.clientHeight);
+ const pointer = { x: 0, y: 0, inside: false };
+ const ring = { x: 0.3, y: 0.1 };
+ const ringLag = { x: 0.3, y: 0.1 };
+ const hover = new Tween(0);
+ const morph = new Tween(1);
+ let pulseT0 = -Infinity;
+ let lastMove = -Infinity;
+ let raf = 0;
+ let running = false;
+ let visible = !document.hidden;
+ let inView = true;
+ let lost = false;
+ let lastNow = 0;
+ const start = performance.now();
+
+ const measureBox = () => {
+ const r = cv.getBoundingClientRect();
+ box.left = r.left;
+ box.pageTop = r.top + window.scrollY;
+ box.width = r.width || 1;
+ box.height = r.height || 1;
+ };
+
+ const size = () => {
+ const pr = Math.min(2, window.devicePixelRatio || 1);
+ const w = Math.max(1, Math.round(cv.clientWidth * pr));
+ const h = Math.max(1, Math.round(cv.clientHeight * pr));
+ if (cv.width !== w || cv.height !== h) {
+ cv.width = w;
+ cv.height = h;
+ }
+ gl.viewport(0, 0, w, h);
+ unit = unitPx(cv.clientWidth, cv.clientHeight);
+ const ex = cv.clientWidth / 2 / unit;
+ const ey = cv.clientHeight / 2 / unit;
+ gl.uniform2f(U.extent, ex, ey);
+ gl.uniform1f(U.pr, pr);
+ gl.uniform1f(U.pscale, (cv.clientWidth / 2000) * 0.9);
+ if (ex + 0.1 > domain.w || ey + 0.1 > domain.h) {
+ domain.w = Math.max(domain.w, ex + 0.2);
+ domain.h = Math.max(domain.h, ey + 0.15);
+ buildCloud();
+ }
+ };
+
+ /* ── The shelf list (keep-out for the cloud) and the icon's home ──
+ Rects are in page pixels; the canvas may sit inside a scaled
+ composition (the hero scales its 1920×1080 artboard), so a page
+ pixel is `scale` canvas pixels. */
+ let zoneEl: HTMLElement | null = null;
+ let zoneRo: ResizeObserver | null = null;
+ let iconEl: HTMLElement | null = null;
+ const toField = (px: number, py: number, c: DOMRect) => {
+ const k = unit * (c.width / Math.max(1, cv.clientWidth));
+ return [(px - c.left - c.width / 2) / k, (c.height / 2 - (py - c.top)) / k];
+ };
+ const pageUnit = (c: DOMRect) => unit * (c.width / Math.max(1, cv.clientWidth));
+ /* The hero swaps between two layouts at 1280px and rebuilds the list
+ when it does, so a held reference goes stale: re-find it whenever
+ the one we have has left the document. */
+ const adoptZone = (el: HTMLElement) => {
+ if (zoneEl === el) return;
+ zoneRo?.disconnect();
+ zoneEl = el;
+ zoneRo = new ResizeObserver(() => measureZone());
+ zoneRo.observe(el);
+ };
+ /* Where the icon forms: a `[data-field-icon]` box inside the field's
+ own host if there is one (the plate marks its open upper half),
+ otherwise centred on the shelf list. */
+ const findIcon = () => {
+ const host = cv.closest("[data-field-host]");
+ iconEl = host?.querySelector<HTMLElement>("[data-field-icon]") ?? null;
+ };
+ const measureZone = () => {
+ if (!zoneEl || !zoneEl.isConnected) {
+ const el = document.querySelector<HTMLElement>("[data-index-zone]");
+ if (el) adoptZone(el);
+ }
+ if (withIcons && (!iconEl || !iconEl.isConnected)) findIcon();
+ const c = cv.getBoundingClientRect();
+ if (withCloud && zoneEl && zoneEl.isConnected) {
+ const z = zoneEl.getBoundingClientRect();
+ const [x0, y0] = toField(z.left, z.bottom, c);
+ const [x1, y1] = toField(z.right, z.top, c);
+ gl.uniform4f(U.zone, x0, y0, x1, y1);
+ } else {
+ gl.uniform4f(U.zone, 0, 0, -1, -1);
+ }
+ if (!withIcons) return;
+ const home = iconEl && iconEl.isConnected ? iconEl : zoneEl && zoneEl.isConnected ? zoneEl : null;
+ if (!home) {
+ gl.uniform4f(U.icon, 0, 0, 0.5, 0.5);
+ return;
+ }
+ const z = home.getBoundingClientRect();
+ const k = c.width / Math.max(1, cv.clientWidth);
+ const sizePx = Math.min(440 * k, Math.max(96 * k, 0.9 * Math.min(z.width, z.height)));
+ const [cx, cy] = toField(z.left + z.width / 2, z.top + z.height / 2, c);
+ const s = sizePx / pageUnit(c);
+ gl.uniform4f(U.icon, cx, cy, s, s);
+ };
+ let zoneTimer = 0;
+ const findZone = (attempt = 0) => {
+ const el = document.querySelector<HTMLElement>("[data-index-zone]");
+ if (el) {
+ adoptZone(el);
+ measureZone();
+ return;
+ }
+ if (attempt < 8) zoneTimer = window.setTimeout(() => findZone(attempt + 1), 250);
+ };
+
+ /* ── Icons ── */
+ let targets: Float32Array[] | null = null;
+ let curIndex: number | null = null;
+ let pending: LitShelf = heroShelf.get();
+ let cancelled = false;
+ const scratch = document.createElement("canvas");
+ const jitter = rng(99);
+ const jitters = new Float32Array(morphN * 2);
+ for (let i = 0; i < jitters.length; i++) jitters[i] = jitter();
+
+ /* The icon answers the hand only: a shelf the loop lights on its own
+ tour leaves the cloud as it is, so the gather always means "you
+ are pointing at this". */
+ const applyShelf = (s: LitShelf, now: number, snap = false) => {
+ pending = s;
+ /* One layout read per hover change: cheap, and it means the icon
+ always forms on the list as it is laid out right now. */
+ const wanted = s.index !== null && (s.held || tour);
+ if (wanted) measureZone();
+ if (!wanted || s.index === null) {
+ hover.go(0, 800, now);
+ return;
+ }
+ hover.go(1, 500, now);
+ if (!targets || s.index === curIndex) return;
+ /* continue from wherever the last gather got to */
+ const m = easeOutCubic(Math.min(1, morph.value));
+ for (let i = 0; i < fromArr.length; i++) fromArr[i] = fromArr[i] + (toArr[i] - fromArr[i]) * m;
+ toArr.set(targets[s.index]);
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf.morphFrom);
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, fromArr);
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf.morphTo);
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, toArr);
+ curIndex = s.index;
+ if (snap) {
+ morph.set(1);
+ } else {
+ morph.set(0);
+ morph.go(1, 600, now);
+ pulseT0 = now;
+ }
+ if (s.accent !== accentName) {
+ accentName = s.accent;
+ pushPalette();
+ }
+ };
+
+ /* ── Frames ── */
+ const drawFrame = (t: number, now: number) => {
+ if (lost) return;
+ gl.uniform1f(U.time, t);
+ gl.uniform1f(U.ringR, 0.175 + Math.sin(t) * 0.03 + Math.cos(t * 3) * 0.02);
+ gl.uniform2f(U.ring, ring.x, ring.y);
+ gl.uniform2f(U.ringLag, ringLag.x, ringLag.y);
+ gl.uniform1f(U.hover, hover.value);
+ gl.uniform1f(U.morph, morph.value);
+ const pulse = (now - pulseT0) / 2000;
+ gl.uniform1f(U.pulse, pulse >= 0 && pulse < 1 ? pulse : -1);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ gl.uniform1f(U.layer, 0);
+ if (withCloud) {
+ bindLayer(0);
+ gl.drawArrays(gl.POINTS, 0, cloudN);
+ }
+ if (withIcons) {
+ gl.uniform1f(U.layer, 1);
+ bindLayer(1);
+ gl.drawArrays(gl.POINTS, 0, morphN);
+ }
+ };
+
+ const still = () => {
+ size();
+ ring.x = ringLag.x = 0.3;
+ ring.y = ringLag.y = 0.1;
+ /* A held row still gets its icon with motion off; otherwise the
+ frame is the plain cloud. */
+ const s = withIcons ? heroShelf.get() : { index: null, accent: null, held: false };
+ if (targets) applyShelf(s, performance.now(), true);
+ hover.set((s.held || tour) && s.index !== null ? 1 : 0);
+ morph.set(1);
+ drawFrame(HELD_TIME, -Infinity);
+ cv.classList.add("is-live");
+ };
+
+ const tick = (now: number) => {
+ raf = requestAnimationFrame(tick);
+ const t = (now - start) / 1000;
+ const dt = Math.min(0.05, (now - lastNow) / 1000 || 0.016);
+ lastNow = now;
+ /* The ring drifts on its own, and only partly toward the hand:
+ a 2%/frame lerp toward the pointer is what reads as weight. */
+ const wx = 0.35 * Math.sin(t * 0.19) + 0.12 * Math.sin(t * 0.47);
+ const wy = 0.3 * Math.cos(t * 0.14) + 0.1 * Math.cos(t * 0.37);
+ const follow = pointer.inside && now - lastMove < IDLE_MS;
+ const tx = follow ? pointer.x + wx * 0.1 : wx * 0.6;
+ const ty = follow ? pointer.y + wy * 0.1 : wy * 0.6;
+ const k = 1 - Math.pow(1 - (follow ? 0.02 : 0.01), dt * 60);
+ ring.x += (tx - ring.x) * k;
+ ring.y += (ty - ring.y) * k;
+ const k2 = 1 - Math.pow(1 - 0.06, dt * 60);
+ ringLag.x += (ring.x - ringLag.x) * k2;
+ ringLag.y += (ring.y - ringLag.y) * k2;
+ hover.tick(now);
+ morph.tick(now);
+ drawFrame(t, now);
+ cv.classList.add("is-live");
+ };
+
+ const sync = () => {
+ const want = live && visible && inView;
+ if (want && !running) {
+ running = true;
+ size();
+ lastNow = performance.now();
+ raf = requestAnimationFrame(tick);
+ } else if (!want && running) {
+ running = false;
+ cancelAnimationFrame(raf);
+ }
+ if (!live) still();
+ };
+
+ const onMove = (e: PointerEvent) => {
+ if (e.pointerType === "touch") return;
+ const top = box.pageTop - window.scrollY;
+ const nx = ((e.clientX - box.left) / box.width) * 2 - 1;
+ const ny = -(((e.clientY - top) / box.height) * 2 - 1);
+ if (nx < -1.05 || nx > 1.05 || ny < -1.05 || ny > 1.05) {
+ pointer.inside = false;
+ return;
+ }
+ pointer.inside = true;
+ pointer.x = (nx * box.width) / 2 / unit;
+ pointer.y = (ny * box.height) / 2 / unit;
+ lastMove = e.timeStamp;
+ };
+ const onVis = () => {
+ visible = !document.hidden;
+ sync();
+ };
+ const onMode = () => {
+ pushPalette();
+ if (!running) still();
+ };
+ const onLost = (e: Event) => {
+ e.preventDefault();
+ lost = true;
+ cancelAnimationFrame(raf);
+ setFailed(true);
+ };
+ const unsubscribe = withIcons
+ ? heroShelf.subscribe((s) => {
+ if (live) applyShelf(s, performance.now());
+ else still();
+ })
+ : () => {};
+
+ let settleRaf = 0;
+ let settleTimer = 0;
+ const measureSettled = () => {
+ cancelAnimationFrame(settleRaf);
+ clearTimeout(settleTimer);
+ settleRaf = requestAnimationFrame(() => {
+ settleRaf = requestAnimationFrame(() => {
+ measureBox();
+ measureZone();
+ if (!running) still();
+ });
+ });
+ /* and once more after the hero's own observer has re-rendered */
+ settleTimer = window.setTimeout(() => {
+ measureBox();
+ measureZone();
+ if (!running) still();
+ }, 160);
+ };
+ const ro = new ResizeObserver(() => {
+ measureBox();
+ size();
+ measureZone();
+ measureSettled();
+ if (!running) still();
+ });
+ ro.observe(cv);
+ const io =
+ typeof IntersectionObserver !== "undefined"
+ ? new IntersectionObserver(([e]) => {
+ inView = e.isIntersecting;
+ sync();
+ })
+ : null;
+ io?.observe(cv);
+
+ if (live) window.addEventListener("pointermove", onMove, { passive: true });
+ window.addEventListener("resize", measureSettled);
+ document.addEventListener("visibilitychange", onVis);
+ window.addEventListener("daemonmodechange", onMode);
+ const modeMo = new MutationObserver(onMode);
+ modeMo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-mode", "data-theme", "style"] });
+ cv.addEventListener("webglcontextlost", onLost);
+
+ /* Prepare the icons; the morph layer twinkles at rest until they land. */
+ (async () => {
+ if (!withIcons) return;
+ const svgs = opts.icons ?? [];
+ const out: Float32Array[] = [];
+ for (const svg of svgs) {
+ const ink = await rasterise(svg, scratch);
+ if (cancelled) return;
+ out.push(targetsFor(morphRest, nearestInked(ink), (i) => jitters[i]));
+ }
+ if (cancelled) return;
+ targets = out;
+ gl.uniform1f(U.iconReady, 1);
+ applyShelf(pending, performance.now(), true);
+ if (!running) still();
+ })().catch(() => {
+ /* No icon: the morph layer just twinkles. */
+ });
+
+ measureBox();
+ findZone();
+ if (withIcons) {
+ if (live) applyShelf(heroShelf.get(), performance.now(), true);
+ }
+ sync();
+
+ if (import.meta.env.DEV) {
+ (window as unknown as Record<string, unknown>)[withIcons ? "__dmnField" : "__dmnCloud"] = {
+ get hover() {
+ return hover.value;
+ },
+ get morph() {
+ return morph.value;
+ },
+ get index() {
+ return curIndex;
+ },
+ get ready() {
+ return targets !== null;
+ },
+ get counts() {
+ return { cloud: cloudN, morph: morphN };
+ },
+ get ring() {
+ return { ...ring };
+ },
+ get iconPx() {
+ /* the icon's centre and size in CSS px, page-relative to the canvas */
+ const v = gl.getUniform(prog, U.icon as WebGLUniformLocation) as Float32Array;
+ const c = cv.getBoundingClientRect();
+ const k = unit * (c.width / Math.max(1, cv.clientWidth));
+ return { x: c.left + c.width / 2 + v[0] * k, y: c.top + c.height / 2 - v[1] * k, size: v[2] * k };
+ },
+ get pointer() {
+ return { ...pointer, box: { ...box }, lastMove };
+ },
+ set(index: number | null, accent: Accent | null) {
+ applyShelf({ index, accent, held: true }, performance.now());
+ },
+ };
+ }
+
+ return () => {
+ cancelled = true;
+ running = false;
+ cancelAnimationFrame(raf);
+ clearTimeout(zoneTimer);
+ unsubscribe();
+ ro.disconnect();
+ io?.disconnect();
+ zoneRo?.disconnect();
+ window.removeEventListener("pointermove", onMove);
+ window.removeEventListener("resize", measureSettled);
+ cancelAnimationFrame(settleRaf);
+ clearTimeout(settleTimer);
+ document.removeEventListener("visibilitychange", onVis);
+ window.removeEventListener("daemonmodechange", onMode);
+ modeMo.disconnect();
+ cv.removeEventListener("webglcontextlost", onLost);
+ Object.values(buf).forEach((b) => b && gl.deleteBuffer(b));
+ gl.deleteProgram(prog);
+ gl.deleteShader(vs);
+ gl.deleteShader(fs);
+ };
+}
diff --git a/src/scripts/dmn/poisson.ts b/src/scripts/dmn/poisson.ts
@@ -0,0 +1,110 @@
+/**
+ * Poisson-disc sampling (Bridson), seeded.
+ *
+ * Blue-noise points: no two closer than `r`, none further than about
+ * `2r` from a neighbour, so the field reads as a cloud rather than as a
+ * grid or as static. Seeded so a given hero looks the same on every
+ * visit and every mount; the seed also keys the per-point randoms the
+ * shader uses for twinkle and arrival order.
+ */
+
+/** mulberry32 — small, fast, good enough for layout. */
+export function rng(seed: number) {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+export interface PoissonOptions {
+ /** Half-extents of the domain, centred on the origin. */
+ halfW: number;
+ halfH: number;
+ /** Minimum spacing between points. */
+ r: number;
+ seed?: number;
+ /** Candidates tried around each active point before it retires. */
+ k?: number;
+}
+
+/**
+ * Points as a flat `[x0, y0, x1, y1, …]` array in the domain's units.
+ * Count is roughly `0.7 · area / r²`; `minDistFor` inverts that.
+ */
+export function poisson({ halfW, halfH, r, seed = 1, k = 30 }: PoissonOptions): Float32Array {
+ const rand = rng(seed);
+ const W = halfW * 2;
+ const H = halfH * 2;
+ const cell = r / Math.SQRT2;
+ const gw = Math.ceil(W / cell);
+ const gh = Math.ceil(H / cell);
+ const grid = new Int32Array(gw * gh).fill(-1);
+ const xs: number[] = [];
+ const ys: number[] = [];
+ const active: number[] = [];
+ const r2 = r * r;
+
+ const put = (x: number, y: number) => {
+ const i = xs.length;
+ xs.push(x);
+ ys.push(y);
+ grid[Math.floor((y + halfH) / cell) * gw + Math.floor((x + halfW) / cell)] = i;
+ active.push(i);
+ return i;
+ };
+
+ const fits = (x: number, y: number) => {
+ if (x < -halfW || x >= halfW || y < -halfH || y >= halfH) return false;
+ const gx = Math.floor((x + halfW) / cell);
+ const gy = Math.floor((y + halfH) / cell);
+ for (let j = Math.max(0, gy - 2); j <= Math.min(gh - 1, gy + 2); j++) {
+ for (let i = Math.max(0, gx - 2); i <= Math.min(gw - 1, gx + 2); i++) {
+ const n = grid[j * gw + i];
+ if (n < 0) continue;
+ const dx = xs[n] - x;
+ const dy = ys[n] - y;
+ if (dx * dx + dy * dy < r2) return false;
+ }
+ }
+ return true;
+ };
+
+ put((rand() - 0.5) * W, (rand() - 0.5) * H);
+
+ while (active.length) {
+ const ai = Math.floor(rand() * active.length);
+ const p = active[ai];
+ let placed = false;
+ for (let n = 0; n < k; n++) {
+ const a = rand() * Math.PI * 2;
+ const d = r * (1 + rand());
+ const x = xs[p] + Math.cos(a) * d;
+ const y = ys[p] + Math.sin(a) * d;
+ if (fits(x, y)) {
+ put(x, y);
+ placed = true;
+ break;
+ }
+ }
+ if (!placed) {
+ active[ai] = active[active.length - 1];
+ active.pop();
+ }
+ }
+
+ const out = new Float32Array(xs.length * 2);
+ for (let i = 0; i < xs.length; i++) {
+ out[i * 2] = xs[i];
+ out[i * 2 + 1] = ys[i];
+ }
+ return out;
+}
+
+/** The spacing that lands about `count` points on a `w × h` domain. */
+export function minDistFor(count: number, w: number, h: number) {
+ return Math.sqrt((0.7 * w * h) / count);
+}
diff --git a/src/styles/app.css b/src/styles/app.css
@@ -3,3 +3,4 @@
@import './global.css';
@import './prose.css';
@import './daemon.css';
+@import './dmn.css';
diff --git a/src/styles/dmn.css b/src/styles/dmn.css
@@ -0,0 +1,98 @@
+/* ═══════════════════════════════════════════════════════
+ * Shared with daemon-sec.xyz: the dot field band and the cursor pill.
+ * Scripts: src/scripts/dmn/. Tokens are this site's mode-flipping set.
+ * ═══════════════════════════════════════════════════════ */
+
+/* The dot field behind the top of the page, on the plain ground. It
+ fades out downward so it never sits behind a long read. Page content
+ is positioned (without a z-index, so no new stacking context) and so
+ paints over it in document order. */
+.dmn-page-field {
+ position: absolute;
+ inset-inline: 0;
+ top: 0;
+ height: 70vh;
+ max-height: 720px;
+ overflow: hidden;
+ pointer-events: none;
+ z-index: 0;
+ /* Thins out early: this site's pages open straight into dense mono
+ text and controls, and the dots must stay behind the words. */
+ -webkit-mask-image: linear-gradient(180deg, #000 0%, rgb(0 0 0 / 0.55) 35%, transparent 85%);
+ mask-image: linear-gradient(180deg, #000 0%, rgb(0 0 0 / 0.55) 35%, transparent 85%);
+}
+.dmn-page-field canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ transition: opacity 2400ms var(--ease-out-soft, ease-out);
+}
+.dmn-page-field canvas.is-live { opacity: 0.6; }
+#main, .dfooter { position: relative; }
+
+/* ── The cursor ──
+ A disc that trails the pointer, a ring over anything clickable, and a
+ pill that names the action over links, images and PDFs. */
+@media (pointer: fine) {
+ html.has-dmn-cursor,
+ html.has-dmn-cursor * { cursor: none; }
+
+ .dmn-cursor {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 99999;
+ width: 9px;
+ height: 9px;
+ border: 1px solid transparent;
+ border-radius: 50%;
+ background: var(--love);
+ pointer-events: none;
+ transform: translate(-50%, -50%);
+ transition: width 180ms ease, height 180ms ease, background-color 180ms ease, border-color 180ms ease, opacity 200ms ease;
+ }
+ .dmn-cursor.on-dark { background: var(--color-night-love); }
+ .dmn-cursor.hot { width: 34px; height: 34px; background: transparent; border-color: var(--love); }
+ .dmn-cursor.hot.on-dark { border-color: var(--color-night-love); }
+
+ .dmn-cursor-pill {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 14px 8px 11px;
+ border: 1px solid color-mix(in srgb, var(--text) 25%, transparent);
+ border-radius: 999px;
+ background: var(--surface);
+ color: var(--text);
+ font: 500 11.5px/1 var(--font-mono);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0);
+ transition: transform 200ms cubic-bezier(0.4, 0, 1, 1), opacity 160ms ease;
+ }
+ .dmn-cursor-icon { display: inline-flex; }
+ .dmn-cursor[data-mode] { background: transparent; border-color: transparent; }
+ .dmn-cursor[data-mode] .dmn-cursor-pill {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ transition: transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 200ms ease;
+ }
+ .dmn-cursor.on-dark .dmn-cursor-pill {
+ border-color: color-mix(in srgb, var(--color-night-text) 28%, transparent);
+ background: var(--color-night-surface);
+ color: var(--color-night-text);
+ }
+}
+@media (pointer: coarse) { .dmn-cursor { display: none; } }
+
+/* The site's own motion switch, for every page: it used to live in the
+ catalog's stylesheet, so it only took effect on /catalog. */
+html[data-motion=reduce] *,
+html[data-motion=reduce] *::before,
+html[data-motion=reduce] *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
diff --git a/src/styles/global.css b/src/styles/global.css
@@ -56,20 +56,20 @@ main, .site-header, .site-footer { position: relative; z-index: 2; }
keeps the entrance in the site's terminal/register language rather than a
generic card float. */
@media (prefers-reduced-motion: no-preference) {
+ /* The daemon-sec entrance: about a second on a curve that spends most
+ of its travel in the last fifth, so a block is seen arriving rather
+ than noticed having arrived. */
.scroll-reveal {
opacity: 0;
- transform: translate3d(-8px, 22px, 0);
- filter: blur(2px);
+ transform: translate3d(0, 28px, 0);
transition:
- opacity 540ms cubic-bezier(0.22, 0.61, 0.36, 1) var(--reveal-delay, 0ms),
- transform 620ms var(--ease-in) var(--reveal-delay, 0ms),
- filter 460ms ease var(--reveal-delay, 0ms);
- will-change: opacity, transform, filter;
+ opacity var(--dur-reveal) var(--ease-out-expo) var(--reveal-delay, 0ms),
+ transform var(--dur-reveal) var(--ease-out-expo) var(--reveal-delay, 0ms);
+ will-change: opacity, transform;
}
.scroll-reveal.is-revealed {
opacity: 1;
transform: translate3d(0, 0, 0);
- filter: blur(0);
will-change: auto;
}
}
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
@@ -257,6 +257,10 @@
/* The site's entrance curve — slower out, hard landing. Distinct from
`--ease`, which is for hover-scale transitions. */
--ease-in: cubic-bezier(0.2, 0.7, 0.2, 1);
+ /* The entrance vocabulary shared with daemon-sec.xyz. */
+ --ease-out-soft: cubic-bezier(0.25, 0.46, 0.45, 0.94);
+ --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
+ --dur-reveal: 1000ms;
}
/* ---- Plate: the DÆMON signature — an inverse (night) panel on the cream