The table fits the window: the hand stays in reach, the maze takes the height

On a desktop the game grid is now exactly the window's height. The
board and the paper rail share the top row, the rail scrolling in
place, and the hand with its End turn sit pinned beneath — nothing a
first turn needs is ever below the fold. The board's window fills
whatever height the row allows instead of a fixed subtraction. A
wide, short window — a laptop in landscape — stands the hand beside
the board on its own until the player chooses a side, since a tall
maze under a bottom hand left a postage stamp. A short window shrinks
the bottom hand's cards a little. The eyes toggle floats over the
board's corner so it costs no height, and the masthead's "invite
friends" hides on phones where it wrapped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-15 23:19:59 -04:00
co-authored by Claude Fable 5.1
parent 4cc0423846
commit ff35e8ef8e
2 changed files with 47 additions and 16 deletions
+42 -15
View File
@@ -468,6 +468,13 @@
const wideQuery = typeof matchMedia === "undefined" ? null : matchMedia("(min-width: 901px)");
let wideScreen = $state(wideQuery?.matches ?? true);
wideQuery?.addEventListener("change", (e) => (wideScreen = e.matches));
/** A wide, short screen — a laptop in landscape — stands the hand beside
* the board unless the player has said otherwise: a tall maze under a
* bottom hand leaves a postage stamp, and a first turn deserves better. */
const shortWideQuery = typeof matchMedia === "undefined" ? null : matchMedia("(min-width: 1180px) and (max-height: 820px)");
let shortWide = $state(shortWideQuery?.matches ?? false);
shortWideQuery?.addEventListener("change", (e) => (shortWide = e.matches));
const handLeftOn = $derived(prefs.handLeftChosen ? prefs.handLeft : shortWide);
const castPanelOn = $derived(wideScreen && !local.active &&
(selectedCard != null || (youMustRespond && !!view?.stack)));
@@ -497,7 +504,7 @@
* width, capped so the full hand still fits without a scrollbar. */
let handEl = $state<HTMLDivElement | null>(null);
function fitHandCards(el: HTMLDivElement) {
if (!prefs.handLeft) { el.style.removeProperty("--card-zoom"); return; }
if (!handLeftOn) { el.style.removeProperty("--card-zoom"); return; }
// Card.svelte is the source of truth for the card's box: 8.2rem wide,
// 11.4rem tall. 5rem covers the masthead and the column's padding.
const rem = 16;
@@ -518,7 +525,7 @@
});
$effect(() => {
void view?.yourHand.length;
void prefs.handLeft;
void handLeftOn;
const el = handEl;
if (el) requestAnimationFrame(() => fitHandCards(el));
});
@@ -1665,7 +1672,7 @@
{:else if net.roomId}
<button class="mast-room" title="copy the invite link — friends can join, or watch once it's begun"
onclick={copyInvite}>
{net.spectating ? "watching" : "invite friends · room"} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
{#if net.spectating}watching{:else}<span class="mast-invite">invite friends · </span>room{/if} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
</button>
{#if net.audience > 0}
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
@@ -1902,9 +1909,9 @@
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
</label>
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.handLeft}
onchange={(e) => setPref("handLeft", e.currentTarget.checked)} />
<span>Hand at the left: your cards stand in a column beside the board (wide screens)</span>
<input type="checkbox" checked={handLeftOn}
onchange={(e) => { setPref("handLeft", e.currentTarget.checked); setPref("handLeftChosen", true); }} />
<span>Hand at the left: your cards stand in a column beside the board (wide screens; a wide, short window does this on its own until you choose)</span>
</label>
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.stepLightAlways}
@@ -2465,7 +2472,7 @@
</div>
</section>
{:else if view}
<div class="game" class:hand-left={prefs.handLeft}>
<div class="game" class:hand-left={handLeftOn}>
{#snippet tableGuidance()}
{#if youMustRespond || selectedDef || selectedCreature || youMustDiscard || discardMode || discardSelection.size > 0}
<div class="hint-strip">
@@ -3479,7 +3486,9 @@
color: #3a2f1f;
margin: 0.2rem 0 0.1rem;
}
.view-toggle { display: flex; justify-content: flex-end; margin: 0 0 0.3rem; }
/* The eyes toggle floats over the board's corner so it costs the maze no height. */
.board-zone { position: relative; }
.view-toggle { position: absolute; top: 0.25rem; right: 0.25rem; z-index: 3; }
.boxlid-tag {
font-size: 0.95rem;
color: #6b5a41;
@@ -3777,9 +3786,27 @@
* the strip dissolves into the grid so its children place themselves.
* Phones keep the bottom hand; the one-column flow re-orders anyway. */
@media (min-width: 901px) {
/* Bottom hand: the table fills the window — board and rail on top,
* the rail scrolling in place, the hand and its buttons pinned in
* view beneath. The board takes whatever height the row allows. */
.game {
flex: 0 0 auto;
height: calc(100dvh - 5.3rem);
grid-template-rows: minmax(0, 1fr) auto;
}
.paper-rail { overflow-y: auto; min-height: 0; padding-right: 2px; }
.board-zone { flex-direction: column; align-items: stretch; min-height: 0; }
.table-stack { flex: 1 1 0; min-height: 0; }
.board-frame { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
.board-viewport { height: auto; flex: 1 1 0; min-height: 0; }
.fpv-primary .board-viewport { flex: 0 0 30dvh; }
.game:not(.hand-left) .table-edge { margin-top: 0.5rem; }
.game.hand-left {
grid-template-columns: minmax(17.4rem, 24rem) minmax(340px, 1fr) minmax(250px, 330px);
grid-template-rows: minmax(0, 1fr) repeat(5, auto);
row-gap: 0.4rem;
}
.game.hand-left .paper-rail { grid-row: 1 / span 6; }
.game.hand-left .hand :global(.card) { zoom: var(--card-zoom, 1); }
.game.hand-left .table-edge { display: contents; }
.game.hand-left .table-edge > * { grid-column: 2; min-width: 0; }
@@ -3793,17 +3820,12 @@
justify-content: center;
align-content: start;
align-self: start;
position: sticky;
top: 0.5rem;
max-height: calc(100dvh - 5rem);
max-height: 100%;
overflow-y: auto;
padding: 0.3rem 0.3rem 0.9rem;
}
.game.hand-left .board-zone { grid-column: 2; grid-row: 1; }
.game.hand-left .paper-rail { grid-column: 3; grid-row: 1; }
/* With the hand out of the bottom strip, the map claims the height. */
.game.hand-left .board-viewport { height: calc(100dvh - 11rem); }
.game.hand-left .fpv-primary .board-viewport { height: 30dvh; }
.game.hand-left .paper-rail { grid-column: 3; }
}
/* The first-person pane wears exactly the board's width, right above
* it: the stack shrink-wraps to the board, and width:0 + min-width
@@ -4363,6 +4385,10 @@
align-items: flex-end;
}
/* A short desktop window: smaller cards under the board so the maze keeps its height. */
@media (min-width: 901px) and (max-height: 820px) {
.game:not(.hand-left) .hand :global(.card) { zoom: 0.78; }
}
@media (max-width: 900px) {
/* One-column phone flow: board, then your controls and hand, THEN the
paperwork — cards must never hide below the chronicle. */
@@ -4384,6 +4410,7 @@
.hotseat-row .stamp, .claim-row .stamp { flex: 0 0 auto; }
.mast-title { font-size: 1.05rem; white-space: nowrap; }
.mast-sub { display: none; }
.mast-invite { display: none; }
.mast-leave { font-size: 0.72rem; }
}
</style>
+5 -1
View File
@@ -25,6 +25,9 @@ export interface Prefs {
/** The hand of cards stands in a column at the board's left instead of
* lying along the bottom (wide screens only). */
handLeft: boolean;
/** The hand's side was picked by hand; until then a wide, short screen
* stands the cards beside the board on its own. */
handLeftChosen: boolean;
/** The first-turn coaching slip has been read and dismissed. */
coached: boolean;
/** The automaton tier was picked by hand (so a finished game leaves it alone). */
@@ -42,7 +45,7 @@ const KEY = "wizwar-prefs";
function load(): Prefs {
const fallback: Prefs = {
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false,
cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false, handLeftChosen: false,
coached: false, tierChosen: false, finishedGames: 0, stepLightAlways: false,
};
try {
@@ -62,6 +65,7 @@ function load(): Prefs {
instantReplay: p.instantReplay !== false,
liveFp: p.liveFp === true,
handLeft: p.handLeft === true,
handLeftChosen: p.handLeftChosen === true || p.handLeft === true,
coached: p.coached === true,
tierChosen: p.tierChosen === true,
finishedGames: typeof p.finishedGames === "number" ? p.finishedGames : 0,