Files
waving-hands/src/lib/components/ReportSlip.svelte
T
Eric WagonerandClaude Opus 5.5 6f9d4de92d An editing pass over the words around the game, and three corrections in the guide
The guide, the hall, the room page and the explanations after a turn
lose their closing aphorisms, most of their semicolons, the
statement-colon-example lines and the page narrating itself. The
tagline is one sentence, and 'a labor of love' is gone from the about
link. The chronicle's imagery and the in-world names stay.

Corrected along the way: a counter-spell does stop wound spells and
lightning (the guide said it did not); a shield is not the only defence
against a newly summoned creature; on the first turn a knife can hurt
you; a palm is no defence against a fireball; a goblin needs five turns
to match one; and only the offline duel lives in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-10 18:51:59 -04:00

185 lines
4.4 KiB
Svelte

<script lang="ts">
import type { Duel } from '$lib/game/duel.svelte';
import { api } from '$lib/game/client';
let { duel, open = $bindable(false) }: { duel: Duel; open?: boolean } = $props();
let happened = $state('');
let expected = $state('');
let image = $state<File | null>(null);
let preview = $state<string | null>(null);
let busy = $state(false);
let sent = $state(false);
let error = $state('');
function pickImage(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0] ?? null;
if (preview) URL.revokeObjectURL(preview);
if (file && file.size > 2_500_000) {
error = 'That picture is over 2.5 MB; a smaller one, please.';
image = null;
preview = null;
return;
}
error = '';
image = file;
preview = file ? URL.createObjectURL(file) : null;
}
function close() {
open = false;
if (preview) URL.revokeObjectURL(preview);
happened = '';
expected = '';
image = null;
preview = null;
sent = false;
error = '';
}
async function send(e: SubmitEvent) {
e.preventDefault();
const r = duel.remote;
if (!r || !happened.trim() || busy) return;
busy = true;
error = '';
try {
const { id } = await api.report(r.roomId, r.token, happened.trim(), expected.trim());
if (image) await api.reportImage(id, image).catch(() => (error = 'The report went; the picture did not.'));
sent = true;
setTimeout(close, 2200);
} catch (err) {
error = err instanceof Error ? err.message : 'The report did not reach the keeper.';
} finally {
busy = false;
}
}
</script>
{#if open && duel.remote}
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
<form class="slip" aria-labelledby="report-heading" onsubmit={send}>
<h2 id="report-heading">Something amiss?</h2>
{#if sent}
<p class="thanks">Recorded beside the room's ledger, with the turn. Thank you. The keeper will look into it.{#if error} {error}{/if}</p>
{:else}
<label class="field">
<span>What happened?</span>
<textarea bind:value={happened} rows="3" maxlength="2000" placeholder="e.g. my fireball landed but Wynne took no damage"></textarea>
</label>
<label class="field">
<span>What did you expect?</span>
<textarea bind:value={expected} rows="2" maxlength="2000" placeholder="e.g. five points of damage"></textarea>
</label>
<label class="field picture">
<span>A screenshot, if you have one</span>
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} />
{#if preview}<img class="preview" src={preview} alt="The screenshot you chose" />{/if}
</label>
<p class="muted small">The room code and the turn are sent with it, so the keeper can replay the duel to this moment.</p>
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions">
<button type="submit" class="reveal small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
<button type="button" class="link" onclick={close}>never mind</button>
</div>
{/if}
</form>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 16px;
}
.slip {
width: min(100%, 30rem);
max-height: 100%;
overflow-y: auto;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 1rem 1.2rem 1.2rem;
}
h2 {
font-size: 1.3rem;
margin-bottom: 0.6rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.7rem;
}
textarea {
font: inherit;
color: var(--bone);
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.6rem;
resize: vertical;
}
textarea:focus-visible {
outline: 2px solid var(--frost);
outline-offset: 1px;
}
.picture input {
font-size: 0.9rem;
}
.preview {
max-height: 8rem;
max-width: 100%;
border: 1px solid var(--rule);
border-radius: 3px;
margin-top: 0.3rem;
}
.small {
font-size: 0.85rem;
margin-top: 0.6rem;
}
.warning {
color: var(--blood);
margin-top: 0.4rem;
}
.thanks {
color: var(--bone);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.9rem;
}
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.link {
background: none;
border: 0;
color: var(--bone-dim);
text-decoration: underline dotted;
padding: 0;
font: inherit;
}
</style>