From 0fd49c08faebd3d6d71e536b096fea103f433e0c Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Tue, 25 Aug 2026 17:24:14 +0200 Subject: [PATCH] Content-owned hero module (WIP branch for local verification) --- hero.js | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ site.yaml | 9 ++-- 2 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 hero.js diff --git a/hero.js b/hero.js new file mode 100644 index 0000000..f08240e --- /dev/null +++ b/hero.js @@ -0,0 +1,134 @@ +// 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. +// +// 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. + +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 CSS = ` +.redoal-hero { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; +} +.redoal-hero svg { + 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); } +} +@media (prefers-reduced-motion: reduce) { + .redoal-hero .swing { animation: none; } +} +`; + +function ensureStyle() { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.appendChild(style); +} + +const NS = 'http://www.w3.org/2000/svg'; + +class SineSwings { + constructor(container) { + ensureStyle(); + this.root = document.createElement('div'); + this.root.className = 'redoal-hero'; + 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); + } + + stop() { + this.root.remove(); + } +} + +export function mount(container) { + return new SineSwings(container); +} diff --git a/site.yaml b/site.yaml index c8e28c1..4a4f33e 100644 --- a/site.yaml +++ b/site.yaml @@ -1,9 +1,6 @@ -# Instance branding - see the schema notes in this repo's README and -# portal's content loader (src/content.rs, SiteConfig). The relay URL -# here drives the landing hero's ambient echoes; the same URL appears -# on index.yaml's gesture requirement for the form widget. +# Instance branding - see uhhm/questions' README for the schema. title: redoal wordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg hero: - kind: gesture - relay: wss://relay.redoal.com + kind: module + module: hero.js # this repo's sine-swings piece, served at /site/hero.js