From 1afd34c22e3aff6d4ad89f77da29783b426f2038 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Tue, 25 Aug 2026 17:26:29 +0200 Subject: [PATCH] Hero becomes a content-owned module; site asset proxy; gesture growth fix site.yaml's hero is now plain | module, where module names a JavaScript file the content repo ships (mount(container) -> handle with stop()). Portal serves content assets same-origin at /site/ (Gitea raw sends no CORS headers), starts the module at HTML parse time, adopts it on hydration, mounts fresh via the inline script's __mountHero on client-side navigation, and stops it on leave. The YES canvas (yes.js) and the gesture hero mode leave the engine - uhhm/questions ships YES as its hero.js, redoal/questions ships a sine-swings band. Gesture form canvas no longer balloons after a stroke: the wrap's aspect-ratio reservation is scoped to :empty (pre-mount) so it can't turn echo-strip height into width inside the flex field. Co-Authored-By: Claude Fable 5 --- README.md | 9 +- gesture.js | 34 +-- public/yes.js | 578 ---------------------------------------------- src/app.rs | 202 ++++++---------- src/content.rs | 147 +++++++++--- src/main.rs | 22 +- src/server/mod.rs | 4 + style/main.css | 118 ++-------- 8 files changed, 230 insertions(+), 884 deletions(-) delete mode 100644 public/yes.js diff --git a/README.md b/README.md index b1bbce5..38f2738 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,12 @@ serves is declared in its content repo, and the same build serves any number of sites. Point an instance at a content repo and that repo *is* the site: -pages, copy, state graphs, and branding (`site.yaml`: title, -wordmark, and which hero the landing page opens on — the YES canvas, -a gesture-drawing canvas wired to a relay, or plain copy). Content +pages, copy, state graphs, branding, and even the landing hero +(`site.yaml`: title, wordmark, and `hero: {kind: module, module: +hero.js}` — a JavaScript module the content repo ships, served +same-origin at `/site/`, that portal mounts at parse time and +stops on navigation; the YES canvas and redoal's sine swings are both +content, not engine). Content pushes hot-reload every running instance; instances differ only by env vars. [uhhm.no](https://uhhm.no) ([uhhm/questions](https://project.uhhm.no/uhhm/questions)) and diff --git a/gesture.js b/gesture.js index 51d40e2..6397462 100644 --- a/gesture.js +++ b/gesture.js @@ -42,14 +42,12 @@ const RECONNECT_BASE_MS = 1000; const RECONNECT_MAX_MS = 30000; class GestureWidget { - constructor(container, hidden, relayUrl, hero) { + constructor(container, hidden, relayUrl) { this.container = container; this.hidden = hidden || null; this.relayUrl = relayUrl || ''; - this.hero = !!hero; this.points = []; this.ghost = null; - this.ambient = []; // hero mode: echoed strokes drawn in place this.drawing = false; this.stopped = false; this.ws = null; @@ -60,11 +58,9 @@ class GestureWidget { this.canvas = document.createElement('canvas'); this.canvas.className = 'gesture-canvas'; container.appendChild(this.canvas); - if (!this.hero) { - this.echoes = document.createElement('div'); - this.echoes.className = 'gesture-echoes'; - container.appendChild(this.echoes); - } + this.echoes = document.createElement('div'); + this.echoes.className = 'gesture-echoes'; + container.appendChild(this.echoes); if (this.relayUrl) { this.status = document.createElement('span'); this.status.className = 'gesture-status offline'; @@ -119,7 +115,6 @@ class GestureWidget { this.drawing = true; this.points = [this.pos(e)]; this.ghost = null; - this.ambient = []; this.render(); } @@ -223,13 +218,7 @@ class GestureWidget { this.ghost = msg.path; this.render(); } else if (msg.type === 'echo') { - if (this.hero) { - this.ambient.push(msg.path); - if (this.ambient.length > MAX_ECHOES) this.ambient.shift(); - this.render(); - } else { - this.addEchoThumbnail(msg); - } + this.addEchoThumbnail(msg); } // 'error' frames are intentionally silent: the widget is a // form field first, and a rate-limited announce shouldn't @@ -284,11 +273,6 @@ class GestureWidget { if (!this.ctx) return; const t = this.theme(); this.ctx.clearRect(0, 0, this.cssWidth, this.cssHeight); - for (const path of this.ambient) { - this.ctx.globalAlpha = 0.25; - this.drawPath(this.ctx, path, this.cssWidth, this.cssHeight, t.echo, 1.5, 0.2); - } - this.ctx.globalAlpha = 1; if (this.ghost) { this.drawPath(this.ctx, this.ghost, this.cssWidth, this.cssHeight, t.ghost, 2, 0.2); } @@ -325,11 +309,5 @@ class GestureWidget { } export function mountGesture(container, hidden, relayUrl) { - return new GestureWidget(container, hidden, relayUrl, false); -} - -// Hero variant: no form field to mirror into, and echoes render as -// ambient strokes on the drawing canvas itself instead of thumbnails. -export function mountGestureHero(container, relayUrl) { - return new GestureWidget(container, null, relayUrl, true); + return new GestureWidget(container, hidden, relayUrl); } diff --git a/public/yes.js b/public/yes.js deleted file mode 100644 index d00aee0..0000000 --- a/public/yes.js +++ /dev/null @@ -1,578 +0,0 @@ -// Ported from ~/repos/webpage/content/visualize/ah.html ("YES - Rasterized -// Lines"), the piece currently live at uhhm.no - kept as the actual asset, -// not reinvented. Two changes from the original: exported as a class (no -// auto-init on `window load`, since Leptos controls when this mounts) and -// a `stop()` method that actually breaks the requestAnimationFrame loop - -// the original ran forever once started, fine for a static page that's -// the whole document, not fine in an SPA where this hero mounts/unmounts -// as you navigate. - -export class RasterizedYES { - constructor() { - this.rasterCanvas = document.getElementById('rasterCanvas'); - this.lineCanvas = document.getElementById('lineCanvas'); - - // The Rust side only constructs this once it's confirmed (via a - // NodeRef) that the canvas is mounted, but that guard has shown - // a real gap on fast client-side re-navigation back to `/` - - // this is the actual failure point, so it gets its own defense - // rather than depending on getting that timing exactly right - // from the other side of the wasm boundary. Leaving the - // instance otherwise-inert (no crash, no animation) rather than - // throwing mid-render - `stop()` already tolerates a partially - // (non-)initialized instance. - if (!this.rasterCanvas || !this.lineCanvas) { - console.warn('RasterizedYES: canvas not in DOM yet, skipping'); - this.destroyed = true; - return; - } - - this.rasterCtx = this.rasterCanvas.getContext('2d'); - this.lineCtx = this.lineCanvas.getContext('2d'); - - this.lines = []; - this.rasterData = null; - this.isActive = true; - this.destroyed = false; - this.time = 0; - - this.containmentStrength = 0.5; - this.wiggleAmount = 0.5; - - // .hero-canvas is inset:0 inside this - freezing an inline - // height here (below) is what actually locks the box, not - // just reading its rect. - this.heroEl = this.rasterCanvas.closest('.hero'); - - this.setupCanvas(); - this.setupResizeHandler(); - this.setupDrift(); - this.setupTheme(); - this.setupScrollFade(); - this.setupClickHandler(); - this.rasterizeText(); - this.initializeLines(); - this.animate(); - } - - stop() { - this.destroyed = true; - if (this._resizeHandler) { - window.removeEventListener('resize', this._resizeHandler); - } - if (this._themeQuery) { - this._themeQuery.removeEventListener('change', this._themeHandler); - } - if (this._scrollHandler) { - window.removeEventListener('scroll', this._scrollHandler); - } - } - - // The hero is position: sticky (main.css), so without this the - // title/wordmark stay pinned at the viewport bottom for the whole - // page and ghost through the translucent cards scrolling over - // them. Fade the copy out across the first half-screen of scroll; - // visibility: hidden at the end so the wordmark link can't be - // clicked while invisible. - setupScrollFade() { - this.heroCopy = this.heroEl ? this.heroEl.querySelector('.hero-copy') : null; - if (!this.heroCopy) return; - this._scrollHandler = () => { - const opacity = Math.max(0, 1 - window.scrollY / (this.displayHeight * 0.5)); - this.heroCopy.style.opacity = opacity; - this.heroCopy.style.visibility = opacity <= 0.01 ? 'hidden' : ''; - }; - window.addEventListener('scroll', this._scrollHandler, { passive: true }); - this._scrollHandler(); - } - - // Canvas paint can't read CSS custom properties, so the piece - // carries its own copy of both palettes and follows - // prefers-color-scheme itself - values must track main.css's :root - // (--paper especially: the fade fill IS the page background where - // the canvas shows through translucent cards). Dark keeps the - // original neon-on-black inks; light restates them as CMYK process - // inks dark enough to carry on paper, since 80%-lightness pastels - // vanish on white. - setupTheme() { - this._themeQuery = window.matchMedia('(prefers-color-scheme: light)'); - this._themeHandler = () => { - this.applyTheme(); - // Repaint the raster ghost in the new theme's ink and - // hard-clear the trails - a slow 3%-alpha fade from the - // old paper color would smear across the flip otherwise. - this.rasterizeText(); - this.lineCtx.fillStyle = this.theme.paper; - this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight); - }; - this._themeQuery.addEventListener('change', this._themeHandler); - this.applyTheme(); - } - - applyTheme() { - this.theme = this._themeQuery.matches - ? { - paper: '#f6f5f1', - fade: 'rgba(246, 245, 241, 0.03)', - // White ground so multiply (the light theme's CSS - // blend mode for #rasterCanvas) leaves the paper - // untouched; gray ink becomes the faint YES ghost. - rasterBg: '#ffffff', - rasterInk: '#6b6b6b', - strokes: [ - 'hsla(185, 70%, 32%, 0.85)', - 'hsla(315, 60%, 38%, 0.85)', - 'hsla(50, 90%, 40%, 0.85)' - ] - } - : { - paper: '#0a0a0a', - fade: 'rgba(10, 10, 10, 0.03)', - rasterBg: '#111111', - rasterInk: '#ffffff', - strokes: [ - 'hsla(180, 90%, 80%, 0.8)', - 'hsla(300, 90%, 80%, 0.8)', - 'hsla(60, 90%, 80%, 0.8)' - ] - }; - } - - // Reading .hero-canvas's rendered rect (a prior attempt at this) - // only helps if that rect actually stays put - and on real mobile - // Safari it didn't: .hero-copy (bottom-aligned via flexbox inside - // .hero-yes) kept sliding down as the address bar collapsed, even - // with a spec'd-stable viewport unit (svh, then lvh) driving the - // box's height. Either the browser isn't holding up its end, or - // something in the cascade is still landing on a live value - not - // provable from here without the device. Rather than keep - // guessing at which CSS viewport unit actually holds still, yes.js - // becomes the source of truth instead: it freezes .hero-yes's - // rendered height to a literal inline px value once, up front. An - // inline style always wins the cascade over the stylesheet's - // `height: 100svh`, so once this runs, nothing the browser does - // with that unit afterward can move the box - the number is fixed - // in the DOM, not recomputed from a unit at all. - setupCanvas() { - const pixelRatio = window.devicePixelRatio || 1; - const width = window.innerWidth; - const height = window.innerHeight; - - if (this.heroEl) { - this.heroEl.style.height = height + 'px'; - } - - this.rasterCanvas.style.width = width + 'px'; - this.rasterCanvas.style.height = height + 'px'; - this.lineCanvas.style.width = width + 'px'; - this.lineCanvas.style.height = height + 'px'; - - this.rasterCanvas.width = width * pixelRatio; - this.rasterCanvas.height = height * pixelRatio; - this.lineCanvas.width = width * pixelRatio; - this.lineCanvas.height = height * pixelRatio; - - this.rasterCtx.scale(pixelRatio, pixelRatio); - this.lineCtx.scale(pixelRatio, pixelRatio); - - this.displayWidth = width; - this.displayHeight = height; - this.pixelRatio = pixelRatio; - } - - setupResizeHandler() { - // Gate on width, not height: mobile Safari's address-bar - // animation changes window.innerHeight continuously with no - // real layout change to react to (that's the live value this - // whole method exists to stop trusting). A genuine resize - - // orientation change, desktop window drag - always changes - // width too, so that's the real signal to re-freeze on. - this._resizeHandler = () => { - if (window.innerWidth === this.displayWidth) return; - this.setupCanvas(); - this.rasterizeText(); - }; - window.addEventListener('resize', this._resizeHandler); - } - - // The two behavior dials (containmentStrength from x, wiggleAmount - // from y) used to follow the pointer. Now a smooth noise field - // wanders them instead - the same 0..1 inputs a mouse would give, - // but drifting at cloud pace, so the piece breathes on its own and - // behaves identically with nobody touching it (which on a landing - // hero is most of the time, and on touch devices was always the - // case between taps). Value noise with two octaves: smooth - // (C1-continuous via smoothstep), never repeats visibly, no jumps. - setupDrift() { - const channel = (seed) => { - const rand = (i) => { - let h = Math.imul(i ^ seed, 2654435761) >>> 0; - h ^= h >>> 13; - h = Math.imul(h, 0x5bd1e995) >>> 0; - // The >>> 0 here is load-bearing: ^ yields a SIGNED - // 32-bit value, and without the reinterpret a set top - // bit made this "0..1" noise go as low as -0.5, - // pushing both dials below their intended floors. - h = (h ^ (h >>> 15)) >>> 0; - return h / 4294967296; - }; - const noise = (t) => { - const i = Math.floor(t); - const f = t - i; - const s = f * f * (3 - 2 * f); - return rand(i) * (1 - s) + rand(i + 1) * s; - }; - // Two octaves, renormalized to 0..1: the slow octave sets - // the overall weather, the faster one keeps it from - // feeling like a pendulum. - return (t) => (noise(t) * 2 / 3 + noise(t * 2.7 + 913) * 1 / 3); - }; - - // One full "weather change" roughly every DRIFT_PERIOD - // seconds per octave - the pace of watching clouds, not of a - // hand on a mouse. - this.driftPeriod = 25; - this.driftX = channel(0x9e3779b9); - this.driftY = channel(0x85ebca6b); - - this.containmentStrength = 0.55; - this.wiggleAmount = 1.05; - } - - updateDrift() { - const t = this.time / this.driftPeriod; - const x = this.driftX(t); - const y = this.driftY(t); - // Same mapping the mouse position used to feed. - this.containmentStrength = 0.1 + (x * 0.9); - this.wiggleAmount = 0.1 + (y * 1.9); - } - - setupClickHandler() { - this.lineCanvas.addEventListener('click', () => { - this.restartAnimation(); - }); - } - - restartAnimation() { - this.time = 0; - this.lineCtx.fillStyle = this.theme.paper; - this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight); - this.initializeLines(); - this.isActive = true; - } - - rasterizeText() { - const width = this.displayWidth; - const height = this.displayHeight; - - const aspectRatio = width / height; - let fontSize; - if (aspectRatio < 1) { - fontSize = width * 0.4; - } else { - fontSize = height * 0.5; - } - - this.fontSize = fontSize; - - this.rasterCtx.font = `bold ${fontSize}px Arial, sans-serif`; - this.rasterCtx.textAlign = 'left'; - this.rasterCtx.textBaseline = 'middle'; - - const fullTextMetrics = this.rasterCtx.measureText('YES'); - const textWidth = fullTextMetrics.width; - const textStartX = (width - textWidth) / 2; - const textY = height / 2; - - const letters = ['Y', 'E', 'S']; - this.letterPositions = []; - let currentX = textStartX; - - for (let i = 0; i < letters.length; i++) { - const letterMetrics = this.rasterCtx.measureText(letters[i]); - this.letterPositions[i] = { - x: currentX, - y: textY, - width: letterMetrics.width, - centerX: currentX + letterMetrics.width / 2 - }; - currentX += letterMetrics.width; - } - - this.letterRasters = []; - - for (let i = 0; i < 3; i++) { - this.rasterCtx.fillStyle = '#111'; - this.rasterCtx.fillRect(0, 0, width, height); - - this.rasterCtx.fillStyle = '#ffffff'; - this.rasterCtx.fillText(letters[i], this.letterPositions[i].x, this.letterPositions[i].y); - - this.letterRasters[i] = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height); - } - - // The visible layer, distinct from the letterRasters sampled - // above (those stay #111/#fff - isInSpecificLetter's red>128 - // test depends on it): painted in theme ink so the ghost works - // under the theme's blend mode (overlay on dark, multiply on - // light - see #rasterCanvas in main.css). - this.rasterCtx.fillStyle = this.theme.rasterBg; - this.rasterCtx.fillRect(0, 0, width, height); - this.rasterCtx.fillStyle = this.theme.rasterInk; - this.rasterCtx.fillText('YES', textStartX, textY); - - this.rasterData = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height); - } - - isInSpecificLetter(x, y, letterIndex) { - const canvasX = x * this.pixelRatio; - const canvasY = y * this.pixelRatio; - - if (!this.letterRasters || !this.letterRasters[letterIndex] || - canvasX < 0 || canvasY < 0 || - canvasX >= this.rasterCanvas.width || canvasY >= this.rasterCanvas.height) { - return false; - } - - const index = (Math.floor(canvasY) * this.rasterCanvas.width + Math.floor(canvasX)) * 4; - const red = this.letterRasters[letterIndex].data[index]; - return red > 128; - } - - initializeLines() { - this.lines = []; - const numLines = 60; - - const letterCentroids = this.calculateLetterCentroids(); - - for (let i = 0; i < numLines; i++) { - const letterIndex = Math.floor(i / (numLines / 3)); - let startX, startY, centerX, centerY; - - if (letterCentroids[letterIndex]) { - centerX = letterCentroids[letterIndex].x; - centerY = letterCentroids[letterIndex].y; - - const startVariation = this.fontSize * 0.1; - startX = centerX + (Math.random() - 0.5) * startVariation; - startY = centerY + (Math.random() - 0.5) * startVariation; - - if (!this.isInSpecificLetter(startX, startY, letterIndex)) { - const nearestPoint = this.findNearestSpecificLetterPixel(startX, startY, letterIndex); - if (nearestPoint) { - startX = nearestPoint.x; - startY = nearestPoint.y; - } else { - startX = centerX; - startY = centerY; - } - } - } else { - const letterPos = this.letterPositions[letterIndex]; - startX = letterPos.centerX; - startY = letterPos.y; - centerX = startX; - centerY = startY; - } - - // Stroke color lives on the theme, looked up per frame by - // letterIndex (see draw()) - not frozen per line - so a - // theme flip recolors live lines instead of leaving - // dark-theme neon smearing across light paper. - this.lines.push({ - relativeX: (startX - centerX) / this.fontSize, - relativeY: (startY - centerY) / this.fontSize, - prevRelativeX: (startX - centerX) / this.fontSize, - prevRelativeY: (startY - centerY) / this.fontSize, - angle: Math.random() * Math.PI * 2, - letterIndex: letterIndex, - lastSeenInside: { x: (startX - centerX) / this.fontSize, y: (startY - centerY) / this.fontSize }, - outsideDuration: 0 - }); - } - } - - calculateLetterCentroids() { - const centroids = []; - - for (let letterIndex = 0; letterIndex < 3; letterIndex++) { - let sumX = 0, sumY = 0, count = 0; - - const letterPos = this.letterPositions[letterIndex]; - const searchStartX = Math.max(0, letterPos.x - this.fontSize * 0.1); - const searchEndX = Math.min(this.displayWidth, letterPos.x + letterPos.width + this.fontSize * 0.1); - const searchStartY = Math.max(0, letterPos.y - this.fontSize * 0.6); - const searchEndY = Math.min(this.displayHeight, letterPos.y + this.fontSize * 0.6); - - const step = Math.max(1, Math.floor(this.fontSize * 0.02)); - for (let y = searchStartY; y <= searchEndY; y += step) { - for (let x = searchStartX; x <= searchEndX; x += step) { - if (this.isInSpecificLetter(x, y, letterIndex)) { - sumX += x; - sumY += y; - count++; - } - } - } - - if (count > 0) { - centroids[letterIndex] = { - x: sumX / count, - y: sumY / count - }; - } else { - centroids[letterIndex] = { - x: letterPos.centerX, - y: letterPos.y - }; - } - } - - return centroids; - } - - updateLines() { - this.time += 0.016; - this.updateDrift(); - if (!this.isActive) return; - - const letterCentroids = this.calculateLetterCentroids(); - - this.lines.forEach(line => { - line.prevRelativeX = line.relativeX; - line.prevRelativeY = line.relativeY; - - const centroid = letterCentroids[line.letterIndex]; - if (!centroid) return; - - const currentX = centroid.x + line.relativeX * this.fontSize; - const currentY = centroid.y + line.relativeY * this.fontSize; - - const currentlyInside = this.isInSpecificLetter(currentX, currentY, line.letterIndex); - - if (currentlyInside) { - line.outsideDuration = 0; - line.lastSeenInside = { x: line.relativeX, y: line.relativeY }; - } else { - line.outsideDuration++; - } - - const visionDistance = 0.08 * this.fontSize; - const centerX = currentX + Math.cos(line.angle) * visionDistance; - const centerY = currentY + Math.sin(line.angle) * visionDistance; - const leftX = currentX + Math.cos(line.angle - 0.4) * visionDistance; - const leftY = currentY + Math.sin(line.angle - 0.4) * visionDistance; - const rightX = currentX + Math.cos(line.angle + 0.4) * visionDistance; - const rightY = currentY + Math.sin(line.angle + 0.4) * visionDistance; - - const centerSees = this.isInSpecificLetter(centerX, centerY, line.letterIndex); - const leftSees = this.isInSpecificLetter(leftX, leftY, line.letterIndex); - const rightSees = this.isInSpecificLetter(rightX, rightY, line.letterIndex); - - let speed = 0.02; - - const attractionThreshold = Math.floor(15 + (1 - this.containmentStrength) * 45); - - if (line.outsideDuration > attractionThreshold) { - const targetX = line.lastSeenInside.x; - const targetY = line.lastSeenInside.y; - const deltaX = targetX - line.relativeX; - const deltaY = targetY - line.relativeY; - const angleToTarget = Math.atan2(deltaY, deltaX); - - let angleDiff = angleToTarget - line.angle; - while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI; - while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI; - - const baseAttraction = Math.min(0.4, line.outsideDuration / 80); - const attractionStrength = baseAttraction * this.containmentStrength; - line.angle += angleDiff * attractionStrength; - } - - if (centerSees) { - const baseWiggle = 0.15; - line.angle += (Math.random() - 0.5) * baseWiggle * this.wiggleAmount; - } else { - speed *= (0.3 + this.containmentStrength * 0.4); - - const baseTurnStrength = 0.3 + (this.containmentStrength * 0.4); - const randomTurnAmount = 0.2 * this.wiggleAmount; - - if (leftSees && !rightSees) { - line.angle -= baseTurnStrength + Math.random() * randomTurnAmount; - } else if (rightSees && !leftSees) { - line.angle += baseTurnStrength + Math.random() * randomTurnAmount; - } else { - const randomTurn = (Math.random() - 0.5) * (0.8 + this.wiggleAmount * 0.7); - line.angle += randomTurn; - } - } - - line.relativeX += Math.cos(line.angle) * speed; - line.relativeY += Math.sin(line.angle) * speed; - - line.relativeX = Math.max(-1.7, Math.min(1.7, line.relativeX)); - line.relativeY = Math.max(-1.7, Math.min(1.7, line.relativeY)); - }); - } - - findNearestSpecificLetterPixel(x, y, letterIndex) { - const searchRadius = this.fontSize * 0.08; - const step = Math.max(1, Math.floor(this.fontSize * 0.01)); - let nearestPoint = null; - let nearestDistance = Infinity; - - for (let dy = -searchRadius; dy <= searchRadius; dy += step) { - for (let dx = -searchRadius; dx <= searchRadius; dx += step) { - const testX = x + dx; - const testY = y + dy; - - if (this.isInSpecificLetter(testX, testY, letterIndex)) { - const distance = Math.sqrt(dx * dx + dy * dy); - if (distance < nearestDistance) { - nearestDistance = distance; - nearestPoint = { x: testX, y: testY }; - } - } - } - } - - return nearestPoint; - } - - draw() { - this.lineCtx.fillStyle = this.theme.fade; - this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight); - - const letterCentroids = this.calculateLetterCentroids(); - - this.lines.forEach(line => { - const centroid = letterCentroids[line.letterIndex]; - if (!centroid) return; - - const currentX = centroid.x + line.relativeX * this.fontSize; - const currentY = centroid.y + line.relativeY * this.fontSize; - const prevX = centroid.x + line.prevRelativeX * this.fontSize; - const prevY = centroid.y + line.prevRelativeY * this.fontSize; - - if (prevX === currentX && prevY === currentY) return; - - this.lineCtx.strokeStyle = this.theme.strokes[line.letterIndex]; - this.lineCtx.lineWidth = this.fontSize * 0.003; - this.lineCtx.lineCap = 'round'; - - this.lineCtx.beginPath(); - this.lineCtx.moveTo(prevX, prevY); - this.lineCtx.lineTo(currentX, currentY); - this.lineCtx.stroke(); - }); - } - - animate() { - if (this.destroyed) return; - this.updateLines(); - this.draw(); - requestAnimationFrame(() => this.animate()); - } -} diff --git a/src/app.rs b/src/app.rs index bb8fda2..fda01e7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -407,32 +407,6 @@ fn ResponsibleNote(responsible: Responsible) -> impl IntoView { } } -// Binds the actual "YES - Rasterized Lines" piece live at uhhm.no -// (ported to `public/yes.js`, an ES module now instead of a page-owning -// script) as a typed JS class - the idiomatic wasm-bindgen way to drive -// an existing JS module from Rust, rather than re-implementing canvas -// animation logic here. -#[cfg(feature = "hydrate")] -mod yes { - use wasm_bindgen::prelude::*; - - // raw_module (a runtime URL, not a bundled snippet) on purpose: - // the file lives in public/ so it's served at the stable /yes.js - // - the same URL the hero's inline early-mount script imports. - // A bundled snippet would sit under a per-build hashed - // /pkg/snippets/ path the inline script couldn't know. - #[wasm_bindgen(raw_module = "/yes.js")] - extern "C" { - #[wasm_bindgen(js_name = RasterizedYES)] - pub type RasterizedYes; - - #[wasm_bindgen(constructor, js_class = "RasterizedYES")] - pub fn new() -> RasterizedYes; - - #[wasm_bindgen(method)] - pub fn stop(this: &RasterizedYes); - } -} // Same idiomatic-typed-module approach as `yes` above, for the // prosekit-backed rich-text requirement kind (`prosekit-editor.js`, @@ -478,12 +452,6 @@ mod gesture { relay_url: &str, ) -> GestureWidget; - #[wasm_bindgen(js_name = mountGestureHero)] - pub fn mount_gesture_hero( - container: &web_sys::HtmlDivElement, - relay_url: &str, - ) -> GestureWidget; - #[wasm_bindgen(method)] pub fn stop(this: &GestureWidget); } @@ -491,131 +459,103 @@ mod gesture { #[component] fn Hero(title: String, description: String, landing: bool, site: SiteConfig) -> impl IntoView { - // Only the landing page gets a full interactive piece - it's the - // one page a hero is actually "the" hero for; other pages - // (thank-you pages, /review) get the plain dark header below. - // WHICH piece is the content repo's call (`site.yaml`'s - // hero.kind): the YES canvas (default - uhhm's look), a redoal - // gesture canvas, or nothing. - let hero_kind = if landing { site.hero.kind.clone() } else { "plain".to_string() }; - let show_yes = hero_kind == "yes"; - let show_gesture = hero_kind == "gesture"; + // Only the landing page gets a piece - it's the one page a hero is + // actually "the" hero for; every other page gets the plain header. + // WHAT the piece is belongs to the content repo: site.yaml's + // `hero: {kind: module, module: hero.js}` names a JavaScript module + // (served same-origin at /site/) exporting + // `mount(container) -> handle` with `handle.stop()`. Portal knows + // nothing about what it draws. + let module = if landing && site.hero.kind == "module" { + site.hero.module.clone() + } else { + None + }; + let has_module = module.is_some(); let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string()); let site_title = site.title.clone().unwrap_or_else(|| SITE_NAME.to_string()); - // Starts the YES animation once the canvas elements exist, and - // explicitly stops the requestAnimationFrame loop on unmount - the - // original page-owning script never needed this since navigating - // away meant a full document unload, which doesn't happen in an - // SPA. - let raster_ref: NodeRef = NodeRef::new(); - let gesture_ref: NodeRef = NodeRef::new(); + let piece_ref: NodeRef = NodeRef::new(); #[cfg(feature = "hydrate")] - if show_yes { - // `on_cleanup` requires Send + Sync (even single-threaded, wasm), - // which a JS-backed value never is - `StoredValue`'s `LocalStorage` - // variant is the established way around this (same pattern - // cnats' WebRTC call state uses for its own non-Send peer map). - let instance: StoredValue, LocalStorage> = + if has_module { + use wasm_bindgen::JsCast; + // Handle = whatever the module's mount() returned; kept as a + // raw JsValue so the contract stays "has a stop()", nothing + // typed on the Rust side. + let handle: StoredValue, LocalStorage> = StoredValue::new_local(None); - // Gated on the canvas NodeRef resolving, not just "the Effect - // ran" - on a fresh page load the DOM is already there by the - // time this fires, but navigating back to `/` client-side hit a - // real race: the Effect ran before the new view's was - // actually inserted, RasterizedYES::new() (yes.js) did an - // unchecked `document.getElementById(...).getContext(...)` on - // null and threw mid-reactive-update - which then corrupted - // wasm_bindgen_futures' single-threaded executor state badly - // enough to panic ("RefCell already borrowed") on the next - // tick. Waiting for the NodeRef itself guarantees DOM presence - // the same way the prosekit editor's own mount Effect does. Effect::new(move |_| { - if raster_ref.get().is_none() { + let Some(container) = piece_ref.get() else { + return; + }; + if handle.with_value(|h| h.is_some()) { return; } - if instance.with_value(|i| i.is_some()) { - return; - } - // Adopt the hero's inline early-mount instance (started at - // HTML parse time, long before this wasm was even fetched) - // instead of starting a second animation. The flag covers - // the opposite ordering too: an inline script that runs - // after this sees it and stays inert. - use wasm_bindgen::JsCast; + // Adopt the inline early-mount's instance (started at HTML + // parse time) if it's there; otherwise (client-side + // navigation back to /) mount fresh through the helper the + // inline script left on window. Either order is safe. let global = js_sys::global(); - let _ = js_sys::Reflect::set(&global, &"__yesAdopted".into(), &true.into()); - let early = js_sys::Reflect::get(&global, &"__yesEarly".into()) + let _ = js_sys::Reflect::set(&global, &"__heroAdopted".into(), &true.into()); + let early = js_sys::Reflect::get(&global, &"__heroEarly".into()) .ok() .filter(|v| !v.is_undefined() && !v.is_null()); - let inst = match early { + match early { Some(v) => { - let _ = js_sys::Reflect::delete_property(&global, &"__yesEarly".into()); - v.unchecked_into::() + let _ = js_sys::Reflect::delete_property(&global, &"__heroEarly".into()); + handle.set_value(Some(v)); } - None => yes::RasterizedYes::new(), - }; - instance.set_value(Some(inst)); + None => { + if let Ok(mounter) = js_sys::Reflect::get(&global, &"__mountHero".into()) { + if let Some(f) = mounter.dyn_ref::() { + if let Ok(promise) = f.call1(&global, &container) { + let promise: js_sys::Promise = promise.unchecked_into(); + leptos::task::spawn_local(async move { + if let Ok(h) = + wasm_bindgen_futures::JsFuture::from(promise).await + { + handle.set_value(Some(h)); + } + }); + } + } + } + } + } }); on_cleanup(move || { - instance.update_value(|opt| { - if let Some(inst) = opt.take() { - inst.stop(); + handle.update_value(|opt| { + if let Some(h) = opt.take() { + if let Ok(stop) = js_sys::Reflect::get(&h, &"stop".into()) { + if let Some(f) = stop.dyn_ref::() { + let _ = f.call0(&h); + } + } } }); }); } - // The gesture hero mounts the same widget the `type: gesture` - // requirement uses, in hero mode (no form field; echoes render as - // ambient strokes on the canvas itself). Same StoredValue + - // on_cleanup shape as the YES piece - it may own a live WebSocket. - #[cfg(feature = "hydrate")] - if show_gesture { - let relay = site.hero.relay.clone().unwrap_or_default(); - let widget: StoredValue, LocalStorage> = - StoredValue::new_local(None); - Effect::new(move |_| { - let Some(container) = gesture_ref.get() else { - return; - }; - if widget.with_value(|w| w.is_some()) { - return; - } - widget.set_value(Some(gesture::mount_gesture_hero(&container, &relay))); - }); - on_cleanup(move || { - widget.update_value(|opt| { - if let Some(w) = opt.take() { - w.stop(); - } - }); - }); - } + // The inline script starts the piece at HTML parse time instead of + // waiting out the wasm bundle; it also leaves __mountHero for the + // hydrate side's client-side-navigation case. Module path is + // validated to plain segments by SiteConfig::validate, so it can't + // break out of the string here. + let early_script = module.as_ref().map(|m| { + format!( + "const url = '/site/{m}'; window.__mountHero = (el) => import(url).then((mod) => mod.mount(el)); if (!window.__heroAdopted && !window.__heroEarly) window.__mountHero(document.querySelector('.hero-piece')).then((h) => {{ if (!window.__heroAdopted) window.__heroEarly = h; else if (h && h.stop) h.stop(); }});" + ) + }); view! { -
- {show_yes +
+ {has_module .then(|| { view! { - - // Starts the animation at HTML parse time - // instead of waiting out the wasm bundle's - // fetch + hydration; the hydrate Effect above - // adopts (never duplicates) the instance, and - // the guards make either execution order safe. - + + } })} - {show_gesture - .then(|| { - view! {
} - })}
site_title/ diff --git a/src/content.rs b/src/content.rs index 935b472..b0c6b78 100644 --- a/src/content.rs +++ b/src/content.rs @@ -347,51 +347,76 @@ pub struct SiteConfig { pub hero: HeroConfig, } -/// What the landing page's hero is: the YES canvas piece (`yes`, the -/// default), a redoal gesture-drawing canvas (`gesture`), or copy -/// only (`plain`). +/// What the landing page's hero is. `plain` is just the copy; `module` +/// hands the hero to a JavaScript module the content repo itself +/// ships (`module: hero.js`, a repo-relative path portal serves +/// same-origin at `/site/`, since Gitea's raw endpoint sends no +/// CORS headers). The module exports `mount(container) -> handle` and +/// the handle has `stop()`; portal starts it at HTML parse time and +/// adopts it on hydration. The piece's look - canvases, SVG, CSS - is +/// entirely the module's: portal only reserves the box. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct HeroConfig { #[serde(default = "default_hero_kind")] pub kind: String, - /// `kind: gesture` only - ws(s):// URL of a redoal-relay for - /// ambient echoes. Absent means the hero draws offline. #[serde(default)] - pub relay: Option, + pub module: Option, } impl Default for HeroConfig { fn default() -> Self { - Self { kind: default_hero_kind(), relay: None } + Self { kind: default_hero_kind(), module: None } } } fn default_hero_kind() -> String { - "yes".to_string() + "plain".to_string() } #[cfg(feature = "ssr")] impl SiteConfig { - /// Shared by `load_site_from_gitea` and the `question-lint` binary - /// so a typo'd hero kind is a caught rejection, not a silently - /// plain hero. + /// Same policy as `validate_questions`: content declares, the + /// runtime refuses what it doesn't understand at load time, so a + /// typo'd hero kind is a caught rejection, not a silently plain + /// hero. pub fn validate(&self) -> anyhow::Result<()> { - if !matches!(self.hero.kind.as_str(), "yes" | "gesture" | "plain") { - anyhow::bail!( - "site.yaml: hero.kind {:?} is not one of yes | gesture | plain", - self.hero.kind - ); - } - if let Some(relay) = &self.hero.relay { - if self.hero.kind != "gesture" { - anyhow::bail!("site.yaml: hero.relay only makes sense with hero.kind: gesture"); + match self.hero.kind.as_str() { + "plain" => { + if self.hero.module.is_some() { + anyhow::bail!("site.yaml: hero.module only makes sense with hero.kind: module"); + } } - validate_relay_url(relay).map_err(|e| anyhow::anyhow!("site.yaml: {e}"))?; + "module" => { + let Some(module) = &self.hero.module else { + anyhow::bail!("site.yaml: hero.kind: module needs hero.module (a repo-relative path like hero.js)"); + }; + if !is_safe_site_path(module) { + anyhow::bail!( + "site.yaml: hero.module {module:?} must be a plain repo-relative path (no scheme, no .., no leading /)" + ); + } + } + other => anyhow::bail!("site.yaml: hero.kind {other:?} is not one of plain | module"), } Ok(()) } } +/// A repo-relative asset path portal is willing to proxy from the +/// content repo: plain segments only - no traversal, no leading slash, +/// no scheme - so `/site/` can only ever reach the content repo. +pub fn is_safe_site_path(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && path.split('/').all(|seg| { + !seg.is_empty() + && seg != ".." + && seg + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + }) +} + /// A relay must be a ws:// or wss:// URL - shared between site.yaml's /// hero and `Requirement.relay` validation. #[cfg(feature = "ssr")] @@ -428,6 +453,15 @@ pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Resul /// Extracts `scheme://host` from a repo's normal browser URL - the /// Gitea API base every helper in this module builds requests against. +#[cfg(feature = "ssr")] +/// `{gitea}/api/v1/repos/{owner}/{repo}/raw` - the base +/// `site_asset_handler` proxies content-shipped files from. +#[cfg(feature = "ssr")] +pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result { + let (owner, repo) = parse_owner_repo(repo_url)?; + Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?)) +} + #[cfg(feature = "ssr")] pub fn gitea_api_base(repo_url: &str) -> anyhow::Result { let parsed = url::Url::parse(repo_url) @@ -1091,6 +1125,54 @@ pub struct GiteaRepoQuery { pub repo: String, } +/// `GET /site/{*path}` - serves a file from the content repo's default +/// branch same-origin, so content-shipped assets (the hero module, +/// its stylesheet) are importable by the page: Gitea's raw endpoint +/// sends no CORS headers, and an ES module import across origins is +/// refused without them. Path is validated to plain segments +/// (`is_safe_site_path`), the response revalidates like /pkg +/// (Cache-Control: no-cache) so a content push shows up on reload. +#[cfg(feature = "ssr")] +pub async fn site_asset_handler( + axum::extract::State(state): axum::extract::State, + axum::extract::Path(path): axum::extract::Path, +) -> axum::response::Response { + use axum::http::{header, StatusCode}; + use axum::response::IntoResponse; + + if !is_safe_site_path(&path) { + return (StatusCode::BAD_REQUEST, "invalid asset path").into_response(); + } + let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch); + let client = openidconnect::reqwest::Client::new(); + let resp = match client.get(&url).send().await.and_then(|r| r.error_for_status()) { + Ok(r) => r, + Err(e) => { + tracing::warn!("site asset {path}: {e}"); + return (StatusCode::NOT_FOUND, "no such site asset").into_response(); + } + }; + let bytes = match resp.bytes().await { + Ok(b) => b, + Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(), + }; + let mime = match path.rsplit('.').next() { + Some("js") | Some("mjs") => "text/javascript; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("json") => "application/json", + Some("png") => "image/png", + Some("webp") => "image/webp", + Some("woff2") => "font/woff2", + _ => "application/octet-stream", + }; + ( + [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "no-cache")], + bytes, + ) + .into_response() +} + /// Looks up `owner/repo` on the same Gitea instance content is loaded /// from (`AppState.gitea_base`) - a raw Axum handler (mounted at /// `/gitea-repo` in `main.rs`), not a Leptos server fn, since the @@ -1386,9 +1468,9 @@ alternatives: } #[test] - fn site_config_defaults_to_the_yes_hero() { + fn site_config_defaults_to_the_plain_hero() { let site = SiteConfig::default(); - assert_eq!(site.hero.kind, "yes"); + assert_eq!(site.hero.kind, "plain"); assert!(site.validate().is_ok()); // And an empty file parses to the same thing. let parsed: SiteConfig = serde_yaml::from_str("{}").unwrap(); @@ -1403,21 +1485,28 @@ alternatives: } #[test] - fn site_config_rejects_relay_without_gesture_hero() { - let site: SiteConfig = - serde_yaml::from_str("hero: { kind: yes, relay: \"wss://relay.redoal.com\" }").unwrap(); - assert!(site.validate().is_err()); + fn site_config_module_hero_needs_a_safe_path() { + let missing: SiteConfig = serde_yaml::from_str("hero: { kind: module }").unwrap(); + assert!(missing.validate().is_err()); + let traversal: SiteConfig = + serde_yaml::from_str("hero: { kind: module, module: ../etc/passwd }").unwrap(); + assert!(traversal.validate().is_err()); + let absolute: SiteConfig = + serde_yaml::from_str("hero: { kind: module, module: https://x/y.js }").unwrap(); + assert!(absolute.validate().is_err()); + let ok: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap(); + assert!(ok.validate().is_ok()); } #[test] fn redoal_site_yaml_shape_parses() { let site: SiteConfig = serde_yaml::from_str( - "title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: gesture\n relay: wss://relay.redoal.com\n", + "title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: module\n module: hero.js\n", ) .unwrap(); assert!(site.validate().is_ok()); assert_eq!(site.title.as_deref(), Some("redoal")); - assert_eq!(site.hero.kind, "gesture"); + assert_eq!(site.hero.module.as_deref(), Some("hero.js")); } #[test] diff --git a/src/main.rs b/src/main.rs index 786a876..e1cb265 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,7 @@ async fn main() -> anyhow::Result<()> { .unwrap_or_else(|_| "https://project.uhhm.no/uhhm/questions".to_string()); let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string()); let gitea_base = content::gitea_api_base(&content_repo)?; + let content_raw_base = content::gitea_raw_base(&content_repo)?; let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?; let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?; content::validate_questions(&questions, &aggregates)?; @@ -67,7 +68,7 @@ async fn main() -> anyhow::Result<()> { tokio::spawn(content::watch_for_reload( nats.clone(), content_repo, - content_branch, + content_branch.clone(), "questions".to_string(), questions.clone(), aggregates.clone(), @@ -82,6 +83,8 @@ async fn main() -> anyhow::Result<()> { aggregates, site, gitea_base, + content_raw_base, + content_branch: content_branch.clone(), oidc: oidc_state, garage, }; @@ -129,10 +132,6 @@ async fn main() -> anyhow::Result<()> { let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root); let wordmark_path = format!("{}/wordmark.svg", leptos_options.site_root); let swiper_path = format!("{}/swiper-element-bundle.min.js", leptos_options.site_root); - // yes.js sits in public/ (not a wasm-bindgen snippet) so the - // hero's inline early-mount script and the wasm binding can share - // one stable URL - see `mod yes` in app.rs. - let yes_path = format!("{}/yes.js", leptos_options.site_root); let fonts_dir = format!("{}/fonts", leptos_options.site_root); let app = Router::new() @@ -142,6 +141,8 @@ async fn main() -> anyhow::Result<()> { .route("/api/{*fn_name}", any(server_fn_handler)) .route("/upload", post(upload::upload)) .route("/gitea-repo", get(content::gitea_repo_handler)) + // Content-shipped assets (hero module etc.), same-origin. + .route("/site/{*path}", get(content::site_asset_handler)) .route("/automation/kv/{bucket}", get(content::automation_kv_handler)) // no-cache = "revalidate before reuse", not "don't cache": // pkg files keep the same names across releases (portal.js, @@ -167,17 +168,6 @@ async fn main() -> anyhow::Result<()> { "/swiper-element-bundle.min.js", ServeFile::new(swiper_path), ) - // Same skew concern as /pkg: the wasm's raw_module import and - // the hero's inline script both load this by fixed name. - .route_service( - "/yes.js", - tower::ServiceBuilder::new() - .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( - axum::http::header::CACHE_CONTROL, - axum::http::HeaderValue::from_static("no-cache"), - )) - .service(ServeFile::new(yes_path)), - ) .leptos_routes_with_context( &state, routes, diff --git a/src/server/mod.rs b/src/server/mod.rs index 6a6b54a..a59a7b7 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -38,6 +38,10 @@ pub struct AppState { /// rather than re-derived per call, since `resolve_gitea_repo` needs /// it too and has no other reason to see `CONTENT_REPO` itself. pub gitea_base: String, + /// `{gitea}/api/v1/repos/{owner}/{repo}/raw` and the branch - what + /// `content::site_asset_handler` proxies content-shipped assets from. + pub content_raw_base: String, + pub content_branch: String, pub oidc: Arc, /// `None` when `GARAGE_*` env vars aren't set - uploads are the one /// optional feature, everything else works without Garage. diff --git a/style/main.css b/style/main.css index 2eba14e..d1bfae1 100644 --- a/style/main.css +++ b/style/main.css @@ -51,7 +51,7 @@ --ink-dim: #9a9a9a; --line: #2a2a2a; - /* Cards float over the sticky YES canvas - translucent so the piece + /* Cards float over a sticky hero piece - translucent so the piece reads faintly through them (paired with backdrop-filter on .alt-card), shadowed so their edge against the animated backdrop is a soft lift rather than a hard contrast line. */ @@ -78,8 +78,9 @@ /* Light theme: the same muted-press idea on white stock - warm paper, near-black ink, and the accent deepened from dusty cyan to a teal ink that actually carries as text on light paper (the dark theme's - #8ec2c0 washes out there). yes.js mirrors this palette on its own - (matchMedia in setupTheme) since canvas paint can't read CSS vars. */ + #8ec2c0 washes out there). Canvas-drawing modules (gesture.js, and + content-shipped hero modules) mirror this palette on their own, + since canvas paint can't read CSS vars. */ @media (prefers-color-scheme: light) { :root { --accent: #47807e; @@ -105,12 +106,6 @@ filter: invert(0.92); } - /* overlay against near-white paper resolves to ~white and the - raster ghost vanishes; multiply lets the light theme's gray YES - (see rasterizeText's themed repaint in yes.js) show as ink. */ - .hero-canvas #rasterCanvas { - mix-blend-mode: multiply; - } } * { @@ -200,98 +195,17 @@ main.not-found { max-width: 46ch; } -/* the actual "YES - Rasterized Lines" piece live at uhhm.no (see - public/yes.js) - landing page only. Full-viewport so it's a real - moment, not a thumbnail; hero-copy overlays near the bottom rather - than interrupting the canvas. */ - -.hero-yes { - /* Sticky at the viewport top for the whole scroll (its containing - block is the page itself), so the piece stays animating behind - everything that follows - the translucent cards scroll over it - and it shows through them and in the gaps around them. z-index 0 - so positioned content below can stack above with z-index 1. */ - position: sticky; - top: 0; - z-index: 0; - max-width: none; - width: 100%; - /* svh here is only the pre-JS/no-JS fallback (and first paint before - yes.js's constructor runs). Once yes.js mounts, it overwrites this - with an inline `height: ` frozen from a single measurement - - see setupCanvas()'s comment in yes.js for why: on real mobile - Safari, content bottom-aligned inside this box kept sliding down - as the address bar collapsed even with a spec'd-stable viewport - unit here, so the box's actual height can't be trusted to stay - put on that unit alone. An inline style set from JS always wins - the cascade over this rule, so that frozen number is what - actually governs once the page is interactive. - Plain-vh fallback declared first - an engine without svh support - ignores the invalid second line rather than falling through to - auto height, which would collapse this to the height of its - in-flow content and clip the canvas via overflow:hidden below. */ - height: 100vh; - height: 100svh; - padding: 0; - justify-content: flex-end; - overflow: hidden; -} - -.hero-canvas { - position: absolute; - inset: 0; -} - -.hero-canvas canvas { - position: absolute; - top: 0; - left: 0; - cursor: pointer; -} - -.hero-canvas #rasterCanvas { - mix-blend-mode: overlay; - opacity: 0.5; -} - -.hero-yes .hero-copy { - position: relative; - z-index: 1; - padding: 0 1.5rem 3.5rem; - gap: 0.6rem; - text-shadow: 0 0.12em 1.4em var(--hero-glow); -} - -/* The gesture hero (site.yaml hero.kind: gesture - redoal.com's - landing): a tall drawing surface where the visitor's stroke and - ambient echoes from the relay share the stage. Not sticky like the - YES piece - drawing and scrolling fight over the same finger. */ -.hero-gesture .hero-draw { +/* A content-shipped hero module (site.yaml hero.kind: module) draws + into .hero-piece; the module owns its look (it may restyle .hero + itself). Portal only reserves the box so the copy doesn't jump when + the module mounts - plain-vh fallback first, as everywhere. */ +.hero-module .hero-piece { position: relative; width: 100%; - max-width: 46rem; - /* Same jump-avoidance as .gesture-wrap: the canvas (55svh tall) - only exists after hydration, so hold its height open from the - first paint. Plain-vh fallback first, like the canvas itself. */ min-height: 55vh; min-height: 55svh; } -.hero-gesture .gesture-canvas { - aspect-ratio: auto; - /* Plain-vh fallback first, same reasoning as .hero-yes's height. */ - height: 55vh; - height: 55svh; - border: none; - background: transparent; -} - -.hero-gesture .gesture-status { - top: auto; - bottom: 0.55rem; - right: 0.55rem; -} - /* alternatives */ /* position + z-index on these three: everything that scrolls over the @@ -564,10 +478,16 @@ textarea:focus { custom properties - so palette changes go there AND here. */ .gesture-wrap { position: relative; - /* The canvas is created by JS only after wasm hydration - reserve - its 3/2 box now so content below doesn't jump when it appears. - aspect-ratio is a preferred size, so the box still grows when the - echo thumbnail strip shows up under the canvas. */ + width: 100%; +} + +/* The canvas is created by JS only after wasm hydration - reserve its + 3/2 box until then so content below doesn't jump. Scoped to :empty + on purpose: on the mounted wrap (a stretched item inside the flex + .field) the same aspect-ratio turned the echo strip's extra height + into extra WIDTH and the whole card ballooned past the viewport + after the first stroke. */ +.gesture-wrap:empty { aspect-ratio: 3 / 2; }