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:
+411
-112
@@ -1,134 +1,433 @@
|
||||
// Undercooked — client shell entry point (phase 01).
|
||||
// Undercooked — the screen state machine (phase 07 task 02; the
|
||||
// composition root — URL params, the snapshot buffer, the render
|
||||
// loop, the wire routing — is `main.js`, phase 07 task 03).
|
||||
//
|
||||
// There is no game logic here on purpose. Phase 05 shipped the server-side
|
||||
// room shell (supervisor, room FSM, session store); phase 06 adds the WebSocket
|
||||
// transport and phase 07 replaces the placeholder button handlers with the lobby
|
||||
// flow and adds render/ (Canvas 2D). What this file owns today:
|
||||
// * the anonymous per-tab session token (PLAN.md A16),
|
||||
// * the last room code typed in this tab, so a reload can rejoin fast,
|
||||
// * mounting the lobby screen and announcing state into #status.
|
||||
// It is a plain ES module served from /static/ — no bundler, no framework, no
|
||||
// remote import (PLAN.md A10/A12).
|
||||
// `createApp()` owns the one client-side state machine: an explicit
|
||||
// screen enum (`LANDING, CONNECTING, LOBBY, COUNTDOWN, PLAYING,
|
||||
// ROUND_END, ERROR`), `show(screen, data)`, and the message reducer
|
||||
// that maps the wire contract (docs/PROTOCOL.md) onto screens —
|
||||
// `welcome`/`lobby` by the room's state, `countdown` → COUNTDOWN,
|
||||
// `snap` → PLAYING, `round` → ROUND_END, fatal `err` codes → ERROR.
|
||||
// Every screen is a module exporting `{ mount, unmount }` (plus an
|
||||
// optional same-screen `update`); no global DOM lives outside them.
|
||||
//
|
||||
// `identity.js` keeps the per-tab identity (PLAN A16): the anonymous
|
||||
// session token and the last room code, both in `sessionStorage`.
|
||||
// The app instance is exposed as `window.__undercooked` — the documented test seam the
|
||||
// e2e suites drive (and the task-05 keyboard module will feed).
|
||||
//
|
||||
// It is a plain ES module served from /static/ — no bundler, no
|
||||
// framework, no remote import (PLAN A10/A12).
|
||||
|
||||
const TOKEN_KEY = "uc.token";
|
||||
const LAST_CODE_KEY = "uc.lastCode";
|
||||
const CODE_LENGTH = 5;
|
||||
const NOT_IMPLEMENTED = "Not implemented until phase 07.";
|
||||
const READY_MESSAGE = "Pick a kitchen to start.";
|
||||
import { createConnection, FATAL_ERR_CODES } from "./net/connection.js";
|
||||
import { getToken, lastCode, rememberCode, randomToken } from "./identity.js";
|
||||
import * as landingScreen from "./screens/landing.js";
|
||||
import * as connectingScreen from "./screens/connecting.js";
|
||||
import * as lobbyScreen from "./screens/lobby.js";
|
||||
import * as countdownScreen from "./screens/countdown.js";
|
||||
import * as playScreen from "./screens/play.js";
|
||||
import * as roundEndScreen from "./screens/round_end.js";
|
||||
import * as errorScreen from "./screens/error.js";
|
||||
|
||||
/** Base64url without padding, so the token is safe in a query string or header. */
|
||||
function base64url(bytes) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
export { randomToken, getToken, lastCode, rememberCode };
|
||||
|
||||
/** A random, unguessable id for this browser tab (identity, not an account). */
|
||||
export function randomToken() {
|
||||
const bytes = new Uint8Array(24);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return base64url(bytes);
|
||||
}
|
||||
/** The screen enum (the task's list, frozen). */
|
||||
export const SCREENS = Object.freeze({
|
||||
LANDING: "landing",
|
||||
CONNECTING: "connecting",
|
||||
LOBBY: "lobby",
|
||||
COUNTDOWN: "countdown",
|
||||
PLAYING: "playing",
|
||||
ROUND_END: "round_end",
|
||||
ERROR: "error",
|
||||
});
|
||||
|
||||
function read(storage, key) {
|
||||
try {
|
||||
return storage.getItem(key);
|
||||
} catch {
|
||||
return null; // private browsing / storage disabled: stay functional for this tab only
|
||||
}
|
||||
}
|
||||
const SCREEN_MODULES = {
|
||||
landing: landingScreen,
|
||||
connecting: connectingScreen,
|
||||
lobby: lobbyScreen,
|
||||
countdown: countdownScreen,
|
||||
playing: playScreen,
|
||||
round_end: roundEndScreen,
|
||||
error: errorScreen,
|
||||
};
|
||||
|
||||
function write(storage, key, value) {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
} catch {
|
||||
/* nothing to persist to; the in-memory value still works */
|
||||
}
|
||||
}
|
||||
/** The room-state value → screen (the `welcome`/`lobby` mapping half). */
|
||||
const SCREEN_BY_ROOM_STATE = {
|
||||
lobby: SCREENS.LOBBY,
|
||||
countdown: SCREENS.COUNTDOWN,
|
||||
playing: SCREENS.PLAYING,
|
||||
round_end: SCREENS.ROUND_END,
|
||||
closed: SCREENS.ERROR,
|
||||
};
|
||||
|
||||
/** The tab's session token, created once per tab and kept in sessionStorage. */
|
||||
export function getToken(storage = globalThis.sessionStorage) {
|
||||
const existing = read(storage, TOKEN_KEY);
|
||||
if (typeof existing === "string" && existing.length > 0) return existing;
|
||||
const token = randomToken();
|
||||
write(storage, TOKEN_KEY, token);
|
||||
return token;
|
||||
}
|
||||
const LOG_CAP = 2000;
|
||||
|
||||
/** Uppercase what the user typed and keep only characters a code can contain. */
|
||||
export function normalizeCode(value) {
|
||||
return String(value ?? "")
|
||||
.toUpperCase()
|
||||
.replace(/[^0-9A-Z]/g, "")
|
||||
.slice(0, CODE_LENGTH);
|
||||
}
|
||||
export function createApp(root = document, options = {}) {
|
||||
const { onWireMessage = null } = options;
|
||||
const screenRoot = root.getElementById("screen-root");
|
||||
const app = {
|
||||
token: getToken(),
|
||||
lastCode: lastCode(),
|
||||
screen: null, // set by the first show()
|
||||
data: {},
|
||||
roomCode: "",
|
||||
welcome: null,
|
||||
you: null, // the viewer's seat (YouWire)
|
||||
spectator: false, // joined mid-round (or the token holds no seat)
|
||||
lobby: null, // the latest `lobby` frame
|
||||
round: null, // the latest `round` frame
|
||||
lastSnapshot: null, // the latest `snap` raw body (the render path is task 03's buffer; this view is the DOM path the screens and the choreography read)
|
||||
session: null, // the `/api/session` projection
|
||||
levels: null, // the `/api/levels` cards (null = not fetched yet)
|
||||
view: makeView(), // the merged snapshot view (full frames reset, deltas patch)
|
||||
error: null, // the fatal `err` frame, when one caused ERROR
|
||||
banner: null, // non-fatal `err` text for the mounted screen
|
||||
messageLog: [], // bounded wire-census for the e2e suites
|
||||
leaving: false, // we closed the socket on purpose
|
||||
};
|
||||
const connection = createConnection();
|
||||
const warnedUnknown = new Set();
|
||||
let mounted = null;
|
||||
|
||||
export function lastCode(storage = globalThis.sessionStorage) {
|
||||
return read(storage, LAST_CODE_KEY) || "";
|
||||
}
|
||||
|
||||
export function rememberCode(code, storage = globalThis.sessionStorage) {
|
||||
write(storage, LAST_CODE_KEY, normalizeCode(code));
|
||||
}
|
||||
|
||||
/** Write the live region. `kind` is "info" | "error" and only changes styling. */
|
||||
export function announce(message, root = document, kind = "info") {
|
||||
const region = root.getElementById("status");
|
||||
if (!region) return;
|
||||
region.textContent = message;
|
||||
region.dataset.kind = kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the lobby screen. Returns the tab identity so later phases can reuse it.
|
||||
*/
|
||||
export function mount(root = document) {
|
||||
const token = getToken();
|
||||
const createButton = root.getElementById("create-lobby");
|
||||
const joinForm = root.getElementById("join-form");
|
||||
const joinInput = root.getElementById("join-code");
|
||||
|
||||
const remembered = lastCode();
|
||||
if (joinInput && remembered) joinInput.value = remembered;
|
||||
|
||||
if (createButton) {
|
||||
createButton.addEventListener("click", () => {
|
||||
// Phase 07 (on the phase-06 transport): POST to the room supervisor,
|
||||
// open /ws/{code}.
|
||||
announce(NOT_IMPLEMENTED, root);
|
||||
});
|
||||
/**
|
||||
* The merged snapshot view — the task-02 seed of task 03's buffer: a
|
||||
* full frame resets the entity maps, a delta patches them (an upsert on
|
||||
* the identity key, a `gone` tombstone drops). The stream's full cadence
|
||||
* (every 20 encoded frames) plus the fresh-connection full keeps this
|
||||
* resyncable; a client-side drift is what task 03's decoder handles.
|
||||
*/
|
||||
function makeView() {
|
||||
return { players: {}, stations: {}, orders: {} };
|
||||
}
|
||||
|
||||
if (joinForm) {
|
||||
joinForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const code = normalizeCode(joinInput ? joinInput.value : "");
|
||||
if (joinInput) joinInput.value = code;
|
||||
if (code.length !== CODE_LENGTH) {
|
||||
announce(`A room code is ${CODE_LENGTH} characters.`, root, "error");
|
||||
if (joinInput) joinInput.focus();
|
||||
return;
|
||||
function stationKey(entry) {
|
||||
return `${entry.k}@${entry.x},${entry.y}`;
|
||||
}
|
||||
|
||||
function applySnapshot(raw) {
|
||||
app.lastSnapshot = raw;
|
||||
const view = app.view;
|
||||
if (raw.full) {
|
||||
view.players = {};
|
||||
view.stations = {};
|
||||
view.orders = {};
|
||||
for (const entry of raw.p ?? []) view.players[entry.i] = entry;
|
||||
for (const entry of raw.st ?? []) view.stations[stationKey(entry)] = entry;
|
||||
for (const entry of raw.o ?? []) view.orders[entry.id] = entry;
|
||||
return;
|
||||
}
|
||||
const delta = raw.d ?? {};
|
||||
for (const entry of delta.p ?? []) {
|
||||
if (entry.gone) delete view.players[entry.i];
|
||||
else view.players[entry.i] = entry;
|
||||
}
|
||||
for (const entry of delta.st ?? []) {
|
||||
const key = stationKey(entry);
|
||||
if (entry.gone) delete view.stations[key];
|
||||
else view.stations[key] = entry;
|
||||
}
|
||||
for (const entry of delta.o ?? []) {
|
||||
if (entry.gone) delete view.orders[entry.id];
|
||||
else view.orders[entry.id] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
function pushLog(frame) {
|
||||
app.messageLog.push(frame);
|
||||
if (app.messageLog.length > LOG_CAP) app.messageLog.shift();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show one screen. An unknown screen name (a bug) logs once and
|
||||
* becomes ERROR; the same screen re-shown in place calls the
|
||||
* module's `update` (a re-mount would drop focus off live controls).
|
||||
*/
|
||||
function show(screen, data = {}) {
|
||||
if (!(screen in SCREEN_MODULES)) {
|
||||
if (!warnedUnknown.has(screen)) {
|
||||
console.warn(
|
||||
`undercooked: unknown screen "${String(screen)}" — going to the error screen`,
|
||||
);
|
||||
warnedUnknown.add(screen);
|
||||
}
|
||||
rememberCode(code);
|
||||
// Phase 07 (on the phase-06 transport): join the room over the
|
||||
// WebSocket.
|
||||
announce(NOT_IMPLEMENTED, root);
|
||||
});
|
||||
screen = SCREENS.ERROR;
|
||||
}
|
||||
app.data = data;
|
||||
if (app.screen === screen && mounted !== null) {
|
||||
if (typeof mounted.update === "function") mounted.update(data, app);
|
||||
return screen;
|
||||
}
|
||||
if (mounted !== null && typeof mounted.unmount === "function") mounted.unmount();
|
||||
mounted = null;
|
||||
app.screen = screen;
|
||||
mounted = SCREEN_MODULES[screen];
|
||||
// The previous screen's DOM is the previous screen's problem no
|
||||
// more: a screen module appends, it does not replace, so the
|
||||
// machine clears the root before mounting (a re-mount would stack
|
||||
// a second copy of the screen on top of the first).
|
||||
screenRoot.replaceChildren();
|
||||
if (typeof mounted.mount === "function") mounted.mount(screenRoot, data, app);
|
||||
return screen;
|
||||
}
|
||||
|
||||
if (!lastCode() && root.getElementById("status")) {
|
||||
announce(READY_MESSAGE, root);
|
||||
function failToLanding(banner) {
|
||||
app.banner = banner;
|
||||
show(SCREENS.LANDING, {});
|
||||
}
|
||||
|
||||
return { token, code: lastCode() };
|
||||
// ------------------------------------------------------------- verbs
|
||||
|
||||
async function createLobby({ name = null } = {}) {
|
||||
app.leaving = false;
|
||||
show(SCREENS.CONNECTING, { what: "creating" });
|
||||
let response;
|
||||
try {
|
||||
response = await fetch("/api/rooms", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: app.token,
|
||||
...(name !== null && name !== "" ? { name } : {}),
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
failToLanding("Cannot reach the server — check the address and try again.");
|
||||
return;
|
||||
}
|
||||
if (response.status === 503) {
|
||||
failToLanding("The server is at capacity — try again in a moment.");
|
||||
return;
|
||||
}
|
||||
if (response.status !== 201) {
|
||||
failToLanding("The server refused the room. Try again.");
|
||||
return;
|
||||
}
|
||||
const body = await response.json().catch(() => null);
|
||||
if (body === null || typeof body.room_code !== "string") {
|
||||
failToLanding("The server answered something unrecognisable. Try again.");
|
||||
return;
|
||||
}
|
||||
app.roomCode = body.room_code;
|
||||
rememberCode(body.room_code);
|
||||
connection.connect(body.room_code, { token: app.token });
|
||||
}
|
||||
|
||||
function join(code) {
|
||||
app.leaving = false;
|
||||
app.roomCode = code;
|
||||
rememberCode(code);
|
||||
show(SCREENS.CONNECTING, { what: "joining", code });
|
||||
connection.connect(code, { token: app.token });
|
||||
}
|
||||
|
||||
function leave() {
|
||||
app.leaving = true;
|
||||
connection.close();
|
||||
show(SCREENS.LANDING, {});
|
||||
}
|
||||
|
||||
function backToLanding() {
|
||||
app.leaving = true;
|
||||
connection.close();
|
||||
app.banner = null;
|
||||
show(SCREENS.LANDING, {});
|
||||
}
|
||||
|
||||
function retryJoin() {
|
||||
if (app.roomCode === "") return;
|
||||
app.leaving = false;
|
||||
show(SCREENS.CONNECTING, { what: "joining", code: app.roomCode });
|
||||
connection.connect(app.roomCode, { token: app.token });
|
||||
}
|
||||
|
||||
function ready() {
|
||||
connection.send({ type: "ready" });
|
||||
}
|
||||
|
||||
function start() {
|
||||
connection.send({ type: "start" });
|
||||
}
|
||||
|
||||
function setLevel(level) {
|
||||
connection.send({ type: "set_level", level });
|
||||
}
|
||||
|
||||
function emote(id) {
|
||||
connection.send({ type: "emote", id });
|
||||
}
|
||||
|
||||
function toLobby() {
|
||||
connection.send({ type: "to_lobby" });
|
||||
}
|
||||
|
||||
function nextLevel() {
|
||||
connection.send({ type: "next_level" });
|
||||
}
|
||||
|
||||
function sendInput(move, act) {
|
||||
connection.sendInput(move, act);
|
||||
}
|
||||
|
||||
async function fetchSession() {
|
||||
try {
|
||||
const response = await fetch("/api/session", {
|
||||
headers: { "X-Undercooked-Token": app.token },
|
||||
});
|
||||
if (response.ok) {
|
||||
app.session = await response.json();
|
||||
if (mounted !== null && typeof mounted.renderBest === "function") {
|
||||
mounted.renderBest(app);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* offline: the previous session stays */
|
||||
}
|
||||
return app.session;
|
||||
}
|
||||
|
||||
async function fetchLevels() {
|
||||
if (app.levels !== null) return app.levels;
|
||||
try {
|
||||
const response = await fetch("/api/levels");
|
||||
app.levels = response.ok ? await response.json() : [];
|
||||
} catch {
|
||||
app.levels = [];
|
||||
}
|
||||
return app.levels;
|
||||
}
|
||||
|
||||
/** The merged view's own half (a `snap` landed, delta or full). */
|
||||
function orderOpen() {
|
||||
return Object.keys(app.view.orders).length > 0;
|
||||
}
|
||||
|
||||
function showReconnecting(visible) {
|
||||
if (mounted !== null && typeof mounted.showReconnecting === "function") {
|
||||
mounted.showReconnecting(visible);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- reducer
|
||||
|
||||
connection.onMessage = (frame) => {
|
||||
// The composition root's half (phase 07 task 03): the `snap` frames
|
||||
// also feed the render buffer before the reducer's DOM view.
|
||||
if (onWireMessage !== null) onWireMessage(frame);
|
||||
pushLog(frame);
|
||||
switch (frame.type) {
|
||||
case "welcome": {
|
||||
app.welcome = frame;
|
||||
app.roomCode = frame.room;
|
||||
app.you = frame.you;
|
||||
// A fresh token in a non-lobby room is a spectator (a rejoin
|
||||
// reclaims its seat, so `rejoined` is the discriminator).
|
||||
app.spectator = frame.state !== "lobby" && frame.rejoined !== true;
|
||||
show(SCREEN_BY_ROOM_STATE[frame.state] ?? SCREENS.ERROR, { code: frame.room });
|
||||
break;
|
||||
}
|
||||
case "lobby": {
|
||||
app.lobby = frame;
|
||||
app.roomCode = frame.code;
|
||||
show(
|
||||
frame.state === "lobby" ? SCREENS.LOBBY : SCREEN_BY_ROOM_STATE[frame.state] ?? SCREENS.ERROR,
|
||||
{},
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "countdown":
|
||||
show(SCREENS.COUNTDOWN, { n: frame.n });
|
||||
break;
|
||||
case "snap":
|
||||
if (frame.raw !== undefined) applySnapshot(frame.raw);
|
||||
show(SCREENS.PLAYING, {});
|
||||
break;
|
||||
case "round":
|
||||
app.round = frame;
|
||||
show(SCREENS.ROUND_END, {});
|
||||
break;
|
||||
case "err": {
|
||||
connection.noteError(frame);
|
||||
if (FATAL_ERR_CODES.includes(frame.code)) {
|
||||
app.error = frame;
|
||||
show(SCREENS.ERROR, { code: frame.code, msg: frame.msg });
|
||||
} else if (mounted !== null && typeof mounted.setBanner === "function") {
|
||||
app.banner = frame.msg;
|
||||
mounted.setBanner(frame.msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "pong":
|
||||
break; // the keep-alive's answer: nothing to render
|
||||
case "ev":
|
||||
break; // FX/log (task 04's renderer consumes these)
|
||||
default:
|
||||
break; // unknown server types are ignored (patch-compatible)
|
||||
}
|
||||
};
|
||||
|
||||
connection.onStatus = (status) => {
|
||||
if (status === "mismatch") {
|
||||
show(SCREENS.ERROR, { code: "protocol_mismatch" });
|
||||
return;
|
||||
}
|
||||
if (status !== "closed") return;
|
||||
if (app.leaving) return; // an intentional close: no error screen
|
||||
const code = connection.lastCloseCode;
|
||||
if (code === 1000 && app.screen === SCREENS.PLAYING) {
|
||||
// A drop mid-round: the seat is kept for the grace; phase 08
|
||||
// reconnects. This task's half is the overlay.
|
||||
showReconnecting(true);
|
||||
return;
|
||||
}
|
||||
show(SCREENS.ERROR, { code: code === 1006 ? "unreachable" : "closed" });
|
||||
};
|
||||
|
||||
// The public verbs — assigned onto `app` itself so the screens (which
|
||||
// hold `app`) and the e2e seam (`window.__undercooked`) see one and
|
||||
// the same live object: a `show()` later is visible through the seam.
|
||||
Object.assign(app, {
|
||||
connection,
|
||||
show,
|
||||
createLobby,
|
||||
join,
|
||||
leave,
|
||||
backToLanding,
|
||||
retryJoin,
|
||||
ready,
|
||||
start,
|
||||
setLevel,
|
||||
emote,
|
||||
toLobby,
|
||||
nextLevel,
|
||||
sendInput,
|
||||
fetchSession,
|
||||
fetchLevels,
|
||||
orderOpen,
|
||||
showReconnecting,
|
||||
});
|
||||
|
||||
show(SCREENS.LANDING, {});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// Auto-mount when loaded as the page's module, but stay importable (and
|
||||
// therefore testable) without a lobby in the document.
|
||||
if (typeof document !== "undefined" && document.getElementById("screen-lobby")) {
|
||||
// Auto-mount when loaded directly as the page's module; stay
|
||||
// importable (and therefore testable) without one. The instance is
|
||||
// the e2e test seam. `main.js` (the composition root) sets
|
||||
// `globalThis.__uc_main` before importing this module — when it is
|
||||
// set, main.js boots the app and this auto-boot stays out of the way.
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
document.getElementById("screen-root") &&
|
||||
!globalThis.__uc_main
|
||||
) {
|
||||
const boot = () => {
|
||||
globalThis.__undercooked = createApp();
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => mount(), { once: true });
|
||||
document.addEventListener("DOMContentLoaded", boot, { once: true });
|
||||
} else {
|
||||
mount();
|
||||
boot();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user