From 7a6aa3d2a19f35c2b7d10b3e96214c585f3e262b Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 16 Sep 2026 23:47:10 -0400 Subject: [PATCH] View controls above the board, decisions below it, the account beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board's controls had three homes: zoom in the corner, the eyes toggle floating above it, and the turn's actions wrapping into rows beneath. Now a toolbar hugs the board's top — First-person at the left; − · level · + · Fit at the right — and a dock sits beneath it in three groups: what the square offers, the tools (punch a wall, hints, discard) set quieter, and draw beside End turn pinned at the right. While a card is aimed the dock leads with the decision in a line — "Choose a destination · lit squares are within 4 · ends your movement" — and a cancel, so the lit squares always say what they mean. The panel beside the board keeps the card and the full account, now with the instruction first and discarding demoted to a quiet last resort. A phone keeps the offers and End turn in view and its tools behind More. With the hand at the left, the columns grow with the cards so they no longer overlap. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/web/src/App.svelte | 257 ++++++++++++++++++++++++------------ 1 file changed, 175 insertions(+), 82 deletions(-) diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 346406c..5420f67 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -113,6 +113,13 @@ * dimensions behind when the first-person pane closes. */ let boardViewportEl = $state(null); let boardZoom = $state(1); + /** Back to the whole maze at once. */ + function resetZoom() { + boardZoom = 1; + if (boardViewportEl) requestAnimationFrame(() => { boardViewportEl!.scrollLeft = 0; boardViewportEl!.scrollTop = 0; }); + } + /** The phone's dock keeps its secondary tools behind "More". */ + let moreOpen = $state(false); function zoomBoard(dir: number) { const el = boardViewportEl; const prev = boardZoom; @@ -132,6 +139,8 @@ 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`); + // The toolbar above the board hugs the board's drawn width, not the column's. + el.closest(".board-zone")?.style.setProperty("--fit-w", `${Math.max(0, fitW)}px`); } $effect(() => { const el = boardViewportEl; @@ -600,6 +609,25 @@ const cellSelectMode = $derived( (selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null, ); + /** The dock's leading line while a card is being aimed: the decision in + * a few words, beside the board where the tap happens. The panel beside + * the board keeps the card and the full account. */ + const dockLead = $derived.by(() => { + if (!selectedCard || !selectedDef || youMustRespond) return null; + const id = selectedCard.cardId; + if (id === "teleport") return pendingTeleport + ? "Tap the marked square again to jump · or pick another" + : "Choose a destination · lit squares are within 4 · ends your movement"; + if (id === "mental-force") return "Choose the wizard, then where they go · within 3 walked squares"; + if (EDGE_CARDS.has(id)) return "Tap a wall line · lit lines are legal"; + if (cellSelectMode) return "Choose a square · lit squares are legal targets"; + if (selectedDef.cardType === "attack") return selectedDef.adjacent + ? "Choose a target · in your square or the one beside you" + : "Choose a target · lit squares are in your sight"; + if (selectedDef.cardType === "number") return "A NUMBER · play it for movement, or tap a spell to power it"; + if (selectedDef.los) return "Choose a target · lit squares are in your sight"; + return `${selectedDef.name} · its panel offers the choices`; + }); const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1); function dispatch(command: Parameters[0]) { @@ -2844,10 +2872,10 @@ }}>Drop it here {/if} {#if selectedCard && isYourTurn && !youMustRespond} - + }}>discard it instead {/if} {#if selectedCard?.cardId === "add"} — ADD rides a second NUMBER: play one number for movement, @@ -2870,14 +2898,25 @@ {/if} {/snippet}
- {#if view.you && !net.spectating} -
- + +
+
+ {#if view.you && !net.spectating} + + {/if}
- {/if} +
+ + {Math.round(boardZoom * 100)}% + + +
+
{#if stepCaption && me}
@@ -2928,10 +2967,6 @@ povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null} />
-
- - -
@@ -3209,6 +3244,7 @@ {/if} {:else if selectedCard} + {@render tableGuidance()}
(faqCardId = id)} />
{#if attachedNumber || attachedMods.length > 0}
riding along
@@ -3225,7 +3261,7 @@ {/if} {/if} {/if} - {@render tableGuidance()} + {#if youMustRespond}{@render tableGuidance()}{/if} {/if}
- {#if isYourTurn && onWarpToken} - + +
+ {#if dockLead && isYourTurn} +
+ {dockLead} + {#if selectedCard?.cardId === "teleport" && pendingTeleport} + + {/if} + +
{/if} {#if isYourTurn} - - {#if pickChoice} - - — whose? - {#each treasuresHere as t (t.id)} - +
+
+ {#if onWarpToken} + + {/if} + + {#if pickChoice} + + — whose? + {#each treasuresHere as t (t.id)} + + {/each} + + + {/if} + + {#each objectsHere as obj (obj.instanceId)} + {/each} - - - {/if} - - {#each objectsHere as obj (obj.instanceId)} - - {/each} - {#each tearTargets as t (t.id)} - - {/each} - - - - - {#if net.pending} - {Date.now() - net.pending.at > 4000 ? "still sending" : "sending"} {net.pending.label}… - {:else if net.confirmed} - ✓ {net.confirmed} confirmed - {/if} - + {#each tearTargets as t (t.id)} + + {/each} +
+ +
+ + + +
+
+ + {#if net.pending} + {Date.now() - net.pending.at > 4000 ? "still sending" : "sending"} {net.pending.label}… + {:else if net.confirmed} + ✓ {net.confirmed} confirmed + {/if} + +
+
{/if}
@@ -3752,7 +3810,35 @@ margin: 0.2rem 0 0.1rem; } /* The eyes toggle floats over the board's corner so it costs the maze no height. */ - .view-toggle { position: absolute; top: 0.25rem; right: 0.25rem; z-index: 3; } + .board-tools { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + margin: 0 auto 0.35rem; + min-height: 1.9rem; + width: 100%; + max-width: max(var(--fit-w, 100%), 24rem); + } + .tools-left, .tools-right { display: flex; align-items: center; gap: 0.3rem; } + .tool { + height: 1.7rem; + min-width: 1.7rem; + padding: 0 0.55rem; + border: 1px solid #8a7a5e; + border-radius: 4px; + background: rgba(233, 225, 203, 0.9); + color: #43331f; + font-family: "Oswald", sans-serif; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + line-height: 1; + cursor: pointer; + } + .tool:disabled { opacity: 0.35; cursor: default; } + .tool.on { background: #43331f; color: #e9e1cb; border-color: #43331f; } + .zoom-level { font-family: "Courier Prime", monospace; font-size: 0.75rem; color: #c9bd9f; min-width: 2.6rem; text-align: center; } .step-caption { align-self: flex-start; max-width: calc(100% - 11rem); @@ -4047,7 +4133,6 @@ .stamp.primary:hover:not(:disabled) { background: #5a4429; } .stamp.big { font-size: 0.95rem; padding: 0.6rem 1.3rem; } .stamp.tiny { font-size: 0.62rem; padding: 0.22rem 0.5rem; box-shadow: 1px 1px 0 rgba(0,0,0,0.3); } - .stamp.discard-one { background: #8a7a5e; } .prefs-slip { text-align: left; } .pref-row { display: flex; @@ -4165,7 +4250,7 @@ * most six, and the hand must span them all to hold the column. */ grid-row: 1 / span 6; display: grid; - grid-template-columns: repeat(2, 8.2rem); + grid-template-columns: repeat(2, calc(8.2rem * var(--card-zoom, 1))); justify-content: center; align-content: start; align-self: start; @@ -4213,26 +4298,6 @@ 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 #8a7a5e; - 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 { @@ -4263,7 +4328,7 @@ align-items: baseline; } .offline-note { font-style: italic; color: #6b3a2f; } - .game.offline .hand, .game.offline .board-zone, .game.offline .actions .stamp { pointer-events: none; opacity: 0.55; } + .game.offline .hand, .game.offline .board-zone, .game.offline .dock .stamp { pointer-events: none; opacity: 0.55; } .sending { font-family: "Courier Prime", monospace; font-size: 0.78rem; @@ -4461,7 +4526,30 @@ color: #43331f; } - .actions { position: relative; display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; min-height: 2rem; } + /* The dock: one row, three homes. The square's offers at the left, the + * tools in the middle, the turn's end pinned at the right whatever wraps. */ + .dock { position: relative; display: flex; flex-direction: column; gap: 0.45rem; min-height: 2.4rem; } + .dock-row { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; } + .dock-context, .dock-tools { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; } + .dock-tools { padding-left: 0.55rem; border-left: 1px solid rgba(233, 225, 203, 0.25); } + .dock-turn { position: relative; margin-left: auto; display: flex; align-items: center; gap: 0.55rem; } + .stamp.quiet { background: rgba(233, 225, 203, 0.14); color: #e9e1cb; border-color: rgba(233, 225, 203, 0.55); box-shadow: none; } + .stamp.quiet:hover:not(:disabled) { background: rgba(233, 225, 203, 0.26); } + .more-btn { display: none; } + .dock-lead { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + background: rgba(233, 225, 203, 0.12); + border: 1px dashed rgba(233, 225, 203, 0.35); + border-radius: 4px; + padding: 0.4rem 0.7rem; + font-size: 0.92rem; + color: #e9e1cb; + } + .dock-cancel { margin-left: auto; } + .discard-one { margin-left: auto; opacity: 0.8; } .draw-pick { color: #a49c86; font-size: 0.9rem; } .attack-scrim { @@ -4787,6 +4875,11 @@ .inspector-card { transform: scale(1.3); } .board-zone { order: 1; } .table-edge { order: 2; margin-top: 0.6rem; } + /* The phone's dock stays compact: the offers and the turn's end in + view, the tools behind More. */ + .more-btn { display: inline-block; } + .dock-tools { display: none; border-left: 0; padding-left: 0; flex-basis: 100%; } + .dock-tools.open { display: flex; } .paper-rail { order: 3; margin-top: 0.9rem; } .board-viewport { height: 56dvh;