The board gets a zoom dial, and its size is never left to chance

Players wanted to magnify crowded squares; the board now lives in a
scrolling viewport with +/− controls (1x fit to 4x), zoom centered on
the view. The svg's width is always set explicitly — fit × zoom,
remeasured by a ResizeObserver — which also cures the stale small
board left behind when the first-person pane closed: auto-sizing
against shifting CSS constraints is gone entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-29 22:57:55 -04:00
co-authored by Claude Fable 5
parent 7f82aa47e0
commit 4430a0def1
+87 -12
View File
@@ -100,6 +100,45 @@
if (!prefs.cautions || reasons.length === 0) go();
else caution = { reasons, go };
}
/** Board zoom: 1 = fitted to the viewport; higher magnifies for crowded
* squares, with the viewport scrolling. The svg's width is always set
* explicitly (fit × zoom) from measurement — auto-sizing left stale
* dimensions behind when the first-person pane closed. */
let boardViewportEl = $state<HTMLDivElement | null>(null);
let boardZoom = $state(1);
function zoomBoard(dir: number) {
const el = boardViewportEl;
const prev = boardZoom;
const next = dir > 0 ? prev * 1.4 : prev / 1.4;
boardZoom = next < 1.05 ? 1 : Math.min(4, Math.round(next * 100) / 100);
if (el && boardZoom !== prev) {
const f = boardZoom / prev;
requestAnimationFrame(() => {
el.scrollLeft = (el.scrollLeft + el.clientWidth / 2) * f - el.clientWidth / 2;
el.scrollTop = (el.scrollTop + el.clientHeight / 2) * f - el.clientHeight / 2;
});
}
}
function fitBoard(el: HTMLDivElement) {
const svg = el.querySelector("svg.board") as SVGSVGElement | null;
const vb = svg?.viewBox.baseVal;
if (!vb || !vb.width || !vb.height) return;
const fitW = Math.min(el.clientWidth, (el.clientHeight * vb.width) / vb.height);
el.style.setProperty("--fit-w", `${Math.max(0, fitW)}px`);
}
$effect(() => {
const el = boardViewportEl;
if (!el) return;
const ro = new ResizeObserver(() => fitBoard(el));
ro.observe(el);
return () => ro.disconnect();
});
$effect(() => {
void view; // the board's viewBox grows with ghost slots and relocations
const el = boardViewportEl;
if (el) requestAnimationFrame(() => fitBoard(el));
});
/** A move with its regret checks: stepping off your home still laden,
* or stepping onto ground that bites. */
function tryMove(direction: Side, over?: boolean) {
@@ -1965,6 +2004,8 @@
: { type: "warpStep" })}
edgeSelect={edgeSelectMode && yourMoment} />
{/if}
<div class="board-frame">
<div class="board-viewport" bind:this={boardViewportEl} style:--zoom={boardZoom}>
<Board
{view}
edgeSelectMode={edgeSelectMode && yourMoment}
@@ -1989,6 +2030,12 @@
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
/>
</div>
<div class="zoom-controls" role="group" aria-label="board zoom">
<button class="zoom-btn" title="zoom in" onclick={() => zoomBoard(1)} disabled={boardZoom >= 4}>+</button>
<button class="zoom-btn" title="zoom out" onclick={() => zoomBoard(-1)} disabled={boardZoom <= 1}></button>
</div>
</div>
</div>
</section>
<aside class="paper-rail">
@@ -2994,9 +3041,10 @@
* keeps the pane out of the intrinsic sizing so the BOARD alone
* decides how wide the column is. */
.table-stack {
display: inline-flex;
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
max-width: 100%;
min-width: 0;
}
@@ -3005,19 +3053,46 @@
/* FPV play: the 3D view is the primary window into the game; the
* top-down board shrinks to a keymap beneath it. */
.table-stack.fpv-primary {
display: flex;
width: 100%;
max-width: 64rem;
}
.board-zone .table-stack.fpv-primary :global(svg.board) {
max-height: 30dvh;
align-self: center;
.board-frame { position: relative; min-width: 0; }
/* The board's window: it owns the fitted size, the svg wears an
* explicit width (fit × zoom), and anything past 1× scrolls. */
.board-viewport {
height: calc(100dvh - 21.5rem);
width: 100%;
overflow: auto;
display: flex;
}
.board-zone :global(svg.board) {
max-height: calc(100dvh - 21.5rem);
width: auto;
max-width: 100%;
.fpv-primary .board-viewport { height: 30dvh; }
.board-viewport :global(svg.board) {
width: calc(var(--fit-w, 100%) * var(--zoom, 1));
flex: none;
max-width: none;
max-height: none;
height: auto;
margin: auto;
}
.zoom-controls {
position: absolute;
right: 0.4rem;
bottom: 0.4rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.zoom-btn {
width: 1.7rem;
height: 1.7rem;
border: 1px solid #8a7a5c;
border-radius: 4px;
background: rgba(233, 225, 203, 0.9);
color: #43331f;
font-size: 1.05rem;
line-height: 1;
cursor: pointer;
}
.zoom-btn:disabled { opacity: 0.35; cursor: default; }
.paper-rail { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
.slip {
@@ -3501,8 +3576,8 @@
.board-zone { order: 1; }
.table-edge { order: 2; margin-top: 0.6rem; }
.paper-rail { order: 3; margin-top: 0.9rem; }
.board-zone :global(svg.board) {
max-height: 56dvh;
.board-viewport {
height: 56dvh;
}
.chronicle { max-height: 30dvh; flex: initial; }
.masthead { gap: 0.45rem; padding-top: 0.5rem; }