diff --git a/typy/.gitignore b/typy/.gitignore
new file mode 100644
index 0000000..397f5ca
--- /dev/null
+++ b/typy/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+__pycache__/
+*.pyc
+*.log
+.env
+.env.*
+.dev.vars
diff --git a/typy/README.md b/typy/README.md
new file mode 100644
index 0000000..a94977f
--- /dev/null
+++ b/typy/README.md
@@ -0,0 +1,92 @@
+# typy
+
+One word at a time. Type it, find the next one, settle into a rhythm.
+
+**Live:** https://typy.yolopreneur.lol/
+**Brief:** [time#254](https://teahub.xyz/teahubxyz/time/issues/254)
+
+## Play
+
+- 30 seconds, 60 seconds, 50 words, endless, and tempo.
+- The first typed character starts the run. Backspace fixes mistakes. Space advances; settings can advance on the last correct letter instead.
+- Tempo waits for each word's beat, accelerates by 3.5% on success, and ends after three missed beats. Its starting pace comes from the device's last measured average.
+- Common words are weighted by frequency. The words menu also accepts a topic or a pasted passage, capped at 300 words.
+- Topic paths such as **/react-hooks** generate a short Venice passage and cache it for 30 days.
+- **/daily** uses one deterministic passage per UTC day and locks the run to 60 seconds. Sharing a completed daily run adds an optional name to the daily board.
+- Finished runs show WPM, accuracy, clean-word streaks, slower words, and a speed sparkline. Replay follows original key timing and supports scrubbing.
+- Choose Share to publish a replay at **/r/:id**. A result includes a real PNG social preview. **/race/:id** uses the original passage, mode, advancement setting, and timing as a ghost.
+
+## Development
+
+Node 22 or newer. The app and Worker have no runtime package dependencies.
+
+ cd typy
+ npm start
+
+The local server runs at http://127.0.0.1:8787 with ephemeral memory storage and the real quota class. Restarting it clears local results. Set PORT to choose another port. Set VENICE_API_KEY to exercise real topic generation locally; otherwise that route returns a clear unavailable response. Common words, pasted text, the daily, sharing, replay, and score images still work locally.
+
+ npm test
+
+The test files can also be run directly for individual test output:
+
+ node test/game.test.js
+ node test/backend.test.js
+
+Optional browser acceptance uses Playwright and a Chromium executable:
+
+ CHROME=/path/to/chrome node test/browser-smoke.mjs
+ CHROME=/path/to/chrome node test/integration.mjs
+
+The integration script uses the actual local backend, creates a test result, reloads its public link, checks the preview PNG, replays it, and opens the ghost race. Set TYPY_BASE_URL to test a deployment; it creates one clearly named test replay and does not submit a synthetic daily-board score.
+
+The build bundles the HTML and static source into Worker ES modules. Commit both the authored files and generated **src/page.js** and **src/assets.js**. Deployment refuses mismatched generated files.
+
+## Deployment
+
+The source lives under **typy/** on the time repository's main branch, following the existing quc Worker pattern. This app needs its own backend, so it is served directly by the Worker rather than the static venture folder.
+
+Deploy with the existing operator credentials: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, and VENICE_API_KEY. Their approved locations are documented in the repository's **.github/time/SECRETS.md**. No credential belongs in this directory or in frontend code.
+
+ # From the repository root:
+ npm run build --prefix typy
+ npm test --prefix typy
+ # Commit and merge the tested source, then deploy from that clean checkout:
+ python3 typy/deploy.py
+ python3 typy/deploy.py --health
+
+The deploy script reuses **.github/time/cloudflare.py**, uploads the committed ES modules, reuses a KV namespace named **time-typy**, and attaches **typy.yolopreneur.lol**. Its first upload creates the SQLite Durable Object class **QuotaGate** with migration **typy-v1**. Later uploads retain the same store and quota namespace. The operator machine needs Python and git; committed generated assets make Node unnecessary there.
+
+The Worker returns its code revision through **/api/health**. The deployment receipt also records the exact source commit. Verify the custom domain and asset responses separately from the upload.
+
+## Storage, privacy, and limits
+
+- Unshared runs and pasted passages remain in the browser. Best scores, settings, and the optional display name use local storage.
+- A share contains the chosen passage, relative key timing and outcome metadata, computed score, mode, and optional name. Actual mistyped characters and input buffers are never serialized or accepted into stored results.
+- Scores are recomputed on the server from replay outcomes; daily submissions must match the current canonical passage and full 60-second run. This is an informal game leaderboard, not a proof of human typing.
+- KV stores one immutable result per ID, one topic per canonical slug, and separate daily-board entries. Concurrent scores do not overwrite a shared leaderboard object. A new link or board entry can take a short time to replicate between locations.
+- The quota object reserves new-generation and save limits atomically. Daily defaults are eight uncached topics per visitor, 80 total, 30 saves per visitor, and 2,000 total. It also spaces new topic requests by eight seconds and saves by one second.
+- Raw IP addresses are used only inside the quota object's request to derive an HMAC scoped to the UTC day. They are never persisted. Old daily hashes and counters are purged. No analytics, account, or persistent visitor cookie is added.
+- Topic input and model output are screened for a general audience, normalized to plain text, and bounded. The provider uses **mistral-small-3-2-24b-instruct**, configurable through TOPIC_MODEL.
+- Endless play can continue indefinitely. Replay memory is bounded to 30,000 events and 6,000 word timings. If a run exceeds that history, the local full score remains available and the incomplete replay cannot be published. Uploads are also capped at 1.5 MB.
+
+## Scoring
+
+WPM is net correct output characters divided by five and by elapsed minutes. Completed words include one normalized separator in either advancement mode. Deleting and retyping characters cannot inflate net output. Accuracy is correct character attempts divided by all character attempts, including mistakes and excluding navigation or correction keys.
+
+Timed runs stop at exactly their deadline, including when a background tab resumes late. Tempo misses are measured against the original beat deadlines. The speed chart omits a zero-duration first word because no measurable typing interval exists for it.
+
+## API
+
+| Route | Purpose |
+|---|---|
+| GET /api/health | Runtime bindings and revision |
+| GET /api/passage?topic=react-hooks | Cached or generated topic |
+| GET /api/daily | Shared UTC-day passage |
+| GET /api/board?day=YYYY-MM-DD | Daily top 50 |
+| POST /api/results | Validate and publish a completed replay |
+| GET /api/results/:id | Read a saved replay |
+| GET /api/og/:id.png | 1200 × 630 PNG score preview |
+
+## Acceptance evidence
+
+**evidence/review.md** records the independent desktop/phone review and the corrected keyboard and long-word regressions. The integration receipts cover actual API persistence, public replay, ghost routing, shared daily text, and PNG rendering. Tests and review receipts distinguish mocked frontend cases from real backend checks.
diff --git a/typy/build.mjs b/typy/build.mjs
new file mode 100644
index 0000000..fd79501
--- /dev/null
+++ b/typy/build.mjs
@@ -0,0 +1,27 @@
+import { readFile, writeFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+const here = dirname(fileURLToPath(import.meta.url));
+const html = await readFile(join(here, 'public/index.html'), 'utf8');
+const page = 'const TEMPLATE = ' + JSON.stringify(html) + ';\n' +
+ 'const escape = (s) => String(s ?? "").replace(/[&<>"\\x27]/g, c => ({"&":"&","<":"<",">":">",\'"\':""","\\x27":"'"}[c]));\n' +
+ 'export function renderPage(meta = {}) {\n' +
+ 'const title = escape(meta.title || "typy — one word at a time");\n' +
+ 'const description = escape(meta.description || "Find your rhythm. One word at a time, a little faster with every beat. Type, replay, and race a friend.");\n' +
+ 'const image = escape(meta.image || "https://typy.yolopreneur.lol/api/og/default.png");\n' +
+ 'const url = escape(meta.url || "https://typy.yolopreneur.lol/");\n' +
+ 'const tags = "
" + title + "";\n' +
+ 'return TEMPLATE.replace("", tags);\n}\n';
+await writeFile(join(here, 'src/page.js'), page);
+const assets = {};
+for (const [url, path, type] of [
+ ['/app.js', 'public/app.js', 'text/javascript; charset=utf-8'],
+ ['/style.css', 'public/style.css', 'text/css; charset=utf-8'],
+ ['/favicon.svg', 'public/favicon.svg', 'image/svg+xml'],
+ ['/game.js', 'src/game.js', 'text/javascript; charset=utf-8'],
+ ['/words.js', 'src/words.js', 'text/javascript; charset=utf-8'],
+]) {
+ assets[url] = {body: await readFile(join(here, path), 'utf8'), type};
+}
+await writeFile(join(here, 'src/assets.js'), 'export const ASSETS = ' + JSON.stringify(assets) + ';\n');
+console.log('typy: built ' + Object.keys(assets).length + ' assets');
diff --git a/typy/deploy.py b/typy/deploy.py
new file mode 100644
index 0000000..e433f68
--- /dev/null
+++ b/typy/deploy.py
@@ -0,0 +1,160 @@
+"""Deploy the committed typy Worker, its KV store, quota object, and custom domain.
+
+Uses the repository's existing Cloudflare helper and operator credentials. Run
+from a clean checkout after npm run build --prefix typy has been committed.
+No Node runtime, package install, workflow change, or D1 permission is needed on
+the deployment machine.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import urllib.request
+
+HERE = Path(__file__).resolve().parent
+ROOT = HERE.parent
+sys.path.insert(0, str(ROOT / ".github" / "time"))
+import cloudflare as cf # noqa: E402
+
+WORKER = "time-typy"
+MIGRATION = "typy-v1"
+DOMAIN = "typy.yolopreneur.lol"
+ASSETS = {
+ "/app.js": "public/app.js",
+ "/style.css": "public/style.css",
+ "/favicon.svg": "public/favicon.svg",
+ "/game.js": "src/game.js",
+ "/words.js": "src/words.js",
+}
+
+
+def git(*args: str) -> str:
+ return subprocess.check_output(["git", "-C", str(ROOT), *args], text=True).strip()
+
+
+def checked_modules() -> dict[str, bytes]:
+ """Never upload stale generated assets beside a newer source file."""
+ assets_text = (HERE / "src" / "assets.js").read_text()
+ prefix = "export const ASSETS = "
+ if not assets_text.startswith(prefix):
+ raise RuntimeError("assets.js is missing or not generated by typy/build.mjs")
+ assets = json.loads(assets_text[len(prefix):].rstrip(";\n"))
+ for url, path in ASSETS.items():
+ if assets.get(url, {}).get("body") != (HERE / path).read_text():
+ raise RuntimeError(f"Stale {url}: run the build and commit its output first")
+ page_first = (HERE / "src" / "page.js").read_text().splitlines()[0]
+ template = json.loads(page_first.removeprefix("const TEMPLATE = ").rstrip(";"))
+ if template != (HERE / "public" / "index.html").read_text():
+ raise RuntimeError("Stale page.js: run the build and commit its output first")
+ modules = {path.name: path.read_bytes() for path in sorted((HERE / "src").glob("*.js"))}
+ if "worker.js" not in modules:
+ raise RuntimeError("src/worker.js is missing")
+ if sum(map(len, modules.values())) > 2_000_000:
+ raise RuntimeError("Worker source exceeds the bounded two-megabyte deployment")
+ return modules
+
+
+def namespace() -> str:
+ for page in range(1, 21):
+ rows = cf.cf("GET", f"{cf.account()}/storage/kv/namespaces?per_page=100&page={page}") or []
+ for row in rows:
+ if row.get("title") == WORKER:
+ return row["id"]
+ if len(rows) < 100:
+ return cf.cf("POST", f"{cf.account()}/storage/kv/namespaces", {"title": WORKER})["id"]
+ raise RuntimeError("KV namespace search exceeded its page limit; no namespace was created")
+
+
+def existing_settings() -> dict:
+ try:
+ return cf.cf("GET", f"{cf.account()}/workers/scripts/{WORKER}/settings") or {}
+ except RuntimeError as error:
+ if "-> 404:" in str(error):
+ return {}
+ raise
+
+
+def revision(modules: dict[str, bytes], settings: dict[str, str]) -> str:
+ digest = hashlib.sha256()
+ for name, data in sorted(modules.items()):
+ digest.update(name.encode() + b"\0" + data + b"\0")
+ digest.update(json.dumps(settings, sort_keys=True).encode())
+ return digest.hexdigest()[:16]
+
+
+def deploy(*, dry_run: bool = False) -> dict:
+ modules = checked_modules()
+ sha = git("rev-parse", "HEAD")
+ if git("status", "--porcelain", "--", "typy"):
+ raise RuntimeError("Deploy only from a clean, committed typy checkout")
+ settings = {
+ "SOURCE_SHA": sha,
+ "TOPIC_MODEL": os.environ.get("TOPIC_MODEL", "mistral-small-3-2-24b-instruct"),
+ "TOPICS_PER_VISITOR": "8",
+ "TOPICS_PER_DAY": "80",
+ "SAVES_PER_VISITOR": "30",
+ "SAVES_PER_DAY": "2000",
+ }
+ settings["REV"] = revision(modules, settings)
+ summary = {
+ "worker": WORKER, "url": "https://" + DOMAIN + "/",
+ "source_sha": sha, "rev": settings["REV"],
+ "modules": {name: len(data) for name, data in modules.items()},
+ }
+ if dry_run:
+ return {**summary, "dry_run": True}
+ for name in ("CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "VENICE_API_KEY"):
+ if not os.environ.get(name):
+ raise RuntimeError(f"{name} is required; no deployment was attempted")
+ previous = existing_settings()
+ tag = previous.get("migration_tag", "")
+ if tag not in ("", MIGRATION):
+ raise RuntimeError("Unexpected Durable Object migration tag; review before changing it")
+ bindings = [
+ {"type": "kv_namespace", "name": "KV", "namespace_id": namespace()},
+ {"type": "durable_object_namespace", "name": "QUOTA", "class_name": "QuotaGate"},
+ {"type": "secret_text", "name": "VENICE_API_KEY", "text": os.environ["VENICE_API_KEY"]},
+ *({"type": "plain_text", "name": key, "text": value} for key, value in settings.items()),
+ ]
+ metadata = {
+ "main_module": "worker.js",
+ "compatibility_date": "2026-09-01",
+ "bindings": bindings,
+ "observability": {"enabled": False},
+ }
+ has_quota = any(binding.get("type") == "durable_object_namespace" and binding.get("name") == "QUOTA"
+ for binding in previous.get("bindings", []))
+ if not tag and not has_quota:
+ metadata["migrations"] = {"new_tag": MIGRATION, "new_sqlite_classes": ["QuotaGate"]}
+ raw, content_type = cf.multipart([
+ ("metadata", "application/json", json.dumps(metadata).encode()),
+ *((name, "application/javascript+module", data) for name, data in modules.items()),
+ ])
+ cf.cf("PUT", f"{cf.account()}/workers/scripts/{WORKER}", data=raw, ctype=content_type)
+ cf.attach(WORKER, DOMAIN)
+ return {**summary, "migration": MIGRATION}
+
+
+def health() -> dict:
+ request = urllib.request.Request("https://" + DOMAIN + "/api/health", headers={"Cache-Control": "no-cache"})
+ with urllib.request.urlopen(request, timeout=15) as response:
+ result = json.load(response)
+ if result.get("ok") is not True:
+ raise RuntimeError("The public health check did not pass")
+ return result
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--dry-run", action="store_true")
+ parser.add_argument("--health", action="store_true", help="Read the public health endpoint without deploying")
+ args = parser.parse_args()
+ try:
+ print(json.dumps(health() if args.health else deploy(dry_run=args.dry_run), indent=2))
+ except Exception as error:
+ sys.exit(str(error))
diff --git a/typy/dev.mjs b/typy/dev.mjs
new file mode 100644
index 0000000..c7a204a
--- /dev/null
+++ b/typy/dev.mjs
@@ -0,0 +1,71 @@
+import { createServer } from 'node:http';
+import worker, { QuotaGate } from './src/worker.js';
+
+class MemoryKV {
+ constructor() { this.values = new Map(); }
+ async get(key, options) {
+ const item = this.values.get(key);
+ if (!item || item.expiration && item.expiration < Date.now()) { this.values.delete(key); return null; }
+ const type = typeof options === 'string' ? options : options?.type;
+ return type === 'json' ? JSON.parse(item.value) : item.value;
+ }
+ async put(key, value, options = {}) {
+ this.values.set(key, {value:String(value),metadata:options.metadata,expiration:options.expirationTtl ? Date.now() + options.expirationTtl * 1000 : null});
+ }
+ async delete(key) { this.values.delete(key); }
+ async list({prefix = '', limit = 1000, cursor = ''} = {}) {
+ const items = [...this.values.entries()].filter(([key, value]) => key.startsWith(prefix) && (!value.expiration || value.expiration > Date.now())).sort(([a],[b]) => a.localeCompare(b));
+ const start = Number(cursor) || 0;
+ return {keys:items.slice(start,start+limit).map(([name,value]) => ({name,metadata:value.metadata})),list_complete:start+limit >= items.length,cursor:start+limit < items.length ? String(start+limit) : ''};
+ }
+}
+
+class MemoryObjectStorage {
+ constructor() { this.values = new Map(); this.queue = Promise.resolve(); }
+ async get(key) { return this.values.get(key); }
+ async put(key, value) { if (typeof key === 'object') { for (const [k,v] of Object.entries(key)) this.values.set(k,v); } else this.values.set(key,value); }
+ async delete(key) { if (Array.isArray(key)) key.forEach(item => this.values.delete(item)); else this.values.delete(key); }
+ async deleteAll() { this.values.clear(); }
+ async setAlarm() {}
+ async getAlarm() { return null; }
+ async list({prefix = ''} = {}) { return new Map([...this.values].filter(([key]) => key.startsWith(prefix))); }
+ transaction(fn) {
+ const result = this.queue.then(() => fn(this));
+ this.queue = result.catch(() => {});
+ return result;
+ }
+}
+
+const env = {
+ REV:'local', SOURCE_SHA:'local',
+ KV:new MemoryKV(),
+ VENICE_API_KEY:process.env.VENICE_API_KEY || '',
+ TOPIC_MODEL:process.env.TOPIC_MODEL || 'mistral-small-3-2-24b-instruct',
+ TOPICS_PER_VISITOR:'8', TOPICS_PER_DAY:'80',
+ SAVES_PER_VISITOR:'30', SAVES_PER_DAY:'2000',
+};
+const storage = new MemoryObjectStorage();
+const gate = new QuotaGate({storage,blockConcurrencyWhile:fn=>fn()},env);
+env.QUOTA = {idFromName:name=>name,get:()=>({fetch:(input,init)=>gate.fetch(new Request(input,init))})};
+
+const port = Number(process.env.PORT || 8787);
+createServer(async (request,response) => {
+ try {
+ const chunks = [];
+ let length = 0;
+ for await (const chunk of request) {
+ length += chunk.length;
+ if (length > 2_000_000) { response.writeHead(413); response.end('Payload too large'); return; }
+ chunks.push(chunk);
+ }
+ const body = chunks.length && !['GET','HEAD'].includes(request.method) ? Buffer.concat(chunks) : undefined;
+ const headers = {...request.headers,'cf-connecting-ip':'127.0.0.1'};
+ const result = await worker.fetch(new Request('http://' + request.headers.host + request.url, {method:request.method,headers,body}),env,{waitUntil:promise=>promise.catch(()=>{})});
+ response.writeHead(result.status,Object.fromEntries(result.headers));
+ response.end(Buffer.from(await result.arrayBuffer()));
+ } catch(error) {
+ console.error('Local request failed:',error.message);
+ response.writeHead(500,{'Content-Type':'application/json'});
+ response.end(JSON.stringify({error:'Local server error.'}));
+ }
+}).listen(port,'127.0.0.1',()=>console.log('typy on http://127.0.0.1:' + port + ' (ephemeral local storage)'));
diff --git a/typy/evidence/backend-review-findings.json b/typy/evidence/backend-review-findings.json
new file mode 100644
index 0000000..83a1f36
--- /dev/null
+++ b/typy/evidence/backend-review-findings.json
@@ -0,0 +1,21 @@
+{
+ "wordMismatch": {
+ "serverLength": 72,
+ "raceEngineLength": 64
+ },
+ "alarm": {
+ "firstStatus": 200,
+ "secondStatus": 200,
+ "scheduledBefore": "2026-10-25T00:01:00.000Z",
+ "scheduledAfter": "2026-10-26T00:01:00.000Z",
+ "visitorRows": 2
+ },
+ "missingResult": {
+ "status": 404,
+ "contentType": "application/json; charset=utf-8",
+ "body": {
+ "error": "This run could not be found. A new link can take a moment to appear.",
+ "code": "not_found"
+ }
+ }
+}
diff --git a/typy/evidence/backend-review-recheck.json b/typy/evidence/backend-review-recheck.json
new file mode 100644
index 0000000..44a35f7
--- /dev/null
+++ b/typy/evidence/backend-review-recheck.json
@@ -0,0 +1,36 @@
+{
+ "timestamp": "2026-10-24T23:08:17.481Z",
+ "scope": "source-level focused backend review recheck; no network",
+ "checks": [
+ {
+ "name": "B1 canonical passage invariant",
+ "status": "PASS",
+ "acceptedLength": 64,
+ "rejectedLengths": [
+ 65,
+ 72,
+ 1,
+ 7
+ ],
+ "normalizationVariantsRejected": true
+ },
+ {
+ "name": "B2 midnight cleanup preservation and idle expiry",
+ "status": "PASS",
+ "preserved": "2026-10-25T00:01:00.000Z",
+ "rearmed": "2026-10-26T00:01:00.000Z",
+ "currentDayRowsAfterFirstCleanup": 1,
+ "rowsAfterIdleNextDayCleanup": 0,
+ "rawIdentityPersisted": false
+ },
+ {
+ "name": "Missing result HTML recovery with JSON data errors",
+ "status": "PASS",
+ "htmlPaths": [
+ "/r/",
+ "/race/"
+ ],
+ "dataStatus": 404
+ }
+ ]
+}
diff --git a/typy/evidence/browser-results.json b/typy/evidence/browser-results.json
new file mode 100644
index 0000000..4a59451
--- /dev/null
+++ b/typy/evidence/browser-results.json
@@ -0,0 +1,89 @@
+{
+ "timestamp": "2026-10-24T23:01:12.661Z",
+ "target": "http://127.0.0.1:8788",
+ "browser": "Chrome 156 / Playwright",
+ "checks": [
+ {
+ "name": "desktop ready stage and no horizontal overflow",
+ "status": "PASS"
+ },
+ {
+ "name": "first letter starts; mistake highlights; Backspace corrects; Space advances",
+ "status": "PASS"
+ },
+ {
+ "name": "Escape resets a run and settings switch to last-letter advancement",
+ "status": "PASS"
+ },
+ {
+ "name": "custom passage completes 50-word mode with a result card",
+ "status": "PASS"
+ },
+ {
+ "name": "replay plays and scrubber moves between original words",
+ "status": "PASS"
+ },
+ {
+ "name": "keyboard Enter activates a focused result Replay button",
+ "status": "FAIL",
+ "error": "Enter on the focused Replay button restarted the run instead of replaying it.\n\nfalse !== true\n"
+ },
+ {
+ "name": "endless can end with Enter and preserves results on failed sharing",
+ "status": "PASS"
+ },
+ {
+ "name": "keyboard Enter opens a focused settings button during an endless run",
+ "status": "FAIL",
+ "error": "Enter on the focused settings button finished the endless run instead of opening settings.\n\nfalse !== true\n"
+ },
+ {
+ "name": "unavailable topic API presents a friendly error and common-word recovery",
+ "status": "PASS"
+ },
+ {
+ "name": "daily route locks to 60 seconds (mock API response)",
+ "status": "PASS"
+ },
+ {
+ "name": "pasted markup is rendered as inert text",
+ "status": "PASS"
+ },
+ {
+ "name": "phone layout fits, exposes a large input, and handles native input events",
+ "status": "PASS",
+ "detail": {
+ "width": 390,
+ "scrollWidth": 390
+ }
+ },
+ {
+ "name": "phone dialogs fit and controls remain accessible",
+ "status": "PASS"
+ },
+ {
+ "name": "phone long pasted words remain inside the viewport",
+ "status": "FAIL",
+ "error": "A legal 64-character pasted word overflows the phone stage: {\"width\":578.1875,\"scrollWidth\":590,\"left\":28.90625,\"right\":607.09375,\"windowWidth\":619,\"documentWidth\":619}\n\nfalse !== true\n"
+ },
+ {
+ "name": "no uncaught browser script errors",
+ "status": "PASS"
+ }
+ ],
+ "findings": [
+ {
+ "name": "keyboard Enter activates a focused result Replay button",
+ "message": "Enter on the focused Replay button restarted the run instead of replaying it.\n\nfalse !== true\n"
+ },
+ {
+ "name": "keyboard Enter opens a focused settings button during an endless run",
+ "message": "Enter on the focused settings button finished the endless run instead of opening settings.\n\nfalse !== true\n"
+ },
+ {
+ "name": "phone long pasted words remain inside the viewport",
+ "message": "A legal 64-character pasted word overflows the phone stage: {\"width\":578.1875,\"scrollWidth\":590,\"left\":28.90625,\"right\":607.09375,\"windowWidth\":619,\"documentWidth\":619}\n\nfalse !== true\n"
+ }
+ ],
+ "errors": []
+}
diff --git a/typy/evidence/desktop-playing.png b/typy/evidence/desktop-playing.png
new file mode 100644
index 0000000..d5cc93e
Binary files /dev/null and b/typy/evidence/desktop-playing.png differ
diff --git a/typy/evidence/desktop.png b/typy/evidence/desktop.png
new file mode 100644
index 0000000..7bee7e5
Binary files /dev/null and b/typy/evidence/desktop.png differ
diff --git a/typy/evidence/keyboard-replay-bug.png b/typy/evidence/keyboard-replay-bug.png
new file mode 100644
index 0000000..a6fe934
Binary files /dev/null and b/typy/evidence/keyboard-replay-bug.png differ
diff --git a/typy/evidence/keyboard-replay-fixed.png b/typy/evidence/keyboard-replay-fixed.png
new file mode 100644
index 0000000..2be2d77
Binary files /dev/null and b/typy/evidence/keyboard-replay-fixed.png differ
diff --git a/typy/evidence/keyboard-settings-bug.png b/typy/evidence/keyboard-settings-bug.png
new file mode 100644
index 0000000..f2f8a8c
Binary files /dev/null and b/typy/evidence/keyboard-settings-bug.png differ
diff --git a/typy/evidence/keyboard-settings-fixed.png b/typy/evidence/keyboard-settings-fixed.png
new file mode 100644
index 0000000..b266f06
Binary files /dev/null and b/typy/evidence/keyboard-settings-fixed.png differ
diff --git a/typy/evidence/local-integration.json b/typy/evidence/local-integration.json
new file mode 100644
index 0000000..5452894
--- /dev/null
+++ b/typy/evidence/local-integration.json
@@ -0,0 +1,27 @@
+{
+ "checkedAt": "2026-10-24T23:06:46.358Z",
+ "base": "http://127.0.0.1:8787",
+ "health": {
+ "ok": true,
+ "rev": "local",
+ "storage": "ready",
+ "quotas": "ready",
+ "topics": false
+ },
+ "score": "312",
+ "resultUrl": "http://127.0.0.1:8787/r/aa770496c5ac4e01b510ca67",
+ "storedWords": 13,
+ "storedEvents": 46,
+ "replayWord": "river",
+ "raceUrl": "http://127.0.0.1:8787/race/aa770496c5ac4e01b510ca67",
+ "ghostVisible": true,
+ "og": {
+ "status": 200,
+ "bytes": 4015,
+ "width": 1200,
+ "height": 630
+ },
+ "dailySame": true,
+ "dailyWords": 300,
+ "errors": []
+}
diff --git a/typy/evidence/local-result.png b/typy/evidence/local-result.png
new file mode 100644
index 0000000..c925c79
Binary files /dev/null and b/typy/evidence/local-result.png differ
diff --git a/typy/evidence/phone-long-word-fixed.png b/typy/evidence/phone-long-word-fixed.png
new file mode 100644
index 0000000..b5088e7
Binary files /dev/null and b/typy/evidence/phone-long-word-fixed.png differ
diff --git a/typy/evidence/phone-long-word-transition-fixed.png b/typy/evidence/phone-long-word-transition-fixed.png
new file mode 100644
index 0000000..03b2a6a
Binary files /dev/null and b/typy/evidence/phone-long-word-transition-fixed.png differ
diff --git a/typy/evidence/phone-long-word-transition.png b/typy/evidence/phone-long-word-transition.png
new file mode 100644
index 0000000..26f01e3
Binary files /dev/null and b/typy/evidence/phone-long-word-transition.png differ
diff --git a/typy/evidence/phone-long-word.png b/typy/evidence/phone-long-word.png
new file mode 100644
index 0000000..eee8dbb
Binary files /dev/null and b/typy/evidence/phone-long-word.png differ
diff --git a/typy/evidence/phone.png b/typy/evidence/phone.png
new file mode 100644
index 0000000..e09bfac
Binary files /dev/null and b/typy/evidence/phone.png differ
diff --git a/typy/evidence/recheck-results.json b/typy/evidence/recheck-results.json
new file mode 100644
index 0000000..43d3257
--- /dev/null
+++ b/typy/evidence/recheck-results.json
@@ -0,0 +1,74 @@
+{
+ "timestamp": "2026-10-24T23:02:52.525Z",
+ "target": "http://127.0.0.1:8788",
+ "checks": [
+ {
+ "name": "Main target contrast improved",
+ "status": "PASS",
+ "detail": {
+ "foreground": "rgb(124, 136, 117)",
+ "background": "rgb(245, 243, 236)",
+ "ratio": 3.36
+ }
+ },
+ {
+ "name": "F1: Enter activates focused Replay",
+ "status": "PASS"
+ },
+ {
+ "name": "F2: Enter opens focused settings during endless play",
+ "status": "PASS"
+ },
+ {
+ "name": "Zero-duration word omitted from per-word graph only",
+ "status": "PASS",
+ "detail": {
+ "wpm": 72,
+ "accuracy": 100,
+ "bestStreak": 3,
+ "correctChars": 6,
+ "keystrokes": 3,
+ "errors": 0,
+ "completedWords": 3,
+ "slowest": [
+ {
+ "word": "a",
+ "index": 1,
+ "ms": 500,
+ "wpm": 48
+ },
+ {
+ "word": "a",
+ "index": 2,
+ "ms": 500,
+ "wpm": 48
+ }
+ ],
+ "sparkline": [
+ 48,
+ 48
+ ]
+ }
+ },
+ {
+ "name": "F3: 64-character pasted word fits phone and resize",
+ "status": "PASS",
+ "detail": {
+ "initial": {
+ "width": 303,
+ "scrollWidth": 303,
+ "left": 43.5,
+ "right": 346.5,
+ "windowWidth": 390,
+ "documentWidth": 390,
+ "fontSize": "8.74088px"
+ },
+ "narrow": {
+ "w": 360,
+ "scrollWidth": 360,
+ "right": 323
+ }
+ }
+ }
+ ]
+}
diff --git a/typy/evidence/result.png b/typy/evidence/result.png
new file mode 100644
index 0000000..3456c50
Binary files /dev/null and b/typy/evidence/result.png differ
diff --git a/typy/evidence/review.md b/typy/evidence/review.md
new file mode 100644
index 0000000..6553098
--- /dev/null
+++ b/typy/evidence/review.md
@@ -0,0 +1,141 @@
+# typy v0 — independent browser acceptance
+
+## Scope
+
+- Acceptance target: `teahubxyz/time#254`, browser gameplay and visible UI.
+- Target: `http://127.0.0.1:8788`, local generated-asset preview.
+- Checkout base: `4c4456168e799aa7733e56fd69d43d1d8f456d88`; typy changes are uncommitted at this review point.
+- Reviewed assets: `public/app.js` SHA-256 `d95ff6da39bfc51a0109b5fb372a2417529e3d3d17a82fce75744f19d470b330`; `public/style.css` SHA-256 `d0cc8d3d6b66f86b1bcce6dd654503e908ec4a108b53c73407c5e48dfa386f55`.
+- Chrome 156 through Playwright. Desktop viewport 1440 × 1000; iPhone 13 emulation 390 × 844, device scale 3. This is mobile browser emulation; a real phone OS keyboard was not exercised.
+- Independent browser-review pass: the reviewer did not modify application implementation. The reviewer authored the pure engine earlier; this browser pass checks the integrated frontend independently of its author.
+
+## Checks
+
+**12 passed, 3 failed** in `test/browser-smoke.mjs`.
+
+Passed:
+
+- Desktop ready stage, first-key start, no ordinary horizontal overflow.
+- Incorrect character feedback, Backspace correction, and Space advancement.
+- Escape restart; last-letter and upcoming-word settings; persisted settings.
+- Pasted text and exact 50-word completion; accuracy and clean-streak result fields.
+- Replay playback, original-word selection, and scrubber movement.
+- Endless stop using Enter; failed sharing preserves the local result and exposes retry.
+- Unavailable topic API renders a readable error and “use common words” recovery.
+- `/daily` locks to 60 seconds and shows the board affordance **using a mocked API response**.
+- Pasted HTML-like text is escaped and inert.
+- Phone layout, a visible input larger than 44 px high, native input-event handling, and dialog bounds.
+- No uncaught browser JavaScript exceptions during the pass.
+
+The local preview intentionally returns 503 for real API calls. Those responses were used to review failure handling. They do not establish a backend defect or a successful backend deployment. Live topic generation, saved results, public replay, race links, board persistence, and OG response acceptance remain with the parent implementation/live-verification pass.
+
+## Findings
+
+### F1 — Enter on a result control restarts the game
+
+**FAILED.** Complete a run, focus the Replay button, and press Enter. Expected: activate Replay. Observed: a new typing run opens. The document key handler handles the result-page Enter shortcut before honoring focused button/link activation.
+
+Evidence: `keyboard-replay-bug.png`; browser check “keyboard Enter activates a focused result Replay button”. The same handler can prevent keyboard activation of other result controls.
+
+### F2 — Enter on a settings control ends an endless run
+
+**FAILED.** Start an endless run, focus the settings button, and press Enter. Expected: open settings. Observed: the run finishes and its result card appears. The global Enter-to-finish shortcut runs before focused control handling.
+
+Evidence: `keyboard-settings-bug.png`; browser check “keyboard Enter opens a focused settings button during an endless run”.
+
+### F3 — A legal 64-character word overflows the phone stage
+
+**FAILED.** Paste a 64-character word and start it on the phone viewport. The word extends beyond the stage and expands the layout viewport from 390 to 619 CSS pixels, shrinking the overall page and clipping the word. The single-word stage must fit the entire supported word length.
+
+Observed dimensions: word right edge 607 px, word content width 590 px, enlarged viewport width 619 px. Evidence: `phone-long-word.png`.
+
+### Observations
+
+- The untyped main word has computed color contrast approximately 1.71:1 (`#b8beb2` on `#f5f3ec`) and looks faint in the inspected screenshots. A darker main target would improve legibility while retaining a subtle upcoming ghost.
+- A first, single-letter word in automatic mode has zero measured typing duration. Its per-word graph point becomes an extreme spike and flattens the remainder of the sparkline. This appears in the synthetic `a b` 50-word test; overall run WPM remains mathematically consistent. Treat zero-duration per-word measurements explicitly if polishing the chart.
+
+## Evidence
+
+- Command: `node typy/test/browser-smoke.mjs`, with temporary Chrome executable `/tmp/typy-browser/chrome-linux64/chrome` and local-preview network/browser permission.
+- Machine-readable outcome: `browser-results.json`.
+- Visually inspected: `desktop.png`, `phone.png`, `phone-long-word.png`, `result.png`.
+- Additional evidence: `desktop-playing.png`, `keyboard-replay-bug.png`, `keyboard-settings-bug.png`.
+- The result screenshot contains synthetic automated typing, not a human speed benchmark.
+
+## Focused remediation recheck
+
+The implementation owner fixed keyboard shortcut priority, added active-word fitting and resize redraw, darkened the target word, and omitted zero-duration per-word speed samples. The reviewer made no implementation changes.
+
+- **F1 PASS:** Enter now activates the focused Replay button. Evidence: `keyboard-replay-fixed.png`.
+- **F2 PASS:** Enter now opens settings during an endless run without finishing it. Evidence: `keyboard-settings-fixed.png`.
+- **F3 static/reduced-motion PASS:** the 64-character word occupies 303 px inside the 390 px phone viewport; document width remains 390 px. Resizing to 360 px also preserves a 360 px document. Evidence: `phone-long-word-fixed.png`.
+- **Contrast PASS:** computed main-target contrast is now **3.36:1** (`rgb(124,136,117)` on `rgb(245,243,236)`). Updated `desktop.png` and `phone.png` were visually inspected.
+- **Graph edge case PASS:** a fixture with one zero-duration word and two measured words produces graph samples `[48,48]`, while retaining all 3 completed words, 6 correct output characters, and overall 72 WPM. The updated `result.png` shows a stable synthetic-typing line without the former first-point spike.
+
+These five checks are recorded in `recheck-results.json`. The rechecked app asset SHA-256 was `5a2bf504cf121700568751713fd89c191bf0a50cadb70bbf8090867fab35fc92`; stylesheet SHA-256 was `6655a1827e0e542b1d1100e24b6c96435be586b1c676057e1489cd61f1d0475d`.
+
+The first remediation build still had an **F3 normal-animation defect**. A targeted default-motion check found the outgoing `previous-word` ghost overflowing after completing a 64-character word. The next word was correct, but the invisible outgoing text expanded the phone document/viewport to **889 px** (outgoing content width 866 px, font size 24.96 px). Reproduction: iPhone viewport, automatic advancement, paste 64 `a` characters followed by `next`, type the first word, wait 250 ms. Historical evidence: `phone-long-word-transition.png` (visually inspected) and `transition-recheck.json`.
+
+### Final outgoing-word recheck — PASS
+
+The implementation owner subsequently copied the departing word's fitted font size onto the outgoing ghost and clipped stage animation overflow. With normal motion enabled, measurements both during the transition and after it finished now show **390 px viewport / 390 px document width**. The next word remains `next`; the outgoing ghost uses the fitted 8.74088 px font. No horizontal expansion occurs.
+
+- Final evidence: `phone-long-word-transition-fixed.png` and `transition-recheck-fixed.json`.
+- Both the final transition screenshot and refreshed `phone.png` were visually inspected.
+- Final reviewed app SHA-256: `40bc694cacbda3c3ffc38205cf070c089fcfcf868e3605445816230dda74221f`.
+- Final reviewed stylesheet SHA-256: `0ef997306a016254e2a0370b1c50fb200bf2ed71ab8b546ea6bdab10e4ece391`.
+- Final reviewed engine SHA-256: `e8dc58fc8fbef76f272393170012b7c07c916e2a0cb9a3a7e84aa94e0d97c072`.
+
+## Outcome
+
+**PASS — reviewed frontend scope.**
+
+All three reported acceptance failures are resolved and rechecked. Active and outgoing long-word geometry, resize handling, main-word contrast, and the zero-duration chart edge case pass. The earlier twelve successful functional checks remain the baseline; the recheck was limited to changed behavior and the concrete outgoing-animation risk. Real backend/deployment acceptance remains a separate parent-owned gate, not a claim made by this frontend receipt.
+
+## Independent backend and deployment-source review
+
+Scope: `src/worker.js`, `src/store.js`, `src/passages.js`, `src/og.js`, and `deploy.py`. Source inspection and local deterministic checks only; no provider or infrastructure calls were made in this review.
+
+`node typy/test/backend.test.js`: **13 tests passed**. These cover saved-field whitelisting and recomputed scores, malformed replay rejection, sharing round trips, origin/content-type checks, canonical daily scoring, concurrent independent board entries, topic cache/filter/provider errors, strict concurrent quota reservation, missing-quota fail-closed behavior, PNG encoding, and escaped social metadata/security headers.
+
+Verified implementation properties:
+
+- Replay bodies are limited to 1.5 MB; accepted fields are reconstructed instead of persisting arbitrary client fields or actual mistyped characters.
+- Scores are recomputed from timing/outcome events. Daily submissions must match today's full canonical 60-second passage.
+- New topic generation reserves a serialized visitor/global allowance before the provider request, has a 25-second timeout and bounded output, and caches suitable text. Provider failure does not substitute fabricated content.
+- Quota state retains daily HMAC identifiers instead of raw IP addresses. The deployment disables application observability logging.
+- Result metadata is escaped; the page has restrictive script/object/frame policies; OG output is a genuine 1200 × 630 PNG.
+- Deployment requires credentials before API writes, refuses a dirty typy checkout, checks generated assets against source, bounds uploaded modules, and refuses unexpected Durable Object migration tags.
+
+### Backend findings at initial review
+
+**B1 — accepted passage differs when raced.** `validateResult` accepted an externally submitted 72-character word, while `TypingGame` truncates it to 64. A saved replay and its race could therefore use different text. Deterministic evidence: `serverLength: 72`, `raceEngineLength: 64` in `backend-review-findings.json`.
+
+**B2 — midnight requests postpone cleanup.** A quota request at 23:59:50 scheduled cleanup for the next day at 00:01. A request at 00:00:10 then replaced that still-pending alarm with one a full day later. Repeated activity in that window can retain expired visitor rows indefinitely. Deterministic evidence: alarm moved from `2026-10-25T00:01:00.000Z` to `2026-10-26T00:01:00.000Z`; old and new visitor rows both remained.
+
+Both findings were routed to the backend implementation owner, who owns the fixes and regression tests. The independent reviewer made no backend changes.
+
+**Initial nonblocking UX observation:** requesting a missing result URL directly returned a readable JSON 404 instead of an HTML recovery page. This included potential KV propagation misses; it was not an assertion that valid deployed links failed.
+
+### Final focused backend recheck — PASS
+
+The backend owner fixed B1 and B2 and added an HTML recovery shell for missing result/race pages. The reviewer independently exercised only those changed behaviors, without network or deployment calls:
+
+- A canonical 64-character passage is accepted and reconstructs identically in `TypingGame`. 65- and 72-character words, fullwidth Unicode text, and curly-apostrophe variants are rejected instead of silently changing between replay and race.
+- A request at 00:00:10 preserves the cleanup alarm already scheduled for 00:01. That alarm removes the prior day's visitor rows, keeps the current day's row, and rearms expiry for the following day.
+- With no further requests, the following day's alarm removes the remaining visitor row and expired counters. Cleanup is no longer dependent on later traffic.
+- Missing `/r/` and `/race/` return HTML 404 pages containing the existing retry UI. `/api/results/` correctly remains a JSON 404.
+
+Evidence: `backend-review-recheck.json`. The initial 13 backend tests were independently run; the backend author's additional regression suite was not redundantly rerun in full. The focused independent fixtures verify the specific corrected invariants above.
+
+Final reviewed source SHA-256 values:
+
+| File | SHA-256 |
+| --- | --- |
+| `src/worker.js` | `3a1b927cb83c3e740c58319088ddd93838a46bc46e89ce232ab0f893d27cb355` |
+| `src/store.js` | `f88e28c1a47987357416c46cfe6f2b1bf4dbc5a6753426a3edb85d641aeea6e1` |
+| `src/passages.js` | `42b8a9721f564fc3b449dd7b05c19d861ea74b8463105df0b857859292a4147f` |
+| `src/og.js` | `c23399b8d88b8cdc620211ec7e227573201c805069bd7e1f12128fa590de264a` |
+| `deploy.py` | `b616a07c9b2f195866c752480428009fc7cc56347c7777b43c36861bc2616656` |
+
+**PASS — bounded backend and deployment-source review.** No remaining blocker was found within this scope. Actual Cloudflare behavior, provider availability, live sharing/races, and final deployment health remain the parent's separate acceptance gate.
diff --git a/typy/evidence/transition-recheck-fixed.json b/typy/evidence/transition-recheck-fixed.json
new file mode 100644
index 0000000..a262d86
--- /dev/null
+++ b/typy/evidence/transition-recheck-fixed.json
@@ -0,0 +1,24 @@
+{
+ "timestamp": "2026-10-24T23:04:31.001Z",
+ "target": "http://127.0.0.1:8788",
+ "check": "F3 default-motion outgoing64-character word transition",
+ "status": "PASS",
+ "samples": [
+ {
+ "word": "next",
+ "viewport": 390,
+ "doc": 390,
+ "oldWidth": 337,
+ "oldFont": "8.74088px",
+ "stageOverflow": "clip"
+ },
+ {
+ "word": "next",
+ "viewport": 390,
+ "doc": 390,
+ "oldWidth": 337,
+ "oldFont": "8.74088px",
+ "stageOverflow": "clip"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/typy/evidence/transition-recheck.json b/typy/evidence/transition-recheck.json
new file mode 100644
index 0000000..fde5304
--- /dev/null
+++ b/typy/evidence/transition-recheck.json
@@ -0,0 +1,7 @@
+{
+ "word": "next",
+ "viewport": 889,
+ "doc": 889,
+ "oldWidth": 866,
+ "oldFont": "24.96px"
+}
\ No newline at end of file
diff --git a/typy/package.json b/typy/package.json
new file mode 100644
index 0000000..94c2cad
--- /dev/null
+++ b/typy/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "typy",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "One word at a time. A typing game with tempo, replay, and friendly races.",
+ "scripts": {
+ "build": "node build.mjs",
+ "start": "node build.mjs && node dev.mjs",
+ "test": "node build.mjs && node --test test/*.test.js",
+ "deploy": "node build.mjs && python3 deploy.py"
+ }
+}
diff --git a/typy/public/app.js b/typy/public/app.js
new file mode 100644
index 0000000..809bfe4
--- /dev/null
+++ b/typy/public/app.js
@@ -0,0 +1,612 @@
+import { TypingGame } from '/game.js';
+import { generateWords, normalizePassage } from '/words.js';
+
+const $ = id => document.getElementById(id);
+const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
+const now = () => performance.now();
+const pretty = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value))) : '0';
+const round1 = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value) * 10) / 10) : '0';
+const modeNames = {'30':'30 seconds','60':'60 seconds','50':'50 words','endless':'endless','tempo':'tempo'};
+const readLocal = (key, fallback) => { try { return JSON.parse(localStorage.getItem('typy.' + key)) ?? fallback; } catch { return fallback; } };
+const writeLocal = (key, value) => { try { localStorage.setItem('typy.' + key, JSON.stringify(value)); } catch {} };
+const settings = {...{advance:'space',preview:true}, ...readLocal('settings', {})};
+const bests = readLocal('bests', {});
+const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
+let selectedMode = '30';
+let source = {kind:'common'};
+let passage = [];
+let game = null;
+let ghost = null;
+let view = 'play';
+let currentResult = null;
+let shareId = null;
+let shareIntent = 'result';
+let currentPath = '/';
+let routeSerial = 0;
+let lastWordKey = '';
+let lastWordIndex = -1;
+let previousWord = '';
+let lastErrorCount = 0;
+let replay = {playing:false,position:0,anchor:0,anchorPosition:0};
+let toastTimer;
+let transitioning = false;
+let isComposing = false;
+
+function toast(message) {
+ clearTimeout(toastTimer);
+ $('toast').textContent = message;
+ $('toast').hidden = false;
+ toastTimer = setTimeout(() => { $('toast').hidden = true; }, 4500);
+}
+
+async function api(path, options = {}) {
+ const response = await fetch(path, {...options, headers:{...(options.body ? {'Content-Type':'application/json'} : {}), ...(options.headers || {})}});
+ let body;
+ try { body = await response.json(); } catch { throw new Error('The server could not answer. Please try again.'); }
+ if (!response.ok) {
+ const message = typeof body.error === 'string' ? body.error : body.error?.message || body.message;
+ const error = new Error(message || 'Something interrupted that request. Please try again.');
+ error.status = response.status;
+ throw error;
+ }
+ return body;
+}
+
+function sourceName() {
+ if (ghost) return 'racing ' + (currentResult?.name || 'a friendly ghost');
+ if (source.kind === 'topic') return (source.topic || '').replaceAll('-', ' ');
+ if (source.kind === 'daily') return 'the daily · ' + (source.day || 'today');
+ if (source.kind === 'paste') return 'your own words';
+ return 'common words';
+}
+
+function localBestKey() { return selectedMode; }
+function updateOptions() {
+ const locked = source.kind === 'daily' || Boolean(ghost);
+ const effectiveAdvance = ghost ? currentResult.run.advance : settings.advance;
+ $('mode-switch').classList.toggle('locked', locked);
+ document.querySelectorAll('[data-mode]').forEach(button => {
+ button.setAttribute('aria-pressed', String(button.dataset.mode === selectedMode));
+ button.disabled = locked || view === 'replay';
+ button.title = locked ? (ghost ? 'A race uses the original run’s mode.' : 'The daily is a shared 60-second run.') : '';
+ });
+ $('source-label').textContent = sourceName();
+ $('advance-hint').innerHTML = selectedMode === 'tempo' ? 'finish before the beat' : effectiveAdvance === 'letter' ? 'last letter → next word' : 'space next word';
+ $('upcoming').hidden = !settings.preview;
+ $('advance-setting').value = effectiveAdvance;
+ $('advance-setting').disabled = Boolean(ghost);
+ $('preview-setting').checked = settings.preview;
+ $('footer-board').hidden = source.kind !== 'daily';
+ $('daily-link').setAttribute('aria-current', source.kind === 'daily' ? 'page' : 'false');
+ $('best-score').textContent = bests[localBestKey()] ? 'device best ' + pretty(bests[localBestKey()]) + ' wpm' : '';
+}
+
+function focusTyping() {
+ if (view !== 'play' || document.querySelector('dialog[open]')) return;
+ $('typing-input').focus({preventScroll:true});
+}
+
+function newCommonPassage() {
+ const words = generateWords(300);
+ words[0] = 'breathe';
+ return words;
+}
+
+function beginRun({freshWords = false, focus = false} = {}) {
+ transitioning = false;
+ if (freshWords && source.kind === 'common' && !ghost) passage = newCommonPassage();
+ if (!passage.length) passage = newCommonPassage();
+ game = new TypingGame({words:passage, mode:selectedMode, advance:ghost ? currentResult.run.advance : settings.advance, paceWpm:readLocal('lastAverage', 40)});
+ view = 'play';
+ replay.playing = false;
+ lastWordKey = '';
+ lastWordIndex = -1;
+ previousWord = '';
+ lastErrorCount = 0;
+ $('load-panel').hidden = true;
+ $('play-panel').hidden = false;
+ $('result-panel').hidden = true;
+ $('replay-controls').hidden = true;
+ $('input-row').hidden = false;
+ $('typing-input').value = '';
+ $('typing-input').disabled = false;
+ $('ghost-race').hidden = !ghost;
+ $('finish-run').hidden = selectedMode !== 'endless' && selectedMode !== 'tempo';
+ $('tempo-track').hidden = selectedMode !== 'tempo';
+ $('restart-tip').hidden = false;
+ updateOptions();
+ renderGame(game.snapshot(now()));
+ if (focus || innerWidth > 700) focusTyping();
+}
+
+function drawWord(word, buffer, index, nextWord, flags = null) {
+ const chars = Array.from(word || '');
+ const typed = Array.from(buffer || '');
+ const key = word + '|' + buffer + '|' + index + '|' + (flags?.join('') || '');
+ if (key === lastWordKey) return;
+ const wordChanged = index !== lastWordIndex;
+ if (wordChanged && lastWordIndex >= 0 && !reducedMotion) {
+ $('previous-word').textContent = previousWord;
+ $('previous-word').style.fontSize = getComputedStyle($('word')).fontSize;
+ $('previous-word').classList.toggle('long', previousWord.length > 12);
+ $('previous-word').getAnimations().forEach(animation => animation.cancel());
+ $('previous-word').animate([{opacity:.18,transform:'translateX(0)'},{opacity:0,transform:'translateX(-35px)'}], {duration:180,easing:'ease-out'});
+ }
+ $('word').classList.toggle('long', chars.length > 12);
+ $('word').classList.toggle('very-long', chars.length > 22);
+ $('word').innerHTML = chars.map((char, i) => {
+ let state = '';
+ if (i < typed.length) state = (flags ? flags[i] : typed[i] === char) ? 'correct' : 'incorrect';
+ if (i === typed.length) state += ' cursor';
+ return '' + escapeHTML(char) + '';
+ }).join('') + (typed.length > chars.length ? '+' + (typed.length - chars.length) + '' : '');
+ $('word').setAttribute('aria-label', 'Word to type: ' + word);
+ $('word').dataset.word = word;
+ $('typing-input').setAttribute('aria-label', 'Type the word: ' + word);
+ // A pasted token may be much longer than a normal word. Measure the actual
+ // glyphs instead of letting it enlarge a phone's layout viewport.
+ $('word').style.fontSize = '';
+ const available = Math.max(1, $('stage').clientWidth - 28);
+ if ($('word').scrollWidth > available) {
+ const natural = parseFloat(getComputedStyle($('word')).fontSize);
+ $('word').style.fontSize = Math.max(8, natural * available / $('word').scrollWidth * .96) + 'px';
+ }
+ $('word-number').textContent = String(index + 1).padStart(3, '0');
+ $('next-word').textContent = nextWord || '';
+ if (wordChanged && !reducedMotion) {
+ $('word').getAnimations().forEach(animation => animation.cancel());
+ $('word').animate([{opacity:.2,transform:'translateX(25px)'},{opacity:1,transform:'translateX(0)'}], {duration:170,easing:'ease-out'});
+ }
+ lastWordKey = key;
+ lastWordIndex = index;
+ previousWord = word;
+}
+
+function renderGame(state) {
+ drawWord(state.word, state.buffer, state.wordIndex, state.nextWord);
+ $('live-wpm').textContent = pretty(state.wpm);
+ $('live-accuracy').textContent = pretty(state.accuracy);
+ $('status-dot').classList.toggle('active', state.status === 'running');
+ if (state.status === 'ready') $('run-status').textContent = ghost ? 'your ghost is ready' : source.kind === 'daily' ? 'same words, your rhythm' : 'a fresh start';
+ else if (state.errorIndex >= 0) $('run-status').textContent = 'a little backspace, then onward';
+ else if (state.waitingForBeat) $('run-status').textContent = 'right on time · wait for the beat';
+ else $('run-status').textContent = state.streak >= 3 ? state.streak + ' clean words in a row' : 'one word, then the next';
+ const hintStart = $('run-hint').querySelector('.hint-start');
+ hintStart.textContent = state.status === 'ready' ? 'type the word to begin' : state.errorIndex >= 0 ? 'backspace to correct' : state.waitingForBeat ? 'hold that rhythm' : 'keep your rhythm';
+ if (selectedMode === '30' || selectedMode === '60') {
+ $('time-label').textContent = 'remaining'; $('live-time').textContent = Math.ceil(state.remainingMs / 1000); $('time-unit').textContent = 's';
+ } else if (selectedMode === '50') {
+ $('time-label').textContent = 'remaining'; $('live-time').textContent = Math.max(0, 50 - state.completedWords); $('time-unit').textContent = 'words';
+ } else if (selectedMode === 'tempo') {
+ $('time-label').textContent = 'beat pace'; $('live-time').textContent = pretty(state.paceWpm); $('time-unit').textContent = 'wpm';
+ } else {
+ $('time-label').textContent = 'in the flow'; $('live-time').textContent = Math.floor(state.elapsedMs / 1000); $('time-unit').textContent = 's';
+ }
+ if (selectedMode === 'tempo') {
+ const fraction = state.beatDurationMs ? Math.max(0, state.beatRemainingMs / state.beatDurationMs) : 1;
+ $('tempo-progress').style.transform = 'scaleX(' + fraction + ')';
+ $('tempo-caption').textContent = state.status === 'ready' ? 'type to catch the first beat' : state.waitingForBeat ? 'got it. the next beat is coming.' : 'finish the word before the line runs out';
+ Array.from($('tempo-lives').children).forEach((dot, i) => dot.classList.toggle('lost', i < state.misses));
+ $('tempo-lives').setAttribute('aria-label', (3 - state.misses) + ' missed beats remaining');
+ }
+ if (ghost) {
+ const completed = ghost.wordTimings.filter(timing => timing.completed && timing.end <= state.elapsedMs).length;
+ $('race-you').textContent = state.completedWords;
+ $('race-them').textContent = completed;
+ const difference = state.completedWords - completed;
+ $('race-delta').textContent = state.status === 'ready' ? '' : difference === 0 ? 'neck and neck' : Math.abs(difference) + (difference > 0 ? ' ahead' : ' behind');
+ }
+ if (state.errors > lastErrorCount && !reducedMotion) {
+ $('word').animate([{transform:'translateX(0)'},{transform:'translateX(-5px)'},{transform:'translateX(5px)'},{transform:'translateX(0)'}], {duration:160});
+ }
+ lastErrorCount = state.errors;
+ if (state.status === 'finished' && !transitioning) finishDisplay();
+}
+
+function press(key) {
+ if (view !== 'play' || !game || document.querySelector('dialog[open]')) return;
+ renderGame(game.press(key, now()));
+}
+
+function finishDisplay() {
+ transitioning = true;
+ const run = game.result(now());
+ const previousBest = Number(bests[selectedMode] || 0);
+ const qualifies = run.finishedReason !== 'manual' || selectedMode === 'endless' || selectedMode === 'tempo';
+ const isBest = qualifies && run.stats.completedWords > 0 && run.stats.wpm > previousBest;
+ if (isBest) { bests[selectedMode] = run.stats.wpm; writeLocal('bests', bests); }
+ if (run.durationMs >= 5000 && run.stats.wpm > 0) writeLocal('lastAverage', Math.max(10, Math.min(200, run.stats.wpm)));
+ const ghostStats = ghost?.stats;
+ currentResult = {run,source:{...source},name:'',local:true,isBest,ghostStats};
+ shareId = null;
+ ghost = null;
+ $('typing-input').blur();
+ displayResult(currentResult);
+}
+
+function displayResult(result) {
+ view = 'result';
+ replay.playing = false;
+ currentResult = result;
+ const run = result.run;
+ const stats = run.stats;
+ selectedMode = run.mode;
+ source = result.source || {kind:'common'};
+ passage = [...run.words];
+ $('load-panel').hidden = true; $('play-panel').hidden = true; $('result-panel').hidden = false;
+ $('result-kicker').textContent = (result.name ? result.name + ' · ' : '') + modeNames[run.mode] + (run.finishedReason === 'manual' && ['30','60','50'].includes(run.mode) ? ' · finished early' : ', well spent');
+ $('result-title').textContent = result.ghostStats ? (stats.wpm > result.ghostStats.wpm ? 'a little ahead of the ghost.' : 'a good race. another round?') : 'there’s your rhythm.';
+ $('result-wpm').textContent = pretty(stats.wpm);
+ $('result-accuracy').textContent = round1(stats.accuracy) + '%';
+ $('result-streak').textContent = stats.bestStreak + (stats.bestStreak === 1 ? ' word' : ' words');
+ $('result-words').textContent = stats.completedWords;
+ $('new-best').hidden = !result.isBest;
+ $('again-button').innerHTML = result.local ? 'go again ↵' : 'try these words ↵';
+ $('share-button').innerHTML = shareId ? 'copy result ↗' : 'share result ↗';
+ $('race-button').textContent = result.local ? 'challenge a friend →' : 'race this →';
+ $('replay-button').disabled = !run.events?.length;
+ $('share-button').disabled = !run.events?.length || run.replayTruncated;
+ $('race-button').disabled = !run.events?.length || run.replayTruncated;
+ $('board-open').hidden = source.kind !== 'daily';
+ if (run.replayTruncated) $('result-note').textContent = 'That was a long flow. Your score is complete; the replay holds the most recent portion, so this run cannot be shared.';
+ else if (source.kind === 'daily' && result.local) $('result-note').textContent = 'Share your result to add it to today’s board. A name is optional.';
+ else if (result.ghostStats) $('result-note').textContent = 'Your ghost finished at ' + pretty(result.ghostStats.wpm) + ' wpm. Race links always use the same passage and mode.';
+ else $('result-note').textContent = result.local ? 'Your result is on this device. Share it when you’re ready.' : 'Watch the original timing, or try the same words against this ghost.';
+ const slowest = stats.slowest?.slice(0, 3) || [];
+ $('slow-words').innerHTML = slowest.length ? slowest.map(row => '' + escapeHTML(row.word) + '' + round1(row.ms / 1000) + 's').join('') : 'your next run starts here';
+ const points = stats.sparkline || [];
+ if (points.length) {
+ const max = Math.max(20, ...points);
+ const coords = points.map((value, i) => [(i / Math.max(1, points.length - 1)) * 896 + 2, 83 - (Math.min(value, max) / max) * 70]);
+ const path = coords.map((point, i) => (i ? 'L' : 'M') + point[0].toFixed(1) + ',' + point[1].toFixed(1)).join(' ');
+ $('speed-chart').innerHTML = '' + coords.filter((_, i) => i === 0 || i === coords.length - 1).map(point => '').join('');
+ } else $('speed-chart').innerHTML = '';
+ updateOptions();
+ $('mode-switch').querySelectorAll('button').forEach(button => { button.disabled = false; });
+}
+
+function showLoading(title, description = '') {
+ view = 'loading';
+ replay.playing = false;
+ $('play-panel').hidden = true; $('result-panel').hidden = true; $('load-panel').hidden = false;
+ $('load-kicker').textContent = 'one moment';
+ $('load-title').textContent = title; $('load-description').textContent = description;
+ $('load-actions').hidden = true; $('loading-spinner').hidden = false;
+}
+
+function showRouteError(error) {
+ view = 'loading';
+ $('load-panel').hidden = false; $('play-panel').hidden = true; $('result-panel').hidden = true;
+ $('loading-spinner').hidden = true; $('load-actions').hidden = false;
+ $('load-kicker').textContent = error.status === 429 ? 'a little breathing room' : 'a small interruption';
+ $('load-title').textContent = error.status === 404 ? 'that rhythm is missing.' : 'the words couldn’t land.';
+ $('load-description').textContent = error.message;
+}
+
+async function route(path, push = true) {
+ const serial = ++routeSerial;
+ document.querySelectorAll('dialog[open]').forEach(dialog => dialog.close());
+ if (push) history.pushState({}, '', path);
+ currentPath = path;
+ ghost = null; shareId = null;
+ if (path === '/') {
+ source = {kind:'common'};
+ currentResult = null;
+ passage = newCommonPassage();
+ beginRun();
+ document.title = 'typy — one word at a time';
+ return;
+ }
+ try {
+ if (path === '/daily' || path === '/daily/') {
+ showLoading('today’s words, for everyone.');
+ const response = await api('/api/daily');
+ if (serial !== routeSerial) return;
+ passage = response.words;
+ source = {kind:'daily',day:response.day};
+ selectedMode = '60';
+ currentResult = null;
+ beginRun();
+ document.title = 'the daily — typy';
+ } else if (/^\/(r|race)\/[a-zA-Z0-9_-]+\/?$/.test(path)) {
+ const [,kind,id] = path.split('/');
+ showLoading(kind === 'race' ? 'a ghost is taking its place.' : 'finding that rhythm.');
+ const response = await api('/api/results/' + encodeURIComponent(id));
+ if (serial !== routeSerial) return;
+ const result = {...response,local:false};
+ if (!result.run?.words?.length || !result.run.stats) throw new Error('This replay is incomplete. Please try another result.');
+ currentResult = result;
+ shareId = result.id || id;
+ source = result.source || {kind:'common'};
+ passage = [...result.run.words];
+ selectedMode = result.run.mode;
+ if (kind === 'race') {
+ ghost = result.run;
+ beginRun();
+ } else displayResult(result);
+ document.title = pretty(result.run.stats.wpm) + ' wpm — typy';
+ } else {
+ const topic = decodeURIComponent(path).replace(/^\/|\/$/g, '');
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(topic)) throw Object.assign(new Error('Try a short topic with letters and hyphens, such as /react-hooks.'), {status:404});
+ source = {kind:'topic',topic};
+ updateOptions();
+ showLoading('a little about ' + topic.replaceAll('-', ' ') + '.', 'Making a short passage for your next run.');
+ const response = await api('/api/passage?topic=' + encodeURIComponent(topic));
+ if (serial !== routeSerial) return;
+ passage = response.words;
+ source = {kind:'topic',topic:response.topic || topic};
+ currentResult = null;
+ beginRun();
+ document.title = topic.replaceAll('-', ' ') + ' — typy';
+ }
+ } catch (error) { if (serial === routeSerial) showRouteError(error); }
+}
+
+function startReplay() {
+ if (!currentResult?.run) return;
+ view = 'replay';
+ ghost = null;
+ lastWordKey = ''; lastWordIndex = -1;
+ $('result-panel').hidden = true; $('play-panel').hidden = false;
+ $('input-row').hidden = true; $('replay-controls').hidden = false;
+ $('ghost-race').hidden = true; $('tempo-track').hidden = true;
+ $('run-status').textContent = 'a rhythm, replayed';
+ $('status-dot').classList.add('active');
+ $('replay-scrubber').max = Math.max(1, currentResult.run.durationMs);
+ $('replay-scrubber').value = '0';
+ $('time-label').textContent = 'replay'; $('time-unit').textContent = 's';
+ replay = {playing:true,position:0,anchor:now(),anchorPosition:0};
+ $('replay-play').textContent = 'pause';
+ updateOptions();
+ renderReplay(0);
+}
+
+function clock(ms) {
+ const seconds = Math.max(0, Math.floor(ms / 1000));
+ return Math.floor(seconds / 60) + ':' + String(seconds % 60).padStart(2, '0');
+}
+
+function renderReplay(position) {
+ const run = currentResult.run;
+ let timing = run.wordTimings[0];
+ for (const candidate of run.wordTimings) {
+ if (candidate.start <= position) timing = candidate;
+ else break;
+ }
+ const index = timing?.index || 0;
+ const word = run.words[index % run.words.length];
+ const flags = [];
+ for (const event of run.events) {
+ if (event.t > position) break;
+ if (event.index !== index) continue;
+ if (event.type === 'hit') flags.push(true);
+ else if (event.type === 'miss') flags.push(false);
+ else if (event.type === 'backspace') flags.pop();
+ }
+ const characters = Array.from(word);
+ const buffer = flags.map((flag, i) => flag ? characters[i] || '' : '·').join('');
+ drawWord(word, buffer, index, run.words[(index + 1) % run.words.length], flags);
+ $('live-wpm').textContent = pretty(run.stats.wpm);
+ $('live-accuracy').textContent = pretty(run.stats.accuracy);
+ $('live-time').textContent = Math.floor(position / 1000);
+ $('replay-scrubber').value = String(position);
+ $('replay-time').textContent = clock(position) + ' / ' + clock(run.durationMs);
+}
+
+function openShare(intent = 'result') {
+ if (!currentResult) return;
+ shareIntent = intent;
+ $('share-kicker').textContent = source.kind === 'daily' ? 'put your rhythm on the board' : 'a good rhythm is worth sharing';
+ $('share-title').textContent = intent === 'race' ? 'a friendly challenge.' : 'leave a little ghost.';
+ $('share-error').hidden = true;
+ $('share-name').value = readLocal('name', '');
+ $('share-create').hidden = Boolean(shareId);
+ $('share-ready').hidden = !shareId;
+ if (shareId) fillShareLink();
+ $('share-dialog').showModal();
+}
+
+function fillShareLink() {
+ $('share-url').value = location.origin + (shareIntent === 'race' ? '/race/' : '/r/') + shareId;
+ $('share-url-label').textContent = shareIntent === 'race' ? 'Your challenge link' : 'Your result link';
+ $('share-ready-note').textContent = shareIntent === 'race' ? 'Same words, same mode. Your friend races your original timing.' : 'Anyone with this link can watch your replay or race your ghost.';
+ $('copy-link').textContent = 'copy';
+}
+
+async function saveResult() {
+ if (!currentResult || shareId) return;
+ const button = $('save-result');
+ button.disabled = true; button.textContent = 'saving your rhythm…';
+ $('share-error').hidden = true;
+ try {
+ const name = $('share-name').value.trim();
+ const response = await api('/api/results', {method:'POST',body:JSON.stringify({run:currentResult.run,source:currentResult.source,name})});
+ if (!response.id) throw new Error('The server did not return a share link. Please try again.');
+ shareId = response.id;
+ currentResult = {...currentResult,id:shareId,name,boardPublished:response.boardPublished};
+ writeLocal('name', name);
+ $('share-create').hidden = true; $('share-ready').hidden = false;
+ fillShareLink();
+ history.replaceState({}, '', '/r/' + shareId);
+ currentPath = '/r/' + shareId;
+ $('share-button').innerHTML = 'copy result ↗';
+ $('result-note').textContent = source.kind === 'daily'
+ ? response.boardPublished === false
+ ? 'Your replay is shared, but the daily board could not update. Your result link still works.'
+ : 'Your result is shared. The daily board may take a moment to refresh.'
+ : 'Your replay is ready to share.';
+ if (source.kind === 'daily' && response.boardPublished === false) toast('Result saved. The daily board could not update this time.');
+ } catch (error) {
+ $('share-error').textContent = error.message;
+ $('share-error').hidden = false;
+ } finally { button.disabled = false; button.textContent = 'create share link ↗'; }
+}
+
+async function copyShareLink() {
+ try { await navigator.clipboard.writeText($('share-url').value); $('copy-link').textContent = 'copied ✓'; }
+ catch { $('share-url').focus(); $('share-url').select(); toast('The link is selected. Copy it with your keyboard or press and hold.'); }
+}
+
+async function loadBoard() {
+ const day = source.day || new Date().toISOString().slice(0, 10);
+ $('board-date').textContent = day + ' · midnight to midnight UTC';
+ $('board-content').innerHTML = '