diff --git a/src/lib/components/Ledger.svelte b/src/lib/components/Ledger.svelte index ba7ce51..e733a27 100644 --- a/src/lib/components/Ledger.svelte +++ b/src/lib/components/Ledger.svelte @@ -22,7 +22,7 @@ cells: Record; } - /** You first, then the others in table order; the gallery sees the table as laid. */ + /** Column order: the viewer first, then the others in seat order; a spectator gets no column of their own. */ const columns = $derived(duel.spectating ? duel.others : [duel.me, ...duel.others]); function placed(who: WizardId, turn: number, phases: string[]): Placed[] { @@ -68,9 +68,42 @@ return !!h && h.who === who && h.hand === hand && index >= h.from && index <= h.to; } + // The stylesheet caps the box at a share of the viewport, which is rarely a whole number of + // rows, so after each turn the cap is trimmed to the header plus as many of the newest rows + // as fit under it, and the box is scrolled to the newest. Older rows then sit wholly under + // the sticky header rather than showing a cut-off sliver. The second header row sticks at + // the measured height of the first, so the two meet exactly whatever the font or wrapping. + // Row heights change with the viewport and the web font, so this is redone on resize and + // once fonts are in. + function fitRows() { + if (!scroller) return; + scroller.style.maxHeight = ''; + const names = scroller.querySelector('thead tr'); + if (names) scroller.style.setProperty('--names-row', `${names.getBoundingClientRect().height}px`); + const cap = parseFloat(getComputedStyle(scroller).maxHeight); + if (Number.isFinite(cap)) { + const head = scroller.querySelector('thead')?.getBoundingClientRect().height ?? 0; + const chrome = scroller.offsetHeight - scroller.clientHeight; + let inner = head; + for (const row of [...scroller.querySelectorAll('tbody tr')].reverse()) { + const h = row.getBoundingClientRect().height; + if (inner > head && inner + h + chrome > cap) break; + inner += h; + } + scroller.style.maxHeight = `${Math.ceil(inner + chrome)}px`; + } + scroller.scrollTop = scroller.scrollHeight; + } + $effect(() => { void turns; - if (scroller) scroller.scrollTop = scroller.scrollHeight; + fitRows(); + }); + + $effect(() => { + window.addEventListener('resize', fitRows); + void document.fonts?.ready.then(fitRows); + return () => window.removeEventListener('resize', fitRows); }); const crowded = $derived(duel.others.length >= 3); @@ -221,7 +254,7 @@ } .scroller { - max-height: clamp(170px, 30vh, 400px); + max-height: clamp(170px, 38vh, 420px); overflow: auto; border-top: 1px solid var(--rule-strong); border-bottom: 1px solid var(--rule-strong); @@ -259,16 +292,13 @@ left: 7.4rem; } - thead th.pin-left, - thead th.pin-right { - z-index: 3; - } - thead th.gone { text-decoration: line-through; color: var(--bone-dim); } + /* The header must stack above every body cell, including the pinned ones (z-index 1 and 2 below), + or a row scrolled under it shows its turn number through the header while its letters are hidden. */ thead th { position: sticky; top: 0; @@ -277,7 +307,7 @@ text-align: center; padding: 0.5rem 0 0.15rem; font-size: 1.05rem; - z-index: 1; + z-index: 3; } thead th.you, @@ -287,7 +317,8 @@ } thead tr.hands th { - top: 2.2rem; + /* The measured height of the row above (set by fitRows), so the two sticky rows meet with no gap. */ + top: var(--names-row, 2.2rem); font-size: 0.8rem; font-weight: 400; color: var(--bone-dim); @@ -312,6 +343,13 @@ padding-top: 0.6rem; } + /* The header's own pinned cells sit above the rest of the header, which slides under them sideways. */ + thead th.turn, + thead th.pin-left, + thead th.pin-right { + z-index: 4; + } + .rownote { display: block; font-size: 0.6rem; diff --git a/static/guide/hall.png b/static/guide/hall.png index 2303c18..bf83a27 100644 Binary files a/static/guide/hall.png and b/static/guide/hall.png differ