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,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");
|
||||
}
|
||||
Reference in New Issue
Block a user