// 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. // // 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, OVERSCAN } from './paths.js'; const STYLE_ID = 'redoal-hero-style'; const CYCLE_MS = 28000; const SEED = 7; const COUNT = 47; const BLEED = `${((OVERSCAN - 1) / 2) * -100}%`; const STYLE_TEXT = ` /* 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; } .redoal-paths { position: absolute; inset: ${BLEED}; overflow: visible; pointer-events: none; --redoal-t: 0; --redoal-seed: ${SEED}; --redoal-count: ${COUNT}; } .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%; } @keyframes redoal-t { from { --redoal-t: 0; } to { --redoal-t: 1; } } @media (prefers-reduced-motion: reduce) { .redoal-paths.worklet { animation: none; --redoal-t: 0.999; } } `; function ensureStyle() { if (document.getElementById(STYLE_ID)) return; const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = STYLE_TEXT; document.head.appendChild(style); } const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches; 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-paths'; this.root.setAttribute('aria-hidden', 'true'); container.appendChild(this.root); this.stopped = false; if (hasPaintApi()) { this.startWorklet(); } else { this.startCanvas(); } } startWorklet() { // Registering makes --redoal-t a real 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: '', 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 PathsHero(container); }