← Focus Timer history

feat(examples): implement five standalone browser games and apps

Actual project-only Git commit. Email addresses are redacted; source bytes and recorded timestamps are preserved. This is a code record, not human acceptance.

commit 9ef84fff03714b1c699b62967181aea0fd75f935
Author:     0xLeif <redacted@invalid>
AuthorDate: Sun Nov 8 19:35:16 2026 -0700
Commit:     0xLeif <redacted@invalid>
CommitDate: Sun Nov 8 19:35:16 2026 -0700

    feat(examples): implement five standalone browser games and apps

diff --git a/apps/focus-timer/README.md b/apps/focus-timer/README.md
new file mode 100644
index 0000000..52d8505
--- /dev/null
+++ b/apps/focus-timer/README.md
@@ -0,0 +1,29 @@
+# Focus Timer
+
+A standalone, local browser example. Twelve minutes thirty seconds, ready when you are.
+
+## Run from this directory
+
+Prerequisites: Bun 1.4.0 (recorded version). TypeScript 5.9.3 is pinned as a development-only checker. There are no runtime dependencies. The initial install may need registry access; the running app uses no network APIs.
+
+```sh
+bun install --frozen-lockfile
+bun run test
+bun run typecheck
+bun run build
+bun run start
+```
+
+Open http://127.0.0.1:4354/. Stop the server with Ctrl+C. PORT can select another local port. The build emits dist/index.html, styles.css and ui.js; relative asset links also support /examples/focus-timer/play/. No dependency on another app or the gallery source is required.
+
+## Controls and limits
+
+Start a twelve-minute-thirty-second session. Pause preserves the remaining time; Resume continues it. Reset returns to 12:30 without starting. Time is computed from elapsed time, so returning from a background tab does not add extra time. Reloading resets the session. There is no alarm, audio or stored history.
+
+Native buttons, visible focus and a separate polite status region support keyboard use. Countdown digits do not announce every tick. No external artwork, accounts, analytics, persistence, audio, import or automatic downloads. Reduced-motion styling disables decoration movement. Time-based code uses a monotonic browser clock while the page is open; no cross-reload or cross-device timer promise is made.
+
+## Source and checks
+
+src/model.ts contains pure immutable transitions with injected time/randomness. src/ui.ts connects them to native controls. tests/model.test.ts preserves the committed first-red assertions. Bun test validates models; TypeScript checks src only in strict ES2022/DOM mode. Build/serve scripts use Bun and are outside that browser-source typecheck. A green model suite does not establish browser accessibility or human acceptance; root owns rendered verification and actual build-story evidence.
+
+Newly authored source is MIT licensed; see LICENSE. No external art is included. Generated dist, node_modules and private/raw execution evidence are not needed as source inputs. Source ZIP packaging and exact fresh-extraction qualification are handled by the gallery owner.
diff --git a/apps/focus-timer/build.ts b/apps/focus-timer/build.ts
new file mode 100644
index 0000000..2a9f2c7
--- /dev/null
+++ b/apps/focus-timer/build.ts
@@ -0,0 +1,4 @@
+const result = await Bun.build({ entrypoints: [new URL("./src/ui.ts", import.meta.url).pathname], outdir: new URL("./dist", import.meta.url).pathname, target: "browser", minify: false });
+if (!result.success) { for (const log of result.logs) console.error(log); throw new Error("Browser build failed"); }
+for (const name of ["index.html", "styles.css"]) await Bun.write(new URL(`./dist/${name}`, import.meta.url), Bun.file(new URL(`./${name}`, import.meta.url)));
+console.log("Built standalone dist/index.html, dist/styles.css and dist/ui.js");
diff --git a/apps/focus-timer/bun.lock b/apps/focus-timer/bun.lock
new file mode 100644
index 0000000..e6035aa
--- /dev/null
+++ b/apps/focus-timer/bun.lock
@@ -0,0 +1,15 @@
+{
+  "lockfileVersion": 2,
+  "configVersion": 1,
+  "workspaces": {
+    "": {
+      "name": "focus-timer",
+      "devDependencies": {
+        "typescript": "5.9.3",
+      },
+    },
+  },
+  "packages": {
+    "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+  }
+}
diff --git a/apps/focus-timer/index.html b/apps/focus-timer/index.html
new file mode 100644
index 0000000..6a34a0d
--- /dev/null
+++ b/apps/focus-timer/index.html
@@ -0,0 +1,2 @@
+<!doctype html>
+<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Focus Timer · chough example</title><link rel="stylesheet" href="./styles.css"><script type="module" src="./ui.js"></script></head><body><main><header><h1>Focus Timer</h1><p>chough / small browser examples</p></header><p>Twelve minutes thirty seconds, ready when you are.</p><noscript><p>This example needs JavaScript to play or run. The instructions below remain available. Run the source with Bun using its README.</p></noscript><div class="frame"><div class="rail"><span class="dots" aria-hidden="true"><i></i><i></i><i></i></span><span>Focus Timer</span></div><div class="timer-panel"><p class="kicker">ONE THING AT A TIME</p><div class="timer-dial"><output id="clock" role="timer" aria-live="off" aria-label="Time remaining">12:30</output></div><p id="phase">A moment to begin.</p></div></div><div class="controls"><button id="start" type="button" disabled>Start focus</button><button id="pause" type="button" disabled>Pause</button><button id="reset" type="button" disabled>Reset to 12:30</button></div><p class="status" id="status" role="status" aria-live="polite">Twelve minutes thirty seconds, ready when you are.</p><p id="error" class="error" role="alert" hidden></p><details><summary>How to use this example</summary><p>Start a twelve-minute-thirty-second session. Pause preserves the remaining time; Resume continues it. Reset returns to 12:30 without starting. Time is computed from elapsed time, so returning from a background tab does not add extra time. Reloading resets the session. There is no alarm, audio or stored history.</p><p>Local example. No accounts, analytics, storage or network APIs. Interface and code are newly authored under the MIT license. Human implementation acceptance is separate from running it.</p></details><footer>Built as a small chough example. Your coding agent writes code; people review the result.</footer></main></body></html>
diff --git a/apps/focus-timer/serve.ts b/apps/focus-timer/serve.ts
new file mode 100644
index 0000000..c5fd7f4
--- /dev/null
+++ b/apps/focus-timer/serve.ts
@@ -0,0 +1,15 @@
+const port = Number(Bun.env.PORT ?? 4354);
+if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT must be between 1 and 65535");
+const server = Bun.serve({
+    hostname: "127.0.0.1", port,
+    async fetch(request) {
+        if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method not allowed", { status: 405, headers: { Allow: "GET, HEAD" } });
+        const path = new URL(request.url).pathname;
+        const name = path.endsWith("/") ? "index.html" : path.split("/").pop();
+        if (!name || !["index.html", "styles.css", "ui.js"].includes(name)) return new Response("Not found", { status: 404 });
+        const file = Bun.file(new URL(`./dist/${name}`, import.meta.url));
+        if (!await file.exists()) return new Response("Build the app first with bun run build", { status: 503 });
+        return new Response(request.method === "HEAD" ? null : file, { headers: { "Content-Type": file.type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" } });
+    }
+});
+console.log(`Focus Timer: ${server.url}`);
diff --git a/apps/focus-timer/src/model.ts b/apps/focus-timer/src/model.ts
index 90b021a..6299157 100644
--- a/apps/focus-timer/src/model.ts
+++ b/apps/focus-timer/src/model.ts
@@ -1,4 +1,4 @@
-// Red-phase API stubs. Root commits these failure inputs before implementation.
+// Pure immutable domain model. Time and randomness are supplied by the caller.
 export const DEFAULT_DURATION_MS = 750_000;
 export type TimerPhase = "idle" | "running" | "paused" | "completed";
 export interface TimerState {
@@ -6,18 +6,26 @@ export interface TimerState {
     readonly remainingMs: number;
     readonly deadlineMs: number | null;
 }
-export function createTimer(): TimerState {
-    throw new Error("Not implemented: createTimer");
+
+function validTime(nowMs: number): void {
+    if (!Number.isFinite(nowMs) || nowMs < 0) throw new RangeError("Time must be finite and nonnegative");
+}
+
+export function createTimer(): TimerState { return { phase: "idle", remainingMs: DEFAULT_DURATION_MS, deadlineMs: null }; }
+export function resetTimer(): TimerState { return createTimer(); }
+export function advanceTimer(state: TimerState, nowMs: number): TimerState {
+    validTime(nowMs);
+    if (state.phase !== "running" || state.deadlineMs === null) return state;
+    const remainingMs = Math.max(0, state.deadlineMs - nowMs);
+    return remainingMs === 0 ? { phase: "completed", remainingMs: 0, deadlineMs: null } : { ...state, remainingMs };
 }
 export function startTimer(state: TimerState, nowMs: number): TimerState {
-    throw new Error("Not implemented: startTimer");
+    validTime(nowMs);
+    if (state.phase === "running") return advanceTimer(state, nowMs);
+    if (state.phase === "completed") return state;
+    return { ...state, phase: "running", deadlineMs: nowMs + state.remainingMs };
 }
 export function pauseTimer(state: TimerState, nowMs: number): TimerState {
-    throw new Error("Not implemented: pauseTimer");
-}
-export function advanceTimer(state: TimerState, nowMs: number): TimerState {
-    throw new Error("Not implemented: advanceTimer");
-}
-export function resetTimer(): TimerState {
-    throw new Error("Not implemented: resetTimer");
+    const current = advanceTimer(state, nowMs);
+    return current.phase === "running" ? { ...current, phase: "paused", deadlineMs: null } : current;
 }
diff --git a/apps/focus-timer/src/ui.ts b/apps/focus-timer/src/ui.ts
new file mode 100644
index 0000000..b239906
--- /dev/null
+++ b/apps/focus-timer/src/ui.ts
@@ -0,0 +1,20 @@
+import { createTimer, startTimer, pauseTimer, advanceTimer, resetTimer } from "./model";
+function get<Value extends HTMLElement>(selector: string): Value { const node = document.querySelector<Value>(selector); if (!node) throw new Error(`Missing ${selector}`); return node; }
+function boot(): void {
+    const start = get<HTMLButtonElement>("#start"), pause = get<HTMLButtonElement>("#pause"), reset = get<HTMLButtonElement>("#reset"), clock = get<HTMLElement>("#clock"), phase = get<HTMLElement>("#phase"), status = get<HTMLElement>("#status");
+    let state = createTimer();
+    function render(): void {
+        const seconds = Math.ceil(state.remainingMs / 1000);
+        clock.textContent = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
+        phase.textContent = { idle: "A moment to begin.", running: "One thing at a time.", paused: "Come back when ready.", completed: "Your focus session is complete." }[state.phase];
+        start.textContent = state.phase === "paused" ? "Resume" : "Start focus";
+        start.disabled = state.phase === "running" || state.phase === "completed";
+        pause.disabled = state.phase !== "running"; reset.disabled = state.phase === "idle";
+    }
+    function tick(): void { const pauseHadFocus = document.activeElement === pause; const before = state.phase; state = advanceTimer(state, performance.now()); render(); if (before !== "completed" && state.phase === "completed") { status.textContent = "Focus session complete. Reset when you want to begin again."; if (pauseHadFocus) reset.focus(); } }
+    start.addEventListener("click", () => { state = startTimer(state, performance.now()); render(); status.textContent = "Focus timer running."; pause.focus(); });
+    pause.addEventListener("click", () => { state = pauseTimer(state, performance.now()); render(); status.textContent = state.phase === "completed" ? "Focus session complete." : `Paused with ${clock.textContent} remaining.`; if (state.phase === "completed") reset.focus(); else start.focus(); });
+    reset.addEventListener("click", () => { state = resetTimer(); render(); status.textContent = "Reset to twelve minutes thirty seconds. Start when ready."; start.focus(); });
+    document.addEventListener("visibilitychange", tick); render(); window.setInterval(tick, 200);
+}
+try { boot(); } catch (error: unknown) { const node = document.getElementById("error"); if (node) { node.hidden = false; node.textContent = error instanceof Error ? error.message : "The timer could not start. Reload to try again."; } }
diff --git a/apps/focus-timer/styles.css b/apps/focus-timer/styles.css
new file mode 100644
index 0000000..45a393a
--- /dev/null
+++ b/apps/focus-timer/styles.css
@@ -0,0 +1,2 @@
+*{box-sizing:border-box} :root{color-scheme:dark;font-family:"Schibsted Grotesk","Helvetica Neue",system-ui,sans-serif;background:#131619;color:#f4f3ef}body{margin:0;padding:clamp(16px,4vw,48px)}main{max-width:800px;margin:auto}header{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:24px}header p{font-size:12px;color:#c8c6be}h1{font-size:clamp(30px,6vw,48px);letter-spacing:-.05em;line-height:1.05;margin:0}p{line-height:1.7}button{font:inherit;cursor:pointer;min-height:48px;padding:10px 20px;border:1px solid #a3a199;border-radius:6px;background:#272c31;color:#f4f3ef}button:disabled{cursor:default;opacity:.55}button:focus-visible,summary:focus-visible,a:focus-visible{outline:3px solid #63d9c7;outline-offset:5px}button[aria-disabled=true]{cursor:default}.frame{border:1px solid #45494c;border-radius:12px;overflow:hidden}.rail{display:flex;align-items:center;gap:12px;padding:10px 16px;background:#1b1f23;color:#c8c6be;font:12px ui-monospace,monospace}.dots{display:flex;gap:5px}.dots i{display:block;width:7px;height:7px;border-radius:50%;background:#a3a199}.controls{display:flex;flex-wrap:wrap;gap:12px;margin-block:20px}.status{min-height:3.4em;color:#c8c6be;margin-block:12px}details{border-top:1px solid #45494c;padding-block:12px}summary{cursor:pointer;min-height:44px;padding-block:10px}details p{color:#c8c6be;font-size:14px}footer{font-size:12px;color:#a3a199;margin-top:24px}.error{padding:16px;border:1px solid #f08a7d;color:#f08a7d}[hidden]{display:none!important}noscript p{padding:16px;border:1px solid #e0b05a} @media(max-width:420px){header{align-items:flex-start;flex-direction:column;gap:8px}.controls button{flex:1}body{padding:16px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
+.timer-panel{display:flex;flex-direction:column;align-items:center;padding:40px 20px;background:#e0916f;color:#15181b}.kicker{font:11px ui-monospace,monospace;letter-spacing:.08em;margin:0 0 24px}.timer-dial{display:grid;place-items:center;width:min(290px,75vw);aspect-ratio:1;border:1px solid #68432e;border-radius:50%}#clock{font-size:clamp(48px,10vw,76px);font-variant-numeric:tabular-nums;letter-spacing:-.06em}#phase{margin:24px 0 0;font-size:16px}.controls button:first-child{background:#e0916f;color:#15181b;border-color:#e0916f}