From 43ec1fe677d2a8b02f05e4f1b3d7e4b1c9dbcbad Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Thu, 14 May 2026 13:39:39 -0700 Subject: [PATCH] fix(wc): section letterbox no longer stretches with content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous CSS pattern (position:absolute, inset:0, max-width:100%, max-height:100%, aspect-ratio:16/9) was over-constrained: inset:0 fixed all four edges, leaving aspect-ratio with nothing to compute, so on initial layout the section silently stretched to fill the host instead of letterboxing 16:9. A subsequent reflow (triggered by a viewport resize) would re-resolve the layout and the section would snap to 16:9 — that's why "resizing fixed it." Switch to explicit container-query sizing: host gets container-type: size, section width and height are computed as min(100cqi, 100cqb * 16 / 9) and min(100cqb, 100cqi * 9 / 16) so both axes are explicit and content cannot push past the box. Host becomes a grid with place-items:center so the letterbox sits centered in its parent. overflow:hidden on the host clips anything that escapes the section before the density auto-fit converges. This also fixes the related density-auto-fit symptom: predictions were correct, but they read a wrong section.clientHeight on the first paint at certain viewports, so the chosen tier reflected ~50% more canvas than was actually 16:9. With the letterbox now stable from the first layout, slide-9 picks the correct density at every viewport on load — no resize required. Coalesce ResizeObserver and fonts.ready into a single rAF-batched fit so post-font-load measurements are never dropped by the previous reentrancy guard. Add a window 'load' backstop for image-bearing slides. Co-Authored-By: Claude Opus 4.7 --- packages/wc/components/slide.ts | 87 ++++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 29 deletions(-) diff --git a/packages/wc/components/slide.ts b/packages/wc/components/slide.ts index c3e7879..f103a68 100644 --- a/packages/wc/components/slide.ts +++ b/packages/wc/components/slide.ts @@ -10,22 +10,31 @@ import { MorkdeckElement } from "../element.ts"; export class Slide extends MorkdeckElement { static override styles = css` :host { - display: block; + /* container-type: size lets the section size off the host's inline + and block dimensions explicitly (cqi / cqb), giving us a fixed + 16:9 letterbox that doesn't grow with content. The previous + aspect-ratio + max-width/max-height pattern was either + over-constrained (with inset:0) or let content expand the section + past 16:9 (with height:auto). */ + container-type: size; + display: grid; + place-items: center; width: 100%; height: 100%; scroll-snap-align: center; - position: relative; + overflow: hidden; } :host > section { container: slide / inline-size; box-sizing: border-box; - position: absolute; - inset: 0; - margin: auto; - aspect-ratio: 16 / 9; - max-width: 100%; - max-height: 100%; + position: relative; + /* Letterbox the 16:9 stage within the host. Width is the lesser of + the host's full width and what the host's height implies at 16:9; + height is the converse. Both axes are explicit so content cannot + stretch the section. */ + width: min(100cqi, calc(100cqb * 16 / 9)); + height: min(100cqb, calc(100cqi * 9 / 16)); background: ${color("base")}; padding: ${dimension("space.xl")}; display: flex; @@ -120,7 +129,8 @@ export class Slide extends MorkdeckElement { accessor total = ""; #resizeObserver?: ResizeObserver; - #fittingDensity = false; + #onWindowLoad?: () => void; + #fitRafId = 0; override connectedCallback(): void { super.connectedCallback(); @@ -130,45 +140,64 @@ export class Slide extends MorkdeckElement { override firstUpdated() { // Re-fit when the slide host resizes (e.g. user changes the viewport). - this.#resizeObserver = new ResizeObserver(() => this.#fitDensity()); + this.#resizeObserver = new ResizeObserver(() => this.#scheduleFit()); this.#resizeObserver.observe(this); - // Re-fit once webfonts have loaded; metrics shift between fallback and - // Recursive, and the first measurement otherwise picks the wrong tier. - document.fonts?.ready.then(() => this.#fitDensity()); + // Re-fit once webfonts have loaded — metrics shift substantially between + // ui-sans-serif fallback and Recursive (Recursive is chunkier), so a fit + // run before Recursive arrives will under-predict line count. + document.fonts?.ready.then(() => this.#scheduleFit()); + + // Backstop: re-fit on `load`, which fires after all subresources have + // resolved (images affect non-text element heights). Single-shot. + if (document.readyState !== "complete") { + this.#onWindowLoad = () => this.#scheduleFit(); + window.addEventListener("load", this.#onWindowLoad, { once: true }); + } } override disconnectedCallback() { super.disconnectedCallback(); this.#resizeObserver?.disconnect(); + if (this.#onWindowLoad) { + window.removeEventListener("load", this.#onWindowLoad); + } + if (this.#fitRafId) cancelAnimationFrame(this.#fitRafId); + } + + /** + * Coalesce multiple fit triggers into a single fit per animation frame. + * + * The previous reentrancy guard (#fittingDensity) silently dropped any + * trigger that overlapped the current fit, so when fonts.ready resolved + * while the initial ResizeObserver fit was still in flight, the + * post-font-load measurement was lost. With rAF batching, every trigger + * is honored: it either coalesces into the current pending fit or + * schedules a fresh one. + */ + #scheduleFit() { + if (this.#fitRafId) return; + this.#fitRafId = requestAnimationFrame(() => { + this.#fitRafId = 0; + this.#fitDensity(); + }); } /** * Pick the loosest density tier whose predicted content fits the * section's content area, then commit it to the DOM. - * - * The prediction is pure: density.ts mirrors the CSS density rules in JS, - * so we can evaluate every tier from a single layout state without any - * setAttribute + reflow cycles between measurements. One DOM read per - * fit, one DOM write at the end. */ #fitDensity() { - if (this.#fittingDensity) return; const section = this.shadowRoot?.querySelector("section") as | HTMLElement | null; if (!section) return; - this.#fittingDensity = true; - try { - const density: Density = pickDensity(this, section); - if (density === "roomy") { - this.removeAttribute("data-density"); - } else { - this.setAttribute("data-density", density); - } - } finally { - this.#fittingDensity = false; + const density: Density = pickDensity(this, section); + if (density === "roomy") { + this.removeAttribute("data-density"); + } else { + this.setAttribute("data-density", density); } } -- 2.51.2