diff --git a/.agents/phases/todo/07_canvas_client_and_hud/00_phase.md b/.agents/phases/complete/07_canvas_client_and_hud/00_phase.md
similarity index 100%
rename from .agents/phases/todo/07_canvas_client_and_hud/00_phase.md
rename to .agents/phases/complete/07_canvas_client_and_hud/00_phase.md
diff --git a/.agents/phases/todo/07_canvas_client_and_hud/01_svg_tileset_and_atlas.md b/.agents/phases/complete/07_canvas_client_and_hud/01_svg_tileset_and_atlas.md
similarity index 100%
rename from .agents/phases/todo/07_canvas_client_and_hud/01_svg_tileset_and_atlas.md
rename to .agents/phases/complete/07_canvas_client_and_hud/01_svg_tileset_and_atlas.md
diff --git a/.agents/phases/todo/07_canvas_client_and_hud/02_screen_state_machine_and_lobby_ui.md b/.agents/phases/complete/07_canvas_client_and_hud/02_screen_state_machine_and_lobby_ui.md
similarity index 100%
rename from .agents/phases/todo/07_canvas_client_and_hud/02_screen_state_machine_and_lobby_ui.md
rename to .agents/phases/complete/07_canvas_client_and_hud/02_screen_state_machine_and_lobby_ui.md
diff --git a/.agents/phases/todo/07_canvas_client_and_hud/03_snapshot_buffer_and_interpolation.md b/.agents/phases/complete/07_canvas_client_and_hud/03_snapshot_buffer_and_interpolation.md
similarity index 100%
rename from .agents/phases/todo/07_canvas_client_and_hud/03_snapshot_buffer_and_interpolation.md
rename to .agents/phases/complete/07_canvas_client_and_hud/03_snapshot_buffer_and_interpolation.md
diff --git a/.agents/phases/todo/07_canvas_client_and_hud/04_canvas_renderer_and_hud.md b/.agents/phases/complete/07_canvas_client_and_hud/04_canvas_renderer_and_hud.md
similarity index 100%
rename from .agents/phases/todo/07_canvas_client_and_hud/04_canvas_renderer_and_hud.md
rename to .agents/phases/complete/07_canvas_client_and_hud/04_canvas_renderer_and_hud.md
diff --git a/.agents/phases/todo/07_canvas_client_and_hud/05_keyboard_input_and_first_playable_e2e.md b/.agents/phases/complete/07_canvas_client_and_hud/05_keyboard_input_and_first_playable_e2e.md
similarity index 100%
rename from .agents/phases/todo/07_canvas_client_and_hud/05_keyboard_input_and_first_playable_e2e.md
rename to .agents/phases/complete/07_canvas_client_and_hud/05_keyboard_input_and_first_playable_e2e.md
diff --git a/AGENTS.md b/AGENTS.md
index 94d932c..9954ad4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -130,9 +130,10 @@
inbound field against the room's state (`server/protocol.py`).
- **Anonymous by design.** No accounts, no emails, no cookies with personal data.
Identity = 24 random bytes per browser tab (`secrets.token_urlsafe(24)` when the
- server mints one, `crypto.getRandomValues` + base64url in `app/static/js/app.js`,
- which is the same entropy and the only option in a browser) held in
- `sessionStorage`. Names are sanitized, length-capped, non-authoritative.
+ server mints one, `crypto.getRandomValues` + base64url in
+ `app/static/js/identity.js`, which is the same entropy and the only option
+ in a browser) held in `sessionStorage`. Names are sanitized, length-capped,
+ non-authoritative.
- **Movement is swept, never corrected.** Stage 3 (`sim/movement.py`) integrates one axis
at a time and clamps each axis with `sim/collision.sweep_axis()` to the greatest legal
delta, so a body is stopped *before* it intrudes — a cook never ends a tick inside a
@@ -197,10 +198,11 @@
| HTTP app (`/`, `/healthz`, `/static`), lifespan, startup content gate, the `room_close` hook (a closing room closes its own sockets with `err room_gone` + close `4401`) and the test-only seam `POST /api/test/kill_socket/{code}/{token}` (registered only when `UNDERCOOKED_TEST_MODE=1`) | `src/undercooked/server/app.py` |
| Content HTTP API (`/api/levels`, `/api/levels/{id}`, `/api/config`) + strict response models | `src/undercooked/server/routes_content.py` |
| Author CLI `undercooked-levels` (`validate`, `list`, `show`, `new`, `axes`); workflow in `docs/LEVELS.md` | `src/undercooked/tools/levels.py` |
+| In-repo SVG art: `assets_src/` (source) served from `app/static/assets/` (copies kept in sync by the generator's check) with `index.json`; the palette (`assets/palette.css` + `js/render/palette.js` — `tests/unit/test_assets.py` keeps the two files' hex values identical in both scopes and pins the SVG `var()` fallbacks to palette entries); the atlas loader (`js/render/atlas.js` — decodes each symbol through a `data:` URL because the page CSP blocks the art's `style` hooks and browsers do not render `#symbol` fragments; throws on unknown id); the index generator (a small stdlib script — `uv run python -m undercooked.tools.gen_art_index` — whose expected ids derive from the sim's enums + the `sim/stations.py` pipeline tables, and which fails naming a duplicated or missing id) | `assets_src/`, `app/static/assets/`, `app/static/js/render/`, `src/undercooked/tools/gen_art_index.py` (guards: `tests/unit/test_assets.py`, `tests/e2e/test_atlas_loads.py`) |
| The replay harness: input scripts, golden hashes, `run_script`/`compare`, the `record`/`check` CLI (contract: `docs/SIMULATION.md` "Replaying and re-blessing goldens") | `src/undercooked/sim/replay.py`, `tests/fixtures/replays/`, `tests/goldens/`, `tests/sim/test_replay.py`, `tests/sim/test_perf_smoke.py` (`slow`) |
| Security headers (CSP `default-src 'self'`, no-sniff, frame-ancestors) | `src/undercooked/server/headers.py` |
| CLI entry point (`undercooked`): flags → `--help` → serve | `src/undercooked/__main__.py` |
-| Renderer, HUD, input, interpolation | `app/static/js/`, `app/static/assets/` |
+| The client app: the composition root (`app/static/js/main.js` — the page's single entry module: the URL params, the snapshot buffer + event queue + the single render loop, the wire routing, the `window.__uc` census seam; `main-flag.js` sets the auto-boot guard before `app.js`), the screen state machine (`app/static/js/app.js` — `show()` clears `#screen-root` and mounts exactly one screen, the message→screen routing, the test seam `window.__undercooked`, the lobby-state re-broadcasts drive the lobby screen), the snapshot stream's client half (`app/static/js/net/` — `decode.js` the `applyDelta` mirror of `server/encoding.py`, `snapshots.js` the keyed buffer + `lerpState` + the gap/resync/freeze rules (injected clock), `events.js` the per-kind TTL + dedup + tick-reset queue), the enum parity (`app/static/js/shared/enums.js` — the wire's enum sets, set-equal to the Python enums both directions, `tests/unit/test_client_enum_parity.py`), the seven screens (`app/static/js/screens/` — landing / connecting / lobby / countdown / play / round-end / error, each `mount`/`unmount`; the `dom.js` in that directory is the shared no-innerHTML builder, not a screen), the WebSocket layer (`app/static/js/net/connection.js` — the reconnect loop, the stale-close guard), the identity (`identity.js`), the keyboard input + help overlay (task 05: `app/static/js/input/keyboard.js` — WASD/arrows → the 8-way intent, `Space`/`Enter` the action's rising edge, `1`–`4` the emotes, the key-hold pump (~48 ms ≈ 20.8 Hz, above the 20 Hz tick — a tick with no frame brakes the body) + the 10 Hz zero-intent keep-alive (the ≥ 5 Hz protocol rule), the ignore rules (input focus, `document.hidden`, modifiers), the blur/hidden/pagehide forced zero, `?controls=off` binds nothing (the scripted-test mode — the e2e suites drive the wire through the seam) and `getKeyMap()` (the canonical key map the help overlay and the README's `### Controls` render, `tests/e2e/test_us04_move_and_cook.py` keeps them identical), `app/static/js/ui/help.js` — the `?`/`Esc` help dialog, the key-map rows, no external docs mid-game), the single render loop (`app/static/js/render/loop.js` — pauses on `document.hidden`, idle off-`PLAYING`, the self-measured `frameMs` census), the Canvas renderer + HUD (task 04: `app/static/js/render/camera.js` the fit-to-viewport / DPR / world⇄screen, `app/static/js/render/canvas.js` the layer-order `drawFrame` + the sprite mapping + `renderStats` + the target highlight, `app/static/js/render/fx.js` the event→effect particle pool + the reduced-motion fallbacks, `app/static/js/ui/hud.js` the DOM HUD — the order rail, the score/target, the non-colour-cue countdown, the streak, the dirty counter, the connection, the spectator; one diff-write per frame, no rAF of its own) — contract: `docs/CLIENT.md` (the module map, the machine, the message table, the buffer/loop section, the keyboard-input section, the renderer/HUD section; guards: `tests/e2e/test_us01_landing_surface.py`, `test_us01_create_lobby.py`, `test_us02_join_by_code.py`, `test_us03_ready_and_start.py`, `test_atlas_loads.py`, `test_us04_render_smoke.py`, `test_us06_interp_math.py`, `test_us06_smoothness.py`, `tests/unit/test_client_sprite_map.py`) | `app/static/js/`, `app/static/assets/`, `app/static/index.html` |
| Documentation (design / testing / deployment; per-phase docs) | `docs/*.md`, indexed by PLAN §13 and guarded by `tests/unit/test_docs_index.py` |
| The pipeline gate itself | `.agents/validate.sh` |
| User-visible flow contract | `.agents/user_stories/US-XX_*.md` |
@@ -211,7 +213,7 @@ Tests mirror that: `tests/unit/` (pure, no-I/O logic incl. HTTP-route unit tests
the level validator, the seeded RNG's golden vectors and the tick's determinism —
plus the replay harness: committed goldens in `tests/goldens/`, scripts in
`tests/fixtures/replays/`, and the `slow` perf budgets — the random-walk
-smoke and the full-pipeline run), `tests/e2e/` (Playwright, named `test_usNN_*.py`, marked `e2e`). `tests/` is
+smoke and the full-pipeline run), `tests/e2e/` (Playwright, named `test_usNN_*.py` — plus the non-story asset guard `test_atlas_loads.py` — marked `e2e`). `tests/` is
not a package; shared fixtures live in `tests/conftest.py`, and the autouse fixture there
points `UNDERCOOKED_LEVELS_DIR` at `tests/fixtures/levels/` so no test silently reads the
shipped `levels/`.
diff --git a/README.md b/README.md
index b0105af..4da68e4 100644
--- a/README.md
+++ b/README.md
@@ -17,13 +17,15 @@ movement and collision, the action button, the hands, the replay goldens),
serve scoring, freshness and the dirty loop, the round verdict with stars)
and **05** (anonymous lobbies and room lifecycle: the `RoomSupervisor`,
the guarded `Room` FSM, the in-memory session store, the lifecycle
-integration suite) and **06** (the realtime WebSocket protocol: the
+integration suite), **06** (the realtime WebSocket protocol: the
`/ws/{code}` transport, the versioned JSON wire contract with inbound
guardrails, input ingestion with sequence discipline, the quantized
-snapshot stream, heartbeats and seat grace — `docs/PROTOCOL.md`)
-are **complete**; the next pending phase is
-`07_canvas_client_and_hud`. What
-runs today:
+snapshot stream, heartbeats and seat grace — `docs/PROTOCOL.md`) and
+**07** (the canvas client, art and HUD: the in-repo SVG atlas, the
+screen state machine, the snapshot buffer with 100 ms interpolation,
+the DPR-aware Canvas renderer, the DOM HUD and the desktop keyboard
+input — `docs/CLIENT.md`) are **complete**; the next pending phase is
+`08_client_prediction_and_touch`. What runs today:
- **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
single typed, validated `Settings` object (`DEBUGPY` support included), and
@@ -221,9 +223,7 @@ runs today:
criterion 4), the seat-grace and lobby-idle expiries through the GC, and
one room's crash closing only itself (the test-only `_test_step_override`
seam on `Room`; contract: `docs/ROOMS.md` "Failure and expiry" and
- "The lifecycle walkthrough"). The client buttons in the page still
- answer "Not implemented until phase 07." until the phase-07 client
- wiring replaces them.
+ "The lifecycle walkthrough").
- **The WebSocket transport (phase 06, task 02).** The room core is on
the wire: `GET /api/stats` answers the ops census (`rooms` / `players`
— the occupied seats / `sockets` — live WebSocket connections /
@@ -334,24 +334,56 @@ runs today:
merged snapshot view (`expect` / `collect` / `send_model`). The
contract, the test seams and the budget table with the measured
values are `docs/PROTOCOL.md` ("Testing the protocol").
+- **The canvas client, art, and the HUD (phase 07).** The browser half
+ is live on top of all of this: the in-repo SVG atlas (`assets_src/`
+ → the served copies in `app/static/assets/`; `gen_art_index`
+ regenerates the `index.json`, and a missing or duplicated symbol
+ fails the build, the expected ids deriving from the sim's own enums
+ and the `sim/stations.py` pipeline tables), the screen state machine
+ (the page's landing create/join, the lobby with its code copy,
+ level picker, ready-up, start and seat chips, the 3-2-1 countdown,
+ the round-end and the error screens, all on the same
+ `WS /ws/{code}` socket), the snapshot buffer with the 100 ms
+ interpolation and the event queue, the single render loop, the
+ DPR-aware Canvas renderer (the kitchen floor, the stations, the
+ y-sorted cooks, the carried items, the progress rings, the hazards,
+ the particles, the target highlight) and the DOM HUD (the order
+ rail, the score/target, the countdown with its non-colour cue, the
+ streak, the dirty counter, the connection indicator, the spectator
+ banner); the desktop keyboard input (WASD/arrows, the action
+ button, the emotes, the `?` help overlay — the touch surface is
+ phase 08) closes it out. `docs/CLIENT.md` is the module map and the
+ contract; the proof is the e2e suites — `test_us01_create_lobby.py`,
+ `test_us02_join_by_code.py`, `test_us03_ready_and_start.py` (the
+ lobby flow), `test_us04_move_and_cook.py` (a scripted keyboard cook
+ serves a soup) and `test_us04_render_smoke.py` (the canvas is
+ provably not blank and moves after input; the HUD score changes
+ within 200 ms of the `served` event), `test_us06_interp_math.py`
+ and `test_us06_smoothness.py` (the interpolation math, exercised
+ through a real page; per-tick displacement bounded with 60 ms of
+ injected latency, both clients converge on `ROUND_END`), and
+ `test_atlas_loads.py` (the atlas decodes with zero console errors;
+ `test_us01_landing_surface.py` asserts the page's only network
+ requests are same-origin).
- **The rules are tests, not promises.** The no-external-asset rule is
`tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is
`tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by
`tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
phase pipeline runs after every task.
-What does not exist yet: the hazards (phase 10) and the
-in-browser
-playable Canvas renderer (07 — the phase-06 WebSocket transport, including
-the snapshot stream, is live but the client still answers "Not
-implemented until phase 07."). Execute phases with the
-`phased-execution` skill
-(`auto-phase.sh`); it always takes the lowest-numbered directory still in
-`.agents/phases/todo/`.
+What does not exist yet: client prediction, reconciliation and the
+touch surface (phase 08), the two-browser co-op E2E with
+contested-resource arbitration (phase 09), the hazards and their
+client FX (phase 10), the rest of the ten-level campaign (phase 11),
+reconnect resilience and the 200-room stress run (phase 12), endless
+mode with the session scoreboard (phase 13), and the accessibility
+polish and hardening pass (phase 14). Execute phases with the
+`phased-execution` skill (`auto-phase.sh`); it always takes the
+lowest-numbered directory still in `.agents/phases/todo/`.
## How to play
-The intended loop, once the room flow ships: open the site, **Create lobby**, read out
+The loop, live in the browser today: open the site, **Create lobby**, read out
the 5-character code, everyone else **Joins** with it, the host picks a level, the room
readies up, and the kitchen starts. Each cook has one movement control and **one action
button** that picks up, uses and puts down whatever is in reach — one carried item at a
@@ -362,22 +394,41 @@ keeps only a random per-tab token and the last code you typed, both in `sessionS
The campaign does remember per token — unlocks and best scores live server-side,
in memory, and a server restart clears all progression (A6: no database).
-What actually works today is the shell of that plus the server-side room
-shell and the phase-06 WebSocket transport:
-the page loads, normalises a typed code to five uppercase alphanumerics (codes
-themselves are Crockford base32, minted by the room supervisor — `server/rooms.py`,
-phase 05, which is live and testable over `POST /api/rooms` / `GET /api/rooms/{code}` /
-`POST /api/rooms/{code}/join` / `GET /api/session`), a `WS /ws/{code}`
-client can already create a room over HTTP, handshake (`hello` → `welcome`
-+ `lobby`), ready up and keep alive — the full contract, including the
-guardrails and the close codes, is `docs/PROTOCOL.md` — but the lobby
-buttons in the page still answer "Not implemented
-until phase 07." until the phase-07 client
-wiring replaces them. The ready-up, the 20 Hz tick ownership, the session store
-(the unlocks and best scores, in memory — a server restart clears all
-progression) and the lifecycle integration suite are live. Movement keys and
-touch controls are defined by the phase-07/08 client and documented in
-`docs/CLIENT.md` once that phase writes it.
+The page does that loop end to end: it loads,
+normalises a typed code to five uppercase alphanumerics (codes
+themselves are Crockford base32, minted by the room supervisor —
+`server/rooms.py`, phase 05, also testable over `POST /api/rooms` /
+`GET /api/rooms/{code}` / `POST /api/rooms/{code}/join` /
+`GET /api/session`), the landing's **Create lobby** / **Join with code**
+open a `WS /ws/{code}` socket (`hello` → `welcome` + `lobby`), the lobby
+shows the code, the level picker (fed by `/api/levels`), the ready and
+start controls and the seat chips, the 3-2-1 countdown runs, and the
+round-end screen reports the verdict — the full contract, including the
+guardrails and the close codes, is `docs/PROTOCOL.md`, the client's own
+contract is `docs/CLIENT.md`. The playable Canvas renderer, the HUD and
+the desktop keyboard controls are live (below; the in-game `?` help
+overlay renders the same rows) — the touch controls arrive with phase 08.
+The ready-up, the
+20 Hz tick ownership, the
+session store (the unlocks and best scores, in memory — a server restart
+clears all progression) and the lifecycle integration suite are live.
+
+### Controls
+
+- **W / A / S / D** — move (or the arrow keys)
+- **Space / Enter** — action (pick, work, set down, serve)
+- **1 / 2 / 3 / 4** — emote (wave, cheer, thumbs up, thumbs down)
+- **?** — help overlay
+- **Esc** — close help / leave the room
+
+The goal is the HUD's target score before the level's deadline: an order
+ticket names a dish, and the action button does everything a hand does —
+pick the ingredient off a shelf, work it on a station (hold the button
+while the station works), plate it and serve it at the window. One
+carried item at a time; a set-down marks the item so a partner can
+pass it. `?` opens the help overlay (the same rows, rendered in-game);
+`Esc` closes it — or leaves the room. The `?controls=off` URL param
+disables the bindings (the touch surface's seam, phase 08).
## Quickstart (development)
@@ -419,6 +470,7 @@ uv run undercooked-levels show 01_first_kitchen --grid # resolved level + reach
uv run undercooked-levels validate # the content gate; --json for scripts
uv run undercooked-levels new 03_riverboat # scaffold a level from the recipe book
uv run undercooked-levels axes # escalation-axis table (phase 11's curve)
+uv run python -m undercooked.tools.gen_art_index # rebuild app/static/assets/index.json from assets_src/
```
`undercooked-levels` is a thin shell over the loader and the PLAN §8 rules
@@ -430,7 +482,13 @@ and never from the current directory, so it works from any path. Exit codes:
failure, unknown id, refused overwrite, usage error). The schema, every field,
the overlay trick and a "which rule did I just hit" table live in
[docs/LEVELS.md](docs/LEVELS.md); `tests/unit/test_content_not_hardcoded.py`
-is what keeps levels and recipes out of the Python.
+is what keeps levels and recipes out of the Python. The in-repo SVG art
+(phase 07 task 01) is guarded the same way: `gen_art_index` fails with the
+missing id named when a symbol is absent or duplicated, and its expected set is
+derived from the simulation's own enums and pipeline tables, so a new
+`ItemKind`/`ItemForm` the sim can produce breaks the build until a symbol is
+drawn for it (guards: `tests/unit/test_assets.py`,
+`tests/e2e/test_atlas_loads.py`).
Run the checks:
@@ -543,6 +601,11 @@ src/undercooked/sim/replay.py the replay harness: the input-script format, the
src/undercooked/tools/levels.py the `undercooked-levels` author CLI — validate, list,
show [--grid], new, axes; same loader, same rules, no
hard-coded content list (docs/LEVELS.md "Authoring workflow")
+src/undercooked/tools/gen_art_index.py the SVG atlas index generator — rebuilds
+ app/static/assets/index.json from assets_src/; the expected
+ ids derive from the sim's enums + the station pipeline
+ tables, a missing or duplicated id fails (guards:
+ tests/unit/test_assets.py)
src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`, mounts the
content API and the room API; installs the supervisor
src/undercooked/server/clock.py injectable time: `Clock`, `SystemClock`, `FakeClock`
@@ -572,8 +635,25 @@ src/undercooked/server/headers.py the security-header middleware
.env.example every configuration variable, with its default
levels/ data-driven content (YAML): the recipe book (A8) and the
first two campaign levels (01_first_kitchen, 02_double_shift)
-app/static/ browser client: the ES-module shell today; Canvas 2D renderer
- and this repo's SVG art arrive with phase 07
+assets_src/ the hand-authored SVG art (tiles, stations, items, cooks,
+ fx, ui) — source for the served copies in
+ app/static/assets/ (a guard test keeps them in sync)
+app/static/ the browser client (ES modules, no bundler):
+ the composition root (`js/main.js`), the screen
+ state machine (`js/app.js`) and the seven screens
+ (`js/screens/`, the no-innerHTML DOM builder
+ `dom.js`), the snapshot buffer + 100 ms
+ interpolation (`js/net/`), the single render loop
+ (`js/render/loop.js`), the DPR-aware Canvas 2D
+ renderer (`js/render/canvas.js` + `camera.js` +
+ `fx.js`), the DOM HUD (`js/ui/hud.js`), the
+ desktop keyboard input (`js/input/keyboard.js`)
+ and the help overlay (`js/ui/help.js`); the
+ in-repo SVG atlas with its `index.json`, the
+ palette (`assets/palette.css` +
+ `js/render/palette.js`, kept hex-identical by a
+ test) and the atlas loader (`js/render/atlas.js`)
+ (contract: docs/CLIENT.md)
tests/ unit/, integration/, e2e/ (one Playwright suite per story) and
sim/ (the pure kernel: grid, entities, purity guard, validation,
the seeded RNG and the tick; plus the replay harness — committed
@@ -587,10 +667,11 @@ docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract),
SIMULATION.md (the tick contract), ROOMS.md (rooms &
lobby lifecycle), PROTOCOL.md (the v1 wire contract —
message tables, error/close codes, limits, the full-round
- sequence, the test seams and the budget table), TESTING.md
- (how this project
- is verified), DEPLOYMENT.md; CLIENT lands with
- phase 07 (the index is PLAN.md §13)
+ sequence, the test seams and the budget table), CLIENT.md
+ (the client module map, the render loop, the snapshot
+ buffer, the input, the assets), TESTING.md (how this
+ project is verified), DEPLOYMENT.md (the index is
+ PLAN.md §13)
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
```
diff --git a/app/static/assets/cooks.svg b/app/static/assets/cooks.svg
new file mode 100644
index 0000000..4227619
--- /dev/null
+++ b/app/static/assets/cooks.svg
@@ -0,0 +1,116 @@
+
diff --git a/app/static/assets/fx.svg b/app/static/assets/fx.svg
new file mode 100644
index 0000000..aef8038
--- /dev/null
+++ b/app/static/assets/fx.svg
@@ -0,0 +1,39 @@
+
diff --git a/app/static/assets/index.json b/app/static/assets/index.json
new file mode 100644
index 0000000..ebfe45c
--- /dev/null
+++ b/app/static/assets/index.json
@@ -0,0 +1,527 @@
+{
+ "cook-1-down-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-1-down-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-1-left-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-1-left-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-1-right-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-1-right-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-1-up-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-1-up-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-2-down-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-2-down-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-2-left-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-2-left-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-2-right-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-2-right-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-2-up-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-2-up-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-3-down-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-3-down-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-3-left-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-3-left-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-3-right-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-3-right-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-3-up-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-3-up-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-4-down-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-4-down-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-4-left-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-4-left-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-4-right-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-4-right-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-4-up-0": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-4-up-1": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-carry": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "cook-stun": {
+ "file": "cooks.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "fx-chop": {
+ "file": "fx.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "fx-deny": {
+ "file": "fx.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "fx-flame": {
+ "file": "fx.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "fx-score": {
+ "file": "fx.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "fx-serve": {
+ "file": "fx.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "fx-splash": {
+ "file": "fx.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "fx-steam": {
+ "file": "fx.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-dough-burnt": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-dough-chopped": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-dough-cooked": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-dough-cooking": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-dough-raw": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-fish-burnt": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-fish-chopped": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-fish-cooked": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-fish-cooking": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-fish-raw": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-lettuce-burnt": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-lettuce-chopped": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-lettuce-cooked": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-lettuce-cooking": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-lettuce-raw": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-onion-burnt": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-onion-chopped": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-onion-cooked": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-onion-cooking": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-onion-raw": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-patty-burnt": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-patty-chopped": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-patty-cooked": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-patty-cooking": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-patty-raw": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-plate-dirty": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-plate-plated": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-plate-raw": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-rice-burnt": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-rice-cooked": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-rice-cooking": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-rice-raw": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-tomato-burnt": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-tomato-chopped": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-tomato-cooked": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-tomato-cooking": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "item-tomato-raw": {
+ "file": "items.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "station-chop_board": {
+ "file": "stations.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "station-pan": {
+ "file": "stations.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "station-pot": {
+ "file": "stations.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "station-prep": {
+ "file": "stations.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "station-serve": {
+ "file": "stations.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "station-sink": {
+ "file": "stations.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "station-spawn_shelf": {
+ "file": "stations.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "station-stove": {
+ "file": "stations.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "station-stove-burning": {
+ "file": "stations.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "station-stove-off": {
+ "file": "stations.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "station-stove-ready": {
+ "file": "stations.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "station-stove-working": {
+ "file": "stations.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "station-trash": {
+ "file": "stations.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "tile-counter": {
+ "file": "tiles.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "tile-floor": {
+ "file": "tiles.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "tile-floor-1": {
+ "file": "tiles.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "tile-floor-2": {
+ "file": "tiles.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "tile-floor-3": {
+ "file": "tiles.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "tile-floor-4": {
+ "file": "tiles.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "tile-void": {
+ "file": "tiles.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "tile-wall": {
+ "file": "tiles.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "ui-crown": {
+ "file": "ui.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "ui-ghost": {
+ "file": "ui.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "ui-ready": {
+ "file": "ui.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "ui-star": {
+ "file": "ui.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "ui-streak": {
+ "file": "ui.svg",
+ "h": 32.0,
+ "w": 32.0
+ },
+ "ui-ticket": {
+ "file": "ui.svg",
+ "h": 32.0,
+ "w": 32.0
+ }
+}
diff --git a/app/static/assets/items.svg b/app/static/assets/items.svg
new file mode 100644
index 0000000..64899c1
--- /dev/null
+++ b/app/static/assets/items.svg
@@ -0,0 +1,202 @@
+
diff --git a/app/static/assets/palette.css b/app/static/assets/palette.css
new file mode 100644
index 0000000..81ff890
--- /dev/null
+++ b/app/static/assets/palette.css
@@ -0,0 +1,34 @@
+/* Undercooked palette (phase 07, task 01).
+ *
+ * The single source of every colour the DOM and the Canvas 2D renderer
+ * both use. The canvas half re-exports the same hex values from
+ * app/static/js/render/palette.js; tests/unit/test_assets.py asserts the
+ * two files agree, so the CSS and the canvas can never drift apart.
+ *
+ * The four cook colours are what a player sees their own and their
+ * teammates' cooks in; [data-palette="cb"] swaps them for the Okabe-Ito
+ * set, which stays distinguishable under deuteranopia/protanopia. The
+ * semantic colours name the game's state vocabulary (a danger reads red,
+ * a ready reads green, dirt reads mud) and are shared by the CSS, the
+ * JS module and the SVG var() fallbacks in app/static/assets/.
+ */
+
+:root {
+ --uc-cook-1: #e0533d;
+ --uc-cook-2: #3d84a8;
+ --uc-cook-3: #7da84b;
+ --uc-cook-4: #b0699a;
+ --uc-danger: #ff5a4e;
+ --uc-hot: #ffa63d;
+ --uc-ready: #67d16c;
+ --uc-dirty: #9a8f7a;
+}
+
+/* Okabe-Ito cook colours: colourblind-safe (deutan/protan) without
+ losing the "four different cooks" signal. */
+[data-palette="cb"] {
+ --uc-cook-1: #d55e00;
+ --uc-cook-2: #0072b2;
+ --uc-cook-3: #009e73;
+ --uc-cook-4: #cc79a7;
+}
diff --git a/app/static/assets/stations.svg b/app/static/assets/stations.svg
new file mode 100644
index 0000000..f958357
--- /dev/null
+++ b/app/static/assets/stations.svg
@@ -0,0 +1,106 @@
+
diff --git a/app/static/assets/tiles.svg b/app/static/assets/tiles.svg
new file mode 100644
index 0000000..ff4c8ec
--- /dev/null
+++ b/app/static/assets/tiles.svg
@@ -0,0 +1,61 @@
+
diff --git a/app/static/assets/ui.svg b/app/static/assets/ui.svg
new file mode 100644
index 0000000..4c8f054
--- /dev/null
+++ b/app/static/assets/ui.svg
@@ -0,0 +1,41 @@
+
diff --git a/app/static/css/screens.css b/app/static/css/screens.css
new file mode 100644
index 0000000..779bc32
--- /dev/null
+++ b/app/static/css/screens.css
@@ -0,0 +1,662 @@
+/* Undercooked — screen styles (phase 07 task 02).
+ *
+ * Rules this file must keep (base.css's, extended):
+ * - every byte is same-origin: no @import, no web fonts, no remote images (A12);
+ * - the palette, spacing and type scale exist only as base.css custom properties,
+ * so a `[data-palette]` swap (e.g. `data-palette="cb"` for the colourblind-safe
+ * alternate in assets/palette.css) recolours the screens automatically;
+ * - 44x44 minimum hit targets (base.css --uc-hit) on every interactive control;
+ * - the layout is fluid from a 360px phone to a 4K desktop without breakpoints;
+ * - motion is decorative: prefers-reduced-motion turns all of it off;
+ * - visible focus rings: base.css's :focus-visible rule covers every element here.
+ */
+
+.screen-root {
+ display: grid;
+ justify-items: center;
+}
+
+/* The screens share the phase-01 panel skin; each owns its content. */
+.panel.lobby,
+.panel.result,
+.panel.error,
+.panel.connecting,
+.panel.countdown {
+ max-width: 40rem;
+}
+
+.section-label {
+ margin: 0;
+ font-size: var(--uc-step--1);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--uc-ink-muted);
+}
+
+/* ------------------------------------------------------------- landing */
+
+.join-field {
+ display: grid;
+ gap: var(--uc-space-1);
+ flex: 1 1 10rem;
+}
+
+/* ------------------------------------------------------------- connecting */
+
+.connecting {
+ text-align: center;
+}
+
+#connecting-text {
+ margin: 0;
+ font-size: var(--uc-step-1);
+}
+
+/* ------------------------------------------------------------- lobby */
+
+.lobby-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--uc-space-2);
+}
+
+.room-code {
+ margin: 0;
+ font-family: var(--uc-font-ui);
+ font-size: var(--uc-step-2);
+ font-weight: 800;
+ letter-spacing: 0.3em;
+ color: var(--uc-accent);
+}
+
+.code-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--uc-space-2);
+}
+
+.seat-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: var(--uc-space-1);
+}
+
+.seat {
+ display: flex;
+ align-items: center;
+ gap: var(--uc-space-1);
+ min-height: calc(var(--uc-hit) - 12px);
+ padding: 0 var(--uc-space-2);
+ background: var(--uc-bg);
+ border: 1px solid var(--uc-line);
+ border-radius: var(--uc-radius);
+}
+
+.chip {
+ width: 1rem;
+ height: 1rem;
+ border-radius: 50%;
+ flex: none;
+ border: 1px solid var(--uc-line);
+}
+
+/* The 4-colour seat palette (server/seats.py `SEAT_PALETTE`,
+ * docs/PROTOCOL.md "Lobby"). Colours ride on classes, not inline
+ * `style` attributes — the CSP's `style-src 'self'` blocks the
+ * latter (a console error). */
+.chip-red {
+ background: #e5484d;
+}
+
+.chip-blue {
+ background: #3b9eff;
+}
+
+.chip-green {
+ background: #3ddc84;
+}
+
+.chip-amber {
+ background: #ffb224;
+}
+
+.seat-index {
+ color: var(--uc-ink-muted);
+ font-size: var(--uc-step--1);
+}
+
+.seat-name {
+ flex: 1;
+ overflow-wrap: anywhere;
+}
+
+.crown {
+ color: var(--uc-accent);
+ font-size: var(--uc-step-1);
+}
+
+.ready-badge,
+.ghost-badge {
+ font-size: var(--uc-step--1);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ padding: 0.1rem 0.5rem;
+ border-radius: var(--uc-radius);
+ border: 1px solid var(--uc-line);
+}
+
+.ready-badge {
+ color: var(--uc-accent-ink);
+ background: var(--uc-accent);
+ border-color: var(--uc-accent);
+}
+
+.ghost-badge {
+ color: var(--uc-ink-muted);
+}
+
+.lobby-controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--uc-space-2);
+}
+
+#ready-btn {
+ min-width: 12rem;
+}
+
+.level-pick {
+ display: grid;
+ gap: var(--uc-space-1);
+}
+
+#level-picker {
+ min-height: var(--uc-hit);
+ padding: 0 var(--uc-space-2);
+ background: var(--uc-bg);
+ color: var(--uc-ink);
+ border: 1px solid var(--uc-line);
+ border-radius: var(--uc-radius);
+ font: inherit;
+}
+
+#level-picker:disabled {
+ opacity: 0.6;
+}
+
+#start-btn:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.session {
+ margin-top: var(--uc-space-2);
+}
+
+.session h3 {
+ margin: 0 0 var(--uc-space-1);
+ font-size: var(--uc-step-0);
+}
+
+.session p,
+.session li {
+ margin: 0;
+}
+
+.bests {
+ margin: var(--uc-space-1) 0 0;
+ padding-left: 1.25rem;
+ display: grid;
+ gap: 0.25rem;
+}
+
+#spectator-note {
+ margin: var(--uc-space-2) 0 0;
+}
+
+#lobby-banner {
+ margin: var(--uc-space-2) 0 0;
+}
+
+/* ------------------------------------------------------------- countdown */
+
+.countdown {
+ text-align: center;
+}
+
+.countdown-num {
+ margin: 0;
+ font-family: var(--uc-font-ui);
+ font-size: clamp(5rem, 20vw, 11rem);
+ font-weight: 800;
+ line-height: 1;
+ color: var(--uc-accent);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .countdown-num {
+ animation: countdown-pulse 1s var(--uc-ease);
+ }
+
+ @keyframes countdown-pulse {
+ from {
+ transform: scale(1.25);
+ opacity: 0.4;
+ }
+ to {
+ transform: scale(1);
+ opacity: 1;
+ }
+ }
+}
+
+/* ------------------------------------------------------------- play */
+
+.play-screen {
+ display: grid;
+ gap: var(--uc-space-2);
+ width: 100%;
+ max-width: 46rem;
+}
+
+.play-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--uc-space-2);
+}
+
+#play-banner {
+ margin: 0;
+}
+
+#kcanvas {
+ width: 100%;
+ aspect-ratio: 2 / 1;
+ background: var(--uc-bg);
+ border: 1px solid var(--uc-line);
+ border-radius: var(--uc-radius);
+}
+
+.hud {
+ min-height: calc(var(--uc-hit) - 8px);
+}
+
+/* The HUD (task 04): the order rail, the score bar, the timer, the
+ status row. The timer's colour bands carry a non-colour cue too:
+ `low` (amber) and `critical` (red) each add a striped background
+ and a label word (`hurry` / `now`). */
+
+.hud-grid {
+ display: grid;
+ grid-template-columns: minmax(12rem, 1fr) minmax(10rem, 1fr) auto;
+ gap: var(--uc-space-2);
+ align-items: start;
+}
+
+@media (max-width: 40rem) {
+ .hud-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.hud-orders .section-label {
+ margin: 0 0 var(--uc-space-1);
+}
+
+.ticket-list {
+ display: grid;
+ gap: var(--uc-space-1);
+}
+
+.ticket {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ grid-template-rows: auto auto;
+ align-items: center;
+ gap: 2px var(--uc-space-1);
+ padding: var(--uc-space-1) var(--uc-space-2);
+ background: var(--uc-surface-2);
+ border: 1px solid var(--uc-line);
+ border-radius: var(--uc-radius);
+}
+
+.ticket.oldest {
+ border-color: var(--uc-accent);
+ box-shadow: 0 0 0 1px var(--uc-accent);
+}
+
+.ticket-icon {
+ width: 1.4rem;
+ height: 1.4rem;
+ grid-row: span 2;
+}
+
+.ticket-pts {
+ font-family: var(--uc-font-ui);
+ font-weight: 700;
+}
+
+.ticket-bar {
+ grid-column: 2 / 4;
+ height: 0.4rem;
+ background: var(--uc-bg);
+ border-radius: 999px;
+ overflow: hidden;
+}
+
+.ticket-bar-fill {
+ height: 100%;
+ background: var(--uc-accent);
+}
+
+.hud-score-line {
+ margin: 0;
+ font-family: var(--uc-font-ui);
+}
+
+.hud-streak {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ margin-left: var(--uc-space-1);
+ color: var(--uc-accent);
+}
+
+.hud-streak img {
+ width: 1.2rem;
+ height: 1.2rem;
+}
+
+.score-bar {
+ margin-top: var(--uc-space-1);
+ height: 0.5rem;
+ background: var(--uc-bg);
+ border: 1px solid var(--uc-line);
+ border-radius: 999px;
+ overflow: hidden;
+}
+
+.score-bar-fill {
+ height: 100%;
+ background: var(--uc-accent);
+}
+
+.hud-timer {
+ padding: var(--uc-space-1) var(--uc-space-2);
+ border: 1px solid var(--uc-line);
+ border-radius: var(--uc-radius);
+ font-family: var(--uc-font-ui);
+ font-size: var(--uc-step-1);
+ min-width: 5.5rem;
+ text-align: center;
+}
+
+/* The non-colour halves: the striped background + the label word. */
+
+.hud-timer-low {
+ color: var(--uc-accent);
+ border-color: var(--uc-accent);
+ background: repeating-linear-gradient(
+ 45deg,
+ transparent 0 0.5rem,
+ color-mix(in srgb, var(--uc-accent) 18%, transparent) 0.5rem 1rem
+ );
+}
+
+.hud-timer-critical {
+ color: var(--uc-danger);
+ border-color: var(--uc-danger);
+ background: repeating-linear-gradient(
+ 45deg,
+ transparent 0 0.5rem,
+ color-mix(in srgb, var(--uc-danger) 22%, transparent) 0.5rem 1rem
+ );
+}
+
+.hud-timer-label {
+ margin-left: 0.4rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ font-size: var(--uc-step--1);
+}
+
+.hud-meta {
+ display: grid;
+ gap: 0.35rem;
+ align-content: start;
+}
+
+.hud-dirty,
+.hud-conn {
+ font-family: var(--uc-font-ui);
+}
+
+.hud-conn.is-live {
+ color: var(--uc-ink-muted);
+}
+
+.hud-conn.is-down {
+ color: var(--uc-danger);
+}
+
+#hud-spectator {
+ margin: 0;
+ color: var(--uc-accent);
+}
+
+.emote-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--uc-space-1);
+}
+
+.emote {
+ min-width: var(--uc-hit);
+ min-height: var(--uc-hit);
+ padding: 0 var(--uc-space-2);
+ font-size: var(--uc-step-1);
+}
+
+.overlay {
+ position: fixed;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ background: color-mix(in srgb, var(--uc-bg) 82%, transparent);
+ z-index: 10;
+}
+
+.overlay p {
+ margin: 0;
+ padding: var(--uc-space-3);
+ background: var(--uc-surface);
+ border: 1px solid var(--uc-line);
+ border-radius: var(--uc-radius);
+ font-size: var(--uc-step-1);
+}
+
+/* ------------------------------------------------------------- result */
+
+.result {
+ text-align: center;
+}
+
+#result-word {
+ font-size: var(--uc-step-2);
+ margin: 0;
+}
+
+.stars {
+ margin: var(--uc-space-1) 0;
+ font-size: var(--uc-step-2);
+ letter-spacing: 0.15em;
+}
+
+.star.filled {
+ color: var(--uc-accent);
+}
+
+.star {
+ color: var(--uc-line);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .star.filled {
+ animation: star-pop 600ms var(--uc-ease);
+ }
+
+ @keyframes star-pop {
+ from {
+ transform: scale(0.3);
+ }
+ 60% {
+ transform: scale(1.3);
+ }
+ to {
+ transform: scale(1);
+ }
+ }
+}
+
+#score-line,
+#best-line,
+#counters-line {
+ margin: var(--uc-space-1) 0;
+}
+
+#result-players {
+ text-align: left;
+}
+
+.result-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: var(--uc-space-2);
+ margin-top: var(--uc-space-2);
+}
+
+/* ------------------------------------------------------------- error */
+
+.error h2 {
+ margin: 0 0 var(--uc-space-1);
+}
+
+#error-detail,
+#error-code {
+ margin: 0 0 var(--uc-space-2);
+}
+
+#error-code {
+ color: var(--uc-ink-muted);
+ font-family: var(--uc-font-ui);
+ letter-spacing: 0.15em;
+}
+
+.error-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--uc-space-2);
+}
+
+/* ------------------------------------------------------------- shared */
+
+/* Screen-reader-only: the result announcement, focusable nowhere. */
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* ------------------------------------------------------------- help (task 05) */
+
+/* The `?`-key in-game help: a body-level fixed overlay over the panel
+ skin, dismissed by its Close button, the backdrop click, or the
+ keyboard's `?`/`Esc` (the module's `Esc` branch defers to it while
+ it is open). The rows are `getKeyMap()`'s (the README's controls
+ section renders the same thing). */
+.help-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ display: grid;
+ place-items: center;
+ background: color-mix(in srgb, var(--uc-bg) 72%, transparent);
+}
+
+.help-panel {
+ width: min(30rem, calc(100vw - 2 * var(--uc-space-3)));
+ margin: 0;
+ max-width: none;
+}
+
+.help-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--uc-space-2);
+}
+
+.help-heading {
+ font-size: var(--uc-step-1);
+}
+
+.help-rows {
+ display: grid;
+ gap: var(--uc-space-1);
+}
+
+.help-row {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: var(--uc-space-2);
+ align-items: baseline;
+}
+
+.help-keys {
+ font-family: var(--uc-font-ui);
+ font-size: var(--uc-step--1);
+ color: var(--uc-accent);
+ background: var(--uc-surface-2);
+ border: 1px solid var(--uc-line);
+ border-radius: calc(var(--uc-radius) / 2);
+ padding: 0.1rem 0.45rem;
+ white-space: nowrap;
+}
+
+.help-action {
+ color: var(--uc-ink);
+}
+
+.help-foot {
+ margin: 0;
+ color: var(--uc-ink-muted);
+ font-size: var(--uc-step--1);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .help-overlay {
+ animation: uc-help-fade 120ms var(--uc-ease);
+ }
+}
+
+@keyframes uc-help-fade {
+ from {
+ opacity: 0;
+ }
+}
diff --git a/app/static/index.html b/app/static/index.html
index a7c3319..5dad871 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -13,6 +13,7 @@
while keeping every byte same-origin (PLAN.md A12). -->
+
@@ -21,47 +22,12 @@
Anonymous co-op kitchen chaos. No accounts, no install, 1–4 cooks.
-
- Kitchen lobby
-
- Create a kitchen and read its 5-character code out loud, or join a kitchen that is
- already open. Nothing is saved and nothing is asked for.
-
-
-
-
-
-
- Five characters, letters and digits, never I, L, O
- or U.
-
-
-
- Pick a kitchen to start.
-
-
-
-
-
+
+
-
+