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:
@@ -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 */
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user