Files
undercooked/app/static/js/screens/lobby.js
T
ducoterra 95ca3fd1fa 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`.
2026-10-10 18:03:52 -04:00

293 lines
10 KiB
JavaScript

// 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");
}