Compare commits

..
3 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5 9a5ebb6715 Hero copy points below, where the canvas now is
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:28:31 +02:00
Bendik Aagaard Lynghaug e6f847b7d3 hero.js: don't shadow the global CSS object 2026-08-25 19:27:37 +02:00
Bendik Aagaard Lynghaug 683c6da6b9 Hero: lysbue's paths paint worklet, animated (WIP branch) 2026-08-25 19:26:54 +02:00
5 changed files with 282 additions and 99 deletions
+9 -5
View File
@@ -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
+121 -93
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';
const CYCLE_MS = 28000;
const SEED = 7;
const COUNT = 47;
// 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 CSS = `
.redoal-hero {
const STYLE_TEXT = `
.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; }
}
`;
@@ -68,67 +58,105 @@ function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = CSS;
style.textContent = STYLE_TEXT;
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);
}
+32
View File
@@ -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 <number> 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]);
}
},
);
+119
View File
@@ -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();
}
+1 -1
View File
@@ -2,7 +2,7 @@ id: /
name: What shapes us?
description: >
redoal is a gesture-addressed network — peers find each other by
drawing similar shapes. The gesture is the address. Draw one above
drawing similar shapes. The gesture is the address. Draw one below
and see who's near.
responsible:
name: Bendik Aagaard Lynghaug