Mid-game invite links and a surprise-report slip

The masthead's room code is now a button: one click copies the /join
link, which seats newcomers in a waiting room and the gallery once the
boards flip. And a 🐞 report opens a two-question slip — what happened,
what was expected — that lands in feedback.jsonl beside the rooms with
the room code, move number, round, and rules rev attached, so any
surprise can be replayed to its exact moment.

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-30 14:08:39 -04:00
co-authored by Claude Fable 5
parent a1fdb37c54
commit 553754a596
4 changed files with 113 additions and 3 deletions
+78 -2
View File
@@ -1179,6 +1179,23 @@
/** The preferences slip (client-side taste, saved on this device). */
let prefsOpen = $state(false);
/** The bug-report slip: what happened vs. what was expected. The room
* code and move number ride along server-side, pinning the moment in
* the ledger for replay. */
let feedbackOpen = $state(false);
let fbHappened = $state("");
let fbExpected = $state("");
let fbSent = $state(false);
function submitFeedback() {
net.sendFeedback(fbHappened.trim(), fbExpected.trim());
fbSent = true;
setTimeout(() => {
feedbackOpen = false;
fbSent = false;
fbHappened = "";
fbExpected = "";
}, 1600);
}
/** First tap of marching the Democratic Monster onto YOURSELF: warn once. */
let dmWarnCell = $state<string | null>(null);
@@ -1360,7 +1377,10 @@
<button class="mast-leave" onclick={() => local.leave()}>set the game aside</button>
<button class="mast-leave" onclick={() => local.abandon()}>abandon game</button>
{:else if net.roomId}
<span class="mast-room">{net.spectating ? "watching" : "room"} <b>{net.roomId}</b></span>
<button class="mast-room" title="copy the invite link — friends can join, or watch once it's begun"
onclick={copyInvite}>
{net.spectating ? "watching" : "room"} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
</button>
{#if net.audience > 0}
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
{/if}
@@ -1368,6 +1388,8 @@
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
{/if}
<button class="mast-leave" onclick={() => net.leave()}>{net.spectating ? "leave the gallery" : "leave table"}</button>
<button class="mast-leave" title="something behaved unexpectedly? tell the wizards"
onclick={() => (feedbackOpen = true)}>🐞 report</button>
{/if}
<button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = "play"; showHelp = true; }}>
help &amp; rules
@@ -1462,6 +1484,35 @@
{/if}
{/if}
{#if feedbackOpen && net.roomId}
<div class="scrim attack-scrim" role="button" tabindex="-1"
onclick={() => (feedbackOpen = false)} onkeydown={() => {}}>
<div class="attack-notice prefs-slip" role="dialog" aria-label="report a surprise" tabindex="-1"
onclick={(ev) => ev.stopPropagation()} onkeydown={() => {}}>
<div class="attack-title">Something unexpected?</div>
{#if fbSent}
<p class="fb-thanks">Recorded beside the room's ledger — thank you. The wizards will study the moment.</p>
{:else}
<label class="fb-field">
<span>What happened?</span>
<textarea bind:value={fbHappened} rows="3" maxlength="2000"
placeholder="e.g. my fireball hit but no damage was taken"></textarea>
</label>
<label class="fb-field">
<span>What did you expect?</span>
<textarea bind:value={fbExpected} rows="3" maxlength="2000"
placeholder="e.g. 5 points of damage"></textarea>
</label>
<p class="fb-note">The room code and move number ride along automatically.</p>
<div class="attack-actions">
<button class="stamp primary" disabled={!fbHappened.trim()} onclick={submitFeedback}>Send it</button>
<button class="hint-cancel" onclick={() => (feedbackOpen = false)}>never mind</button>
</div>
{/if}
</div>
</div>
{/if}
{#if prefsOpen}
<div class="scrim attack-scrim" role="button" tabindex="-1"
onclick={() => (prefsOpen = false)} onkeydown={() => {}}>
@@ -2619,8 +2670,33 @@
letter-spacing: 0.06em;
color: #6b6454;
}
.mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
.mast-room {
margin-left: auto;
font-size: 0.85rem;
color: #a49c86;
background: none;
border: none;
font-family: inherit;
padding: 0;
cursor: pointer;
}
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
.fb-field { display: block; margin: 0.5rem 0; }
.fb-field span { display: block; margin-bottom: 0.2rem; color: #6b5a41; }
.fb-field textarea {
width: 100%;
box-sizing: border-box;
font-family: inherit;
font-size: 0.9rem;
padding: 0.35rem 0.5rem;
border: 1px solid #8a7a5e;
border-radius: 4px;
background: #f3ecd8;
color: #43331f;
resize: vertical;
}
.fb-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; }
.fb-thanks { color: #43331f; padding: 0.6rem 0; }
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
.mind-read { max-width: min(92vw, 46rem); }
.caution-reason { padding: 0.15rem 0; }