Files
portal/public/yes.js
T
Bendik Aagaard LynghaugandClaude Fable 5 452ea88fbf
Test / test (push) Successful in 23s
Filesystem routes, sections, dynamic segments; instant YES hero
The questions/ tree is the router now: ids derive from file paths
(index.yaml names its directory; explicit id still wins for legacy
content), actions and requires_chain accept relative refs, nested
non-index files infer followup, and _section.yaml applies qualifies/
requires_chain/responsible to everything under its directory. Dynamic
[name].yaml pages serve any /dir/<value> with the segment substituted
into {name} resource-key placeholders; submissions index their chain
node in a portal_chains KV so requires_chain pages can verify a
visitor's ?chain= lineage actually ends at the required question.
Loading uses one recursive git-trees call; question_lint walks
subdirectories the same way. Implements docs/design/filesystem-routes.md.

Also: the YES hero now starts at HTML parse time via an inline module
script (yes.js moved to public/ for a stable /yes.js the wasm binding
raw_module-imports too - snippet paths are per-build-hashed), with
hydration adopting the running instance; and both gesture containers
reserve their box in CSS so mounting doesn't shift content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:30:28 +02:00

579 lines
23 KiB
JavaScript

// 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());
}
}