daemon-sec-lotl

DÆMONBins: GTFOBins × LOLBAS × WADComs × LOOBins in one filterable catalog
git clone https://git.daemon-sec.xyz/daemon-sec-lotl.git
Log | Files | Refs | Submodules | README | LICENSE

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:
Dpublic/.nojekyll | 0
Msrc/components/Footer.astro | 4++--
Dsrc/components/Operator.astro | 71-----------------------------------------------------------------------
Dsrc/components/RecordRow.astro | 48------------------------------------------------
Dsrc/components/SectionHeader.astro | 26--------------------------
Msrc/layouts/Base.astro | 2++
Msrc/scripts/app.ts | 21++++++++++++++++-----
Asrc/scripts/dmn/cursor.ts | 256+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/scripts/dmn/icon-targets.ts | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/scripts/dmn/init.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/scripts/dmn/particle-field.ts | 1036+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/scripts/dmn/poisson.ts | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/styles/app.css | 1+
Asrc/styles/dmn.css | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/styles/global.css | 14+++++++-------
Msrc/styles/tokens.css | 4++++
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