Files
game-kit/template/src/lib/components/ReportSlip.svelte
T
Eric WagonerandClaude Opus 5.5 bbbe8ec312 Writing for players: the editing passes' lessons, and the template written by them
The conventions gain a list of the habits players read as generated text
(a semicolon in every paragraph, statement-colon-explanation, "The" plus
an abstract noun as a heading, clipped fragments in threes, closing
aphorisms, "X, not Y", the page talking about itself), and of the errors
that come from inherited text: hidden information where there is none,
points where there are none, "both" at a larger table, unchecked claims,
and ledger-stored words reworded. new-game.sh points at the list.

The template follows it: the about link loses 'a labor of love', the
tabs are Origins and Games so far, and the hall, lobby, room, report
slip, table talk, guide and demo board read as Waving Hands' and
Hnefatafl's now do. The landing page and family.json carry the two
games' new taglines and lose their semicolons and the tip jar's aphorism.

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

156 lines
4.0 KiB
Svelte

<script lang="ts">
import { api, type Room } from '$lib/net/room.svelte';
let { room, open = $bindable(false) }: { room: Room; 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();
if (!happened.trim() || busy) return;
busy = true;
error = '';
try {
const { id } = await api.report(room.roomId, room.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>
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
{#if open}
<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 move went in but nothing changed"></textarea>
</label>
<label class="field">
<span>What did you expect?</span>
<textarea bind:value={expected} rows="2" maxlength="2000" placeholder="e.g. a point for me"></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 note">The room code and the turn are sent with it, so the keeper can replay the game to this moment.</p>
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions">
<button type="submit" class="commit 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;
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
grid-template-rows: minmax(0, 1fr);
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;
}
.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;
}
.note {
margin-top: 0.6rem;
}
.warning {
margin-top: 0.4rem;
}
.thanks {
color: var(--bone);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.9rem;
}
</style>