diff --git a/README.md b/README.md index 2c3dd46..39cf14b 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,15 @@ The full schema reference lives in this repo only notes what's distinctive here: - **`site.yaml`** rebrands the instance: title `redoal`, its own - wordmark, and a `module` hero — `hero.js` in this repo, the drifting - "sine swings" band grown from lysbue's logo (pure SVG + CSS, colors - from portal's own custom properties, still under - prefers-reduced-motion). No input up there: the form's gesture - canvas below is the one place a visitor draws. The wordmark is + wordmark, and a `module` hero — `hero.js` in this repo, lysbue's + "paths" paint worklet brought back and animated: 47 generative + turning-function curves (`paths.js`, seeded so every repaint agrees) + tracing themselves in from the center. A real CSS Paint Worklet + where the API exists (`paths-worklet.js`, driven by a registered + `--redoal-t` CSS animation), a canvas with the same generator + elsewhere; colors from portal's custom properties, frozen under + prefers-reduced-motion. No input up there: the form's gesture canvas + below is the one place a visitor draws. The wordmark is white-stroked like uhhm's, because portal's light theme lands wordmarks as ink via an invert filter. - **`type: gesture`** on `index.yaml`'s "Draw a path and see who's near" — the diff --git a/hero.js b/hero.js index f08240e..4a18d99 100644 --- a/hero.js +++ b/hero.js @@ -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 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 SineSwings(container); + return new PathsHero(container); } diff --git a/paths-worklet.js b/paths-worklet.js new file mode 100644 index 0000000..b4bb21a --- /dev/null +++ b/paths-worklet.js @@ -0,0 +1,32 @@ +// CSS Paint Worklet entry for redoal.com's hero: `background: +// paint(redoal-paths)` on the hero box, animated by the page changing +// --redoal-t (a registered custom property driven by a CSS +// animation - every change repaints). Colors arrive as computed input +// properties, so the worklet follows the theme without a palette copy. +import { makePaths, drawPaths } from './paths.js'; + +registerPaint( + 'redoal-paths', + class { + static get inputProperties() { + return ['--redoal-t', '--redoal-seed', '--redoal-count', '--accent', '--ink-dim']; + } + paint(ctx, size, props) { + const num = (name, fallback) => { + const v = parseFloat(props.get(name).toString()); + return Number.isFinite(v) ? v : fallback; + }; + const seed = num('--redoal-seed', 7); + const count = num('--redoal-count', 47); + const t = num('--redoal-t', 0); + if (!this.paths || this.seed !== seed || this.count !== count) { + this.paths = makePaths(seed, count); + this.seed = seed; + this.count = count; + } + const accent = props.get('--accent').toString().trim() || '#47807e'; + const dim = props.get('--ink-dim').toString().trim() || '#6d6c66'; + drawPaths(ctx, size.width, size.height, this.paths, t % 1, [accent, dim]); + } + }, +); diff --git a/paths.js b/paths.js new file mode 100644 index 0000000..c783855 --- /dev/null +++ b/paths.js @@ -0,0 +1,119 @@ +// The generator behind redoal.com's hero - a port of lysbue's +// "paths" CSS paint worklet (static/worklet/paint/paths.js): a path is +// a turning function, a list of (angle shift, distance) stops walked +// from the center and smoothed into quadratic arcs. Shared verbatim by +// the paint worklet (paths-worklet.js, where the browser has the CSS +// Paint API) and the canvas fallback (hero.js) so both draw the same +// field. Everything is seeded: a worklet is instantiated whenever the +// engine likes, and unseeded randomness would reshuffle the picture on +// every repaint. + +export function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function isInBounds(x, y) { + return x > -0.5 && x < 0.5 && y > -0.5 && y < 0.5; +} + +// Flat array of (angle shift, distance) pairs; distance 1 = the +// frame's short side. The first pair is the start: absolute angle and +// distance from the center. +export function createTurningFunction(rand, stopCount) { + const turning = [rand() * Math.PI * 2, rand() * 0.3]; + let radianSum = turning[0]; + let x = Math.cos(turning[0]) * turning[1]; + let y = Math.sin(turning[0]) * turning[1]; + for (let i = 0; i < stopCount; i++) { + let shiftAngle = 0, shiftDistance = 0.1, next = radianSum; + for (let j = 0; j < 80; j++) { + shiftAngle = Math.pow(1 + rand() * 4, -2) * Math.PI * 2 * (rand() > 0.5 ? -1 : 1); + shiftDistance = Math.max(rand() * 0.25, 0.05); + next = radianSum + shiftAngle; + if (isInBounds(x + Math.cos(next) * shiftDistance, y + Math.sin(next) * shiftDistance)) break; + } + radianSum = next; + x += Math.cos(radianSum) * shiftDistance; + y += Math.sin(radianSum) * shiftDistance; + turning.push(shiftAngle, shiftDistance); + } + return turning; +} + +// Smooth the stops into quadratic arcs (control point = half a step +// along the previous heading, like the original) and flatten each arc +// into `samples` points so the curve can be traced progressively. +export function turningFunctionToPoints(turning, samples = 14) { + const pts = []; + let heading = turning[0]; + let x = Math.cos(turning[0]) * turning[1]; + let y = Math.sin(turning[0]) * turning[1]; + pts.push(x, y); + for (let i = 2; i < turning.length; i += 2) { + const cx = x + Math.cos(heading) * (turning[i + 1] / 2); + const cy = y + Math.sin(heading) * (turning[i + 1] / 2); + heading += turning[i]; + const nx = x + Math.cos(heading) * turning[i + 1]; + const ny = y + Math.sin(heading) * turning[i + 1]; + for (let s = 1; s <= samples; s++) { + const u = s / samples, v = 1 - u; + pts.push(v * v * x + 2 * v * u * cx + u * u * nx, v * v * y + 2 * v * u * cy + u * u * ny); + } + x = nx; + y = ny; + } + return pts; +} + +export function makePaths(seed, count, stops = 10) { + const rand = mulberry32(seed); + const paths = []; + for (let i = 0; i < count; i++) { + paths.push(turningFunctionToPoints(createTurningFunction(rand, stops))); + } + return paths; +} + +// t in [0, 1) is the animation phase. Each path traces itself in over +// one cycle, offset by its index so the field is always mid-draw, and +// fades as it completes; the whole field drifts slowly and a soft +// wave runs through every line. `colors` is [accent, dim] - the +// palette comes from the page's own custom properties either way. +export function drawPaths(ctx, width, height, paths, t, colors) { + ctx.clearRect(0, 0, width, height); + const s = Math.min(width, height) * 0.9; + ctx.save(); + ctx.translate(width / 2, height / 2); + ctx.rotate(t * Math.PI * 2 * 0.08); + ctx.lineWidth = 1; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + const n = paths.length; + const wave = t * Math.PI * 2; + for (let i = 0; i < n; i++) { + const pts = paths[i]; + const phase = (t + i / n) % 1; + // ease: quick to appear, long to complete + const progress = Math.min(1, phase * 1.35); + const count = Math.max(2, Math.floor((pts.length / 2) * progress)); + const alpha = phase < 0.85 ? 0.75 : 0.75 * (1 - (phase - 0.85) / 0.15); + ctx.strokeStyle = i % 3 === 0 ? colors[0] : colors[1]; + ctx.globalAlpha = alpha * (i % 3 === 0 ? 1 : 0.55); + ctx.beginPath(); + for (let k = 0; k < count; k++) { + const x = pts[k * 2], y = pts[k * 2 + 1]; + const wy = y + Math.sin(x * 7 + wave + i) * 0.008; + if (k === 0) ctx.moveTo(x * s, wy * s); + else ctx.lineTo(x * s, wy * s); + } + ctx.stroke(); + } + ctx.restore(); +}