diff --git a/hero.js b/hero.js index 6b79b4e..bf52fbf 100644 --- a/hero.js +++ b/hero.js @@ -44,6 +44,9 @@ const TAU_MS = 140; // browser scales the canvas up to the screen; at 1080p that is about // 0.7 of the screen's pixels, at 4K about a third. const MAX_PIXELS = 1.2e6; +// How long to wait for the app to adopt the piece before drawing it +// anyway (no wasm, a blocked script): longer than any hydration. +const ATTACH_FALLBACK_MS = 4000; // /usr/share/gpupaper/shaders/light_line.frag, as is. const LIGHT_LINE_FRAG = `// Lightning @@ -232,6 +235,14 @@ const CSS = ` height: 100%; display: block; pointer-events: none; + animation: westra-in 700ms ease-out; +} + +/* They arrive after the app has hydrated (see attachWhenHydrated), so + they arrive as a fade rather than a pop. */ +@keyframes westra-in { + from { opacity: 0; } + to { opacity: 1; } } /* The name, laid out as the logo lays it out: a box with the logo's @@ -245,6 +256,7 @@ const CSS = ` .westra-name { position: absolute; inset: 0; + animation: westra-in 700ms ease-out; display: flex; align-items: center; justify-content: center; @@ -308,7 +320,6 @@ const CSS = ` `; function ensureStyle() { - if (DEBUG.has('nostyle')) return; if (document.getElementById(STYLE_ID)) return; const style = document.createElement('style'); style.id = STYLE_ID; @@ -337,7 +348,7 @@ class WestraHero { this.hero = container.closest('.hero') || container.parentElement; // Where the module's custom properties go: an element the app // never hydrates, so writing to it cannot disturb hydration. - this.host = DEBUG.has('nobody') ? document.createElement('div') : document.body; + this.host = document.body; this.reduced = window.matchMedia('(prefers-reduced-motion: reduce)'); this.dark = window.matchMedia('(prefers-color-scheme: dark)'); @@ -356,9 +367,9 @@ class WestraHero { mark.append(a, b); this.name.append(mark); this.mark = mark; - // Both inside the piece, and nothing outside it (see .westra-line). + // Both go inside the piece, and nothing outside it (see + // .westra-line) - but not yet: see attachWhenHydrated(). this.container = container; - if (!DEBUG.has('nodom')) container.append(this.canvas, this.name); this.width = 0; this.height = 1; @@ -367,7 +378,44 @@ class WestraHero { this.t0 = performance.now(); this.last = this.t0; this.raf = 0; + this.attached = false; + this.attachWhenHydrated(); + } + + // Portal starts this module at HTML parse time, before its app + // has hydrated the page, and hydration walks the DOM as served: + // nodes added to the piece before it runs throw it off (verified + // on the live site: with the DOM added at once the router never + // came up and every click was a full page load; with nothing + // added it hydrated every time). So the stylesheet goes in at + // once (the sticky header and the hidden wordmark are CSS) and + // the canvas and the name wait for the app to adopt this + // instance (portal sets __heroAdopted when it does), or for + // ATTACH_FALLBACK_MS if no app comes - then they fade in. On a + // client-side navigation back here the app mounts the module + // itself, after hydration, so they go in at once. + attachWhenHydrated() { + const ready = () => window.__heroAdopted === true || performance.now() - this.t0 > ATTACH_FALLBACK_MS; + const poll = () => { + if (this.stopped) return; + if (!ready()) { + this.raf = requestAnimationFrame(poll); + return; + } + // One more frame after the flag, so the app's own work + // for this frame is done before the piece changes. + this.raf = requestAnimationFrame(() => { + this.raf = 0; + if (!this.stopped) this.attach(); + }); + }; + poll(); + } + + attach() { + this.attached = true; + this.container.append(this.canvas, this.name); this.gl = this.initGl(); this.size(); @@ -583,6 +631,7 @@ class WestraHero { stop() { this.stopped = true; if (this.raf) cancelAnimationFrame(this.raf); + if (!this.attached) return; window.removeEventListener('resize', this._onResize); window.removeEventListener('scroll', this._onScroll); if (this.observer) this.observer.disconnect(); @@ -602,13 +651,6 @@ class WestraHero { } } -// Temporary, to find what disturbs hydration on the live site: -// ?hero=off mounts nothing, ?hero=nostyle skips the stylesheet, -// ?hero=nobody skips the custom properties on , ?hero=nodom -// adds nothing to the piece. Removed once known. -const DEBUG = new Set((new URLSearchParams(location.search).get('hero') || '').split(',')); - export function mount(container) { - if (DEBUG.has('off')) return { stop() {} }; return new WestraHero(container); }