What you missed, in facts; a timeline on the reel; room for table talk

A returning player's slip now lists what happened while they were away
— "Automaton took your treasure.", "You lost 3 life.", "It's your
turn." — each fact opening the catch-up reel at its moment, read from
the missed steps as the server redacted them for that seat; when
nothing touched them it says what each wizard was up to instead. The
reel gains a timeline: a tick per move in the mover's color, taller
where a turn begins, a blow lands, or gold changes hands, and any tick
jumps the reel there.

The chronicle takes a filter — All, Game, Table talk — with an unread
count on the talk that arrived while the casting panel covered it (a
wizard's own words never count), a peek at the latest line above the
composer while the panel is up, and the composer itself no longer
leaves when a response is owed.

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-17 00:22:54 -04:00
co-authored by Claude Fable 5.1
parent 80dd7a7865
commit 8582b7feb7
4 changed files with 262 additions and 13 deletions
+76 -8
View File
@@ -10,6 +10,7 @@
import Card from "./Card.svelte";
import Help from "./Help.svelte";
import type { HelpTab } from "./help-state";
import { summarizeMissed } from "./catchup";
import Replay from "./Replay.svelte";
import FxGallery from "./FxGallery.svelte";
import TokenGallery from "./TokenGallery.svelte";
@@ -1476,6 +1477,26 @@
return view ? view.treasures.filter((t) => t.owner !== p.id && t.position &&
t.position.x === p.home.x && t.position.y === p.home.y).length : 0;
}
/** What the chronicle shows: everything, the game's own lines, or the talk. */
let logFilter = $state<"all" | "game" | "talk">("all");
const isTalk = (text: string) => text.startsWith("\u{1F4AC}") || text.startsWith("\u{1F3B2}");
function showLine(line: { text: string }): boolean {
return logFilter === "all" ? true : logFilter === "talk" ? isTalk(line.text) : !isTalk(line.text);
}
/** Talk that arrived while the chronicle was covered or filtered away. */
let seenTalk = $state(0);
$effect(() => {
const count = net.chatCount;
if ((!castPanelOn && logFilter !== "game") || net.lastTalkBy === net.you) seenTalk = count;
});
const unreadTalk = $derived(Math.max(0, net.chatCount - seenTalk));
const lastTalk = $derived.by(() => {
for (let i = net.log.length - 1; i >= 0; i--) {
const t = net.log[i]!.text;
if (t.startsWith("\u{1F4AC}")) return t.slice(2).trim();
}
return null;
});
/** The table is out of reach: play waits, and nothing here can be sent. */
const offline = $derived(!local.active && net.roomId != null && net.status !== "connected");
// Talk that never arrived goes back into the composer, with a word.
@@ -2354,7 +2375,7 @@
<Replay steps={net.moment.steps} moment pov={net.moment.owner}
onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
{:else if net.catchUp && net.catchUp.length > 0}
<Replay steps={net.catchUp}
<Replay steps={net.catchUp} startAt={net.catchUpStart}
onshare={net.catchUp[0]?.seq === 0 ? () => net.requestShare(-1) : null}
onclose={() => net.closeCatchUp()} />
{:else if local.replaySteps && local.replaySteps.length > 0}
@@ -2983,9 +3004,24 @@
<aside class="paper-rail">
{#if !local.active && net.missedMoves > 0}
<div class="slip catchup">
You missed {net.missedMoves} move{net.missedMoves === 1 ? "" : "s"}.
<button class="stamp tiny" onclick={() => net.requestCatchUp()}>▶ Watch what happened</button>
<button class="hint-cancel" onclick={() => net.markSeen()}>skip</button>
<div class="catchup-head">
You missed {net.missedMoves} move{net.missedMoves === 1 ? "" : "s"}.
<button class="stamp tiny" onclick={() => net.watchCatchUp(0)}>▶ Watch what happened</button>
<button class="hint-cancel" onclick={() => net.markSeen()}>skip</button>
</div>
{#if net.missedSteps && view.you}
<ul class="catchup-facts">
{#each summarizeMissed(net.missedSteps, view.you, view) as f, i (i)}
<li>
{#if f.step >= 0}
<button class="fact-jump" onclick={() => net.watchCatchUp(f.step)} title="watch this moment">▶ {f.text}</button>
{:else}
<span>{f.text}</span>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
{#if view.phase === "finished"}
@@ -3273,6 +3309,16 @@
{#if youMustRespond}{@render tableGuidance()}{/if}
</div>
{/if}
<div class="log-filters" class:tucked={castPanelOn} role="group" aria-label="what the chronicle shows">
{#each [["all", "All"], ["game", "Game"], ["talk", "Table talk"]] as [k, label] (k)}
<button class:current={logFilter === k} onclick={() => (logFilter = k as typeof logFilter)}>
{label}{#if k === "talk" && unreadTalk > 0}<span class="talk-badge">{unreadTalk}</span>{/if}
</button>
{/each}
</div>
{#if castPanelOn && !local.active && unreadTalk > 0 && lastTalk}
<div class="talk-peek">💬 {lastTalk}</div>
{/if}
<div class="chronicle" class:tucked={castPanelOn} aria-label="game log" bind:this={chronicleEl}
onscroll={onChronicleScroll}>
{#snippet receipt(line: { text: string; receipt?: string[] })}
@@ -3284,13 +3330,13 @@
{/if}
{/snippet}
{#if local.active}
{#each local.log as line, i (i)}
{#each local.log.filter(showLine) as line, i (i)}
<div class:table-talk={line.text.startsWith("\u{1F4AC}")}>
{#if line.receipt}{@render receipt(line)}{:else}{line.text}{/if}
</div>
{/each}
{:else}
{#each net.log as line, i (i)}
{#each net.log.filter(showLine) as line, i (i)}
<div class:table-talk={line.text.startsWith("\u{1F4AC}")}>
<span class="log-mark" class:blank={!line.actor} style:background={line.actor ? playerColor(line.actor) : "transparent"}></span>{#if line.receipt}{@render receipt(line)}{:else}{line.text}{/if}
{#if line.notable && line.turn !== null && prefs.instantReplay && !net.spectating}
@@ -3307,7 +3353,7 @@
onclick={() => local.rollTableDie()}>🎲 roll the die</button>
</div>
{/if}
{#if !local.active && net.roomId && !net.spectating && !castPanelOn}
{#if !local.active && net.roomId && !net.spectating}
<form class="say-box" onsubmit={(e) => {
e.preventDefault();
const t = chatDraft.trim();
@@ -4331,7 +4377,29 @@
.slip.coach .stamp { margin-left: 0.5rem; }
.slip.urgent { border-left: 4px solid #b3372b; transform: rotate(0.4deg); }
.slip.ambush-note { border-left: 4px solid #43331f; font-size: 0.85rem; }
.slip.catchup { border-left: 4px solid #5b3f9e; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.slip.catchup { border-left: 4px solid #5b3f9e; }
.catchup-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.catchup-facts { list-style: none; margin: 0.45rem 0 0; padding: 0; font-size: 0.9rem; }
.catchup-facts li { margin: 0.15rem 0; }
.fact-jump { background: none; border: 0; padding: 0; font: inherit; color: #43331f; cursor: pointer; text-align: left; }
.fact-jump:hover { text-decoration: underline; }
.log-filters { display: flex; gap: 0.25rem; margin-bottom: 0.3rem; }
.log-filters.tucked { display: none; }
.log-filters button {
font-family: "Oswald", sans-serif;
font-size: 0.62rem;
letter-spacing: 0.1em;
text-transform: uppercase;
background: none;
border: 1px solid rgba(233, 225, 203, 0.35);
border-radius: 3px;
color: #c9bd9f;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
.log-filters button.current { border-color: #e9e1cb; color: #e9e1cb; background: rgba(233, 225, 203, 0.14); }
.talk-badge { margin-left: 0.3rem; background: #b3372b; color: #fff; border-radius: 999px; padding: 0 0.35rem; font-size: 0.6rem; }
.talk-peek { font-family: "Courier Prime", monospace; font-size: 0.82rem; color: #e9e1cb; background: rgba(233, 225, 203, 0.12); border-left: 3px solid #b3372b; padding: 0.3rem 0.5rem; border-radius: 3px; }
/* The table out of reach: one plain notice, and the controls beneath it go quiet. */
.slip.offline-slip {
border-left: 4px solid #b3372b;