← Target Tap 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 09b7c07d4bc22f3cc97637f066a793bcc6ee4676
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/target-tap/README.md b/apps/target-tap/README.md
new file mode 100644
index 0000000..9a166c4
--- /dev/null
+++ b/apps/target-tap/README.md
@@ -0,0 +1,29 @@
+# Target Tap
+
+A standalone, local browser example. Start a round when ready. Hit targets for twenty seconds.
+
+## 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:4351/. 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/target-tap/play/. No dependency on another app or the gallery source is required.
+
+## Controls and limits
+
+Start, then click or tap the target. Keyboard: Start focuses the target; release and press Enter or Space for each hit. Holding a key does not score repeatedly. A missed target scores nothing. Hits at the deadline do not count. The countdown continues in a background tab. No scores are saved.
+
+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/target-tap/build.ts b/apps/target-tap/build.ts
new file mode 100644
index 0000000..2a9f2c7
--- /dev/null
+++ b/apps/target-tap/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/target-tap/bun.lock b/apps/target-tap/bun.lock
new file mode 100644
index 0000000..2eb0688
--- /dev/null
+++ b/apps/target-tap/bun.lock
@@ -0,0 +1,15 @@
+{
+  "lockfileVersion": 2,
+  "configVersion": 1,
+  "workspaces": {
+    "": {
+      "name": "target-tap",
+      "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/target-tap/index.html b/apps/target-tap/index.html
new file mode 100644
index 0000000..3709726
--- /dev/null
+++ b/apps/target-tap/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>Target Tap · chough example</title><link rel="stylesheet" href="./styles.css"><script type="module" src="./ui.js"></script></head><body><main><header><h1>Target Tap</h1><p>chough / small browser examples</p></header><p>Start a round when ready. Hit targets for twenty seconds.</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>Target Tap</span></div><div class="stats"><span>Score <strong id="score">0</strong></span><span>Seconds <strong id="time">20</strong></span></div><div class="target-board" id="board"><p id="board-message">ON YOUR MARK.</p><button id="target" type="button" aria-label="Hit target for one point" hidden><span aria-hidden="true">+</span></button></div></div><div class="controls"><button id="start" type="button" disabled>Start 20-second round</button></div><p class="status" id="status" role="status" aria-live="polite">Start a round when ready. Hit targets for twenty seconds.</p><p id="error" class="error" role="alert" hidden></p><details><summary>How to use this example</summary><p>Start, then click or tap the target. Keyboard: Start focuses the target; release and press Enter or Space for each hit. Holding a key does not score repeatedly. A missed target scores nothing. Hits at the deadline do not count. The countdown continues in a background tab. No scores are saved.</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/target-tap/serve.ts b/apps/target-tap/serve.ts
new file mode 100644
index 0000000..225d5c3
--- /dev/null
+++ b/apps/target-tap/serve.ts
@@ -0,0 +1,15 @@
+const port = Number(Bun.env.PORT ?? 4351);
+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(`Target Tap: ${server.url}`);
diff --git a/apps/target-tap/src/model.ts b/apps/target-tap/src/model.ts
index daa5e7f..b27edf9 100644
--- a/apps/target-tap/src/model.ts
+++ b/apps/target-tap/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 ROUND_DURATION_MS = 20_000;
 export type RandomSource = () => number;
 export type RoundPhase = "ready" | "running" | "finished";
@@ -14,15 +14,36 @@ export interface RoundState {
     readonly target: Target | null;
     readonly targetSerial: number;
 }
+
+function validTime(nowMs: number): void {
+    if (!Number.isFinite(nowMs) || nowMs < 0) throw new RangeError("Time must be finite and nonnegative");
+}
+
+function nextTarget(id: number, random: RandomSource): Target {
+    const sample = (): number => {
+        const value = random();
+        if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("Random sample must be in [0, 1)");
+        return 0.12 + 0.76 * value;
+    };
+    return { id, x: sample(), y: sample() };
+}
 export function createRound(): RoundState {
-    throw new Error("Not implemented: createRound");
+    return { phase: "ready", score: 0, deadlineMs: null, target: null, targetSerial: 0 };
+}
+export function advanceRound(state: RoundState, nowMs: number): RoundState {
+    validTime(nowMs);
+    return state.phase === "running" && state.deadlineMs !== null && nowMs >= state.deadlineMs
+        ? { ...state, phase: "finished", target: null } : state;
 }
 export function startRound(state: RoundState, nowMs: number, random: RandomSource): RoundState {
-    throw new Error("Not implemented: startRound");
+    validTime(nowMs);
+    if (state.phase === "running") return advanceRound(state, nowMs);
+    const targetSerial = state.targetSerial + 1;
+    return { phase: "running", score: 0, deadlineMs: nowMs + ROUND_DURATION_MS, target: nextTarget(targetSerial, random), targetSerial };
 }
 export function hitTarget(state: RoundState, targetId: number, nowMs: number, random: RandomSource): RoundState {
-    throw new Error("Not implemented: hitTarget");
-}
-export function advanceRound(state: RoundState, nowMs: number): RoundState {
-    throw new Error("Not implemented: advanceRound");
+    const current = advanceRound(state, nowMs);
+    if (current.phase !== "running" || current.target?.id !== targetId) return current;
+    const targetSerial = current.targetSerial + 1;
+    return { ...current, score: current.score + 1, target: nextTarget(targetSerial, random), targetSerial };
 }
diff --git a/apps/target-tap/src/ui.ts b/apps/target-tap/src/ui.ts
new file mode 100644
index 0000000..6ab0c93
--- /dev/null
+++ b/apps/target-tap/src/ui.ts
@@ -0,0 +1,39 @@
+import { createRound, startRound, hitTarget, advanceRound } 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"), target = get<HTMLButtonElement>("#target"), board = get<HTMLDivElement>("#board"), score = get<HTMLElement>("#score"), time = get<HTMLElement>("#time"), status = get<HTMLElement>("#status"), message = get<HTMLElement>("#board-message");
+    let state = createRound();
+    let activationId: number | null = null;
+    function render(): void {
+        score.textContent = String(state.score);
+        time.textContent = String(state.phase === "ready" ? 20 : state.phase === "finished" ? 0 : Math.max(0, Math.ceil(((state.deadlineMs ?? 0) - performance.now()) / 1000)));
+        start.disabled = state.phase === "running";
+        start.textContent = state.phase === "finished" ? "Play again" : "Start 20-second round";
+        target.hidden = state.target === null;
+        message.hidden = state.phase === "running";
+        message.textContent = state.phase === "finished" ? `ROUND COMPLETE. ${state.score} HITS.` : "ON YOUR MARK.";
+        if (state.target) {
+            target.style.left = `${36 + Math.max(0, board.clientWidth - 72) * state.target.x}px`;
+            target.style.top = `${36 + Math.max(0, board.clientHeight - 72) * state.target.y}px`;
+        }
+    }
+    function tick(): void {
+        const targetHadFocus = document.activeElement === target;
+        const before = state.phase; state = advanceRound(state, performance.now()); render();
+        if (before === "running" && state.phase === "finished") { status.textContent = `Round complete. Final score: ${state.score}. Play again when ready.`; if (targetHadFocus) start.focus(); }
+    }
+    start.addEventListener("click", () => { state = startRound(state, performance.now(), Math.random); activationId = null; render(); status.textContent = "Round started. Hit the target with a pointer, or press Enter or Space while it is focused."; target.focus(); });
+    target.addEventListener("pointerdown", () => { activationId = state.target?.id ?? null; });
+    target.addEventListener("keydown", (event) => { if (event.key === " " || event.key === "Enter") { if (event.repeat) { event.preventDefault(); return; } activationId = state.target?.id ?? null; } });
+    target.addEventListener("click", () => {
+        const id = activationId ?? state.target?.id; activationId = null;
+        if (id === undefined) return;
+        const oldScore = state.score; state = hitTarget(state, id, performance.now(), Math.random); render();
+        if (state.score !== oldScore) status.textContent = `Hit. Score ${state.score}.`;
+        if (state.phase === "finished") { status.textContent = `Round complete. Final score: ${state.score}.`; start.focus(); }
+    });
+    window.addEventListener("resize", render);
+    document.addEventListener("visibilitychange", tick);
+    start.disabled = false; render(); window.setInterval(tick, 100);
+}
+try { boot(); } catch (error: unknown) { const node = document.getElementById("error"); if (node) { node.hidden = false; node.textContent = error instanceof Error ? error.message : "The game could not start. Reload to try again."; } }
diff --git a/apps/target-tap/styles.css b/apps/target-tap/styles.css
new file mode 100644
index 0000000..e082c1e
--- /dev/null
+++ b/apps/target-tap/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}}
+.stats{display:flex;justify-content:space-between;padding:18px 24px;background:#1b1f23}.stats span{color:#c8c6be}.stats strong{display:block;font:32px ui-monospace,monospace;color:#63d9c7}.target-board{position:relative;height:360px;background-color:#131619;background-image:radial-gradient(#28423f 1px,transparent 1px);background-size:20px 20px;overflow:hidden}.target-board p{padding:24px;margin:0;font-size:clamp(34px,7vw,64px);line-height:1;max-width:8ch;letter-spacing:-.055em;color:#63d9c7;font-weight:700}#target{position:absolute;transform:translate(-50%,-50%);width:72px;height:72px;min-height:72px;padding:0;border:1px solid #63d9c7;border-radius:50%;background:#45d0bc;color:#15181b;box-shadow:0 0 0 12px #45d0bc22,0 0 0 25px #45d0bc11;font-size:44px;line-height:1}#target:focus-visible{outline:3px solid #f4f3ef;outline-offset:6px}@media(max-width:420px){.target-board{height:320px}}