From 4192a05a78889fcf12400d4bb30ea9b7b2f54597 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Wed, 15 Jul 2026 14:12:44 +0200 Subject: [PATCH] Initial build: typo, a typing test built from your own code No backend, no build step -- static HTML/CSS/JS, themed identically to flit/wisp (same CSS vars, same "> " title prefix, same dark/mono/green aesthetic). Snippets are real, shipped code pulled from six repos in this family (flit, wisp, keep, stash, delve-term, goonk) rather than generic pangrams. Stats: WPM = (correct chars / 5) / minutes elapsed, timing from first keystroke not page load. Accuracy = correct keystrokes / total keystrokes tracked at the keydown level (comparing the pressed key against the exact next expected character before it's inserted), so backspaced-over mistakes still count against it rather than only diffing the final text. Keys/sec updates live via a 200ms tick. Tabs in source are rendered as two spaces so players never need to press Tab -- sidesteps a browser default-Tab-moves-focus fight entirely rather than fighting it with preventDefault. Verified via simulated typing sessions (puppeteer), not just static review -- both an artificially fast pass (confirms the math is internally consistent: 308 correct/310 total keystrokes -> 99%, matches displayed) and a realistic ~60wpm-paced pass (confirms no unit-conversion bug that only a slow, deliberate typist would hit tests-at-lightspeed wouldn't surface). That process caught two real bugs before they'd have hit a real player: 1. A stray `tabindex="0"` on the code display let the browser's native click-to-focus behavior steal focus away from the hidden textarea before our own focus() call ran -- typing silently did nothing. Fixed by removing the tabindex and switching to a mousedown+preventDefault handler instead of click. 2. The help modal's CSS set `display: flex` unconditionally, which (author CSS always beats the UA stylesheet) overrode the `hidden` attribute's default `display: none` -- the modal was invisibly covering the entire viewport and intercepting every click, at all times, whether or not it had been opened. Fixed with an explicit `.modal-overlay:not([hidden])` rule. Also verified the actual Docker image builds and serves correctly (nginx, no build stage needed since there's nothing to compile). Co-Authored-By: Claude Sonnet 5 --- .env.example | 8 + .gitea/workflows/docker.yml | 53 +++++++ .gitignore | 1 + Dockerfile | 3 + README.md | 54 +++++++ app.js | 159 ++++++++++++++++++++ docker-compose.yml | 8 + index.html | 57 ++++++++ snippets.json | 50 +++++++ style.css | 284 ++++++++++++++++++++++++++++++++++++ 10 files changed, 677 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/docker.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.js create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 snippets.json create mode 100644 style.css diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0c41762 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Copy to .env to override docker-compose.yml defaults. + +# Image to deploy — set by CI/CD normally; only needed for a manual +# docker compose up. +IMAGE=repo.explewd.com/explewd/typo:latest + +# Host port to expose (container always listens on 80). +HOST_PORT=8850 diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..093185e --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,53 @@ +name: Docker + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Derive image name and tags + id: meta + shell: bash + run: | + set -euo pipefail + SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}" + REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}" + SHA="${GITHUB_SHA:-${GITEA_SHA:-}}" + if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then + echo "Missing SERVER_URL/REPO/SHA env vars." >&2; exit 1 + fi + HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||') + IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]') + SHORT_SHA=$(echo "${SHA}" | cut -c1-7) + echo "host=${HOST}" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + + - name: Log in to Gitea container registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.meta.outputs.host }} + username: ${{ github.actor }} + # Create a Gitea PAT with packages:write scope and add it as a + # repository secret named TKNTKN (Settings → Secrets) + password: ${{ secrets.TKNTKN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + tags: | + ${{ steps.meta.outputs.image }}:latest + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4de2fff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM nginx:alpine +COPY index.html style.css app.js snippets.json /usr/share/nginx/html/ +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7194b5e --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# typo + +A typing test built from your own code. Every snippet is real, shipped +code pulled from real repos — flit, wisp, keep, stash, delve-term, goonk +itself — not generic pangrams or Lorem Ipsum. + +No backend, no build step — plain HTML/CSS/JS served statically, with +snippets baked into `snippets.json`. + +## Stats + +- **WPM** — (correct characters / 5) / minutes elapsed, the standard + typing-test formula. Timing starts on the first keystroke, not page load. +- **Accuracy** — correct keystrokes / total keystrokes, including ones you + backspaced over. Tracked at the keydown level (comparing the pressed key + against the exact next expected character), not just diffed after the + fact. +- **Keys/sec** — total keystrokes / elapsed seconds, live. + +Mistakes are highlighted in place rather than blocking progress — you can +backspace to fix them or keep going, same as most real typing tests. +Tabs in the original source are shown as two spaces so you never need to +press Tab. + +## Adding snippets + +Edit `snippets.json` — each entry is `{ repo, file, lang, code }`. Keep +snippets short (10-20 lines), self-contained, and free of exotic +Unicode in comments (em dashes etc. are typeable but annoying) if you +want them pleasant to type. + +## Local development + +Any static file server works, e.g.: + +```bash +python3 -m http.server 8850 +``` + +## Status + +Built and verified via simulated typing sessions (not just static +review): focus handling, live character-by-character highlighting, +newline handling for multi-line snippets, and the WPM/accuracy/KPS math +all confirmed against both artificially fast and realistic human-speed +input. Caught and fixed two real bugs in the process — a focus-stealing +issue caused by a stray `tabindex` on the code display, and a modal +overlay that was invisibly covering the whole page and intercepting +every click because its CSS unconditionally overrode the `hidden` +attribute. + +## License + +MIT. diff --git a/app.js b/app.js new file mode 100644 index 0000000..c12b5ce --- /dev/null +++ b/app.js @@ -0,0 +1,159 @@ +(() => { + const sourceLine = document.getElementById('source-line'); + const codeDisplay = document.getElementById('code-display'); + const input = document.getElementById('typing-input'); + const statWpm = document.getElementById('stat-wpm'); + const statAcc = document.getElementById('stat-acc'); + const statKps = document.getElementById('stat-kps'); + const statTime = document.getElementById('stat-time'); + const restartBtn = document.getElementById('restart-btn'); + const gameCard = document.getElementById('game-card'); + const helpBtn = document.getElementById('help-btn'); + const closeHelp = document.getElementById('close-help'); + const helpModal = document.getElementById('help-modal'); + + let snippets = []; + let target = ''; + let startTime = null; + let finished = false; + let totalKeystrokes = 0; + let correctKeystrokes = 0; + let tickHandle = null; + let resultBanner = null; + + function escapeHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>'); + } + + function pickSnippet() { + return snippets[Math.floor(Math.random() * snippets.length)]; + } + + function loadSnippet() { + const s = pickSnippet(); + // Tabs shown as two spaces so the player never needs to press Tab -- + // a plain textarea's default Tab behavior is "move focus," and + // preventDefault-ing that just to insert a literal tab is more + // fragile (IME/mobile edge cases) than sidestepping it entirely. + target = s.code.replace(/\t/g, ' '); + sourceLine.innerHTML = `typing: ${escapeHtml(s.repo)}/${escapeHtml(s.file)}`; + + startTime = null; + finished = false; + totalKeystrokes = 0; + correctKeystrokes = 0; + input.value = ''; + if (resultBanner) { resultBanner.remove(); resultBanner = null; } + render(); + input.disabled = false; + input.focus(); + stopTick(); + startTick(); + } + + function render() { + const typed = input.value; + let html = ''; + for (let i = 0; i < target.length; i++) { + const targetChar = target[i] === '\n' ? '\n' : target[i]; + let cls = 'char-pending'; + if (i < typed.length) { + cls = typed[i] === target[i] ? 'char-correct' : 'char-incorrect'; + } else if (i === typed.length) { + cls = 'char-pending char-cursor'; + } + html += `${escapeHtml(targetChar)}`; + } + codeDisplay.innerHTML = html; + updateStats(); + } + + function elapsedSeconds() { + if (startTime === null) return 0; + return (performance.now() - startTime) / 1000; + } + + function updateStats() { + const typed = input.value; + let correctSoFar = 0; + for (let i = 0; i < typed.length && i < target.length; i++) { + if (typed[i] === target[i]) correctSoFar++; + } + const secs = elapsedSeconds(); + const mins = secs / 60; + const wpm = mins > 0 ? Math.round((correctSoFar / 5) / mins) : 0; + const acc = totalKeystrokes > 0 ? Math.round((correctKeystrokes / totalKeystrokes) * 100) : 100; + const kps = secs > 0 ? (totalKeystrokes / secs) : 0; + + statWpm.textContent = wpm; + statAcc.textContent = `${acc}%`; + statKps.textContent = kps.toFixed(1); + statTime.textContent = `${secs.toFixed(1)}s`; + } + + function startTick() { + tickHandle = setInterval(() => { if (!finished) updateStats(); }, 200); + } + function stopTick() { + if (tickHandle) clearInterval(tickHandle); + tickHandle = null; + } + + function finish() { + finished = true; + input.disabled = true; + stopTick(); + updateStats(); + resultBanner = document.createElement('div'); + resultBanner.className = 'result-banner'; + resultBanner.textContent = `Done — ${statWpm.textContent} wpm, ${statAcc.textContent} accuracy, ${statTime.textContent}. Nice.`; + gameCard.insertBefore(resultBanner, gameCard.querySelector('.actions')); + } + + input.addEventListener('keydown', (e) => { + if (finished) return; + const isBackspace = e.key === 'Backspace'; + const isNewline = e.key === 'Enter'; + const isChar = e.key.length === 1; + if (!isBackspace && !isNewline && !isChar) return; // ignore Shift/Control/Arrow/Tab/etc + + if (startTime === null && !isBackspace) startTime = performance.now(); + + totalKeystrokes++; + if (!isBackspace) { + const expected = target[input.value.length]; + const pressed = isNewline ? '\n' : e.key; + if (expected !== undefined && pressed === expected) correctKeystrokes++; + } + }); + + input.addEventListener('input', () => { + render(); + if (input.value === target) finish(); + }); + + // mousedown + preventDefault, not click -- a click handler runs after the + // browser's own default-focus behavior has already fired, which was + // stealing focus away from the hidden textarea whenever this had a + // native tabindex. preventDefault on mousedown stops that entirely, so + // our explicit focus() call is the only thing that happens. + codeDisplay.addEventListener('mousedown', (e) => { + e.preventDefault(); + if (!finished) input.focus(); + }); + restartBtn.addEventListener('click', loadSnippet); + + helpBtn.addEventListener('click', () => { helpModal.hidden = false; }); + closeHelp.addEventListener('click', () => { helpModal.hidden = true; }); + helpModal.addEventListener('click', (e) => { if (e.target === helpModal) helpModal.hidden = true; }); + + fetch('snippets.json') + .then(r => r.json()) + .then(data => { + snippets = data; + loadSnippet(); + }) + .catch(() => { + sourceLine.textContent = 'Could not load snippets.'; + }); +})(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..27b5f47 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + app: + image: ${IMAGE} + container_name: typo + restart: unless-stopped + pull_policy: always + ports: + - "${HOST_PORT:-8850}:80" diff --git a/index.html b/index.html new file mode 100644 index 0000000..b3a691c --- /dev/null +++ b/index.html @@ -0,0 +1,57 @@ + + + + +typo + + + + + + + + +
+
+

typo

+ +
+

real code, from real repos — type it as fast and cleanly as you can

+ +
+
loading a snippet…
+

+      
+
+      
+
0wpm
+
100%accuracy
+
0.0keys/sec
+
0.0stime
+
+ +
+ +
+
+ + +
+ + + diff --git a/snippets.json b/snippets.json new file mode 100644 index 0000000..bb641a3 --- /dev/null +++ b/snippets.json @@ -0,0 +1,50 @@ +[ + { + "repo": "delve-term", + "file": "game/combat.go", + "lang": "go", + "code": "func withThe(name string) string {\n\tif strings.HasPrefix(name, \"the \") {\n\t\treturn name\n\t}\n\treturn \"the \" + name\n}" + }, + { + "repo": "delve-term", + "file": "game/graves.go", + "lang": "go", + "code": "func RandomGraveNear(floor, spread int) *Grave {\n\tgraveMu.Lock()\n\tdefer graveMu.Unlock()\n\tvar candidates []Grave\n\tfor _, g := range graves {\n\t\td := g.Floor - floor\n\t\tif d < 0 {\n\t\t\td = -d\n\t\t}\n\t\tif d <= spread {\n\t\t\tcandidates = append(candidates, g)\n\t\t}\n\t}\n\tif len(candidates) == 0 {\n\t\treturn nil\n\t}\n\treturn &candidates[rand.Intn(len(candidates))]\n}" + }, + { + "repo": "flit", + "file": "cli/cmd/flit/main.go", + "lang": "go", + "code": "func decodeInvite(s string) (*invite, error) {\n\tconst prefix = \"flit:\"\n\tif len(s) < len(prefix) || s[:len(prefix)] != prefix {\n\t\treturn nil, fmt.Errorf(\"not a flit invite\")\n\t}\n\traw, err := base64.RawURLEncoding.DecodeString(s[len(prefix):])\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar inv invite\n\tif err := json.Unmarshal(raw, &inv); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &inv, nil\n}" + }, + { + "repo": "wisp", + "file": "client/src/crypto.ts", + "lang": "ts", + "code": "function concat(...parts: Uint8Array[]): Uint8Array {\n const total = parts.reduce((n, p) => n + p.length, 0)\n const out = new Uint8Array(total)\n let offset = 0\n for (const p of parts) {\n out.set(p, offset)\n offset += p.length\n }\n return out\n}" + }, + { + "repo": "stash", + "file": "src/server/db.ts", + "lang": "ts", + "code": "export function deleteArticle(id: number): boolean {\n const result = db.prepare('DELETE FROM articles WHERE id = ?').run(id);\n return result.changes > 0;\n}\n\nexport function isValidToken(token: string): boolean {\n const row = db.prepare('SELECT 1 FROM tokens WHERE token = ?').get(token);\n return !!row;\n}" + }, + { + "repo": "keep", + "file": "src/server/db.ts", + "lang": "ts", + "code": "export function hasGrant(vaultKey: string, recipientId: string): boolean {\n return !!getGrant(vaultKey, recipientId);\n}\n\nexport function hasWriteGrant(vaultKey: string, recipientId: string): boolean {\n const grant = getGrant(vaultKey, recipientId);\n return !!grant && grant.canWrite;\n}" + }, + { + "repo": "goonk", + "file": "api/routes/lastfm.mjs", + "lang": "js", + "code": "router.get('/top-artists', async (req, res) => {\n const period = req.query.period || '7day';\n const limit = Math.min(Number(req.query.limit) || 10, 50);\n try {\n const data = await cached(`top-artists:${period}:${limit}`, CACHE_TTL_MS, () =>\n lfm('user.gettopartists', { period, limit })\n );\n res.json(data.topartists?.artist ?? []);\n } catch (e) {\n res.status(502).json({ error: e.message });\n }\n});" + }, + { + "repo": "goonk", + "file": "public/scripts/easter-eggs.js", + "lang": "js", + "code": "const typedWordAchievements = ['sudo', 'vim', 'emacs', 'gravity'];\nif (typedWordAchievements.includes(achievementKey.toLowerCase()) && !unlocked.includes('console')) {\n setTimeout(() => unlockAchievement('CONSOLE'), 1000);\n}" + } +] diff --git a/style.css b/style.css new file mode 100644 index 0000000..c5e4f85 --- /dev/null +++ b/style.css @@ -0,0 +1,284 @@ +:root { + --bg: #080808; + --bg-alt: #0e0e0e; + --text: #c8c8c8; + --muted: #555; + --heading: #f0f0f0; + --accent: #00e87a; + --accent-dim: rgba(0, 232, 122, 0.12); + --accent-border:rgba(0, 232, 122, 0.25); + --border: rgba(255, 255, 255, 0.07); + --shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6); + --error: #ff6b6b; + --error-dim: rgba(255, 107, 107, 0.14); + + --mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace; + --sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; + + font: 16px/1.6 var(--sans); + color: var(--text); + background: var(--bg); + color-scheme: dark; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +*, *::before, *::after { box-sizing: border-box; } + +body { margin: 0; } + +#app { + width: 640px; + max-width: 100%; + margin: 0 auto; + padding: 32px 16px 64px; +} + +h1, h2, h3 { font-family: var(--mono); color: var(--heading); line-height: 1.15; margin: 0 0 0.5em; } +p { margin: 0; } + +/* ── Header ─────────────────────────────────────────────────────────── */ + +.header-row { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 8px; + position: relative; +} + +.header-row h1 { + font-size: 32px; + font-weight: 700; + letter-spacing: -0.5px; + text-align: center; + margin: 0; +} + +.header-row h1::before { + content: '> '; + color: var(--muted); + font-weight: 400; +} + +.header-row .help-btn { + position: absolute; + right: 0; +} + +.help-btn { + font-family: var(--mono); + font-size: 12px; + font-weight: 700; + width: 22px; + height: 22px; + line-height: 20px; + border-radius: 50%; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + cursor: pointer; + padding: 0; +} + +.help-btn:hover { + border-color: var(--accent-border); + color: var(--accent); +} + +.hint { + font-family: var(--mono); + font-size: 12px; + color: var(--muted); + text-align: center; + margin-bottom: 32px; +} + +/* ── Card ───────────────────────────────────────────────────────────── */ + +.card { + border: 1px solid var(--border); + border-radius: 10px; + padding: 24px; + background: var(--bg-alt); + box-shadow: var(--shadow); +} + +.source-line { + font-family: var(--mono); + font-size: 12px; + color: var(--muted); + margin-bottom: 14px; +} + +.source-line .accent { color: var(--accent); } + +.code-display { + font-family: var(--mono); + font-size: 15px; + line-height: 1.7; + color: var(--muted); + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + cursor: text; + outline: none; + min-height: 3em; +} + +.code-display:focus { + border-color: var(--accent-border); +} + +.code-display .char-correct { color: var(--text); } +.code-display .char-incorrect { color: var(--error); background: var(--error-dim); border-radius: 2px; } +.code-display .char-pending { color: var(--muted); } +.code-display .char-cursor { + border-left: 2px solid var(--accent); + animation: blink 1s step-end infinite; +} +@keyframes blink { 50% { border-color: transparent; } } + +/* Real input, visually hidden but focusable/typeable — a hidden input is + more reliable across mobile keyboards and IME composition than trying + to capture raw keydown events on a div. */ +.sr-input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + pointer-events: none; +} + +/* ── Stats ──────────────────────────────────────────────────────────── */ + +.stat-row { + display: flex; + justify-content: space-between; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.stat-value { + font-family: var(--mono); + font-size: 20px; + font-weight: 700; + color: var(--heading); + font-variant-numeric: tabular-nums; +} + +.stat-label { + font-family: var(--mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +/* ── Actions / buttons ──────────────────────────────────────────────── */ + +.actions { + display: flex; + justify-content: center; + margin-top: 20px; +} + +.btn { + font-family: var(--mono); + font-size: 13px; + font-weight: 600; + padding: 10px 16px; + border-radius: 6px; + border: 1px solid var(--border); + background: transparent; + color: var(--text); + cursor: pointer; + transition: all 0.15s; + letter-spacing: 0.01em; +} + +.btn:hover { border-color: var(--accent-border); color: var(--heading); } +.btn:active { transform: scale(0.98); } + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #000; + font-weight: 700; +} + +.btn-primary:hover { background: #00ff88; border-color: #00ff88; color: #000; } + +/* ── Result banner ──────────────────────────────────────────────────── */ + +.result-banner { + text-align: center; + font-family: var(--mono); + font-size: 13px; + color: var(--accent); + margin-top: 14px; +} + +/* ── Help modal ─────────────────────────────────────────────────────── */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.88); + align-items: center; + justify-content: center; + z-index: 100; + padding: 16px; + backdrop-filter: blur(4px); +} + +/* Author CSS always wins over the UA stylesheet's [hidden]{display:none}, + so an unconditional `display: flex` above would keep this covering the + whole viewport (and intercepting every click) even while `hidden` is + set. Only apply flex layout when it isn't hidden. */ +.modal-overlay:not([hidden]) { + display: flex; +} + +.modal-card { + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + width: 100%; + max-width: 420px; + max-height: 80vh; + overflow-y: auto; + box-shadow: var(--shadow); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; + font-family: var(--mono); + font-size: 13px; + color: var(--heading); +} + +.modal-card ul { + padding-left: 1.2em; + margin: 0; +} + +.modal-card li { margin-bottom: 0.8em; font-size: 14px; line-height: 1.5; } +.modal-card li:last-child { margin-bottom: 0; }