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:
2026-10-06 01:51:20 -04:00
parent f84c6ebaef
commit 6fd83446ec
43 changed files with 5478 additions and 30 deletions
+246
View File
@@ -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;
}
}
+76
View File
@@ -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&ndash;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&trade; with its own art, code and content. Every byte
you see is served by this one process &mdash; no third-party requests, no trackers.
</p>
</footer>
<script type="module" src="/static/js/app.js"></script>
</body>
</html>
+131
View File
@@ -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();
}
}