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