From 1b7ffe0f436d1889ad39e126f662904a0cdb359f Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sun, 16 Aug 2026 15:53:55 +0200 Subject: [PATCH] Noise-driven YES, sticky hero with frosted cards, full light theme Three changes that belong together, all verified against a real mounted page in headless WebKit: - yes.js's two behavior dials (containment/wiggle) now drift on a smooth two-octave value-noise field at cloud pace (~25s per weather change) instead of following the mouse. Includes a real bug fix found in verification: the hash's final XOR yields a SIGNED 32-bit value in JS, so the "0..1" noise dipped to -0.36 without a reinterpreting >>> 0. - The landing hero is position: sticky, so the piece keeps animating behind the whole page. Cards go translucent with backdrop blur and a soft shadow so the piece reads faintly through and around them (near-opaque fallback where backdrop-filter is unsupported). The hero copy fades out over the first half-screen of scroll - pinned, it ghosted through the cards. The bottom fade gradient is gone: its hard-cut reason disappeared with the canvas behind everything. - Full prefers-color-scheme light theme: warm paper, near-black ink, accent deepened from dusty cyan to teal ink (#8ec2c0 washes out on white), wordmark inverted via filter. yes.js mirrors the palette itself (canvas can't read CSS vars): CMYK process-ink strokes dark enough to carry on paper, raster ghost repainted as multiply-blended gray on white, live re-theme on scheme flip with a trail clear. Co-Authored-By: Claude Fable 5 --- style/main.css | 110 ++++++++++++++++++++++------ yes.js | 192 +++++++++++++++++++++++++++++++++++-------------- 2 files changed, 228 insertions(+), 74 deletions(-) diff --git a/style/main.css b/style/main.css index fd6c9f5..098d869 100644 --- a/style/main.css +++ b/style/main.css @@ -47,11 +47,22 @@ --accent-soft: rgba(142, 194, 192, 0.16); --paper: #0a0a0a; - --paper-raised: #161616; --ink: #ededed; --ink-dim: #9a9a9a; --line: #2a2a2a; + /* Cards float over the sticky YES canvas - 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. */ + --card-bg: rgba(22, 22, 22, 0.55); + --card-bg-opaque: rgba(22, 22, 22, 0.94); + --card-shadow: rgba(0, 0, 0, 0.5); + + --error: #ff6b6b; + /* Behind .hero-copy: dark halo on dark stock, paper halo on light. */ + --hero-glow: rgba(0, 0, 0, 0.8); + --heading: "Quicksand", var(--sans); --sans: -apple-system, "SF Pro Text", ui-sans-serif, "Segoe UI", system-ui, sans-serif; @@ -64,6 +75,44 @@ font-size: max(12px, calc(2.1lvmin + 3pt)); } +/* 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. */ +@media (prefers-color-scheme: light) { + :root { + --accent: #47807e; + --accent-soft: rgba(71, 128, 126, 0.16); + + --paper: #f6f5f1; + --ink: #1d1d1b; + --ink-dim: #6d6c66; + --line: #d9d7cf; + + --card-bg: rgba(252, 251, 248, 0.55); + --card-bg-opaque: rgba(252, 251, 248, 0.94); + --card-shadow: rgba(50, 48, 42, 0.18); + + --error: #a83a32; + --hero-glow: rgba(246, 245, 241, 0.85); + } + + /* The wordmark SVG is a hardcoded white stroke (also used raw in + dark contexts elsewhere) - flip it to ink here rather than fork + the asset. */ + .wordmark img { + 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; + } +} + * { box-sizing: border-box; } @@ -157,7 +206,14 @@ main.not-found { than interrupting the canvas. */ .hero-yes { - position: relative; + /* 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 @@ -203,28 +259,17 @@ main.not-found { z-index: 1; padding: 0 1.5rem 3.5rem; gap: 0.6rem; - text-shadow: 0 0.12em 1.4em rgba(0, 0, 0, 0.8); -} - -/* Softens the otherwise hard cut where the full-bleed canvas meets the - page background right below it - fades to the same --paper color - over the last few ems instead of stopping dead. Non-interactive so - it never steals clicks meant for the canvas underneath. */ -.hero-yes::after { - content: ""; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 8rem; - background: linear-gradient(to bottom, transparent, var(--paper)); - pointer-events: none; - z-index: 1; + text-shadow: 0 0.12em 1.4em var(--hero-glow); } /* alternatives */ +/* position + z-index on these three: everything that scrolls over the + sticky canvas must be a positioned box above the hero's z-index 0, + or the (positioned) hero would paint over it. */ .alternatives { + position: relative; + z-index: 1; max-width: 75ch; margin: 0 auto 5rem; padding: 0 1.5rem; @@ -232,6 +277,12 @@ main.not-found { gap: 1.25rem; } +.question-nav, +.question-responsible { + position: relative; + z-index: 1; +} + @media (max-width: 43.75rem) { .alternatives { padding: 0; @@ -295,10 +346,25 @@ main.not-found { } .alt-card { - background: var(--paper-raised); + background: var(--card-bg); + /* Frosted glass over the animated canvas behind: the blur is what + keeps text on a translucent card readable while the piece still + reads through it in color and motion. */ + -webkit-backdrop-filter: blur(0.8rem); + backdrop-filter: blur(0.8rem); border: 0.06rem solid var(--line); border-radius: var(--radius); padding: 1.75rem 1.9rem; + box-shadow: 0 1.1rem 2.4rem var(--card-shadow); +} + +/* Without backdrop blur, 0.55-alpha cards put text straight over the + moving canvas - illegible. Engines lacking the filter get a + near-opaque card instead. */ +@supports not ((backdrop-filter: blur(1rem)) or (-webkit-backdrop-filter: blur(1rem))) { + .alt-card { + background: var(--card-bg-opaque); + } } .alt-image { @@ -570,7 +636,7 @@ textarea:focus { font-family: var(--sans); font-weight: 600; font-size: 0.95rem; - color: #0a0a0a; + color: var(--paper); background: var(--accent); border: none; border-radius: 0.7rem; @@ -609,7 +675,7 @@ textarea:focus { } .resource-error { - color: #ff6b6b; + color: var(--error); font-size: 0.9rem; } diff --git a/yes.js b/yes.js index 5061c84..d00aee0 100644 --- a/yes.js +++ b/yes.js @@ -36,8 +36,6 @@ export class RasterizedYES { this.destroyed = false; this.time = 0; - this.mouseX = 0.5; - this.mouseY = 0.5; this.containmentStrength = 0.5; this.wiggleAmount = 0.5; @@ -48,7 +46,9 @@ export class RasterizedYES { this.setupCanvas(); this.setupResizeHandler(); - this.setupMouseTracking(); + this.setupDrift(); + this.setupTheme(); + this.setupScrollFade(); this.setupClickHandler(); this.rasterizeText(); this.initializeLines(); @@ -60,6 +60,82 @@ export class RasterizedYES { 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) @@ -119,50 +195,59 @@ export class RasterizedYES { window.addEventListener('resize', this._resizeHandler); } - setupMouseTracking() { - const updatePosition = (clientX, clientY) => { - this.mouseX = clientX / this.displayWidth; - this.mouseY = clientY / this.displayHeight; - this.containmentStrength = 0.1 + (this.mouseX * 0.9); - this.wiggleAmount = 0.1 + (this.mouseY * 1.9); + // 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); }; - window.addEventListener('mousemove', (e) => { - updatePosition(e.clientX, e.clientY); - }); + // 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); - // Scoped to the canvas itself, not `window` (see below), and - // no longer calling preventDefault(): the original standalone - // page was the whole document, so blocking default touch - // behavior was harmless (nothing else to scroll to). Embedded - // as a hero above a longer page, .hero-yes covers the entire - // first screen - preventDefault() here was fighting the - // browser's own native scroll for any touch gesture starting - // in that region, which is what actually caused scrolling to - // jump/stutter (only once this component had mounted and - // attached these listeners - a plain page scroll never needed - // default prevented in the first place, just the position for - // the cosmetic line-cluster tracking below). - this.lineCanvas.addEventListener('touchmove', (e) => { - if (e.touches.length > 0) { - const touch = e.touches[0]; - updatePosition(touch.clientX, touch.clientY); - } - }, { passive: true }); - - this.lineCanvas.addEventListener('touchstart', (e) => { - if (e.touches.length > 0) { - const touch = e.touches[0]; - updatePosition(touch.clientX, touch.clientY); - } - }, { passive: true }); - - this.mouseX = 0.5; - this.mouseY = 0.5; 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(); @@ -171,7 +256,7 @@ export class RasterizedYES { restartAnimation() { this.time = 0; - this.lineCtx.fillStyle = '#0a0a0a'; + this.lineCtx.fillStyle = this.theme.paper; this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight); this.initializeLines(); this.isActive = true; @@ -227,9 +312,14 @@ export class RasterizedYES { this.letterRasters[i] = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height); } - this.rasterCtx.fillStyle = '#111'; + // 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 = '#ffffff'; + 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); @@ -286,19 +376,16 @@ export class RasterizedYES { centerY = startY; } - const colors = [ - 'hsl(180, 90%, 70%)', - 'hsl(300, 90%, 70%)', - 'hsl(60, 90%, 70%)' - ]; - + // 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, - color: colors[letterIndex], letterIndex: letterIndex, lastSeenInside: { x: (startX - centerX) / this.fontSize, y: (startY - centerY) / this.fontSize }, outsideDuration: 0 @@ -347,6 +434,7 @@ export class RasterizedYES { updateLines() { this.time += 0.016; + this.updateDrift(); if (!this.isActive) return; const letterCentroids = this.calculateLetterCentroids(); @@ -454,7 +542,7 @@ export class RasterizedYES { } draw() { - this.lineCtx.fillStyle = 'rgba(10, 10, 10, 0.03)'; + this.lineCtx.fillStyle = this.theme.fade; this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight); const letterCentroids = this.calculateLetterCentroids(); @@ -470,7 +558,7 @@ export class RasterizedYES { if (prevX === currentX && prevY === currentY) return; - this.lineCtx.strokeStyle = line.color.replace('70%)', '80%, 0.8)'); + this.lineCtx.strokeStyle = this.theme.strokes[line.letterIndex]; this.lineCtx.lineWidth = this.fontSize * 0.003; this.lineCtx.lineCap = 'round';