diff --git a/style/main.css b/style/main.css index 46d6f49..7fc537c 100644 --- a/style/main.css +++ b/style/main.css @@ -153,16 +153,16 @@ main.loading, main.not-found { position: relative; max-width: none; width: 100%; - /* svh (small viewport height): a fixed, stable length - computed - once, never recalculated mid-scroll as mobile Safari's address - bar hides/shows, unlike plain vh/dvh on some engines. Pinned to - the *smallest* possible viewport (toolbar visible) so the box - never needs to grow into space the toolbar might reclaim. - yes.js's canvas measures this box's own rendered rect (not - window.innerWidth/innerHeight) via ResizeObserver, so the canvas - always matches whatever height this resolves to - the two can't - disagree, which is what was actually causing the content below to - visibly jump. + /* 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 diff --git a/yes.js b/yes.js index 9c9ce3c..5061c84 100644 --- a/yes.js +++ b/yes.js @@ -41,8 +41,13 @@ export class RasterizedYES { 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.setupResizeObserver(); + this.setupResizeHandler(); this.setupMouseTracking(); this.setupClickHandler(); this.rasterizeText(); @@ -52,24 +57,34 @@ export class RasterizedYES { stop() { this.destroyed = true; - if (this._resizeObserver) { - this._resizeObserver.disconnect(); + if (this._resizeHandler) { + window.removeEventListener('resize', this._resizeHandler); } } - // Measures the canvases' own containing box (.hero-canvas, which - // tracks .hero-yes via inset:0) rather than window.innerWidth/ - // innerHeight. window.innerHeight is the live viewport - it moves - // as mobile Safari's address bar hides/shows on scroll, which - // doesn't itself match whatever fixed value .hero-yes's CSS height - // is pinned to. Sizing the canvas off the box's own rect instead - // means the two can never disagree, whichever viewport unit ends - // up governing that box. + // 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 rect = this.rasterCanvas.parentElement.getBoundingClientRect(); - const width = rect.width; - const height = rect.height; + 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'; @@ -89,19 +104,19 @@ export class RasterizedYES { this.pixelRatio = pixelRatio; } - // ResizeObserver reacts to the container's box actually changing - // size - a fixed-height box (svh, lvh, or plain px) never fires - // this at all during the address-bar animation, so there's no - // need for the old "only redraw if width changed" heuristic that - // guessed at which resize events were real. - setupResizeObserver() { - this._resizeObserver = new ResizeObserver((entries) => { - const { width, height } = entries[0].contentRect; - if (width === this.displayWidth && height === this.displayHeight) return; + 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(); - }); - this._resizeObserver.observe(this.rasterCanvas.parentElement); + }; + window.addEventListener('resize', this._resizeHandler); } setupMouseTracking() {