A report may carry a screenshot

The 🐞 form takes a picture beside its words — PNG, JPEG, or WebP, under
2.5 MB, previewed before sending. The socket's message cap is far too
small for one, so the picture goes through a plain HTTP door after the
report is filed, addressed to the report's id, one per report within
the hour, checked by its own first bytes, and kept in feedback-images/
beside the reports with a line on the report saying so. The desk's
skill knows where to look.

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-21 11:02:59 -04:00
co-authored by Claude Fable 5.1
parent d76a18d5e6
commit 48350a3686
5 changed files with 106 additions and 8 deletions
+22 -2
View File
@@ -1517,14 +1517,27 @@
let fbHappened = $state("");
let fbExpected = $state("");
let fbSent = $state(false);
/** A screenshot for the desk, chosen with the report. */
let fbImage = $state<File | null>(null);
let fbPreview = $state<string | null>(null);
function pickImage(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0] ?? null;
if (fbPreview) URL.revokeObjectURL(fbPreview);
if (file && file.size > 2_500_000) { net.flash("That picture is over 2.5 MB — a smaller one, please"); fbImage = null; fbPreview = null; return; }
fbImage = file;
fbPreview = file ? URL.createObjectURL(file) : null;
}
function submitFeedback() {
net.sendFeedback(fbHappened.trim(), fbExpected.trim());
net.sendFeedback(fbHappened.trim(), fbExpected.trim(), fbImage);
fbSent = true;
setTimeout(() => {
feedbackOpen = false;
fbSent = false;
fbHappened = "";
fbExpected = "";
if (fbPreview) URL.revokeObjectURL(fbPreview);
fbImage = null;
fbPreview = null;
}, 1600);
}
@@ -2068,7 +2081,12 @@
<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>
<label class="fb-field fb-picture">
<span>A screenshot, if you have one</span>
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} />
{#if fbPreview}<img class="fb-preview" src={fbPreview} alt="the screenshot you chose" />{/if}
</label>
<p class="fb-note">The room code and move number ride along automatically; a picture is worth a thousand of them.</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>
@@ -3663,6 +3681,8 @@
resize: vertical;
}
.fb-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; }
.fb-picture input { font-size: 0.85rem; color: #43331f; }
.fb-preview { display: block; max-width: 100%; max-height: 9rem; margin-top: 0.4rem; border: 1px solid #b3a687; border-radius: 3px; }
.fb-thanks { color: #43331f; padding: 0.6rem 0; }
.respond-card { display: inline-block; vertical-align: middle; margin-left: 0.4rem; zoom: 0.72; }
.chronicle.tucked { display: none; }
+17 -2
View File
@@ -4,6 +4,10 @@ import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
import { cardDef } from "@wizwar/engine";
import { receiptFor } from "./receipt";
/** The same server over plain HTTP, for the one door a socket cannot carry: a picture. */
const API_URL =
import.meta.env.VITE_WIZWAR_SERVER ? String(import.meta.env.VITE_WIZWAR_SERVER).replace(/^ws/, "http")
: location.port === "5173" ? `http://${location.hostname}:8787` : "";
const SERVER_URL =
import.meta.env.VITE_WIZWAR_SERVER ??
(location.port === "5173" // the vite dev server; anything else serves its own socket
@@ -659,9 +663,17 @@ class Net {
case "feedbackList":
this.feedbackReports = msg.reports ?? [];
break;
case "feedbackReceived":
case "feedbackReceived": {
const image = this.pendingImage;
this.pendingImage = null;
if (image && typeof msg.id === "string") {
fetch(`${API_URL}/api/feedback-image/${msg.id}`, { method: "POST", headers: { "content-type": image.type }, body: image })
.then((r) => { if (!r.ok) this.flash("The words went through; the picture did not — a PNG or JPEG under 2.5 MB, please"); })
.catch(() => this.flash("The words went through; the picture did not"));
}
this.refreshFeedback();
break;
}
case "games": {
this.games = msg.games;
for (const g of msg.games as GameSummary[]) {
@@ -773,7 +785,10 @@ class Net {
}
/** A surprise report: the server pins it to the room and move number. */
sendFeedback(happened: string, expected: string): void {
/** A screenshot waiting for its report's id, then posted beside it. */
private pendingImage: File | null = null;
sendFeedback(happened: string, expected: string, image: File | null = null): void {
this.pendingImage = image;
this.send({ type: "feedback", happened, expected });
}