2026-08-25 19:26:54 +02:00
|
|
|
// redoal.com's hero: lysbue's "paths" paint worklet, animated - the
|
|
|
|
|
// site this content descends from painted its pages with generative
|
|
|
|
|
// turning-function curves (static/worklet/paint/paths.js); this is
|
|
|
|
|
// that field, alive. No input up here: the form's gesture canvas is
|
|
|
|
|
// the one place a visitor draws.
|
2026-08-25 17:24:14 +02:00
|
|
|
//
|
2026-08-25 19:26:54 +02:00
|
|
|
// Content-owned: portal knows only site.yaml's `hero: {kind: module,
|
|
|
|
|
// module: hero.js}` and the contract - `mount(container) -> handle`
|
|
|
|
|
// with `stop()` - and serves this repo's files same-origin under
|
|
|
|
|
// /site/. Two renderers, one generator (paths.js):
|
|
|
|
|
//
|
|
|
|
|
// - Where the CSS Paint API exists (Chromium), the real thing: the
|
|
|
|
|
// worklet paints the box's background and a CSS animation on the
|
|
|
|
|
// registered --redoal-t property drives repaints - no JS per frame.
|
|
|
|
|
// - Elsewhere (Safari, Firefox, WebKitGTK have no Paint API), a
|
|
|
|
|
// canvas with a requestAnimationFrame loop draws the identical
|
|
|
|
|
// field, colors read from the page's custom properties.
|
|
|
|
|
//
|
|
|
|
|
// prefers-reduced-motion freezes the field fully drawn either way.
|
|
|
|
|
|
2026-08-30 15:17:26 +02:00
|
|
|
import { makePaths, drawPaths, OVERSCAN } from './paths.js';
|
2026-08-25 17:24:14 +02:00
|
|
|
|
|
|
|
|
const STYLE_ID = 'redoal-hero-style';
|
2026-08-25 19:26:54 +02:00
|
|
|
const CYCLE_MS = 28000;
|
|
|
|
|
const SEED = 7;
|
|
|
|
|
const COUNT = 47;
|
2026-08-25 17:24:14 +02:00
|
|
|
|
2026-08-30 15:17:26 +02:00
|
|
|
const BLEED = `${((OVERSCAN - 1) / 2) * -100}%`;
|
|
|
|
|
|
2026-08-25 19:27:37 +02:00
|
|
|
const STYLE_TEXT = `
|
2026-08-30 16:48:33 +02:00
|
|
|
/* The field bleeds 25% past the hero piece on every side. Sideways
|
|
|
|
|
it is clipped at the header box (clip, not hidden: no scroll
|
|
|
|
|
container, and overflow-y stays visible) - an element wider than
|
|
|
|
|
the viewport, even unscrollable, makes phones re-size the layout
|
|
|
|
|
viewport when it appears, which read as the page jumping as the
|
|
|
|
|
input loaded. Vertically it may run into the copy below. */
|
|
|
|
|
.hero-module { overflow-x: clip; overflow-y: visible; }
|
2026-08-25 19:26:54 +02:00
|
|
|
.redoal-paths {
|
2026-08-25 17:24:14 +02:00
|
|
|
position: absolute;
|
2026-08-30 15:17:26 +02:00
|
|
|
inset: ${BLEED};
|
|
|
|
|
overflow: visible;
|
2026-08-25 17:24:14 +02:00
|
|
|
pointer-events: none;
|
2026-08-25 19:26:54 +02:00
|
|
|
--redoal-t: 0;
|
|
|
|
|
--redoal-seed: ${SEED};
|
|
|
|
|
--redoal-count: ${COUNT};
|
2026-08-25 17:24:14 +02:00
|
|
|
}
|
2026-08-25 19:26:54 +02:00
|
|
|
.redoal-paths.worklet {
|
|
|
|
|
background: paint(redoal-paths);
|
|
|
|
|
animation: redoal-t ${CYCLE_MS}ms linear infinite;
|
|
|
|
|
}
|
|
|
|
|
.redoal-paths canvas {
|
2026-08-25 17:24:14 +02:00
|
|
|
position: absolute;
|
|
|
|
|
inset: 0;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
}
|
2026-08-25 19:26:54 +02:00
|
|
|
@keyframes redoal-t {
|
|
|
|
|
from { --redoal-t: 0; }
|
|
|
|
|
to { --redoal-t: 1; }
|
2026-08-25 17:24:14 +02:00
|
|
|
}
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
2026-08-25 19:26:54 +02:00
|
|
|
.redoal-paths.worklet { animation: none; --redoal-t: 0.999; }
|
2026-08-25 17:24:14 +02:00
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
function ensureStyle() {
|
|
|
|
|
if (document.getElementById(STYLE_ID)) return;
|
|
|
|
|
const style = document.createElement('style');
|
|
|
|
|
style.id = STYLE_ID;
|
2026-08-25 19:27:37 +02:00
|
|
|
style.textContent = STYLE_TEXT;
|
2026-08-25 17:24:14 +02:00
|
|
|
document.head.appendChild(style);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 19:26:54 +02:00
|
|
|
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
2026-08-25 17:24:14 +02:00
|
|
|
|
2026-08-25 19:26:54 +02:00
|
|
|
function hasPaintApi() {
|
|
|
|
|
return typeof CSS !== 'undefined' && 'paintWorklet' in CSS && typeof CSS.registerProperty === 'function';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class PathsHero {
|
2026-08-25 17:24:14 +02:00
|
|
|
constructor(container) {
|
|
|
|
|
ensureStyle();
|
|
|
|
|
this.root = document.createElement('div');
|
2026-08-25 19:26:54 +02:00
|
|
|
this.root.className = 'redoal-paths';
|
2026-08-25 17:24:14 +02:00
|
|
|
this.root.setAttribute('aria-hidden', 'true');
|
|
|
|
|
container.appendChild(this.root);
|
2026-08-25 19:26:54 +02:00
|
|
|
this.stopped = false;
|
|
|
|
|
|
|
|
|
|
if (hasPaintApi()) {
|
|
|
|
|
this.startWorklet();
|
|
|
|
|
} else {
|
|
|
|
|
this.startCanvas();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
startWorklet() {
|
|
|
|
|
// Registering makes --redoal-t a real <number> the animation
|
|
|
|
|
// can interpolate; a second mount in the same document sees it
|
|
|
|
|
// already registered, which throws and is fine.
|
|
|
|
|
try {
|
|
|
|
|
CSS.registerProperty({
|
|
|
|
|
name: '--redoal-t',
|
|
|
|
|
syntax: '<number>',
|
|
|
|
|
inherits: false,
|
|
|
|
|
initialValue: '0',
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
/* already registered */
|
|
|
|
|
}
|
|
|
|
|
const url = new URL('./paths-worklet.js', import.meta.url).href;
|
|
|
|
|
CSS.paintWorklet
|
|
|
|
|
.addModule(url)
|
|
|
|
|
.then(() => {
|
|
|
|
|
if (!this.stopped) this.root.classList.add('worklet');
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
if (!this.stopped) this.startCanvas();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
startCanvas() {
|
|
|
|
|
this.canvas = document.createElement('canvas');
|
|
|
|
|
this.root.appendChild(this.canvas);
|
|
|
|
|
this.ctx = this.canvas.getContext('2d');
|
|
|
|
|
this.paths = makePaths(SEED, COUNT);
|
|
|
|
|
this._onResize = () => this.size();
|
|
|
|
|
window.addEventListener('resize', this._onResize);
|
|
|
|
|
this.size();
|
|
|
|
|
if (reducedMotion()) {
|
|
|
|
|
this.frame(0.999);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.start = performance.now();
|
|
|
|
|
const loop = (now) => {
|
|
|
|
|
if (this.stopped) return;
|
|
|
|
|
this.frame(((now - this.start) % CYCLE_MS) / CYCLE_MS);
|
|
|
|
|
this.raf = requestAnimationFrame(loop);
|
|
|
|
|
};
|
|
|
|
|
this.raf = requestAnimationFrame(loop);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
size() {
|
|
|
|
|
const rect = this.root.getBoundingClientRect();
|
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
|
this.w = Math.max(1, rect.width);
|
|
|
|
|
this.h = Math.max(1, rect.height);
|
|
|
|
|
this.canvas.width = Math.round(this.w * dpr);
|
|
|
|
|
this.canvas.height = Math.round(this.h * dpr);
|
|
|
|
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
|
|
this.frame(this.lastT || 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
frame(t) {
|
|
|
|
|
this.lastT = t;
|
|
|
|
|
const cs = getComputedStyle(this.root);
|
|
|
|
|
const accent = cs.getPropertyValue('--accent').trim() || '#47807e';
|
|
|
|
|
const dim = cs.getPropertyValue('--ink-dim').trim() || '#6d6c66';
|
|
|
|
|
drawPaths(this.ctx, this.w, this.h, this.paths, t, [accent, dim]);
|
2026-08-25 17:24:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stop() {
|
2026-08-25 19:26:54 +02:00
|
|
|
this.stopped = true;
|
|
|
|
|
if (this.raf) cancelAnimationFrame(this.raf);
|
|
|
|
|
if (this._onResize) window.removeEventListener('resize', this._onResize);
|
2026-08-25 17:24:14 +02:00
|
|
|
this.root.remove();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function mount(container) {
|
2026-08-25 19:26:54 +02:00
|
|
|
return new PathsHero(container);
|
2026-08-25 17:24:14 +02:00
|
|
|
}
|