phase: 07_canvas_client_and_hud

**Final report — phase 07 final verification pass**

- **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found.
- **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase).
- **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔.
- **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry.
- **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`.
- **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate.
- **Next pending phase:** `08_client_prediction_and_touch`.
This commit is contained in:
2026-10-10 18:03:52 -04:00
parent f26849c1f3
commit 95ca3fd1fa
78 changed files with 13650 additions and 259 deletions
+32
View File
@@ -0,0 +1,32 @@
// Undercooked — the connecting screen (phase 07 task 02).
//
// The brief in-between: a room is being created (or a code is being
// joined) and the `welcome` has not landed yet. *Cancel* is a clean
// close of the socket — the room, once created, lives on server-side
// (the 30 s seat grace / the GC decide its fate), so cancelling never
// deletes anything.
import { el } from "./dom.js";
export function mount(target, data, app) {
const text =
data.what === "creating"
? "Creating your kitchen…"
: `Connecting to ${data.code ?? "…"}…`;
target.append(
el("div", { class: "panel connecting" },
el("h2", {}, "Connecting"),
el("p", { id: "connecting-text" }, text),
el("button", {
id: "cancel-connect",
class: "btn",
type: "button",
onClick: () => app.backToLanding(),
}, "Cancel"),
),
);
}
export function unmount() {
/* static */
}
+38
View File
@@ -0,0 +1,38 @@
// Undercooked — the countdown screen (phase 07 task 02).
//
// The 3-second `COUNTDOWN` in big numerals (one `countdown` frame per
// whole second: 3, 2, 1). The pulse is decorative: under
// `prefers-reduced-motion` the numeral just changes (the CSS in
// `screens.css` gates the animation), and a re-mount per numeral is the
// only motion machinery.
import { el } from "./dom.js";
let numEl = null;
export function mount(target, data, app) {
numEl = el("p", { id: "countdown-num", class: "countdown-num" }, String(data.n ?? 3));
target.append(
el("div", { class: "panel countdown" },
el("h2", {}, "Get ready"),
numEl,
),
);
}
/** A `countdown` frame landed (same-screen re-show): the numeral
* replaces itself so the (motion-gated) pulse restarts. */
export function update(data, _app) {
if (numEl === null) return;
numEl.textContent = String(data.n ?? "");
const parent = numEl.parentElement;
if (parent !== null) {
const fresh = el("p", { id: "countdown-num", class: "countdown-num" }, numEl.textContent);
parent.replaceChildren(fresh);
numEl = fresh;
}
}
export function unmount() {
numEl = null;
}
+72
View File
@@ -0,0 +1,72 @@
// Undercooked — the screens' tiny DOM helper (phase 07 task 02).
//
// The screens are built with `document.createElement` and `textContent`
// only — never `innerHTML` with server data (names, messages and level
// ids arrive from the server and must not parse as HTML). This module
// is that rule made cheap: `el()` builds a subtree from a declarative
// description, and every string lands in a text node or an attribute.
/**
* Build a DOM node.
*
* `el("button", { class: "btn", type: "button", "aria-label": "Copy" },
* el("span", {}, "Copy"), "Copy")`
*
* Children may be nodes, strings (become text nodes) or null (skipped).
* Attribute values that are boolean are set/unset as flags; numbers are
* converted; `value`/`textContent` shortcuts are not needed — `text` in
* the attributes object sets `textContent` (and forbids children).
*/
export function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [name, value] of Object.entries(attrs)) {
if (value === null || value === undefined || value === false) continue;
if (name === "text") {
node.textContent = String(value);
} else if (name === "value") {
node.value = value;
} else if (name === "dataset") {
for (const [key, item] of Object.entries(value)) {
node.dataset[key] = item;
}
} else if (name.startsWith("on") && typeof value === "function") {
// `onClick` → the DOM event `click` (listeners are case-sensitive).
node.addEventListener(name.slice(2).toLowerCase(), value);
} else if (value === true) {
node.setAttribute(name, "");
} else {
node.setAttribute(name, String(value));
}
}
if ("text" in attrs) return node;
for (const child of children) {
if (child === null || child === undefined) continue;
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return node;
}
/** A `document.createDocumentFragment` of the given children. */
export function frag(...children) {
const node = document.createDocumentFragment();
for (const child of children) {
if (child === null || child === undefined) continue;
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return node;
}
/**
* The 5-character code the user typed, the way the server normalises it
* (docs/PROTOCOL.md: Crockford base32 — the confusables `I L O U` are
* the server's to map; the client only uppercases and keeps what a code
* can contain). Returns "" when the input is not a full code.
*/
export function normalizeCode(value) {
return String(value ?? "")
.toUpperCase()
.replace(/[^0-9A-Z]/g, "")
.slice(0, 5);
}
export const CODE_LENGTH = 5;
+87
View File
@@ -0,0 +1,87 @@
// Undercooked — the error screen (phase 07 task 02).
//
// Friendly copy per failure (the `err` codes of docs/PROTOCOL.md and
// the close codes the connection classifies), with the *right* retry
// affordance for each: a `4400` protocol mismatch must not auto-retry
// (the client is out of date), a `4401` room-gone must not retry the
// dead code, a `4402` full room points at another room — and the room
// code is shown for support.
import { el } from "./dom.js";
/** code → { title, detail, retry } (retry = "a retry is offered"). */
const COPY = {
room_gone: {
title: "That kitchen is gone",
detail: "It may have closed, or the code was mistyped.",
retry: false,
},
room_full: {
title: "That kitchen is full",
detail: "Every seat is taken. Start your own room, or try another code.",
retry: false,
},
protocol_mismatch: {
title: "Protocol mismatch",
detail:
"This server speaks a protocol version this client does not. Retrying will not help — the client needs an update.",
retry: false,
},
evicted: {
title: "You are on another tab",
detail: "A second tab took this seat. Use the other tab, or come back later.",
retry: false,
},
internal: {
title: "The kitchen hit an internal error",
detail: "The round was aborted for everyone. Start a new room.",
retry: false,
},
closed: {
title: "The connection closed",
detail: "The server closed the socket. You can try the same room again.",
retry: true,
},
unreachable: {
title: "Cannot reach the server",
detail: "The socket could not be opened. Check the address and try again.",
retry: true,
},
};
export function mount(target, data, app) {
const code = data.code ?? "closed";
const copy = COPY[code] ?? COPY.closed;
const buttonRow = el(
"div",
{ class: "error-actions" },
el("button", {
id: "error-back",
class: "btn btn-primary",
type: "button",
onClick: () => app.backToLanding(),
}, "Back to landing"),
);
if (copy.retry && app.roomCode !== "") {
buttonRow.prepend(
el("button", {
id: "error-retry",
class: "btn",
type: "button",
onClick: () => app.retryJoin(),
}, "Try again"),
);
}
const children = [
el("h2", { id: "error-title" }, copy.title),
el("p", { id: "error-detail" }, copy.detail),
];
if (app.roomCode !== "") {
children.push(el("p", { id: "error-code" }, `Room code: ${app.roomCode}`));
}
target.append(el("div", { class: "panel error" }, el("div", { role: "alert" }, ...children), buttonRow));
}
export function unmount() {
/* static */
}
+84
View File
@@ -0,0 +1,84 @@
// Undercooked — the landing screen (phase 07 task 02).
//
// The door of the app: *Create lobby* (a `POST /api/rooms`, the token in
// the body — the session is minted server-side and returned once) and the
// join form (code normalised to five uppercase characters, remembered in
// `sessionStorage` so a reload rejoins fast). Server refusals that land
// here — the process's `room_full` on create, an unreachable server —
// render in the status region, which stays on the screen (the app only
// leaves for a real room).
import { el, frag, normalizeCode, CODE_LENGTH } from "./dom.js";
let statusEl = null;
let joinInput = null;
let bannerText = "";
export function mount(target, _data, app) {
bannerText = app.banner ?? "";
statusEl = el(
"p",
{ id: "status", class: "status", role: "status", "aria-live": "polite" },
bannerText || "Pick a kitchen to start.",
);
joinInput = el("input", {
id: "join-code",
name: "code",
type: "text",
inputmode: "text",
autocapitalize: "characters",
spellcheck: "false",
maxlength: String(CODE_LENGTH),
size: String(CODE_LENGTH),
placeholder: "ABCDE",
"aria-describedby": "join-hint",
required: true,
value: app.lastCode || "",
});
const form = el("form", { id: "join-form", class: "panel", autocomplete: "off" },
el("h2", { id: "lobby-heading" }, "Kitchen lobby"),
el("p", { id: "lobby-help", class: "tagline" },
"Create a kitchen and read its 5-character code out loud, or join one that is already open. Nothing is saved and nothing is asked for."),
el("button", {
id: "create-lobby",
class: "btn btn-primary",
type: "button",
onClick: () => {
app.banner = null;
app.createLobby();
},
}, "Create lobby"),
el("label", { for: "join-code" }, "Room code"),
el("div", { class: "join-row" }, joinInput, el("button", { class: "btn", type: "submit" }, "Join")),
el("p", { id: "join-hint", class: "hint" },
"Five characters, letters and digits, never ",
el("code", {}, "I"), ", ",
el("code", {}, "L"), ", ",
el("code", {}, "O"), " or ",
el("code", {}, "U"), "."),
statusEl,
);
form.addEventListener("submit", (event) => {
event.preventDefault();
const code = normalizeCode(joinInput.value);
joinInput.value = code;
if (code.length !== CODE_LENGTH) {
setBanner(`A room code is ${CODE_LENGTH} characters.`);
joinInput.focus();
return;
}
app.banner = null;
app.join(code);
});
target.append(frag(form));
}
export function unmount() {
/* no timers; the app's show() clears the root */
}
export function setBanner(message) {
if (statusEl === null) return;
bannerText = message;
statusEl.textContent = message || "Pick a kitchen to start.";
}
+292
View File
@@ -0,0 +1,292 @@
// Undercooked — the lobby screen (phase 07 task 02).
//
// The room, as the server broadcasts it: the code with a copy button
// (the clipboard API, with a select-and-copy fallback), the seat list
// (index, name, colour chip, ready, the crown, disconnected), the
// *Ready* toggle, the host-only level picker (fed by `/api/levels` —
// difficulty pips, target, time, blurb in the option's tooltip), the
// *Start* button (enabled from `lobby.can_start`, disabled with a
// reason otherwise — the client's mirror of `Room.can_start`), the
// session panel (`/api/session`'s public projection) and a
// spectator notice.
//
// The DOM is built once on mount; every `lobby` re-broadcast (a join,
// a ready-up, a host change, a level pick) calls `update()`, which
// patches the dynamic parts in place — a re-mount would drop focus off
// the very control the player just touched.
import { el, frag } from "./dom.js";
let built = false;
let seatsUl = null;
let readyBtn = null;
let readyLabel = null;
let levelSelect = null;
let levelLabel = null;
let startBtn = null;
let startReason = null;
let sessionPanel = null;
let specNote = null;
let bannerEl = null;
let codeEl = null;
/** The level a `lobby` frame points at, with the `welcome`'s as fallback. */
function currentLobby(app) {
if (app.lobby !== null) return app.lobby;
const welcome = app.welcome;
return {
code: app.roomCode,
state: "lobby",
level: welcome !== null ? welcome.level : null,
host_seat: null,
can_start: false,
seats: welcome !== null ? welcome.seats : [],
spectators: [],
unlocked: null,
round_no: 0,
};
}
/**
* The server's 4-colour seat palette (server/seats.py `SEAT_PALETTE`,
* docs/PROTOCOL.md "Lobby") as CSS classes — an inline `style`
* attribute would trip the CSP's `style-src 'self'` (a console error),
* so the colour rides on a class. An unknown colour (the palette ever
* grows) falls back to the neutral `.chip` skin.
*/
const CHIP_CLASS = {
"#e5484d": "chip-red",
"#3b9eff": "chip-blue",
"#3ddc84": "chip-green",
"#ffb224": "chip-amber",
};
function seatRow(seat, ownSeatIndex) {
const children = [
el("span", {
class: `chip ${CHIP_CLASS[seat.color ?? ""] ?? ""}`.trim(),
"aria-hidden": "true",
}),
el("span", { class: "seat-index", "aria-hidden": "true" }, `#${seat.index + 1}`),
el("span", { class: "seat-name" }, seat.name),
];
if (seat.host) children.push(el("span", { class: "crown", "aria-label": "host" }, "♛"));
if (seat.ready) children.push(el("span", { class: "ready-badge" }, "ready"));
if (seat.connected === false) children.push(el("span", { class: "ghost-badge" }, "disconnected"));
const row = el("li", { class: "seat" }, frag(...children));
if (seat.index === ownSeatIndex) row.dataset.you = "";
return row;
}
function renderSeats(app) {
const lobby = currentLobby(app);
const rows = lobby.seats.map((seat) => seatRow(seat, app.you !== null ? app.you.seat : null));
seatsUl.replaceChildren(frag(...rows));
}
/** The client's mirror of `Room.can_start`'s refusal prose. */
function startReasonFor(app) {
const lobby = currentLobby(app);
const ownHost = app.you !== null && app.you.host;
if (!ownHost) return "Only the host can start";
if (lobby.level === null) return "Pick a level first";
return lobby.can_start ? "" : "Waiting for ready cooks";
}
function renderControls(app) {
const lobby = currentLobby(app);
const own = app.you !== null ? lobby.seats.find((seat) => seat.index === app.you.seat) : undefined;
// The ready toggle is a toggle on the wire: the label shows the state.
readyLabel.textContent = own !== undefined && own.ready ? "Unready" : "Ready up";
readyBtn.dataset.ready = own !== undefined && own.ready ? "true" : "false";
// The picker: host-only; the selected level is the server's truth.
const ownHost = app.you !== null && app.you.host;
levelSelect.disabled = !ownHost;
levelLabel.textContent = ownHost ? "Level" : "Level (host only)";
if (ownHost && lobby.level !== null) {
const options = levelSelect.querySelectorAll("option");
const match = [...options].find((option) => option.value === lobby.level);
if (match !== undefined) levelSelect.value = match.value;
}
// Start: enabled from `can_start`, disabled with a reason otherwise.
const reason = startReasonFor(app);
startBtn.disabled = reason !== "";
startReason.textContent = reason;
startReason.hidden = reason === "";
}
function renderLevelOptions(app) {
const levels = app.levels ?? [];
const loading = app.levels === null || app.levels === undefined;
levelSelect.replaceChildren(
frag(
el("option", { value: "" }, loading ? "Loading levels…" : "Pick a level"),
...levels.map((level) => {
const pips = Math.ceil(level.difficulty / 2);
const pipText = "●".repeat(pips) + "○".repeat(5 - pips);
return el(
"option",
{ value: level.id, title: level.blurb },
`#${level.order} ${level.name} — ${level.target_score} pts / ${Math.round(level.time_limit)} s ${pipText}`,
);
}),
),
);
const lobby = currentLobby(app);
if (lobby.level !== null) {
const match = [...levelSelect.querySelectorAll("option")].find((option) => option.value === lobby.level);
if (match !== undefined) levelSelect.value = match.value;
}
}
function renderSession(app) {
const session = app.session;
if (session === null) {
sessionPanel.replaceChildren(el("p", {}, "Your session: loading…"));
return;
}
const bests = Object.entries(session.best ?? {}).map(([levelId, run]) =>
el("li", {}, `${levelId} — ${run.score} pts, ${"★".repeat(run.stars)}${"☆".repeat(3 - run.stars)}`),
);
sessionPanel.replaceChildren(
frag(
el("h3", {}, "Your session"),
el("p", {}, `Unlocked: level ${session.unlocked}`),
el("p", {}, `Rounds: ${session.rounds_played} · Dishes served: ${session.dishes_served}`),
el("p", {}, "Best:"),
bests.length === 0
? el("p", { class: "hint" }, "no best runs yet")
: el("ul", { class: "bests" }, frag(...bests)),
),
);
}
function renderSpectators(app) {
const spectators = currentLobby(app).spectators;
specNote.hidden = spectators.length === 0;
specNote.textContent =
spectators.length === 0 ? "" : `Watching: ${spectators.map((spec) => spec.name).join(", ")}`;
}
export function mount(target, _data, app) {
const copyButton = el("button", {
id: "copy-code",
class: "btn",
type: "button",
"aria-label": "Copy the room code",
}, "Copy code");
copyButton.addEventListener("click", () => void copyCode(app, copyButton));
codeEl = el("p", { id: "room-code", class: "room-code" }, app.roomCode || "…");
seatsUl = el("ul", { id: "seat-list", class: "seat-list" });
readyBtn = el("button", {
id: "ready-btn",
class: "btn",
type: "button",
onClick: () => app.ready(),
});
readyLabel = el("span", { class: "ready-label" }, "Ready");
readyBtn.append(readyLabel);
levelSelect = el("select", {
id: "level-picker",
"aria-labelledby": "level-label",
}, el("option", { value: "" }, "Loading levels…"));
levelSelect.addEventListener("change", () => {
if (levelSelect.value !== "") app.setLevel(levelSelect.value);
});
levelLabel = el("label", { id: "level-label", for: "level-picker" }, "Level");
startBtn = el("button", {
id: "start-btn",
class: "btn btn-primary",
type: "button",
disabled: true,
onClick: () => app.start(),
}, "Start");
startReason = el("p", { id: "start-reason", class: "hint", role: "status" }, "");
sessionPanel = el("div", { id: "session-panel", class: "panel session" });
specNote = el("p", { id: "spectator-note", class: "hint", hidden: true });
bannerEl = el("p", { id: "lobby-banner", class: "status", role: "status", "aria-live": "polite", hidden: true });
target.append(
el("div", { class: "panel lobby" },
el("div", { class: "lobby-head" },
el("h2", {}, "Kitchen lobby"),
el("div", { class: "code-row" }, codeEl, copyButton),
),
el("h3", { class: "section-label" }, "Cooks"),
seatsUl,
el("div", { class: "lobby-controls" }, readyBtn),
el("div", { class: "level-pick" }, levelLabel, levelSelect),
el("div", { class: "lobby-controls" }, startBtn, startReason),
specNote,
sessionPanel,
bannerEl,
),
);
built = true;
renderAll(app);
void app.fetchLevels().then(() => {
if (built) {
renderLevelOptions(app);
renderControls(app);
}
});
void app.fetchSession().then(() => {
if (built) renderSession(app);
});
}
function renderAll(app) {
renderSeats(app);
renderLevelOptions(app);
renderControls(app);
renderSession(app);
renderSpectators(app);
}
/** The app's same-screen re-show: a `lobby` re-broadcast while mounted
* (the reducer keeps `app.lobby` fresh before calling this). */
export function update(_data, app) {
if (!built || app === undefined) return;
codeEl.textContent = app.roomCode || "…";
renderAll(app);
}
export function unmount() {
built = false;
/* the app's show() clears the root */
}
export function setBanner(message) {
if (bannerEl === null) return;
bannerEl.textContent = message ?? "";
bannerEl.hidden = message === null || message === "";
}
async function copyCode(app, button) {
const code = app.roomCode;
let copied = false;
try {
await navigator.clipboard.writeText(code);
copied = true;
} catch {
// The clipboard API needs a secure context and a gesture fallback:
// select a throwaway input and copy from it. The hidden class, not
// an inline `style` (the CSP's `style-src 'self'` blocks the
// latter and the console would log it).
const input = document.createElement("input");
input.value = code;
input.setAttribute("readonly", "");
input.classList.add("visually-hidden");
document.body.append(input);
input.select();
try {
copied = document.execCommand("copy");
} catch {
copied = false;
}
input.remove();
}
setBanner(copied ? "Code copied — read it out loud." : `Copy failed — the code is ${code}.`);
button.setAttribute("data-copied", copied ? "true" : "false");
}
+94
View File
@@ -0,0 +1,94 @@
// Undercooked — the play screen (phase 07 task 02).
//
// The kitchen's frame: the canvas host (`#kcanvas` — hidden until the
// task-04 renderer sizes and unhides it), the HUD root (`#hud`, filled
// by task 04), the *Leave* control, the reconnecting overlay (its
// *logic* lands in phase 08; this task shows the sign), the emote row
// (the `docs/PROTOCOL.md` whitelist: `wave`, `cheer`, `thumbs_up`,
// `thumbs_down`, `angry`, `star`), the spectator notice (a join during
// `COUNTDOWN`/`PLAYING`/`ROUND_END` watches, per the wire contract) and
// the banner region (non-fatal `err` codes, e.g. a `start` refusal).
import { el, frag } from "./dom.js";
/** The emote whitelist (docs/PROTOCOL.md "EmoteMsg"): id → label. */
const EMOTES = [
["wave", "Wave", "👋"],
["cheer", "Cheer", "🎉"],
["thumbs_up", "Thumbs up", "👍"],
["thumbs_down", "Thumbs down", "👎"],
["angry", "Angry", "😠"],
["star", "Star", "⭐"],
];
let overlay = null;
let bannerEl = null;
export function mount(target, _data, app) {
overlay = el(
"div",
{ id: "reconnect-overlay", class: "overlay", hidden: true, role: "status" },
el("p", {}, "Connection lost — reconnecting… (phase 08)"),
);
bannerEl = el("p", {
id: "play-banner",
class: "status",
role: "status",
"aria-live": "polite",
hidden: true,
});
target.append(
el("div", { class: "play-screen" },
el("div", { class: "play-head" },
el("p", {
id: "spectator-note",
class: "hint",
hidden: !app.spectator,
}, "You are watching — the room is full."),
el("button", {
id: "leave-btn",
class: "btn",
type: "button",
onClick: () => app.leave(),
}, "Leave"),
),
bannerEl,
el("canvas", { id: "kcanvas", hidden: true, "aria-hidden": "true" }),
el("div", { id: "hud", class: "hud" }),
el("div", { class: "emote-row", role: "group", "aria-label": "Emotes" },
frag(
...EMOTES.map(([id, label, glyph]) =>
el(
"button",
{
class: "btn emote",
type: "button",
"aria-label": label,
"data-emote": id,
onClick: () => app.emote(id),
},
el("span", { "aria-hidden": "true" }, glyph),
),
),
),
),
overlay,
),
);
}
/** The reconnecting overlay (populated in phase 08). */
export function showReconnecting(visible) {
if (overlay !== null) overlay.hidden = !visible;
}
export function setBanner(message) {
if (bannerEl === null) return;
bannerEl.textContent = message ?? "";
bannerEl.hidden = message === null || message === "";
}
export function unmount() {
overlay = null;
bannerEl = null;
}
+143
View File
@@ -0,0 +1,143 @@
// Undercooked — the result screen (phase 07 task 02).
//
// The verdict, as the `round` frame carries it: the result word, the
// stars (1–3, the pop is decorative and `prefers-reduced-motion`-gated),
// score against target, the session's best for the level that was
// played, the per-player contribution list (display only — PLAN §5:
// never competitive gatekeeping), the counters (`served` / `missed` /
// `wasted`, the longest streak), and the three exits — *Next level*
// (only when `round.unlocked` names a level), *Retry* (`next_level`)
// and *Back to lobby* (`to_lobby`). The outcome is announced to screen
// readers through an `aria-live="polite"` region.
import { el, frag } from "./dom.js";
let bestEl = null;
let bannerEl = null;
function starsFor(stars) {
const count = Math.max(0, Math.min(3, stars));
const spans = [];
for (let i = 0; i < 3; i += 1) {
spans.push(
el("span", { class: i < count ? "star filled" : "star", "aria-hidden": "true" },
i < count ? "★" : "☆"),
);
}
return el("p", {
id: "stars",
class: "stars",
"aria-label": `${count} of 3 stars`,
}, frag(...spans));
}
function resultWord(result) {
if (result === "won") return "Won";
if (result === "lost") return "Lost";
if (result === "aborted") return "Aborted";
return "Round over";
}
function announcement(result, score, target, stars) {
const word = result === "won" ? "won" : result === "lost" ? "lost" : "ended";
return `You ${word} ${score} of ${target} points with ${stars} star${stars === 1 ? "" : "s"}.`;
}
export function mount(target, _data, app) {
const round = app.round ?? {};
const result = round.result ?? null;
const stars = round.stars ?? 0;
const score = round.score ?? 0;
const targetScore = round.target ?? 0;
const players = (round.players ?? []).map((player) =>
el("li", { class: "result-player" },
el("span", { class: "seat-name" }, player.name),
el("span", {}, `${player.score} pts · ${player.dishes_served} dish${player.dishes_served === 1 ? "" : "es"}`),
),
);
const counters = round.counters ?? {};
bestEl = el("p", { id: "best-line" }, "Best: loading…");
bannerEl = el("p", { class: "hint", id: "result-banner", hidden: true, role: "status", "aria-live": "polite" });
const actions = el("div", { class: "result-actions" },
el("button", {
id: "retry-btn",
class: "btn",
type: "button",
onClick: () => app.nextLevel(),
}, "Retry"),
el("button", {
id: "to-lobby-btn",
class: "btn btn-primary",
type: "button",
onClick: () => app.toLobby(),
}, "Back to lobby"),
);
if (round.unlocked !== null && round.unlocked !== undefined) {
actions.prepend(
el("button", {
id: "next-level-btn",
class: "btn btn-primary",
type: "button",
onClick: () => app.nextLevel(),
}, "Next level"),
);
}
target.append(
el("div", { class: "panel result" },
el("h2", { id: "result-word" }, resultWord(result)),
starsFor(stars),
el("p", { id: "score-line" }, `Score ${score} of ${targetScore}`),
bestEl,
el("h3", { class: "section-label" }, "Cooks"),
players.length === 0
? el("p", { class: "hint" }, "no players")
: el("ul", { id: "result-players", class: "seat-list" }, frag(...players)),
el("p", { id: "counters-line" },
`Served ${counters.served ?? 0} · Missed ${counters.missed ?? 0} · Wasted ${counters.wasted ?? 0} · Longest streak ${round.longest_streak ?? 0}`),
actions,
bannerEl,
),
el("p", {
id: "result-announce",
class: "visually-hidden",
role: "status",
"aria-live": "polite",
}, announcement(result, score, targetScore, stars)),
);
// The best for the level that was played (the session update lands
// with the `round` frame — `round_over` records before it broadcasts).
void app.fetchSession().then(() => {
if (bestEl !== null) renderBest(app);
});
}
export function renderBest(app) {
if (bestEl === null) return;
const round = app.round ?? {};
const session = app.session;
const best = session !== null && round.level_id !== undefined ? session.best?.[round.level_id] : undefined;
bestEl.textContent =
best !== undefined
? `Best: ${best.score} pts, ${"★".repeat(best.stars)}${"☆".repeat(3 - best.stars)}`
: "Best: —";
}
/** Same-screen re-show: re-render the best line (a `round` re-broadcast
* or a session refresh). */
export function update(_data, app) {
if (app !== undefined) renderBest(app);
}
export function setBanner(message) {
if (bannerEl === null) return;
bannerEl.textContent = message ?? "";
bannerEl.hidden = message === null || message === "";
}
export function unmount() {
bestEl = null;
bannerEl = null;
}