Hero: lysbue's paths paint worklet, animated (WIP branch)

This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-25 19:26:54 +02:00
parent c5b445ecb1
commit 683c6da6b9
4 changed files with 279 additions and 96 deletions
+119 -91
View File
@@ -1,66 +1,56 @@
// redoal.com's hero: "sine swings" - the drifting layered sine paths
// from lysbue's logo (the site this content descends from), grown from
// a clipped 400px circle into a full-width band. No input: the form's
// gesture canvas below is the one place a visitor draws. Content-owned:
// portal knows only site.yaml's `hero: {kind: module, module: hero.js}`
// and the contract - `mount(container) -> handle` with `stop()`; it
// serves this file same-origin at /site/hero.js, starts it at HTML
// parse time, adopts it on hydration, and stops it on navigation.
// 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.
//
// Pure SVG + CSS: the strokes take their color from portal's own
// custom properties (--accent, --ink-dim), so both themes come for
// free and there's no palette copy to keep in sync. Motion is a CSS
// keyframe on each layer's transform, disabled under
// prefers-reduced-motion.
// 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.
import { makePaths, drawPaths } from './paths.js';
const STYLE_ID = 'redoal-hero-style';
// One period is 400 units wide; the path is written two periods long
// so translating by exactly one period loops seamlessly.
const SWING =
'M 0 200 Q 100 100 200 200 Q 300 300 400 200 ' +
'Q 500 100 600 200 Q 700 300 800 200 ' +
'Q 900 100 1000 200 Q 1100 300 1200 200';
// (y offset, stroke width, opacity, seconds per period, direction)
const LAYERS = [
[0, 6.5, 0.55, 9, 1],
[7, 3, 0.45, 11, 1],
[9, 1, 0.5, 13, 1],
[15, 0.5, 0.6, 17, 1],
[-60, 1.5, 0.18, 23, -1],
[70, 1, 0.14, 29, -1],
];
const CYCLE_MS = 28000;
const SEED = 7;
const COUNT = 47;
const CSS = `
.redoal-hero {
.redoal-paths {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
--redoal-t: 0;
--redoal-seed: ${SEED};
--redoal-count: ${COUNT};
}
.redoal-hero svg {
.redoal-paths.worklet {
background: paint(redoal-paths);
animation: redoal-t ${CYCLE_MS}ms linear infinite;
}
.redoal-paths canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.redoal-hero .swing {
fill: none;
stroke: var(--accent);
stroke-linecap: round;
animation: redoal-swing var(--dur) linear infinite;
animation-direction: var(--dir);
}
.redoal-hero .swing.far {
stroke: var(--ink-dim);
}
@keyframes redoal-swing {
from { transform: translateX(0); }
to { transform: translateX(-400px); }
@keyframes redoal-t {
from { --redoal-t: 0; }
to { --redoal-t: 1; }
}
@media (prefers-reduced-motion: reduce) {
.redoal-hero .swing { animation: none; }
.redoal-paths.worklet { animation: none; --redoal-t: 0.999; }
}
`;
@@ -72,63 +62,101 @@ function ensureStyle() {
document.head.appendChild(style);
}
const NS = 'http://www.w3.org/2000/svg';
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
class SineSwings {
function hasPaintApi() {
return typeof CSS !== 'undefined' && 'paintWorklet' in CSS && typeof CSS.registerProperty === 'function';
}
class PathsHero {
constructor(container) {
ensureStyle();
this.root = document.createElement('div');
this.root.className = 'redoal-hero';
this.root.className = 'redoal-paths';
this.root.setAttribute('aria-hidden', 'true');
// viewBox 400 wide per period; preserveAspectRatio "slice" so
// the band always fills the box and the loop is seamless at
// any width - the SVG is 1200 wide but only translates by 400.
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '0 0 800 400');
svg.setAttribute('preserveAspectRatio', 'xMidYMid slice');
// The logo's clipped circle, centered - the near layers live
// inside it; the far layers drift across the whole band.
const defs = document.createElementNS(NS, 'defs');
const clip = document.createElementNS(NS, 'clipPath');
clip.id = 'redoal-hero-clip';
const circle = document.createElementNS(NS, 'circle');
circle.setAttribute('cx', '400');
circle.setAttribute('cy', '200');
circle.setAttribute('r', '150');
clip.appendChild(circle);
defs.appendChild(clip);
svg.appendChild(defs);
const far = document.createElementNS(NS, 'g');
const near = document.createElementNS(NS, 'g');
near.setAttribute('clip-path', 'url(#redoal-hero-clip)');
// The logo's tilt.
near.setAttribute('transform', 'rotate(-13 400 200)');
for (const [dy, width, opacity, dur, dir] of LAYERS) {
const path = document.createElementNS(NS, 'path');
path.setAttribute('d', SWING);
path.setAttribute('stroke-width', String(width));
path.setAttribute('opacity', String(opacity));
path.setAttribute('transform', `translate(-200 ${dy})`);
const isFar = Math.abs(dy) > 30;
path.setAttribute('class', isFar ? 'swing far' : 'swing');
path.style.setProperty('--dur', `${dur}s`);
path.style.setProperty('--dir', dir < 0 ? 'reverse' : 'normal');
(isFar ? far : near).appendChild(path);
}
svg.append(far, near);
this.root.appendChild(svg);
container.appendChild(this.root);
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]);
}
stop() {
this.stopped = true;
if (this.raf) cancelAnimationFrame(this.raf);
if (this._onResize) window.removeEventListener('resize', this._onResize);
this.root.remove();
}
}
export function mount(container) {
return new SineSwings(container);
return new PathsHero(container);
}