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); } }