phase: 01_runnable_service_scaffold
**Phase 01 final verification pass — all criteria green**
- Verified (nothing left to implement; all 4 task files already in `complete/`): toolchain lock, typed `Settings`+`.env.example`, debugpy bootstrap, app factory, CLI (`--host/--port/--reload/--levels-dir`, `python -m undercooked`), static shell + `/static`, security headers, no-CDN/doc guards, `validate.sh`.
- `uv sync --extra dev` → exit 0; `uv.lock` present (untracked → harness commits it with the phase).
- `uv run undercooked --host 127.0.0.1 --port 8000` → `curl /healthz` = 200 `{"status":"ok","version":"0.1.0","rooms":0}` (superset of the criterion's body — now documented in README); `curl /` = 200 shell HTML; static css/js 200; CSP/nosniff/no-referrer/DENY on 200, 404 and 405 too.
- `DEBUGPY=1`: port 5678 is held on this machine by the user's own podman dev-container debugpy adapter — **no live change made/killed**; verified via `DEBUGPY=1 DEBUGPY_PORT=5679` → listener opens, `/healthz` still 200; the occupied-port case degraded gracefully as designed (warning, server serves). Bad config → one line + exit 2 ✓.
- `bash .agents/validate.sh` (root **and** `.agents/`) → exit 0: `ruff check .` 0 errors, `pyright` 0 errors, levels-CLI notice-skip (matches README prose), **284 passed** full suite incl. 10 Playwright e2e, coverage **100.00%** (floor 90), repo hygiene clean. `uv run pytest -m e2e` → 10 passed.
- Docs fixed: README Status → phase 01 complete + next pending 02 (was pointing at `todo/01…`), documented real `/healthz` body, precedence now "CLI flags > env > .env > default", added the PLAN §13-promised **How to play** section (honest about shell-only today), Layout rows marked what exists vs phases 02/07. AGENTS.md: recorded the `data:`-URL exemption in the no-CDN rule (test allows it) and matched the session-token rule to the browser's WebCrypto/base64url implementation. Also corrected a stale "session-wide chromium" docstring in `tests/e2e/conftest.py` (fixtures are function-scoped).
- Deviations: no container build run (needs an image pull; not a phase criterion — `Containerfile`/`compose.yaml` reviewed and consistent with `docs/DEPLOYMENT.md`); `HEAD /` is 405 (routes are GET-only by design; health checks use GET). No git index/history commands run.
- Next pending phase: `02_level_content_and_validation`.
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
/* Undercooked — base stylesheet (phase 01 shell).
|
||||
*
|
||||
* Rules that later phases must keep:
|
||||
* - every byte is same-origin: no @import, no web fonts, no remote images (A12);
|
||||
* - the palette, spacing and type scale exist only as custom properties below;
|
||||
* - 44x44 minimum hit targets and >= 4.5:1 text contrast (PLAN.md phase 14);
|
||||
* - 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.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Palette — warm kitchen, dark by default. Text pairs are >= 4.5:1. */
|
||||
--uc-bg: #1a1713;
|
||||
--uc-surface: #241f1a;
|
||||
--uc-surface-2: #2f2820;
|
||||
--uc-line: #857259; /* 3.1:1 on every surface: UI edges are visible (WCAG 1.4.11) */
|
||||
--uc-ink: #f6f1e7; /* 13.9:1 on --uc-bg */
|
||||
--uc-ink-muted: #cfc5b5; /* 8.6:1 on --uc-bg */
|
||||
--uc-accent: #ff9f1c; /* 7.6:1 on --uc-bg */
|
||||
--uc-accent-ink: #1a1713; /* 7.6:1 on --uc-accent */
|
||||
--uc-danger: #ff8a80; /* 7.4:1 on --uc-bg */
|
||||
--uc-focus: #ffe066;
|
||||
|
||||
/* Fluid type scale: 360px phone -> 3840px desktop, no breakpoints needed. */
|
||||
--uc-step--1: clamp(0.78rem, 0.74rem + 0.2vw, 0.92rem);
|
||||
--uc-step-0: clamp(1rem, 0.95rem + 0.3vw, 1.2rem);
|
||||
--uc-step-1: clamp(1.25rem, 1.15rem + 0.6vw, 1.7rem);
|
||||
--uc-step-2: clamp(1.7rem, 1.4rem + 1.6vw, 3rem);
|
||||
|
||||
--uc-space-1: 0.5rem;
|
||||
--uc-space-2: 0.75rem;
|
||||
--uc-space-3: 1.25rem;
|
||||
--uc-space-4: 2rem;
|
||||
|
||||
--uc-radius: 0.65rem;
|
||||
--uc-hit: 44px; /* WCAG 2.5.5 minimum target size */
|
||||
--uc-content: 74rem;
|
||||
--uc-font: ui-sans-serif, system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--uc-font-ui: ui-rounded, "Segoe UI Variable Display", var(--uc-font);
|
||||
--uc-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100svh;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: var(--uc-space-3);
|
||||
padding: max(var(--uc-space-3), env(safe-area-inset-top))
|
||||
max(var(--uc-space-2), env(safe-area-inset-right))
|
||||
max(var(--uc-space-3), env(safe-area-inset-bottom))
|
||||
max(var(--uc-space-2), env(safe-area-inset-left));
|
||||
background: radial-gradient(120% 90% at 50% -10%, var(--uc-surface-2), var(--uc-bg) 60%);
|
||||
color: var(--uc-ink);
|
||||
font-family: var(--uc-font);
|
||||
font-size: var(--uc-step-0);
|
||||
line-height: 1.45;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.shell,
|
||||
.colophon {
|
||||
width: 100%;
|
||||
max-width: var(--uc-content);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.masthead {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
margin: 0;
|
||||
font-family: var(--uc-font-ui);
|
||||
font-size: var(--uc-step-2);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--uc-accent);
|
||||
}
|
||||
|
||||
.tagline,
|
||||
.hint,
|
||||
.colophon p {
|
||||
margin: var(--uc-space-1) 0 0;
|
||||
color: var(--uc-ink-muted);
|
||||
font-size: var(--uc-step--1);
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: var(--uc-space-2);
|
||||
margin: var(--uc-space-3) auto 0;
|
||||
padding: var(--uc-space-3);
|
||||
max-width: 34rem;
|
||||
background: var(--uc-surface);
|
||||
border: 1px solid var(--uc-line);
|
||||
border-radius: var(--uc-radius);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0;
|
||||
font-family: var(--uc-font-ui);
|
||||
font-size: var(--uc-step-1);
|
||||
}
|
||||
|
||||
.join-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--uc-space-2);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--uc-space-1);
|
||||
font-size: var(--uc-step--1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--uc-ink-muted);
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
flex: 1 1 8.5rem;
|
||||
/* An <input>'s intrinsic width is its default 20 characters, which would widen
|
||||
the whole fluid layout on a 360px phone. The value is 5 characters. */
|
||||
min-width: 0;
|
||||
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;
|
||||
font-size: var(--uc-step-1);
|
||||
letter-spacing: 0.35em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
input[type="text"]::placeholder {
|
||||
color: #8d8272; /* 4.6:1 on --uc-bg: placeholder text is real text */
|
||||
letter-spacing: 0.35em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-height: var(--uc-hit);
|
||||
min-width: var(--uc-hit);
|
||||
padding: 0 var(--uc-space-3);
|
||||
background: var(--uc-surface-2);
|
||||
color: var(--uc-ink);
|
||||
border: 1px solid var(--uc-line);
|
||||
border-radius: var(--uc-radius);
|
||||
font: inherit;
|
||||
font-size: var(--uc-step-0);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 120ms var(--uc-ease),
|
||||
background-color 120ms var(--uc-ease);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #3b3226;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--uc-accent);
|
||||
color: var(--uc-accent-ink);
|
||||
border-color: var(--uc-accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #ffb445;
|
||||
}
|
||||
|
||||
/* Visible focus for keyboard and low-vision users; never remove this. */
|
||||
:where(a, button, input, [tabindex]):focus-visible {
|
||||
outline: 3px solid var(--uc-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--uc-radius);
|
||||
}
|
||||
|
||||
.status {
|
||||
min-height: calc(var(--uc-hit) - 8px);
|
||||
padding: var(--uc-space-2);
|
||||
background: var(--uc-bg);
|
||||
border: 1px dashed var(--uc-line);
|
||||
border-radius: var(--uc-radius);
|
||||
font-size: var(--uc-step--1);
|
||||
}
|
||||
|
||||
.status[data-kind="error"] {
|
||||
color: var(--uc-danger);
|
||||
border-color: var(--uc-danger);
|
||||
}
|
||||
|
||||
#kcanvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 10;
|
||||
background: #0f0d0b;
|
||||
border: 1px solid var(--uc-line);
|
||||
border-radius: var(--uc-radius);
|
||||
}
|
||||
|
||||
#kcanvas[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, "Cascadia Mono", "Segoe UI Mono", Menlo, monospace;
|
||||
font-size: 0.95em;
|
||||
color: var(--uc-accent);
|
||||
}
|
||||
|
||||
.colophon {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Undercooked: anonymous cooperative kitchen chaos in the browser. Create a lobby, read out the 5-character code, cook together."
|
||||
/>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Undercooked</title>
|
||||
<!-- No favicon file yet: an empty data: URL stops the browser probing /favicon.ico
|
||||
while keeping every byte same-origin (PLAN.md A12). -->
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="/static/css/base.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="shell">
|
||||
<header class="masthead">
|
||||
<p class="wordmark">Undercooked</p>
|
||||
<p class="tagline">Anonymous co-op kitchen chaos. No accounts, no install, 1–4 cooks.</p>
|
||||
</header>
|
||||
|
||||
<section id="screen-lobby" class="panel" aria-labelledby="lobby-heading">
|
||||
<h2 id="lobby-heading">Kitchen lobby</h2>
|
||||
<p id="lobby-help">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<button id="create-lobby" class="btn btn-primary" type="button">Create lobby</button>
|
||||
|
||||
<form id="join-form" class="join" autocomplete="off">
|
||||
<label for="join-code">Room code</label>
|
||||
<div class="join-row">
|
||||
<input
|
||||
id="join-code"
|
||||
name="code"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocapitalize="characters"
|
||||
spellcheck="false"
|
||||
size="5"
|
||||
maxlength="5"
|
||||
placeholder="ABCDE"
|
||||
aria-describedby="join-hint"
|
||||
required
|
||||
/>
|
||||
<button class="btn" type="submit">Join</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="join-hint" class="hint">
|
||||
Five characters, letters and digits, never <code>I</code>, <code>L</code>, <code>O</code>
|
||||
or <code>U</code>.
|
||||
</p>
|
||||
|
||||
<div id="status" class="status" role="status" aria-live="polite">
|
||||
Pick a kitchen to start.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The renderer (phase 07) sizes and unhides this canvas; it stays out of
|
||||
the layout and the a11y tree until then. -->
|
||||
<canvas id="kcanvas" hidden aria-hidden="true"></canvas>
|
||||
</main>
|
||||
|
||||
<footer class="colophon">
|
||||
<p>
|
||||
An independent homage to Overcooked™ with its own art, code and content. Every byte
|
||||
you see is served by this one process — no third-party requests, no trackers.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,131 @@
|
||||
// Undercooked — client shell entry point (phase 01).
|
||||
//
|
||||
// There is no game logic here on purpose. Phase 05 replaces the placeholder
|
||||
// button handlers with the lobby flow, phase 06 adds net/ (WebSocket + snapshots),
|
||||
// phase 07 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).
|
||||
|
||||
const TOKEN_KEY = "uc.token";
|
||||
const LAST_CODE_KEY = "uc.lastCode";
|
||||
const CODE_LENGTH = 5;
|
||||
const NOT_IMPLEMENTED = "Not implemented until phase 05.";
|
||||
const READY_MESSAGE = "Pick a kitchen to start.";
|
||||
|
||||
/** 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(/=+$/, "");
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
function read(storage, key) {
|
||||
try {
|
||||
return storage.getItem(key);
|
||||
} catch {
|
||||
return null; // private browsing / storage disabled: stay functional for this tab only
|
||||
}
|
||||
}
|
||||
|
||||
function write(storage, key, value) {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
} catch {
|
||||
/* nothing to persist to; the in-memory value still works */
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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 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 05: POST to the room supervisor and open /ws/{code}.
|
||||
announce(NOT_IMPLEMENTED, root);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
rememberCode(code);
|
||||
// Phase 05: join the room over the WebSocket.
|
||||
announce(NOT_IMPLEMENTED, root);
|
||||
});
|
||||
}
|
||||
|
||||
if (!lastCode() && root.getElementById("status")) {
|
||||
announce(READY_MESSAGE, root);
|
||||
}
|
||||
|
||||
return { token, code: lastCode() };
|
||||
}
|
||||
|
||||
// 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")) {
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => mount(), { once: true });
|
||||
} else {
|
||||
mount();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user