import { buildAtlas } from './agent/atlas' import { createRenderer } from './agent/renderer' import { createField } from './field/field' import { createProgressReader } from './cycle/progress' import { sample, beatIndexAt, BEAT } from './cycle/beats' import { createSmoothed, stepSmoothed, HALF_LIFE_SCROLL } from './motion/spring' import { commitRenderPath } from './poster' /** * The narrow-viewport breakpoint, written here and in src/styles.css. * * Below it the stylesheet stops giving the canvas `--frame`'s share and hands it * the whole viewport, because there is no corridor to yield on a phone. The loop * has to size the drawing buffer to the same box, so the number lives in two * languages — and, like WALL_X, a rule written twice is only honest if something * checks the two copies agree. tests/unit/breakpoint.test.ts is that check. */ const NARROW = '(max-width: 900px)' const canvas = document.getElementById('agent') as HTMLCanvasElement | null const fieldEl = document.getElementById('field') as HTMLPreElement | null const insertEl = document.getElementById('insert') as HTMLPreElement | null const codaLabel = document.getElementById('codaLabel') const still = matchMedia('(prefers-reduced-motion: reduce)').matches function boot(): void { if (!canvas) return const renderer = createRenderer(canvas, buildAtlas()) if (!renderer) { // No GPU path. The poster is already on screen — it is markup, parsed before // this file ran — so there is nothing to draw, nothing to schedule, and // nothing further to do but say which of the two pictures the reader is // looking at. The stylesheet takes the canvas down off the same attribute. commitRenderPath('poster', { still }) return } if (!fieldEl || !insertEl) return commitRenderPath('webgl2', { still }) const field = createField(fieldEl, insertEl) const progress = createProgressReader() const smoothed = createSmoothed(progress.read()) let visible = true document.addEventListener('visibilitychange', () => { visible = !document.hidden }) // Cached once per resize, never per frame. `innerWidth` is a viewport metric // the browser already holds; `clientHeight` is a real layout read, which is // exactly why it lives here and not in the loop. The canvas's width follows // `--frame`, so the loop derives it arithmetically instead of asking the DOM. // // Except below NARROW, where it does not. The stylesheet gives the canvas // `calc(var(--frame) * 100vw)`, then overrides it to a flat `100vw` inside // `@media (max-width: 900px)` — there is no corridor to yield on a narrow // screen, so the agent takes the whole box and dims instead. The loop used to // derive the width from `--frame` regardless, which handed the renderer 48% of // the real CSS width for every beat but the coda: the drawing buffer came out // half-size and the browser stretched it ~2.08x across, so the agent was smeared // horizontally on every phone. Deriving arithmetically is still right — it just // has to do the same arithmetic the stylesheet does. const narrowMq = matchMedia(NARROW) let narrow = narrowMq.matches let viewportW = window.innerWidth let canvasH = canvas.clientHeight const onResize = (): void => { progress.refresh() field.resize() narrow = narrowMq.matches viewportW = window.innerWidth canvasH = canvas.clientHeight } window.addEventListener('resize', onResize, { passive: true }) narrowMq.addEventListener('change', onResize) // Sections can reflow after fonts settle; re-measure once they do. document.fonts?.ready.then(onResize) // A lost context is the poster's other case. The canvas is blank and is not // coming back on its own, so the page stops claiming a GPU path it no longer // has: it hands the frame back to the poster, stops the loop, and says why. let lost = false canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault() lost = true commitRenderPath('poster', { still, note: 'context lost' }) }) canvas.addEventListener('webglcontextrestored', () => { location.reload() }) let labelState = -1 /** * Which of the coda's three captions is showing. * * It picks; it does not write. The three lines are markup in index.html, like * every other user-visible line on this page, and the stylesheet shows the one * whose `data-stop` matches. This used to assign `textContent`, which replaces * every child of the element with a single text node — so the the caption * is built around was destroyed on the first animation frame and the --signal * accent never rendered once. An attribute leaves the markup alone. */ function setCodaLabel(beat: number, wordMix: number): void { if (!codaLabel) return const s = beat < BEAT.codaMark ? 0 : wordMix < 0.5 ? 1 : 2 if (s === labelState) return labelState = s codaLabel.dataset['stop'] = String(s) } const t0 = performance.now() let prevNow = t0 let tick = 0 let prevFrame = '' function frame(now: number): void { // The re-arm is deliberately AFTER this check and before everything else: // once the context is gone there is nothing to draw into, and a loop that // keeps calling into a dead context is exactly the runtime cost the poster // path is supposed to have none of. if (lost) return requestAnimationFrame(frame) if (!visible) { prevNow = now return } const dt = Math.min(0.1, (now - prevNow) / 1000) prevNow = now const raw = progress.read() const p = still ? raw : stepSmoothed(smoothed, raw, dt, HALF_LIFE_SCROLL) const state = sample(p) const beat = beatIndexAt(p) // The boundary freezes the field: hold the tick instead of advancing it. if (beat !== BEAT.boundary && !still) tick += 1 // `--frame` is no longer only the canvas's width: the prose column's measure // is a clamp() on it too, so a write here dirties the whole document's // layout, not one fixed box. HOLD keeps `frame` flat across most of every // beat and five of the nine keyframes share 0.48, so the value is unchanged // on the large majority of frames — say so, instead of assigning it again. const share = state.frame.toFixed(4) if (share !== prevFrame) { prevFrame = share document.documentElement.style.setProperty('--frame', share) } document.body.classList.toggle('resolving', state.morph > 0.02) setCodaLabel(beat, state.wordMix) renderer!.resize(narrow ? viewportW : state.frame * viewportW, canvasH) renderer!.draw(state, still ? 6 : (now - t0) / 1000) field.update(state, beat, tick) } requestAnimationFrame(frame) } boot()