diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte
index be91f5b..64f5337 100644
--- a/packages/web/src/App.svelte
+++ b/packages/web/src/App.svelte
@@ -437,6 +437,16 @@
const TWO_CELL_CARDS = new Set(["trader", "dimensional-warp", "redirection"]);
const CREATURE_TARGET_CARDS = new Set(["mega-monster"]);
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner", "power-attack"]);
+ /** Casting panel: on wide screens the paper-rail's chronicle yields to
+ * the spell being built (or answered), controls and all — table talk
+ * returns the moment the cast resolves. Phones keep the under-board
+ * strips; a rail below the fold is no place to aim from. */
+ const wideQuery = typeof matchMedia === "undefined" ? null : matchMedia("(min-width: 901px)");
+ let wideScreen = $state(wideQuery?.matches ?? true);
+ wideQuery?.addEventListener("change", (e) => (wideScreen = e.matches));
+ const castPanelOn = $derived(wideScreen && !local.active &&
+ (selectedCard != null || (youMustRespond && !!view?.stack)));
+
/** The hand in the player's own order: drag a card onto another to take
* its place. The order lives only in this browser — the server's hand
* array is authority for contents, this for arrangement. */
@@ -2095,6 +2105,201 @@
{:else if view}
+ {#snippet tableGuidance()}
+ {#if youMustRespond || selectedDef || selectedCreature || youMustDiscard || discardMode || discardSelection.size > 0}
+
+ {#if selectedCreature && !selectedDef}
+ {@const sc = view.creatures.find((c) => c.id === selectedCreature)}
+ {#if sc}
+ Commanding {cardDef(sc.kind).name} — {creatureStats(sc)}.
+ Tap a square beside it to march, a target in its square to attack.
+ {#if dmWarnCell}
+ ⚠ it will claw YOU the moment it enters your square — tap again if you mean it
+ {/if}
+ (selectedCreature = null)}>done
+ {/if}
+ {/if}
+ {#if youMustRespond && view.stack}
+
+ {#if view.stack.defenderId === view.you && selectedCard?.cardId === "teleport"}
+ {pendingTeleport
+ ? "Escaping by teleport — tap the marked square again to jump."
+ : "Escaping by teleport — tap a square within four spaces to mark it."}
+ {:else if view.stack.defenderId === view.you}
+ {view.stack.attackerId} attacks with
+ {attackingCreature ? `the ${cardDef(attackingCreature.kind).name}`
+ : view.stack.attackCard?.cardId === "illusionary-attack" && view.stack.params?.cardId
+ ? `an illusionary ${cardDef(view.stack.params.cardId).name}`
+ : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name
+ : view.stack.tearTreasure ? "a grab at your treasure" : "a punch"} —
+ tap a counteraction card, or
+ {:else}
+ {view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""}
+ {/if}
+
+ let it resolve
+ 🎓 hints
+ {/if}
+ {#if youMustDiscard}
+ Hand over the limit — mark cards and discard.
+ {:else if discardMode && discardSelection.size === 0}
+ Mark the cards to throw away, then confirm.
+ {/if}
+ {#if discardSelection.size > 0}
+ Discard {discardSelection.size} marked
+ {/if}
+ {#if discardMode || (discardSelection.size > 0 && !youMustDiscard)}
+ never mind
+ {/if}
+ {#if selectedDef}
+ {selectedDef.name}
+ {#if pendingCellFor}
+ — now click where {pendingCellFor} goes{selectedCard?.cardId === "mental-force"
+ ? " (within three walked spaces of them — no sight needed)" : ""}
+ {/if}
+ {#if selectedCard && EDGE_CARDS.has(selectedCard.cardId)}
+ — {EDGE_HINTS[selectedCard.cardId] ?? "click a wall line"}
+ {/if}
+ {#if selectedCard && WAND_IDS.has(selectedCard.cardId) && view.wandCharges[selectedCard.instanceId] == null && !attachedNumber}
+ — first use sets the charges: tap a NUMBER card first, or cast bare for a single charge
+ {:else if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")}
+ — click a target, or a wall line to batter it{attachedNumber ? ` (powered by a ${numberTotal})` : ""}
+ {/if}
+ {#if attachedMods.length > 0}
+ [+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]
+ {/if}
+ {#if attachedMods.some((m) => m.cardId === "power-attack")}
+ burn
+
+ {#each [1, 2, 3, 4, 5] as n (n)}
+ {#if me && n < me.life}{n} {/if}
+ {/each}
+
+ life for +{paPoints} damage
+ {/if}
+ {#if selectedCard?.cardId === "boobytrap"}
+ — place 4 tokens ({trapCells.length}/4; the first is real)
+ {/if}
+ {#if selectedCard?.cardId === "redirection"}
+ {tradeFrom ? "— now the exit it should connect TO" : "— click the first exit"}
+ {:else if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)}
+ {tradeFrom ? "— now the second square" : "— click the first square"}
+ {/if}
+ {#if selectedCard?.cardId === "teleport" && !youMustRespond}
+ {pendingTeleport
+ ? "— tap the marked square again to jump, or pick another"
+ : "— tap a destination (up to four squares, walls ignored)"}
+ {#if pendingTeleport}
+ teleport!
+ {/if}
+ {/if}
+ {#if selectedCard?.cardId === "mega-monster"}
+ — double its
+ (megaBoost = "life")}>life-points
+ (megaBoost = "movement")}>movement
+ — then tap the monster
+ {/if}
+ {#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"}
+ hold the door open
+ {/if}
+ {#if selectedCard?.cardId === "master-key" && isYourTurn &&
+ !me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)}
+ Display the key
+ {/if}
+ {#if selectedCard?.cardId === "rotate-sector"}
+ clockwise
+ — click the sector
+ {/if}
+ {#if selectedCard?.cardId === "relocate-sector"}
+ {pendingSectorFrom
+ ? (relocateGhosts?.length
+ ? "— now tap a dashed landing beside the maze"
+ : "— that sector has nowhere legal to go; pick another")
+ : "— click the sector to move"}
+ {/if}
+ {#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)}
+ name
+
+ {#each nameSuggestions as n (n)} {/each}
+
+ {#if selectedCard.cardId === "deja-vu"}
+ {
+ const id = nameToCardId(nameInput);
+ if (id && selectedCard) {
+ dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cardId: id } });
+ clearSelection();
+ }
+ }}>Retrieve
+ {:else}
+ — then click the target
+ {/if}
+ {/if}
+ {#if selectedCard?.cardId === "swap-meet"}
+ {#if !swapMeetTarget}
+ — click the other trader
+ {:else if myTradables.length === 0}
+ — you carry no items to trade
+ {:else if !swapMine}
+ — trade away your:
+ {#each myTradables as c (c.key)}
+ (swapMine = c.key)}>{c.label}
+ {/each}
+ {:else if theirTradables.length === 0}
+ — they show no item you could claim
+ {:else}
+ — and take their:
+ {#each theirTradables as c (c.key)}
+ castSwapMeet(c.key)}>{c.label}
+ {/each}
+ {/if}
+ {/if}
+ {#if selectedCard?.cardId === "waterbolt"}
+ damage
+ (knockback {numberTotal - wbDamage})
+ {/if}
+ {#if selectedCard?.cardId === "power-run"}
+ life to trade
+ Run!
+ {/if}
+ {#if selectedCard && confirmCastable(selectedCard.cardId)}
+
+ Cast{attachedNumber ? ` with the ${numberTotal}` : ""}
+
+ {/if}
+ {#if selectedCard && isMovableObject(selectedCard.cardId) && isYourTurn}
+ {
+ if (selectedCard) dispatch({ type: "dropObject", instanceId: selectedCard.instanceId });
+ clearSelection();
+ }}>Drop it here
+ {/if}
+ {#if selectedCard && isYourTurn && !youMustRespond}
+ {
+ if (selectedCard) dispatch({ type: "discard", instanceIds: [selectedCard.instanceId] });
+ clearSelection();
+ }}>Discard it
+ {/if}
+ {#if selectedCard?.cardId === "add"}
+ — ADD rides a second NUMBER: play one number for movement,
+ then play another, and the Add spends itself
+ {/if}
+ {#if selectedCard && isNumberCard(selectedCard.cardId)}
+ {@const needsAdd = view.turn.numberPlayedForMovement}
+ {@const haveAdd = view.yourHand.some((c) => c.cardId === "add")}
+ {#if needsAdd && !haveAdd}
+ — a second movement number needs an ADD card
+ {:else}
+
+ Play for +{cardDef(selectedCard.cardId).value} movement{needsAdd ? " (spends your Add)" : ""}
+
+ {/if}
+ {/if}
+ cancel
+ {/if}
+
+ {/if}
+ {/snippet}
{#if prefs.liveFp && view.you && !net.spectating}
@@ -2331,7 +2536,51 @@
-
+ {#if youMustRespond && view.stack}
+
⚔ incoming
+ {#if view.stack.attackCard}
+
(faqCardId = id)} />
+ {:else}
+
a physical blow — no card behind it
+ {/if}
+ {#if view.stack.numberValue != null || view.stack.amplifyFactor > 1}
+
+ {view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""}
+ {view.stack.amplifyFactor > 1 ? ` amplified ×${view.stack.amplifyFactor}` : ""}
+
+ {/if}
+ {#if view.stack.counters.length > 0}
+
counters so far
+
+ {#each view.stack.counters as c (c.card.instanceId)}
+
+ {}} />
+ {/each}
+
+ {/if}
+ {:else if selectedCard}
+
(faqCardId = id)} />
+ {#if attachedNumber || attachedMods.length > 0}
+
riding along
+
+ {#if attachedNumber}
+ {}} />
+ {/if}
+ {#each attachedMods as m (m.instanceId)}
+ {}} />
+ {/each}
+
+ {#if attachedNumber}
+
number total {numberTotal}
+ {/if}
+ {/if}
+ {/if}
+ {@render tableGuidance()}
+
+ {/if}
+
{#if local.active}
{#each local.log as line, i (i)}
@@ -2355,7 +2604,7 @@
onclick={() => local.rollTableDie()}>🎲 roll the die
{/if}
- {#if !local.active && net.roomId && !net.spectating}
+ {#if !local.active && net.roomId && !net.spectating && !castPanelOn}