srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-09-11 21:53:00 +0200
committersrdusr <[email protected]>2025-09-11 21:53:00 +0200
commita726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch)
tree4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /web
parent8c95e90fa54db565919ec323818f77e7256812c8 (diff)
downloadtyperpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.tar.gz
typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.zip
Add multiplayer bots, typing languages, and rework the UI layout
Multiplayer - Quick match: POST /api/multiplayer/quickmatch returns whichever room is still filling, or opens one. Players never see a room code; joining by code stays for racing specific people. - Bots fill quick-match rooms after a short wait so a new game is never an empty lobby. They only ever join quick-match rooms, never a room opened by code. One or two per room, drawn from separate ~40 and ~80 WPM tiers so two bots are never near each other's pace, and they stall to correct mistakes rather than typing a clean straight line. - Live player count via GET /api/multiplayer/online, shown on the Multiplayer control and under the main menu's Multiplayer button. - Per-racer colours: you are the theme accent, opponents take distinct hues that stay the same from lobby to race. - The countdown no longer holds the room lock for its full three seconds, which is what reset clients mid-countdown. Typing languages - 16 languages for the generated-word modes, each with its own high-frequency vocabulary rather than a translation of the English list. - Picker in the top-right rail; non-English uses its own list at every difficulty tier instead of falling back to English words. Fix UTF-8 accuracy in the game core - update_game_state mixed byte and character counts: total_characters_typed accumulated byte-length deltas while total_correct_characters compared a char index against that byte count. Equal on ASCII, so it went unnoticed; a correctly typed Spanish passage scored 6%. The old byte slicing would also have panicked if an index landed inside a multi-byte character. Rewritten char-based, with regression tests. Programming mode - Replaced prose about programming with real code: 26 syntax-highlighted snippets across JavaScript, Python, Rust, C/Go/Java and shell. Single-line by necessity, since the typing input is a single-line field. Layout and readability - One icon rail arrangement on every screen: Settings/Store under the wordmark, Language/Theme/Friends/Account top-right, Stats/Leaderboard/ Multiplayer bottom-right. - Main menu: mode picker moved out of the Single Player button, which it was notching a divider through and pushing the label off-centre. - Escape returns to the menu, closing any open popover first, and confirms before abandoning a live race. - Split --text-color and --sub-color per theme; they shared one value that measured 3.65:1 against the background, below the 4.5:1 body-text floor. - Semantic colours used in exactly one place each: gold for a personal best, amber for the race countdown and the mobile-result badge. - Passage now sits in the same place on the typing and end screens, and its column is a whole number of characters wide so wrapping cannot leave a permanent gap on the right. - End screen: keystrokes and a correct/wrong/extra/missed split, attribution carried over from the typing screen, and a graph with a separate error axis, axis titles including seconds, and smoothed lines.
Diffstat (limited to 'web')
-rw-r--r--web/README.md67
-rw-r--r--web/index.html26
-rwxr-xr-xweb/launch.sh78
-rw-r--r--web/serve.mjs76
-rw-r--r--web/src/api.js43
-rw-r--r--web/src/app.js362
-rw-r--r--web/src/auth.js75
-rw-r--r--web/src/chart.js420
-rw-r--r--web/src/cornerRail.js125
-rw-r--r--web/src/counts.js61
-rw-r--r--web/src/customText.js155
-rw-r--r--web/src/data/texts.json978
-rw-r--r--web/src/deviceDetect.js10
-rw-r--r--web/src/game.js17
-rw-r--r--web/src/keySounds.js42
-rw-r--r--web/src/keyStats.js59
-rw-r--r--web/src/languages.js205
-rw-r--r--web/src/main.js16
-rw-r--r--web/src/multiplayer.js81
-rw-r--r--web/src/pb.js56
-rw-r--r--web/src/profileStats.js62
-rw-r--r--web/src/screens/accountScreen.js121
-rw-r--r--web/src/screens/endScreen.js226
-rw-r--r--web/src/screens/friendsScreen.js149
-rw-r--r--web/src/screens/icons.js109
-rw-r--r--web/src/screens/leaderboardScreen.js120
-rw-r--r--web/src/screens/lyricsScreen.js127
-rw-r--r--web/src/screens/mainMenu.js531
-rw-r--r--web/src/screens/multiplayerScreen.js265
-rw-r--r--web/src/screens/passiveMode.js159
-rw-r--r--web/src/screens/placeholderScreen.js31
-rw-r--r--web/src/screens/publicProfileScreen.js92
-rw-r--r--web/src/screens/statsScreen.js85
-rw-r--r--web/src/screens/storeScreen.js145
-rw-r--r--web/src/screens/typingGame.js309
-rw-r--r--web/src/settings.js40
-rw-r--r--web/src/stats.js66
-rw-r--r--web/src/styles.css2440
-rw-r--r--web/src/theme.js66
-rw-r--r--web/src/themeButton.js70
-rw-r--r--web/src/tooltip.js66
-rw-r--r--web/src/topRail.js114
-rw-r--r--web/src/util.js5
-rw-r--r--web/src/wordGenerator.js185
-rw-r--r--web/tests/README.md42
-rw-r--r--web/tests/helpers.py38
-rw-r--r--web/tests/run_all.py42
-rw-r--r--web/tests/test_account_and_store.py56
-rw-r--r--web/tests/test_leaderboard_and_friends.py60
-rw-r--r--web/tests/test_typing_flow.py54
50 files changed, 8827 insertions, 0 deletions
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..afe57aa
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,67 @@
+# TyperPunk Web
+
+A modern web-based typing test application, in plain HTML, CSS, and JavaScript, backed by the shared Rust/WASM game core. No npm packages, no bundler, no build step. This avoids pulling in the npm dependency tree entirely, which removes the main supply-chain attack surface a typical React/Vite frontend carries.
+
+Features:
+
+- Real-time WPM and accuracy tracking
+- Ghost text typing interface
+- Light/dark mode support
+- Responsive design
+- Modern UI with cyberpunk-inspired theme
+
+## Prerequisites
+
+- Rust and `cargo` (for the WASM game core)
+- `wasm-pack` (installed automatically by `launch.sh` if missing)
+- Node.js (used only to run the zero-dependency dev server and dataset scripts; no npm packages are installed)
+
+## Getting Started
+
+```bash
+# From repo root: builds WASM, merges the dataset, and starts the dev server
+./web/launch.sh
+```
+
+Opens http://localhost:4173
+
+## Project Structure
+
+```
+web/
+├── src/
+│ ├── screens/ # Screen renderers (main menu, typing game, end screen)
+│ ├── app.js # Top-level screen controller
+│ ├── game.js # WASM game instance lifecycle
+│ ├── chart.js # Canvas-based WPM/accuracy graph
+│ ├── stats.js # WPM/accuracy calculation
+│ ├── theme.js # Light/dark theme state
+│ ├── main.js # Application entry point
+│ └── styles.css # Global styles
+├── wasm/ # WASM bindings, copied here by launch.sh (gitignored)
+├── index.html # HTML entry point
+└── serve.mjs # Zero-dependency static file server
+```
+
+## Development
+
+The project uses:
+
+- Vanilla JavaScript (ES modules), no framework or bundler
+- The browser's native `<canvas>` API for the results graph
+- Node's built-in `http` module for local serving (`serve.mjs`)
+- Plain CSS for styling
+
+Because there is no build step, editing a file under `src/` and reloading the page is the whole workflow.
+
+## Contributing
+
+1. Fork the repository
+2. Create your feature branch (`git checkout -b feature/amazing-feature`)
+3. Commit your changes (`git commit -m 'Add some amazing feature'`)
+4. Push to the branch (`git push origin feature/amazing-feature`)
+5. Open a Pull Request
+
+## License
+
+This project is licensed under the MIT License - see the LICENSE file for details.
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..7a32152
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html lang="en" data-theme="dark">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>TyperPunk - Typing Test</title>
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23000'/%3E%3Cpolyline points='6,9 14,16 6,23' fill='none' stroke='%2300ff9d' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3Crect x='17' y='20' width='10' height='3' fill='%2300ff9d'/%3E%3C/svg%3E">
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
+ <link rel="stylesheet" href="/src/styles.css">
+</head>
+<body>
+ <script>
+ // Local dev only: the static file server (4173) and typerpunk-server
+ // (8787) are different origins here. A production deployment behind
+ // a reverse proxy that routes /api/* to the backend needs neither --
+ // same-origin is the default (see web/src/api.js).
+ if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
+ window.TYPERPUNK_API_URL = `${location.protocol}//${location.hostname}:8787`;
+ }
+ </script>
+ <div id="root"></div>
+ <script type="module" src="/src/main.js"></script>
+</body>
+</html>
diff --git a/web/launch.sh b/web/launch.sh
new file mode 100755
index 0000000..bcde2d6
--- /dev/null
+++ b/web/launch.sh
@@ -0,0 +1,78 @@
+#!/bin/bash
+
+# Colors for output
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+RED='\033[0;31m'
+NC='\033[0m' # No Color
+YELLOW='\033[0;33m'
+
+echo -e "${BLUE}Starting TyperPunk Web...${NC}"
+
+# Resolve directories
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="${SCRIPT_DIR}/.."
+
+# Ensure wasm-pack is available (auto-install if possible)
+if ! command -v wasm-pack &> /dev/null; then
+ echo -e "${YELLOW}wasm-pack not found. Attempting to install via cargo...${NC}"
+ if command -v cargo &> /dev/null; then
+ cargo install wasm-pack || {
+ echo -e "${RED}Failed to install wasm-pack automatically. Please install manually: cargo install wasm-pack${NC}"
+ exit 1
+ }
+ else
+ echo -e "${RED}Rust cargo is not installed. Please install Rust and then run: cargo install wasm-pack${NC}"
+ exit 1
+ fi
+fi
+
+# Ensure node is available (used only to run scripts, no npm packages required)
+if ! command -v node &> /dev/null; then
+ echo -e "${RED}Node.js is not installed. Please install Node.js to run the dataset merge and dev server scripts.${NC}"
+ exit 1
+fi
+
+# Ensure dataset exists by merging packs at repo root (best-effort, no npm needed)
+echo "Ensuring dataset (texts.json) exists by merging packs..."
+(cd "$ROOT_DIR" && node scripts/merge_packs.js) \
+ && echo "Merged packs into texts.json" \
+ || echo -e "${YELLOW}Warning:${NC} Could not merge packs; continuing with existing texts.json"
+
+# Build the WASM module
+echo "Building WASM module..."
+cd "$ROOT_DIR/crates/wasm"
+
+# Clean previous build
+rm -rf pkg target
+
+# Build with wasm-pack
+wasm-pack build --target web --release
+
+# Check if build was successful
+if [ $? -ne 0 ]; then
+ echo -e "${RED}Failed to build WASM module${NC}"
+ exit 1
+fi
+
+cd "$SCRIPT_DIR"
+
+# Copy the built WASM bindings into web/wasm for the browser to import directly
+mkdir -p wasm
+cp "$ROOT_DIR/crates/wasm/pkg/typerpunk_wasm.js" wasm/
+cp "$ROOT_DIR/crates/wasm/pkg/typerpunk_wasm_bg.wasm" wasm/
+echo "Copied WASM bindings into web/wasm/"
+
+# Copy shared texts.json into web/src/data if present
+mkdir -p src/data
+if [ -f "$ROOT_DIR/texts.json" ]; then
+ cp "$ROOT_DIR/texts.json" src/data/texts.json
+ echo "Copied shared texts.json into web/src/data/"
+else
+ echo -e "${YELLOW}Warning:${NC} ../texts.json not found. Using fallback web/src/data/texts.json"
+fi
+
+# Start the static file server (no bundler, no npm dependencies)
+echo -e "${GREEN}Starting development server...${NC}"
+echo -e "${GREEN}Website will be available at: http://localhost:4173${NC}"
+node serve.mjs
diff --git a/web/serve.mjs b/web/serve.mjs
new file mode 100644
index 0000000..64d6339
--- /dev/null
+++ b/web/serve.mjs
@@ -0,0 +1,76 @@
+#!/usr/bin/env node
+// Zero-dependency static file server for the TyperPunk web app.
+// Replaces the Vite dev server so the web app has no npm runtime dependency.
+import { createServer } from 'node:http';
+import { readFile, stat } from 'node:fs/promises';
+import { extname, join, normalize, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = resolve(fileURLToPath(new URL('.', import.meta.url)));
+const PORT = Number(process.env.PORT) || 4173;
+const HOST = process.env.HOST || '0.0.0.0';
+
+const MIME_TYPES = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.mjs': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.wasm': 'application/wasm',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.ico': 'image/x-icon',
+};
+
+async function resolveFile(urlPath) {
+ let decoded;
+ try {
+ decoded = decodeURIComponent(urlPath.split('?')[0]);
+ } catch {
+ return null;
+ }
+ const safePath = normalize(join(ROOT, decoded));
+ if (safePath !== ROOT && !safePath.startsWith(ROOT + sep)) return null;
+
+ let target = safePath;
+ try {
+ const info = await stat(target);
+ if (info.isDirectory()) target = join(target, 'index.html');
+ } catch {
+ return null;
+ }
+
+ try {
+ await stat(target);
+ return target;
+ } catch {
+ return null;
+ }
+}
+
+const server = createServer(async (req, res) => {
+ const filePath = (await resolveFile(req.url)) || (await resolveFile('/index.html'));
+ if (!filePath) {
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end('Not found');
+ return;
+ }
+ try {
+ const body = await readFile(filePath);
+ const type = MIME_TYPES[extname(filePath)] || 'application/octet-stream';
+ // This replaces a dev server, and the app is under active
+ // development - no cache headers at all left the browser free to
+ // hold onto old JS/CSS indefinitely with no way to tell it changed,
+ // which has already caused a real "why don't I see the fix" report.
+ res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache' });
+ res.end(body);
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
+ res.end('Internal server error');
+ console.error(err);
+ }
+});
+
+server.listen(PORT, HOST, () => {
+ console.log(`TyperPunk web running at http://localhost:${PORT}`);
+});
diff --git a/web/src/api.js b/web/src/api.js
new file mode 100644
index 0000000..3651067
--- /dev/null
+++ b/web/src/api.js
@@ -0,0 +1,43 @@
+// Thin fetch wrapper for the typerpunk-server backend. Base URL is
+// same-origin by default (a reverse proxy is expected to route /api/* to the
+// backend in that setup); window.TYPERPUNK_API_URL overrides it for local
+// dev or any split frontend/backend hosting, mirroring the existing
+// window.TYPERPUNK_TEXTS_URL override pattern already used for the dataset.
+const BASE_URL = (typeof window !== 'undefined' && window.TYPERPUNK_API_URL) || '';
+
+export class ApiError extends Error {
+ constructor(status, message) {
+ super(message);
+ this.status = status;
+ }
+}
+
+async function request(method, path, body) {
+ const res = await fetch(`${BASE_URL}${path}`, {
+ method,
+ credentials: 'include',
+ headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ });
+
+ let data = null;
+ try { data = await res.json(); } catch { /* empty body, e.g. logout */ }
+
+ if (!res.ok) {
+ throw new ApiError(res.status, (data && data.error) || `request failed (${res.status})`);
+ }
+ return data;
+}
+
+export const api = {
+ get: path => request('GET', path),
+ post: (path, body) => request('POST', path, body ?? {}),
+ delete: path => request('DELETE', path),
+};
+
+// For the rare case a caller needs a real page navigation instead of a
+// fetch - the Spotify OAuth login link has to be an actual top-level
+// navigation so the browser can load Spotify's own consent screen.
+export function apiUrl(path) {
+ return `${BASE_URL}${path}`;
+}
diff --git a/web/src/app.js b/web/src/app.js
new file mode 100644
index 0000000..fac89d4
--- /dev/null
+++ b/web/src/app.js
@@ -0,0 +1,362 @@
+import { renderMainMenu } from './screens/mainMenu.js';
+import { renderTypingGame } from './screens/typingGame.js';
+import { renderEndScreen } from './screens/endScreen.js';
+import { renderPassiveMode } from './screens/passiveMode.js';
+import { renderStatsScreen } from './screens/statsScreen.js';
+import { renderPlaceholderScreen } from './screens/placeholderScreen.js';
+import { renderAccountScreen } from './screens/accountScreen.js';
+import { renderLeaderboardScreen } from './screens/leaderboardScreen.js';
+import { renderFriendsScreen } from './screens/friendsScreen.js';
+import { renderMultiplayerScreen } from './screens/multiplayerScreen.js';
+import { renderLyricsScreen } from './screens/lyricsScreen.js';
+import { renderPublicProfileScreen } from './screens/publicProfileScreen.js';
+import { renderStoreScreen } from './screens/storeScreen.js';
+import { createGame, freeGame } from './game.js';
+import { getSettings } from './settings.js';
+import { generateWordStream, generateWeakKeyStream, wordCountForDuration } from './wordGenerator.js';
+import { getWeakChars } from './keyStats.js';
+
+const FALLBACK_TEXT = { category: 'general', content: 'The quick brown fox jumps over the lazy dog.', attribution: 'Traditional pangram' };
+
+function uniqueCategories(items) {
+ const set = new Set();
+ for (const t of items) if (t.category) set.add(t.category);
+ return Array.from(set).sort();
+}
+
+function pickRandom(arr) {
+ return arr[Math.floor(Math.random() * arr.length)];
+}
+
+function getRandomTextItem(items, category) {
+ const pool = category && category !== 'random' ? items.filter(t => t.category === category) : items;
+ return pool.length ? pickRandom(pool) : FALLBACK_TEXT;
+}
+
+export function startApp(root, localTexts) {
+ let allTexts = localTexts;
+ let categories = uniqueCategories(localTexts);
+ let selectedCategory = localStorage.getItem('typerpunk:last_mode') || 'time';
+ let customText = null; // { name, chunks: [{content, time}], language, timed }
+ let session = { type: 'random', customIndex: 0 };
+ let game = null;
+ let cleanupScreen = null;
+
+ const onlineUrl = window.TYPERPUNK_TEXTS_URL;
+ if (onlineUrl) {
+ fetch(onlineUrl, { cache: 'no-store' })
+ .then(res => (res.ok ? res.json() : null))
+ .then(data => {
+ if (Array.isArray(data)) {
+ allTexts = data;
+ categories = uniqueCategories(data);
+ }
+ })
+ .catch(() => {});
+ }
+
+ function teardown() {
+ if (cleanupScreen) {
+ cleanupScreen();
+ cleanupScreen = null;
+ }
+ }
+
+ // Which screen is showing, and whether leaving it costs the user
+ // something. Escape is a global "get me out of here", but walking out of a
+ // live race drops you from it for everyone else in the room too, so that
+ // one case asks first.
+ let currentScreenName = 'menu';
+ let escapeIsCostly = () => false;
+
+ function setScreen(name, costly = () => false) {
+ currentScreenName = name;
+ escapeIsCostly = costly;
+ }
+
+ function handleGlobalEscape(e) {
+ if (e.key !== 'Escape') return;
+ // Let an open popover close itself first - one Escape should not both
+ // dismiss a menu and navigate away from the screen behind it.
+ const openPopover = document.querySelector(
+ '.mode-popover:not([hidden]), .rail-settings-panel:not([hidden]), .lang-popover:not([hidden]), .theme-gallery-popover:not([hidden])');
+ if (openPopover) {
+ openPopover.hidden = true;
+ return;
+ }
+ if (currentScreenName === 'menu') return;
+ if (escapeIsCostly() && !window.confirm('Leave the race? Your opponents will see you drop out.')) return;
+ showMainMenu();
+ }
+ document.addEventListener('keydown', handleGlobalEscape);
+
+ function showMainMenu() {
+ teardown();
+ freeGame(game);
+ game = null;
+ setScreen('menu');
+ cleanupScreen = renderMainMenu(root, {
+ onStartGame: () => { session = { type: 'random', customIndex: 0 }; startGame(); },
+ categories,
+ selectedCategory,
+ onSelectCategory: cat => {
+ selectedCategory = cat;
+ try { localStorage.setItem('typerpunk:last_mode', cat); } catch {}
+ showMainMenu();
+ },
+ customText,
+ onLoadCustom: loaded => {
+ customText = loaded;
+ showMainMenu();
+ },
+ onClearCustom: () => {
+ customText = null;
+ showMainMenu();
+ },
+ onStartCustom: () => {
+ session = { type: 'custom', customIndex: 0 };
+ startGame();
+ },
+ onStartPassive: startPassive,
+ onShowStats: showStats,
+ onShowPlaceholder: showPlaceholder,
+ onShowAccount: showAccount,
+ onShowLeaderboard: showLeaderboard,
+ onShowFriends: showFriends,
+ onShowMultiplayer: showMultiplayer,
+ onShowStore: showStore,
+ onShowLyrics: showLyrics,
+ onSimulateTest: simulateTest,
+ });
+ }
+
+ // Dev-only shortcut: jumps straight to the end screen with synthetic
+ // results, no actual typing needed - for iterating on the end screen's
+ // design without retyping a full passage every time.
+ function simulateTest() {
+ teardown();
+ const text = 'The quick brown fox jumps over the lazy dog while typing very quickly today, right now, in this exact moment.';
+ const wpm = 70 + Math.random() * 90;
+ const accuracy = 85 + Math.random() * 15;
+ const time = 8 + Math.random() * 15;
+ const charTimings = [];
+ const keypressHistory = [];
+ for (let i = 0; i < text.length; i++) {
+ const t = (i / text.length) * time;
+ const isCorrect = Math.random() * 100 < accuracy;
+ charTimings.push({ time: t, isCorrect, index: i });
+ keypressHistory.push({ time: t, isCorrect, index: i });
+ }
+ const incorrectChars = charTimings.filter(c => !c.isCorrect).length;
+ const stats = {
+ wpm, rawWpm: wpm * 1.08, accuracy, time,
+ correctChars: text.length - incorrectChars, incorrectChars, totalChars: text.length,
+ currentStreak: 0, bestStreak: Math.floor(text.length / 3),
+ };
+ showEndScreen({ stats, text, userInput: text, charTimings, keypressHistory, modeKey: null });
+ }
+
+ function showStats() {
+ teardown();
+ setScreen('stats');
+ cleanupScreen = renderStatsScreen(root, { onBack: showMainMenu, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore });
+ }
+
+ function showPlaceholder(title, description) {
+ teardown();
+ setScreen('placeholder');
+ cleanupScreen = renderPlaceholderScreen(root, { title, description, onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore });
+ }
+
+ function showAccount() {
+ teardown();
+ setScreen('account');
+ cleanupScreen = renderAccountScreen(root, { onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore });
+ }
+
+ function showLeaderboard() {
+ teardown();
+ setScreen('leaderboard');
+ cleanupScreen = renderLeaderboardScreen(root, { onBack: showMainMenu, onShowPublicProfile: showPublicProfile, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore });
+ }
+
+ function showPublicProfile(username) {
+ teardown();
+ setScreen('profile');
+ cleanupScreen = renderPublicProfileScreen(root, { username, onBack: showLeaderboard, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore });
+ }
+
+ function showFriends() {
+ teardown();
+ setScreen('friends');
+ cleanupScreen = renderFriendsScreen(root, { onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowMultiplayer: showMultiplayer, onShowStore: showStore });
+ }
+
+ function showMultiplayer() {
+ teardown();
+ setScreen('multiplayer', () => !!document.querySelector('.mp-opponents'));
+ cleanupScreen = renderMultiplayerScreen(root, { onBack: showMainMenu, onFinish: showEndScreen, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowStore: showStore });
+ }
+
+ function showStore() {
+ teardown();
+ setScreen('store');
+ cleanupScreen = renderStoreScreen(root, { onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer });
+ }
+
+ function showLyrics() {
+ teardown();
+ setScreen('lyrics');
+ cleanupScreen = renderLyricsScreen(root, {
+ onBack: showMainMenu,
+ onLyricsReady: loaded => {
+ customText = loaded;
+ session = { type: 'custom', customIndex: 0 };
+ startGame();
+ },
+ onShowStats: showStats,
+ onShowPlaceholder: showPlaceholder,
+ onShowAccount: showAccount,
+ onShowLeaderboard: showLeaderboard,
+ onShowFriends: showFriends,
+ onShowMultiplayer: showMultiplayer,
+ onShowStore: showStore,
+ });
+ }
+
+ async function startGame() {
+ teardown();
+ let content;
+ let attribution;
+ let language;
+ let progress;
+ let timeLimit;
+ // Custom text has no stable, repeatable challenge to compare against,
+ // so it's excluded from personal-best tracking (stays undefined).
+ let modeKey;
+
+ if (session.type === 'custom' && customText) {
+ const chunk = customText.chunks[session.customIndex];
+ content = chunk.content;
+ attribution = customText.name;
+ language = customText.language;
+ progress = `${customText.name} · segment ${session.customIndex + 1}/${customText.chunks.length}`;
+ } else if (selectedCategory === 'words') {
+ const s = getSettings();
+ content = generateWordStream(s.wordCount || 25, { punctuation: s.wordsPunctuation, numbers: s.wordsNumbers, tier: s.wordListTier, language: s.language });
+ modeKey = `words-${s.wordCount || 25}`;
+ } else if (selectedCategory === 'time') {
+ const s = getSettings();
+ timeLimit = s.timeDuration || 30;
+ content = generateWordStream(wordCountForDuration(timeLimit), { punctuation: s.wordsPunctuation, numbers: s.wordsNumbers, tier: s.wordListTier, language: s.language });
+ modeKey = `time-${timeLimit}`;
+ } else if (selectedCategory === 'zen') {
+ // No timer, no fixed length - type until you choose to stop (the
+ // visible Finish button in the typing screen). No modeKey: like
+ // custom text, there's no fixed-length challenge to compare a
+ // "personal best" against since every session covers a different
+ // amount of text.
+ const s = getSettings();
+ content = generateWordStream(800, { punctuation: s.wordsPunctuation, numbers: s.wordsNumbers, tier: s.wordListTier, language: s.language });
+ } else if (selectedCategory === 'practice') {
+ // Adaptive, personal, and different every time by nature - no
+ // modeKey, same reasoning as Zen/Custom: there's no fixed
+ // challenge here to compare a "personal best" against.
+ const s = getSettings();
+ const weakChars = getWeakChars();
+ content = generateWeakKeyStream(wordCountForDuration(30), weakChars, { punctuation: s.wordsPunctuation, numbers: s.wordsNumbers });
+ } else {
+ const item = getRandomTextItem(allTexts, selectedCategory);
+ content = item.content;
+ attribution = item.attribution;
+ // Code snippets highlight themselves; prose packs have no language
+ // and fall through as plain text.
+ language = item.language || undefined;
+ modeKey = `quote-${selectedCategory || 'random'}`;
+ }
+
+ try {
+ game = await createGame();
+ game.set_text(content);
+ } catch (err) {
+ console.error('Failed to start game:', err);
+ showMainMenu();
+ return;
+ }
+ setScreen('typing');
+ cleanupScreen = renderTypingGame(root, {
+ game,
+ text: content,
+ attribution,
+ language,
+ progress,
+ timeLimit,
+ modeKey,
+ zenMode: selectedCategory === 'zen' && !(session.type === 'custom' && customText),
+ onFinish: showEndScreen,
+ onMainMenu: showMainMenu,
+ onRestart: startGame,
+ onShowStats: showStats,
+ onShowPlaceholder: showPlaceholder,
+ onShowAccount: showAccount,
+ onShowLeaderboard: showLeaderboard,
+ onShowFriends: showFriends,
+ onShowMultiplayer: showMultiplayer,
+ onShowStore: showStore,
+ });
+ }
+
+ function playAgain() {
+ if (session.type === 'custom' && customText) {
+ if (session.customIndex + 1 < customText.chunks.length) {
+ session.customIndex += 1;
+ } else {
+ showMainMenu();
+ return;
+ }
+ }
+ startGame();
+ }
+
+ function showEndScreen(result) {
+ teardown();
+ setScreen('end');
+ cleanupScreen = renderEndScreen(root, {
+ ...result,
+ onPlayAgain: playAgain,
+ onMainMenu: showMainMenu,
+ onShowStats: showStats,
+ onShowPlaceholder: showPlaceholder,
+ onShowAccount: showAccount,
+ onShowLeaderboard: showLeaderboard,
+ onShowFriends: showFriends,
+ onShowMultiplayer: showMultiplayer,
+ onShowStore: showStore,
+ });
+ }
+
+ function startPassive() {
+ teardown();
+ if (!customText) {
+ showMainMenu();
+ return;
+ }
+ setScreen('passive');
+ cleanupScreen = renderPassiveMode(root, {
+ name: customText.name,
+ chunks: customText.chunks,
+ onExit: showMainMenu,
+ });
+ }
+
+ const { skipMenu, favoriteMode } = getSettings();
+ const bootMode = favoriteMode || selectedCategory;
+ if (skipMenu && bootMode !== 'custom') {
+ selectedCategory = bootMode;
+ try { localStorage.setItem('typerpunk:last_mode', bootMode); } catch {}
+ session = { type: 'random', customIndex: 0 };
+ startGame();
+ } else {
+ showMainMenu();
+ }
+}
diff --git a/web/src/auth.js b/web/src/auth.js
new file mode 100644
index 0000000..d4913ca
--- /dev/null
+++ b/web/src/auth.js
@@ -0,0 +1,75 @@
+import { api, ApiError } from './api.js';
+import { mergeRemoteBests } from './pb.js';
+
+// Mirrors theme.js's pattern: one module-level value plus a subscriber set,
+// so every screen showing sign-in state (the corner rail, the main menu's
+// bottom-left widget, the Account screen) repaints from the same source
+// instead of each tracking its own copy.
+let currentUser = null;
+let resolved = false;
+const listeners = new Set();
+
+function setUser(user) {
+ currentUser = user;
+ resolved = true;
+ for (const fn of listeners) fn(currentUser);
+ // Reconciles local personal bests against the server's on every
+ // sign-in (including the session restored on page load, not just an
+ // explicit login) - a pure max(local, remote) merge, so running it
+ // every time is harmless and keeps a second device's progress visible
+ // here without a dedicated "sync now" action.
+ if (user) {
+ api.get('/api/stats/me').then(({ personal_bests }) => mergeRemoteBests(personal_bests)).catch(() => {});
+ }
+}
+
+// Fire-and-forget on module load: restores sign-in state from the session
+// cookie (if any) without the caller needing to await anything up front.
+const initial = api.get('/api/auth/me').then(user => setUser(user)).catch(() => setUser(null));
+
+export function getUser() {
+ return currentUser;
+}
+
+/** True once the initial /api/auth/me check has resolved either way. */
+export function isAuthResolved() {
+ return resolved;
+}
+
+export function onAuthChange(fn) {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+}
+
+export function whenAuthReady() {
+ return initial;
+}
+
+export async function register(username, password) {
+ const user = await api.post('/api/auth/register', { username, password });
+ setUser(user);
+ return user;
+}
+
+export async function login(username, password) {
+ const user = await api.post('/api/auth/login', { username, password });
+ setUser(user);
+ return user;
+}
+
+// Fire-and-forget: called after every finished test regardless of sign-in
+// state, so callers (endScreen.js) don't need their own "am I logged in"
+// branch. No-ops for guests; a failed upload (session expired mid-test,
+// network hiccup) is silently dropped rather than surfacing an error over a
+// result the player has already seen and moved past.
+export function submitTestResult(result) {
+ if (!currentUser) return;
+ api.post('/api/stats', result).catch(() => {});
+}
+
+export async function logout() {
+ try { await api.post('/api/auth/logout'); } catch { /* cookie may already be gone */ }
+ setUser(null);
+}
+
+export { ApiError };
diff --git a/web/src/chart.js b/web/src/chart.js
new file mode 100644
index 0000000..b1189eb
--- /dev/null
+++ b/web/src/chart.js
@@ -0,0 +1,420 @@
+export function buildGraphPoints(charTimings, stats, keypressHistory) {
+ // Prefer keypressHistory: it's an append-only log of every keystroke as
+ // it actually happened, so a mistake you later backspaced away still
+ // shows up as a real event at the time it occurred. charTimings is
+ // current-state-only - backspacing overwrites the slot for that
+ // position, so a corrected mistake leaves no trace by the end of the
+ // test, and raw silently collapses onto wpm for the whole graph.
+ let timings = (keypressHistory && keypressHistory.length > 0) ? keypressHistory : charTimings;
+ if (!timings || timings.length === 0) {
+ if (!(stats.time > 0 && stats.totalChars > 0)) return [];
+ const total = stats.correctChars + stats.incorrectChars;
+ timings = [];
+ for (let i = 0; i < total; i++) {
+ timings.push({ time: (i / total) * stats.time, isCorrect: i < stats.correctChars });
+ }
+ }
+
+ const maxTime = Math.max(timings[timings.length - 1].time, stats.time);
+ const seconds = Math.max(1, Math.ceil(maxTime));
+ // Cap the number of plotted buckets regardless of how long the test
+ // actually took wall-clock-wise (nothing stops a test from sitting open,
+ // unfinished, for hours) by widening the bucket instead of the point
+ // count. For any normal-length test (<= 300s) this divides out to 1s
+ // buckets exactly as before - pure safety net, no behavior change.
+ const MAX_BUCKETS = 300;
+ const bucketSeconds = Math.max(1, Math.ceil(seconds / MAX_BUCKETS));
+ const bucketCount = Math.ceil(seconds / bucketSeconds);
+
+ const buckets = Array.from({ length: bucketCount }, () => ({ total: 0, correct: 0 }));
+ for (const c of timings) {
+ const idx = Math.min(bucketCount - 1, Math.floor(c.time / bucketSeconds));
+ if (idx < 0) continue;
+ buckets[idx].total++;
+ if (c.isCorrect) buckets[idx].correct++;
+ }
+
+ // Each point is a sliding window over the last ~3 seconds, not just its
+ // own isolated bucket. A single 1-second bucket with zero keystrokes is
+ // completely normal human behavior (the brief pause between words), but
+ // read in isolation it computes as a hard 0 WPM - a sharp, unrealistic
+ // spike to the graph's floor that doesn't happen on MonkeyType/TypeRacer
+ // because they smooth over exactly this kind of momentary gap. A real
+ // multi-second stall still shows up as a real dip; it just isn't a
+ // razor to zero from one quiet second.
+ const minutesPerBucket = bucketSeconds / 60;
+ const windowBuckets = Math.max(1, Math.round(3 / bucketSeconds));
+ return buckets.map((_, i) => {
+ const start = Math.max(0, i - windowBuckets + 1);
+ let correctSum = 0;
+ let totalSum = 0;
+ for (let j = start; j <= i; j++) {
+ correctSum += buckets[j].correct;
+ totalSum += buckets[j].total;
+ }
+ const windowMinutes = (i - start + 1) * minutesPerBucket;
+ return {
+ time: (i + 1) * bucketSeconds,
+ wpm: (correctSum / 5) / windowMinutes,
+ raw: (totalSum / 5) / windowMinutes,
+ };
+ });
+}
+
+/// Consistency: how steady the WPM stayed across the test, expressed as a
+/// percentage (100 = perfectly even pace). Same coefficient-of-variation
+/// approach MonkeyType uses: lower spread relative to the average pace
+/// scores higher.
+export function calculateConsistency(graphPoints) {
+ const values = graphPoints.map(p => p.wpm).filter(v => v > 0);
+ if (values.length < 2) return 100;
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
+ if (mean === 0) return 100;
+ const variance = values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length;
+ const stddev = Math.sqrt(variance);
+ const coefficientOfVariation = stddev / mean;
+ return Math.max(0, Math.min(100, Math.round((1 - coefficientOfVariation) * 100)));
+}
+
+export function buildErrorPoints(graphPoints, keypressHistory, charTimings) {
+ const source = (keypressHistory && keypressHistory.length > 0) ? keypressHistory : charTimings;
+ if (!source || source.length === 0 || graphPoints.length === 0) return [];
+ // One marker per bucket carrying how many errors landed in it, rather
+ // than one marker per error sitting on top of the wpm line. Two mistakes
+ // in the same second used to draw two dots at the same coordinates, so
+ // the graph could not show that a moment was worse than another.
+ const byBucket = new Map();
+ for (const { time, isCorrect, index } of source) {
+ if (isCorrect) continue;
+ const closest = graphPoints.reduce((prev, curr) =>
+ Math.abs(curr.time - time) < Math.abs(prev.time - time) ? curr : prev, graphPoints[0]);
+ const entry = byBucket.get(closest.time);
+ if (entry) entry.count += 1;
+ else byBucket.set(closest.time, { x: closest.time, count: 1, index });
+ }
+ return [...byBucket.values()].sort((a, b) => a.x - b.x);
+}
+
+export function niceStep(maxValue, plotSize, pxPerLabel) {
+ const maxLabels = Math.max(2, Math.floor(plotSize / pxPerLabel));
+ const rawStep = (maxValue || 1) / maxLabels;
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
+ const residual = rawStep / magnitude;
+ const niceResidual = residual > 5 ? 10 : residual > 2 ? 5 : residual > 1 ? 2 : 1;
+ return Math.max(1, niceResidual * magnitude);
+}
+
+/// Shared plot geometry so hit-testing (hover) can map mouse coordinates the
+/// same way drawChart maps data coordinates.
+export function getPlotGeometry(canvas, graphPoints, xMax, errorPoints = []) {
+ const width = canvas.clientWidth;
+ const height = canvas.clientHeight;
+ // Room on both sides for a rotated axis title plus its tick labels.
+ const margin = { top: 14, right: 52, bottom: 42, left: 52 };
+ const plotW = Math.max(1, width - margin.left - margin.right);
+ const plotH = Math.max(1, height - margin.top - margin.bottom);
+ const maxWpm = Math.max(1, ...graphPoints.map(p => Math.max(p.wpm, p.raw)), 1);
+ const yStep = niceStep(maxWpm, plotH, 28);
+ const yMax = Math.max(yStep, Math.ceil(maxWpm / yStep) * yStep);
+
+ // Errors get their own scale. Plotted against the WPM axis they were
+ // pinned to whatever the line happened to be doing, which said nothing
+ // about how many errors there were - and on a fast test they all bunched
+ // near the top of the chart.
+ const maxErrors = Math.max(1, ...errorPoints.map(p => p.count || 1));
+ // Whole numbers only: half an error does not exist.
+ const errStep = Math.max(1, Math.ceil(maxErrors / 4));
+ // One step of headroom above the worst bucket, so the tallest marker never
+ // sits on the top edge of the plot or underneath the legend.
+ const errMax = Math.max(errStep, (Math.ceil(maxErrors / errStep) + 1) * errStep);
+
+ return {
+ xToPx: t => margin.left + (t / (xMax || 1)) * plotW,
+ yToPx: v => margin.top + plotH - (v / yMax) * plotH,
+ yErrToPx: v => margin.top + plotH - (v / errMax) * plotH,
+ margin, plotW, plotH, yMax, errMax, errStep,
+ };
+}
+
+// Canvas fillStyle/strokeStyle can't take a CSS var() directly, so the
+// chart's colors are read from the active theme's actual custom properties
+// instead of being hardcoded - otherwise every non-default theme (Dracula,
+// Nord, etc.) would show a graph in colors that don't match anything else
+// on the page.
+export function themeColors() {
+ const style = getComputedStyle(document.documentElement);
+ const get = (name, fallback) => style.getPropertyValue(name).trim() || fallback;
+ return {
+ primary: get('--primary-color', '#00ff9d'),
+ secondary: get('--secondary-color', '#00cc8f'),
+ error: get('--error-color', '#ff3b3b'),
+ sub: get('--sub-color', '#646669'),
+ };
+}
+
+export function drawChart(canvas, { graphPoints, errorPoints, xMax }) {
+ const dpr = window.devicePixelRatio || 1;
+ const width = canvas.clientWidth;
+ const height = canvas.clientHeight;
+ if (width === 0 || height === 0) return;
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+ const ctx = canvas.getContext('2d');
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+ if (graphPoints.length === 0) return;
+
+ const { xToPx, yToPx, yErrToPx, margin, yMax, errMax, errStep } = getPlotGeometry(canvas, graphPoints, xMax, errorPoints);
+ const yStep = niceStep(yMax, canvas.clientHeight - margin.top - margin.bottom, 28);
+ const colors = themeColors();
+
+ ctx.font = '11px "JetBrains Mono", monospace';
+ ctx.strokeStyle = 'rgba(100,102,105,0.15)';
+ ctx.fillStyle = colors.sub;
+ ctx.lineWidth = 1;
+ for (let v = 0; v <= yMax; v += yStep) {
+ const y = yToPx(v);
+ ctx.beginPath();
+ ctx.moveTo(margin.left, y);
+ ctx.lineTo(width - margin.right, y);
+ ctx.stroke();
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(String(Math.round(v)), margin.left - 6, y);
+ }
+
+ // Start at xStep, not 0 - the y-axis already prints its own "0" right
+ // at the origin corner, and a second "0" from the x-axis lands on top
+ // of it.
+ const xStep = niceStep(xMax, canvas.clientWidth - margin.left - margin.right, 40);
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ for (let t = xStep; t <= xMax; t += xStep) {
+ ctx.fillText(String(t), xToPx(t), height - margin.bottom + 6);
+ }
+
+ // Right-hand error axis. Its own scale, so a spike in mistakes is legible
+ // regardless of how fast the run was.
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ for (let v = 0; v <= errMax; v += errStep) {
+ ctx.fillText(String(v), width - margin.right + 6, yErrToPx(v));
+ }
+
+ // Axis titles. The x axis had none at all, so its numbers could have been
+ // seconds, words or buckets.
+ drawAxisTitle(ctx, 'words per minute', 12, margin.top + (height - margin.top - margin.bottom) / 2, -Math.PI / 2, colors.sub);
+ drawAxisTitle(ctx, 'errors', width - 10, margin.top + (height - margin.top - margin.bottom) / 2, Math.PI / 2, colors.sub);
+ drawAxisTitle(ctx, 'seconds', margin.left + (width - margin.left - margin.right) / 2, height - 6, 0, colors.sub);
+
+ const drawLine = (key, color, dashed) => {
+ ctx.beginPath();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = dashed ? 1.5 : 2;
+ ctx.setLineDash(dashed ? [6, 5] : []);
+ const pts = graphPoints.map(p => ({ x: xToPx(p.time), y: yToPx(p[key]) }));
+ strokeSmooth(ctx, pts);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ };
+ drawLine('raw', colors.secondary, true);
+ drawLine('wpm', colors.primary, false);
+
+ const baselineY = yErrToPx(0);
+ for (const p of errorPoints) {
+ const ex = xToPx(p.x);
+ const ey = yErrToPx(p.count || 1);
+ // Stem first, so the dot sits on top of it.
+ ctx.beginPath();
+ ctx.strokeStyle = colors.error;
+ ctx.globalAlpha = 0.45;
+ ctx.lineWidth = 1;
+ ctx.moveTo(ex, baselineY);
+ ctx.lineTo(ex, ey);
+ ctx.stroke();
+ ctx.globalAlpha = 1;
+ ctx.beginPath();
+ ctx.fillStyle = colors.error;
+ ctx.arc(ex, ey, 3, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ drawLegend(ctx, width, colors, margin);
+}
+
+// A rotated axis caption. Canvas has no text-orientation, so the context is
+// rotated around the label's own centre and restored immediately.
+function drawAxisTitle(ctx, text, x, y, angle, color) {
+ ctx.save();
+ ctx.translate(x, y);
+ ctx.rotate(angle);
+ ctx.fillStyle = color;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.font = '10px "JetBrains Mono", monospace';
+ ctx.fillText(text, 0, 0);
+ ctx.restore();
+ ctx.font = '11px "JetBrains Mono", monospace';
+}
+
+// Centripetal-ish Catmull-Rom through the points, emitted as bezier segments.
+// A raw polyline made every one-second bucket a hard corner, which read as
+// far noisier than the underlying pace actually was.
+function strokeSmooth(ctx, pts) {
+ if (pts.length === 0) return;
+ ctx.moveTo(pts[0].x, pts[0].y);
+ if (pts.length < 3) {
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
+ return;
+ }
+ for (let i = 0; i < pts.length - 1; i++) {
+ const p0 = pts[i - 1] || pts[i];
+ const p1 = pts[i];
+ const p2 = pts[i + 1];
+ const p3 = pts[i + 2] || p2;
+ // 1/6 keeps the curve close to the data; a larger factor overshoots
+ // into visible bulges around sharp changes of pace.
+ ctx.bezierCurveTo(
+ p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6,
+ p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6,
+ p2.x, p2.y,
+ );
+ }
+}
+
+function drawLegend(ctx, width, colors, margin) {
+ const entries = [
+ { label: 'wpm', color: colors.primary, dashed: false },
+ { label: 'raw', color: colors.secondary, dashed: true },
+ { label: 'error', color: colors.error, dot: true },
+ ];
+ ctx.font = '11px "JetBrains Mono", monospace';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ const swatchW = 16;
+ const gap = 6;
+ const groupGap = 18;
+ // Left-aligned inside the plot rather than flush to the right edge, where
+ // it sat on top of the error axis's tick labels.
+ let x = margin.left + 8;
+ const y = 8;
+ for (const e of entries) {
+ if (e.dot) {
+ ctx.fillStyle = e.color;
+ ctx.beginPath();
+ ctx.arc(x + swatchW / 2, y, 3, 0, Math.PI * 2);
+ ctx.fill();
+ } else {
+ ctx.strokeStyle = e.color;
+ ctx.lineWidth = e.dashed ? 1.5 : 2;
+ ctx.setLineDash(e.dashed ? [4, 3] : []);
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x + swatchW, y);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ ctx.fillStyle = colors.sub;
+ ctx.fillText(e.label, x + swatchW + gap, y);
+ x += swatchW + gap + ctx.measureText(e.label).width + groupGap;
+ }
+}
+
+// WPM across recent completed tests (x-axis is test order, not time within a
+// single test) - a different shape from drawChart above, which plots one
+// test's speed over its own elapsed seconds. Kept separate rather than
+// bent to fit drawChart's per-test contract (its legend assumes raw/error
+// series that don't exist here).
+export function drawProgressChart(canvas, history) {
+ const dpr = window.devicePixelRatio || 1;
+ const width = canvas.clientWidth;
+ const height = canvas.clientHeight;
+ if (width === 0 || height === 0) return;
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+ const ctx = canvas.getContext('2d');
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+ if (history.length === 0) return;
+
+ const margin = { top: 12, right: 16, bottom: 22, left: 36 };
+ const plotW = Math.max(1, width - margin.left - margin.right);
+ const plotH = Math.max(1, height - margin.top - margin.bottom);
+ const maxWpm = Math.max(1, ...history.map(h => h.wpm));
+ const yStep = niceStep(maxWpm, plotH, 28);
+ const yMax = Math.max(yStep, Math.ceil(maxWpm / yStep) * yStep);
+ const xMax = Math.max(1, history.length - 1);
+ const xToPx = i => margin.left + (i / xMax) * plotW;
+ const yToPx = v => margin.top + plotH - (v / yMax) * plotH;
+
+ const colors = themeColors();
+ ctx.font = '11px "JetBrains Mono", monospace';
+ ctx.strokeStyle = 'rgba(100,102,105,0.15)';
+ ctx.fillStyle = colors.sub;
+ ctx.lineWidth = 1;
+ for (let v = 0; v <= yMax; v += yStep) {
+ const y = yToPx(v);
+ ctx.beginPath();
+ ctx.moveTo(margin.left, y);
+ ctx.lineTo(width - margin.right, y);
+ ctx.stroke();
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(String(Math.round(v)), margin.left - 6, y);
+ }
+
+ ctx.beginPath();
+ ctx.strokeStyle = colors.primary;
+ ctx.lineWidth = 2;
+ history.forEach((h, i) => {
+ const x = xToPx(i);
+ const y = yToPx(h.wpm);
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ });
+ ctx.stroke();
+
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = colors.sub;
+ ctx.fillText('oldest', margin.left, height - margin.bottom + 6);
+ ctx.textAlign = 'right';
+ ctx.fillText('latest', width - margin.right, height - margin.bottom + 6);
+}
+
+/// Returns the graph point nearest to mouseX (by time), for a "hover
+/// anywhere on the line" readout - not just the error dots.
+export function hitTestLine(canvas, graphPoints, xMax, mouseX) {
+ if (graphPoints.length === 0) return null;
+ const { xToPx } = getPlotGeometry(canvas, graphPoints, xMax);
+ let closest = graphPoints[0];
+ let closestDist = Math.abs(xToPx(closest.time) - mouseX);
+ for (const p of graphPoints) {
+ const dist = Math.abs(xToPx(p.time) - mouseX);
+ if (dist < closestDist) {
+ closestDist = dist;
+ closest = p;
+ }
+ }
+ return closest;
+}
+
+/// Returns the closest error point to (mouseX, mouseY) in canvas-local
+/// pixels, if within `radius` px, else null. Used to make the error dots
+/// respond to hover instead of being purely decorative.
+export function hitTestError(canvas, errorPoints, graphPoints, xMax, mouseX, mouseY, radius = 8) {
+ if (errorPoints.length === 0) return null;
+ const { xToPx, yErrToPx } = getPlotGeometry(canvas, graphPoints, xMax, errorPoints);
+ let closest = null;
+ let closestDist = radius;
+ for (const p of errorPoints) {
+ const px = xToPx(p.x);
+ const py = yErrToPx(p.count || 1);
+ const dist = Math.hypot(px - mouseX, py - mouseY);
+ if (dist <= closestDist) {
+ closestDist = dist;
+ closest = p;
+ }
+ }
+ return closest;
+}
diff --git a/web/src/cornerRail.js b/web/src/cornerRail.js
new file mode 100644
index 0000000..6859a3b
--- /dev/null
+++ b/web/src/cornerRail.js
@@ -0,0 +1,125 @@
+import { STATS_ICON, SETTINGS_ICON, MULTIPLAYER_ICON, LEADERBOARD_ICON, STORE_ICON } from './screens/icons.js';
+import { attachTooltips } from './tooltip.js';
+import { getSettings, updateSettings } from './settings.js';
+import { onCountsChange, getCounts } from './counts.js';
+
+const SOUND_THEMES = ['off', 'click', 'mech'];
+function soundThemeLabel(theme) {
+ if (theme === 'click') return 'Click';
+ if (theme === 'mech') return 'Mechanical';
+ return 'Off';
+}
+
+// The icon rails, in the same places on every screen - the main menu builds
+// the identical clusters inline (see mainMenu.js) because it owns its own
+// Settings panel, so the two must stay visually in step.
+//
+// Top-left, under the wordmark: what customises the app (Settings, Store).
+// Bottom-right: numbers and play (Stats, Leaderboard, Multiplayer). Identity
+// - Account and Friends - lives in the top-right rail beside the language
+// and theme controls (see topRail.js), and the bottom-left corner is
+// deliberately left empty so the passage and its stats have room.
+//
+// Multiplayer carries a live count, so you can see whether anyone is around
+// to race without opening the screen.
+export function renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowLeaderboard, onShowMultiplayer, onShowStore }) {
+ const settings = getSettings();
+ const left = document.createElement('div');
+ left.className = 'corner-rail-left';
+ left.innerHTML = `
+ <div class="rail-settings-group">
+ <button class="corner-icon-button" data-action="rail-settings" aria-label="Settings" data-tooltip="Settings">${SETTINGS_ICON}</button>
+ <div class="rail-settings-panel" hidden>
+ <div class="settings-hint">Typing</div>
+ <div class="settings-row-group">
+ <button class="menu-button small" data-action="rail-toggle-live-stats" data-tooltip="Show WPM/ACC while you type, or only reveal them on the end screen.">Live Stats: ${settings.hideLiveStats ? 'Off' : 'On'}</button>
+ <button class="menu-button small" data-action="rail-toggle-caret-blink" data-tooltip="Make the current-character caret blink, or keep it solid.">Blink Caret: ${settings.caretBlink ? 'On' : 'Off'}</button>
+ <button class="menu-button small" data-action="rail-toggle-blind-mode" data-tooltip="Hide correct/incorrect coloring while typing - only revealed on the end screen.">Blind Mode: ${settings.blindMode ? 'On' : 'Off'}</button>
+ <button class="menu-button small" data-action="rail-cycle-sound-theme" data-tooltip="Play a sound on each keystroke. Click to cycle.">Sound: ${soundThemeLabel(settings.soundTheme)}</button>
+ </div>
+ </div>
+ </div>
+
+ ${onShowStore ? `<button class="corner-icon-button" data-action="rail-store" aria-label="Store" data-tooltip="Store">${STORE_ICON}</button>` : ''}
+ `;
+
+ const right = document.createElement('div');
+ right.className = 'corner-rail-right';
+ right.innerHTML = `
+ ${onShowStats ? `<button class="corner-icon-button" data-action="rail-stats" aria-label="Stats" data-tooltip="Stats">${STATS_ICON}</button>` : ''}
+ ${onShowLeaderboard ? `<button class="corner-icon-button" data-action="rail-leaderboard" aria-label="Leaderboard" data-tooltip="Leaderboard">${LEADERBOARD_ICON}</button>` : ''}
+ ${onShowMultiplayer ? `<button class="corner-icon-button has-badge" data-action="rail-multiplayer" aria-label="Multiplayer" data-tooltip="Multiplayer">${MULTIPLAYER_ICON}<span class="rail-badge accent" data-badge="online" hidden></span></button>` : ''}
+ `;
+
+ root.appendChild(left);
+ root.appendChild(right);
+ attachTooltips(left);
+ attachTooltips(right);
+
+ const statsBtn = right.querySelector('[data-action="rail-stats"]');
+ if (statsBtn) statsBtn.addEventListener('click', onShowStats);
+
+ const settingsGroup = left.querySelector('.rail-settings-group');
+ const settingsPanel = left.querySelector('.rail-settings-panel');
+ left.querySelector('[data-action="rail-settings"]').addEventListener('click', e => {
+ e.stopPropagation();
+ settingsPanel.hidden = !settingsPanel.hidden;
+ });
+ const liveStatsBtn = left.querySelector('[data-action="rail-toggle-live-stats"]');
+ liveStatsBtn.addEventListener('click', () => {
+ const next = !getSettings().hideLiveStats;
+ updateSettings({ hideLiveStats: next });
+ liveStatsBtn.textContent = `Live Stats: ${next ? 'Off' : 'On'}`;
+ });
+ const caretBlinkBtn = left.querySelector('[data-action="rail-toggle-caret-blink"]');
+ caretBlinkBtn.addEventListener('click', () => {
+ const next = !getSettings().caretBlink;
+ updateSettings({ caretBlink: next });
+ caretBlinkBtn.textContent = `Blink Caret: ${next ? 'On' : 'Off'}`;
+ });
+ const blindModeBtn = left.querySelector('[data-action="rail-toggle-blind-mode"]');
+ blindModeBtn.addEventListener('click', () => {
+ const next = !getSettings().blindMode;
+ updateSettings({ blindMode: next });
+ blindModeBtn.textContent = `Blind Mode: ${next ? 'On' : 'Off'}`;
+ });
+ const soundThemeBtn = left.querySelector('[data-action="rail-cycle-sound-theme"]');
+ soundThemeBtn.addEventListener('click', () => {
+ const current = getSettings().soundTheme;
+ const next = SOUND_THEMES[(SOUND_THEMES.indexOf(current) + 1) % SOUND_THEMES.length];
+ updateSettings({ soundTheme: next });
+ soundThemeBtn.textContent = `Sound: ${soundThemeLabel(next)}`;
+ });
+
+ const multiplayerBtn = right.querySelector('[data-action="rail-multiplayer"]');
+ if (multiplayerBtn) multiplayerBtn.addEventListener('click', onShowMultiplayer);
+ const leaderboardBtn = right.querySelector('[data-action="rail-leaderboard"]');
+ if (leaderboardBtn) leaderboardBtn.addEventListener('click', onShowLeaderboard);
+ const storeBtn = left.querySelector('[data-action="rail-store"]');
+ if (storeBtn) storeBtn.addEventListener('click', onShowStore);
+
+ // Badges -----------------------------------------------------------
+ const onlineBadge = right.querySelector('[data-badge="online"]');
+ function paintBadges({ online }) {
+ if (onlineBadge) {
+ onlineBadge.hidden = !online;
+ onlineBadge.textContent = String(online);
+ const btn = onlineBadge.closest('.corner-icon-button');
+ if (btn) btn.dataset.tooltip = online === 1 ? '1 player racing now' : `${online} players racing now`;
+ }
+ }
+ paintBadges(getCounts());
+ const unsubscribeCounts = onCountsChange(paintBadges);
+
+ const handleOutsideClick = e => {
+ if (!settingsPanel.hidden && !settingsGroup.contains(e.target)) settingsPanel.hidden = true;
+ };
+ document.addEventListener('click', handleOutsideClick);
+
+ return () => {
+ unsubscribeCounts();
+ document.removeEventListener('click', handleOutsideClick);
+ left.remove();
+ right.remove();
+ };
+}
diff --git a/web/src/counts.js b/web/src/counts.js
new file mode 100644
index 0000000..1b98bbe
--- /dev/null
+++ b/web/src/counts.js
@@ -0,0 +1,61 @@
+import { api } from './api.js';
+import { getOnlineCount } from './multiplayer.js';
+import { getUser, onAuthChange } from './auth.js';
+
+// Live counts for the badges on the Friends and Multiplayer controls: how
+// many people you can race right now, and how many friends you have.
+//
+// Shared and cached rather than fetched per control. The rail is rebuilt on
+// every screen change, and without this each navigation fired a fresh pair of
+// requests - and the main menu's Multiplayer button would have fired its own
+// on top of that, asking the server the same question twice per screen.
+const REFRESH_MS = 30000;
+
+let cache = { online: 0, friends: 0 };
+const listeners = new Set();
+let timer = null;
+let inFlight = false;
+
+async function refresh() {
+ if (inFlight) return;
+ inFlight = true;
+ try {
+ const online = await getOnlineCount().then(r => r.players).catch(() => cache.online);
+ // Friends only exist for a signed-in user; asking while signed out is
+ // a guaranteed 401, so skip it rather than log noise on every poll.
+ const friends = getUser()
+ ? await api.get('/api/friends').then(r => (r.friends || []).length).catch(() => cache.friends)
+ : 0;
+ cache = { online, friends };
+ for (const fn of listeners) fn(cache);
+ } finally {
+ inFlight = false;
+ }
+}
+
+export function getCounts() {
+ return cache;
+}
+
+// Returns an unsubscribe function. Polling runs only while something is
+// listening, so a screen with no badges costs nothing.
+export function onCountsChange(fn) {
+ listeners.add(fn);
+ if (!timer) {
+ refresh();
+ timer = setInterval(refresh, REFRESH_MS);
+ }
+ return () => {
+ listeners.delete(fn);
+ if (listeners.size === 0 && timer) {
+ clearInterval(timer);
+ timer = null;
+ }
+ };
+}
+
+// Signing in or out changes the friend count immediately - waiting up to the
+// full poll interval would leave a stale badge behind.
+onAuthChange(() => {
+ if (listeners.size > 0) refresh();
+});
diff --git a/web/src/customText.js b/web/src/customText.js
new file mode 100644
index 0000000..dc74541
--- /dev/null
+++ b/web/src/customText.js
@@ -0,0 +1,155 @@
+const LANGUAGE_BY_EXT = {
+ js: 'javascript', jsx: 'javascript', mjs: 'javascript', ts: 'javascript', tsx: 'javascript',
+ py: 'python',
+ rs: 'rust',
+ c: 'clike', h: 'clike', cpp: 'clike', hpp: 'clike', cc: 'clike', java: 'clike', go: 'clike', cs: 'clike',
+ sh: 'shell', bash: 'shell', zsh: 'shell',
+};
+
+const KEYWORDS = {
+ javascript: new Set(['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'class', 'import', 'export', 'from', 'new', 'this', 'async', 'await', 'try', 'catch', 'switch', 'case', 'break', 'continue', 'typeof', 'null', 'undefined', 'true', 'false']),
+ python: new Set(['def', 'return', 'if', 'elif', 'else', 'for', 'while', 'class', 'import', 'from', 'as', 'try', 'except', 'with', 'lambda', 'None', 'True', 'False', 'pass', 'break', 'continue', 'yield', 'self']),
+ rust: new Set(['fn', 'let', 'mut', 'return', 'if', 'else', 'for', 'while', 'loop', 'match', 'struct', 'enum', 'impl', 'trait', 'pub', 'use', 'mod', 'self', 'Self', 'true', 'false', 'const', 'static']),
+ clike: new Set(['int', 'float', 'double', 'char', 'void', 'if', 'else', 'for', 'while', 'return', 'struct', 'class', 'public', 'private', 'static', 'const', 'new', 'true', 'false', 'null']),
+ shell: new Set(['if', 'then', 'else', 'fi', 'for', 'do', 'done', 'while', 'function', 'echo', 'export', 'local']),
+};
+
+const LINE_COMMENT = { javascript: '//', rust: '//', clike: '//', shell: '#', python: '#' };
+
+export function languageForFilename(filename) {
+ const ext = (filename || '').split('.').pop().toLowerCase();
+ return LANGUAGE_BY_EXT[ext] || null;
+}
+
+export function highlightClasses(text, language) {
+ const classes = new Array(text.length).fill(null);
+ if (!language || !KEYWORDS[language]) return classes;
+ const keywords = KEYWORDS[language];
+ const commentMarker = LINE_COMMENT[language];
+ const tokenRegex = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|\/\/[^\n]*|#[^\n]*|\b\d+(?:\.\d+)?\b|[A-Za-z_][A-Za-z0-9_]*)/g;
+ let match;
+ while ((match = tokenRegex.exec(text)) !== null) {
+ const token = match[0];
+ const start = match.index;
+ let cls = null;
+ if (token[0] === '"' || token[0] === "'" || token[0] === '`') {
+ cls = 'syn-string';
+ } else if ((token.startsWith('//') || token.startsWith('#')) && token.startsWith(commentMarker || '\0')) {
+ cls = 'syn-comment';
+ } else if (/^\d/.test(token)) {
+ cls = 'syn-number';
+ } else if (keywords.has(token)) {
+ cls = 'syn-keyword';
+ }
+ if (cls) {
+ for (let i = 0; i < token.length; i++) classes[start + i] = cls;
+ }
+ }
+ return classes;
+}
+
+function splitLong(str, maxLen = 400) {
+ if (str.length <= maxLen) return [str];
+ const parts = [];
+ let start = 0;
+ while (start < str.length) {
+ const end = Math.min(start + maxLen, str.length);
+ const slice = str.slice(start, end);
+ const lastNewline = slice.lastIndexOf('\n');
+ const lastSpace = slice.lastIndexOf(' ');
+ const minCut = Math.floor(maxLen * 0.4);
+ const cut = lastNewline > minCut ? lastNewline + 1 : (lastSpace > minCut ? lastSpace + 1 : slice.length);
+ parts.push(str.slice(start, start + cut));
+ start += cut;
+ }
+ return parts;
+}
+
+export function chunkPlainText(raw) {
+ const normalized = raw.replace(/\r\n/g, '\n').trim();
+ if (!normalized) return [];
+ const paragraphs = normalized.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
+ const source = paragraphs.length > 1 ? paragraphs : normalized.split('\n').map(l => l.trim()).filter(Boolean);
+ const chunks = [];
+ for (const block of source) {
+ for (const piece of splitLong(block)) {
+ const trimmed = piece.trim();
+ if (trimmed) chunks.push({ content: trimmed, time: null });
+ }
+ }
+ return chunks;
+}
+
+function timeToSeconds(str) {
+ // "00:00:01,000" / "00:00:01.000" (h:mm:ss) or "00:12.34" (mm:ss, lrc-style)
+ const parts = str.replace(',', '.').split(':').map(Number);
+ if (parts.some(Number.isNaN)) return null;
+ if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
+ if (parts.length === 2) return parts[0] * 60 + parts[1];
+ return parts[0] ?? null;
+}
+
+function parseSrt(raw) {
+ const blocks = raw.replace(/\r\n/g, '\n').trim().split(/\n\s*\n/);
+ const chunks = [];
+ for (const block of blocks) {
+ const lines = block.split('\n').filter(Boolean);
+ let i = 0;
+ if (/^\d+$/.test((lines[i] || '').trim())) i += 1;
+ let time = null;
+ const timing = lines[i] || '';
+ const startMatch = timing.match(/(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->/);
+ if (startMatch) {
+ time = timeToSeconds(startMatch[1]);
+ i += 1;
+ }
+ const text = lines.slice(i).join(' ').replace(/<[^>]+>/g, '').trim();
+ if (text) chunks.push({ content: text, time });
+ }
+ return chunks;
+}
+
+function parseVtt(raw) {
+ const body = raw.replace(/\r\n/g, '\n').replace(/^WEBVTT[^\n]*\n/, '').trim();
+ const blocks = body.split(/\n\s*\n/);
+ const chunks = [];
+ for (const block of blocks) {
+ const lines = block.split('\n').filter(Boolean);
+ let i = 0;
+ if (lines[i] && !/-->/.test(lines[i])) i += 1; // optional cue identifier
+ let time = null;
+ const timing = lines[i] || '';
+ const startMatch = timing.match(/(\d{2}:)?(\d{2}:\d{2}[.,]\d{3})\s*-->/);
+ if (startMatch) {
+ time = timeToSeconds((startMatch[1] || '') + startMatch[2]);
+ i += 1;
+ }
+ const text = lines.slice(i).join(' ').replace(/<[^>]+>/g, '').trim();
+ if (text) chunks.push({ content: text, time });
+ }
+ return chunks;
+}
+
+function parseLrc(raw) {
+ const lines = raw.replace(/\r\n/g, '\n').split('\n');
+ const chunks = [];
+ for (const line of lines) {
+ const match = line.match(/^((?:\[\d{2}:\d{2}(?:\.\d{2,3})?\])+)(.*)$/);
+ if (!match) continue;
+ const text = match[2].trim();
+ if (!text) continue;
+ const firstStamp = match[1].match(/\[(\d{2}:\d{2}(?:\.\d{2,3})?)\]/);
+ const time = firstStamp ? timeToSeconds(firstStamp[1]) : null;
+ chunks.push({ content: text, time });
+ }
+ return chunks;
+}
+
+export function parseCustomContent(raw, filename) {
+ const ext = (filename || '').split('.').pop().toLowerCase();
+ if (ext === 'srt') return { chunks: parseSrt(raw), language: null, timed: true };
+ if (ext === 'vtt') return { chunks: parseVtt(raw), language: null, timed: true };
+ if (ext === 'lrc') return { chunks: parseLrc(raw), language: null, timed: true };
+ const language = languageForFilename(filename);
+ return { chunks: chunkPlainText(raw), language, timed: false };
+}
diff --git a/web/src/data/texts.json b/web/src/data/texts.json
new file mode 100644
index 0000000..0aef950
--- /dev/null
+++ b/web/src/data/texts.json
@@ -0,0 +1,978 @@
+[
+ {
+ "category": "history",
+ "content": "The Cold War divided much of the world into two opposing blocs for nearly half a century, one aligned with the United States and the other with the Soviet Union.",
+ "attribution": "History"
+ },
+ {
+ "category": "philosophy",
+ "content": "Existentialism explores questions about individual freedom, choice, and meaning, arguing that people must create their own purpose in a universe that does not provide one automatically.",
+ "attribution": "Philosophy"
+ },
+ {
+ "category": "business",
+ "content": "The man who dies rich, dies disgraced.",
+ "attribution": "Andrew Carnegie"
+ },
+ {
+ "category": "movies",
+ "content": "Stop motion animation creates the illusion of movement by photographing physical objects one frame at a time and repositioning them slightly between each shot.",
+ "attribution": "Film"
+ },
+ {
+ "category": "technology",
+ "content": "A blockchain is a distributed ledger that records transactions across many computers, making it difficult to alter any single record without changing every subsequent block.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "nature",
+ "content": "Bees pollinate roughly a third of the crops humans rely on for food, making them essential to global agriculture despite their small size.",
+ "attribution": "Nature"
+ },
+ {
+ "category": "business",
+ "content": "A balance sheet summarizes what a company owns and owes at a single point in time, split into assets, liabilities, and shareholders' equity.",
+ "attribution": "Business"
+ },
+ {
+ "category": "history",
+ "content": "The Industrial Revolution transformed economies across Europe and North America during the eighteenth and nineteenth centuries, shifting production from hand tools to machines.",
+ "attribution": "History"
+ },
+ {
+ "category": "literature",
+ "content": "In my younger and more vulnerable years my father gave me some advice that I've been turning over in my mind ever since. Whenever you feel like criticizing anyone, he told me, just remember that all the people in this world haven't had the advantages that you've had.",
+ "attribution": "F. Scott Fitzgerald, The Great Gatsby"
+ },
+ {
+ "category": "movies",
+ "content": "Method acting, popularized in the mid twentieth century, encourages performers to draw on personal memories and emotions to portray a character as realistically as possible.",
+ "attribution": "Film"
+ },
+ {
+ "category": "quotes",
+ "content": "Whatever you are, be a good one.",
+ "attribution": "Abraham Lincoln"
+ },
+ {
+ "category": "general",
+ "content": "A day on Venus is longer than its year. The planet rotates so slowly on its axis that it takes longer to spin around once than it does to complete an entire orbit around the sun.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "quotes",
+ "content": "If there is no struggle, there is no progress. Those who profess to favor freedom, and yet depreciate agitation, are men who want crops without plowing up the ground.",
+ "attribution": "Frederick Douglass"
+ },
+ {
+ "category": "programming",
+ "content": "def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)",
+ "attribution": "Python",
+ "language": "python"
+ },
+ {
+ "category": "general",
+ "content": "The Great Wall of China was not built all at once. It was constructed and extended over many centuries by different dynasties, each adding new sections and rebuilding older ones to defend against invasions from the north.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "programming",
+ "content": "struct node { int value; struct node *next; };",
+ "attribution": "C",
+ "language": "clike"
+ },
+ {
+ "category": "general",
+ "content": "The Pacific Ocean covers more of the planet than all the continents combined. It stretches from the icy waters near Antarctica to the Arctic Circle, and its deepest point, the Mariana Trench, plunges nearly eleven kilometers below the surface.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "science",
+ "content": "Marie Curie remains the only person to win Nobel Prizes in two different sciences, physics and chemistry, for her pioneering research into radioactivity.",
+ "attribution": "Science"
+ },
+ {
+ "category": "literature",
+ "content": "Call me Ishmael. Some years ago, never mind how long precisely, having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.",
+ "attribution": "Herman Melville, Moby-Dick"
+ },
+ {
+ "category": "technology",
+ "content": "A virtual private network encrypts a device's internet connection, helping to protect data from being intercepted while it travels over a public or untrusted network.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "philosophy",
+ "content": "It is not that we have a short time to live, but that we waste a lot of it.",
+ "attribution": "Seneca, On the Shortness of Life"
+ },
+ {
+ "category": "nature",
+ "content": "Deserts are defined by low precipitation rather than temperature, which is why some of the coldest places on Earth, including parts of Antarctica, are technically deserts.",
+ "attribution": "Nature"
+ },
+ {
+ "category": "science",
+ "content": "Sound travels as a wave of pressure through a medium such as air or water, and it cannot travel through the vacuum of space, since there are no particles to carry the vibration.",
+ "attribution": "Science"
+ },
+ {
+ "category": "nature",
+ "content": "The Amazon rainforest is home to an estimated ten percent of all known species on Earth, making it one of the most biodiverse regions on the planet.",
+ "attribution": "Nature"
+ },
+ {
+ "category": "programming",
+ "content": "with open('data.csv', encoding='utf-8') as f: rows = [line.split(',') for line in f]",
+ "attribution": "Python",
+ "language": "python"
+ },
+ {
+ "category": "quotes",
+ "content": "The only limit to our realization of tomorrow is our doubts of today.",
+ "attribution": "Franklin D. Roosevelt"
+ },
+ {
+ "category": "quotes",
+ "content": "Genius is one percent inspiration and ninety-nine percent perspiration.",
+ "attribution": "Thomas Edison"
+ },
+ {
+ "category": "technology",
+ "content": "Cloud computing allows businesses and individuals to rent computing power and storage over the internet instead of maintaining their own physical servers.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "movies",
+ "content": "Silent films relied on live musicians and title cards to convey dialogue and emotion, since synchronized sound was not introduced into feature films until the late 1920s.",
+ "attribution": "Film"
+ },
+ {
+ "category": "literature",
+ "content": "It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness.",
+ "attribution": "Charles Dickens, A Tale of Two Cities"
+ },
+ {
+ "category": "quotes",
+ "content": "It always seems impossible until it's done.",
+ "attribution": "Nelson Mandela"
+ },
+ {
+ "category": "general",
+ "content": "Bamboo is one of the fastest growing plants on Earth. Some species can grow nearly a meter in a single day under the right conditions, which is why it is used so widely in construction and manufacturing across Asia.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "programming",
+ "content": "const unique = [...new Set(values)].sort((a, b) => a - b);",
+ "attribution": "JavaScript",
+ "language": "javascript"
+ },
+ {
+ "category": "nature",
+ "content": "The Sahara Desert was once a green landscape with lakes and grasslands, before a shift in the Earth's orbit altered rainfall patterns thousands of years ago.",
+ "attribution": "Nature"
+ },
+ {
+ "category": "movies",
+ "content": "Alfred Hitchcock was known for making brief cameo appearances in nearly all of his own films, a habit that became something of a signature.",
+ "attribution": "Film"
+ },
+ {
+ "category": "general",
+ "content": "The invention of the printing press in the fifteenth century transformed how knowledge spread across Europe. Books that once took months to copy by hand could suddenly be reproduced by the hundreds, making literacy far more accessible.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "science",
+ "content": "Genetics research has shown that humans share about ninety-nine percent of their DNA with chimpanzees, the closest living relative to our species.",
+ "attribution": "Science"
+ },
+ {
+ "category": "programming",
+ "content": "if err != nil { return fmt.Errorf(\"read config: %w\", err) }",
+ "attribution": "Go",
+ "language": "clike"
+ },
+ {
+ "category": "history",
+ "content": "The fall of the Berlin Wall in 1989 marked a turning point in the Cold War, as East and West Germany moved toward reunification the following year.",
+ "attribution": "History"
+ },
+ {
+ "category": "business",
+ "content": "The purpose of a business is to create and keep a customer.",
+ "attribution": "Peter Drucker"
+ },
+ {
+ "category": "technology",
+ "content": "Open source software is released with its source code publicly available, allowing anyone to view, modify, and distribute it under the terms of its license.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "business",
+ "content": "A startup's burn rate measures how quickly it spends its available cash, an important number for founders trying to plan how long the company can operate before running out of money.",
+ "attribution": "Business"
+ },
+ {
+ "category": "philosophy",
+ "content": "I think, therefore I am.",
+ "attribution": "Rene Descartes, Discourse on the Method"
+ },
+ {
+ "category": "programming",
+ "content": "export PATH=\"$HOME/.local/bin:$PATH\" && command -v rg >/dev/null || echo missing",
+ "attribution": "Shell",
+ "language": "shell"
+ },
+ {
+ "category": "programming",
+ "content": "if (typeof value !== 'string' || value.length === 0) { return null; }",
+ "attribution": "JavaScript",
+ "language": "javascript"
+ },
+ {
+ "category": "philosophy",
+ "content": "Every new beginning comes from some other beginning's end.",
+ "attribution": "Seneca, Letters from a Stoic"
+ },
+ {
+ "category": "history",
+ "content": "Ancient Egyptian civilization flourished along the Nile River for thousands of years, leaving behind monuments such as the pyramids of Giza and the temples of Luxor.",
+ "attribution": "History"
+ },
+ {
+ "category": "literature",
+ "content": "To Sherlock Holmes she is always the woman. I have seldom heard him mention her under any other name. In his eyes she eclipses and predominates the whole of her sex.",
+ "attribution": "Arthur Conan Doyle, A Scandal in Bohemia"
+ },
+ {
+ "category": "literature",
+ "content": "My father's family name being Pirrip, and my Christian name Philip, my infant tongue could make of both names nothing longer or more explicit than Pip. So, I called myself Pip, and came to be called Pip.",
+ "attribution": "Charles Dickens, Great Expectations"
+ },
+ {
+ "category": "literature",
+ "content": "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife. However little known the feelings or views of such a man may be on his first entering a neighbourhood, this truth is so well fixed in the minds of the surrounding families.",
+ "attribution": "Jane Austen, Pride and Prejudice"
+ },
+ {
+ "category": "technology",
+ "content": "The World Wide Web was invented by Tim Berners-Lee in 1989 as a way to share documents over the internet using hyperlinks.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "movies",
+ "content": "The highest grossing films of all time are dominated by franchises built around superheroes, fantasy worlds, and long running science fiction universes.",
+ "attribution": "Film"
+ },
+ {
+ "category": "programming",
+ "content": "match result { Ok(v) => println!(\"{v}\"), Err(e) => eprintln!(\"error: {e}\") }",
+ "attribution": "Rust",
+ "language": "rust"
+ },
+ {
+ "category": "programming",
+ "content": "let total: i64 = values.iter().filter(|v| **v > 0).sum();",
+ "attribution": "Rust",
+ "language": "rust"
+ },
+ {
+ "category": "science",
+ "content": "Water is one of the few substances that becomes less dense as it freezes, which is why ice floats on top of liquid water instead of sinking to the bottom.",
+ "attribution": "Science"
+ },
+ {
+ "category": "movies",
+ "content": "The Academy Awards, commonly known as the Oscars, have been presented annually since 1929 to honor achievements in the film industry.",
+ "attribution": "Film"
+ },
+ {
+ "category": "literature",
+ "content": "All we have to decide is what to do with the time that is given us.",
+ "attribution": "J.R.R. Tolkien, The Fellowship of the Ring"
+ },
+ {
+ "category": "business",
+ "content": "Market research helps a business understand its customers' needs and preferences before committing resources to a new product or service.",
+ "attribution": "Business"
+ },
+ {
+ "category": "general",
+ "content": "A group of flamingos is called a flamboyance. The birds get their pink color from the pigments in the shrimp and algae they eat.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "programming",
+ "content": "for f in *.log; do gzip -9 \"$f\" && echo \"packed $f\"; done",
+ "attribution": "Shell",
+ "language": "shell"
+ },
+ {
+ "category": "business",
+ "content": "Customer obsession, rather than competitor focus, is the surest path to long term success.",
+ "attribution": "Jeff Bezos"
+ },
+ {
+ "category": "technology",
+ "content": "A central processing unit executes the instructions of a computer program by performing basic arithmetic, logic, and input and output operations.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "science",
+ "content": "The speed of light in a vacuum is approximately two hundred ninety nine thousand, seven hundred ninety two kilometers per second, and nothing with mass can reach or exceed it.",
+ "attribution": "Science"
+ },
+ {
+ "category": "science",
+ "content": "The human body contains roughly thirty seven trillion cells, each one specialized to carry out one of hundreds of different jobs inside the body.",
+ "attribution": "Science"
+ },
+ {
+ "category": "nature",
+ "content": "In every walk with nature one receives far more than he seeks.",
+ "attribution": "John Muir"
+ },
+ {
+ "category": "business",
+ "content": "Branding is the practice of shaping how customers perceive a company, going beyond a logo to include tone, values, and the overall experience of using a product.",
+ "attribution": "Business"
+ },
+ {
+ "category": "technology",
+ "content": "The transistor, invented in 1947, replaced bulky vacuum tubes and became the foundational building block of nearly every modern electronic device.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "programming",
+ "content": "pub struct Point { pub x: f64, pub y: f64 }",
+ "attribution": "Rust",
+ "language": "rust"
+ },
+ {
+ "category": "general",
+ "content": "Octopuses have three hearts and blue blood. Two of the hearts pump blood to the gills, while the third circulates it to the rest of the body, and the blue color comes from a copper-based protein instead of iron.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "general",
+ "content": "The Amazon rainforest produces roughly twenty percent of the oxygen found in the planet's atmosphere, earning it the nickname the lungs of the Earth.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "quotes",
+ "content": "If I have seen further, it is by standing on the shoulders of giants.",
+ "attribution": "Isaac Newton"
+ },
+ {
+ "category": "general",
+ "content": "A single bolt of lightning can heat the surrounding air to temperatures hotter than the surface of the sun. The rapid expansion of that superheated air is what produces the crack of thunder we hear moments later.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "business",
+ "content": "Henry Ford, founder of the Ford Motor Company, is credited with popularizing the moving assembly line, which dramatically reduced the time needed to build an automobile.",
+ "attribution": "History of Business"
+ },
+ {
+ "category": "business",
+ "content": "A merger combines two companies into one, while an acquisition occurs when one company purchases and absorbs another, though the two terms are often used loosely in everyday conversation.",
+ "attribution": "Business"
+ },
+ {
+ "category": "movies",
+ "content": "The special effects in the original Star Wars trilogy were largely achieved with physical models and practical effects, long before computer generated imagery became common.",
+ "attribution": "Film"
+ },
+ {
+ "category": "history",
+ "content": "The printing press, developed by Johannes Gutenberg in the fifteenth century, made it possible to produce books far more quickly and cheaply than copying them by hand.",
+ "attribution": "History"
+ },
+ {
+ "category": "science",
+ "content": "Vaccines work by training the immune system to recognize a weakened or partial version of a pathogen, so the body can respond quickly if it ever encounters the real threat.",
+ "attribution": "Science"
+ },
+ {
+ "category": "nature",
+ "content": "Wolves live and hunt in structured packs, usually led by a breeding pair, and communicate through howls that can be heard for several kilometers.",
+ "attribution": "Nature"
+ },
+ {
+ "category": "business",
+ "content": "Supply and demand describes how the price of a good tends to rise when demand exceeds supply, and fall when supply exceeds demand.",
+ "attribution": "Business"
+ },
+ {
+ "category": "nature",
+ "content": "The deepest part of the ocean, the Mariana Trench, plunges nearly eleven kilometers below the surface, deeper than Mount Everest is tall.",
+ "attribution": "Nature"
+ },
+ {
+ "category": "literature",
+ "content": "I read that every known superstition in the world is gathered into the horseshoe of the Carpathians, as if it were the centre of some sort of imaginative whirlpool, and it made me uneasy in a way I could not explain.",
+ "attribution": "Bram Stoker, Dracula"
+ },
+ {
+ "category": "general",
+ "content": "The human brain generates enough electricity to power a small light bulb. Billions of neurons firing electrical signals at once produce roughly the same wattage as a dim household lamp.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "quotes",
+ "content": "Four score and seven years ago our fathers brought forth on this continent a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal.",
+ "attribution": "Abraham Lincoln, The Gettysburg Address"
+ },
+ {
+ "category": "programming",
+ "content": "export async function load(url) { const res = await fetch(url); return res.json(); }",
+ "attribution": "JavaScript",
+ "language": "javascript"
+ },
+ {
+ "category": "science",
+ "content": "Mitochondria convert nutrients into a usable form of energy for the cell, which is why biology textbooks often call them the powerhouse of the cell.",
+ "attribution": "Science"
+ },
+ {
+ "category": "history",
+ "content": "The Wright brothers achieved the first powered, controlled flight in 1903 near Kitty Hawk, North Carolina, a flight that lasted only twelve seconds.",
+ "attribution": "History"
+ },
+ {
+ "category": "literature",
+ "content": "It was the best of times, it was the worst of times.",
+ "attribution": "Charles Dickens, A Tale of Two Cities"
+ },
+ {
+ "category": "movies",
+ "content": "Pixar's first feature film, Toy Story, released in 1995, was the first full length movie created entirely with computer animation.",
+ "attribution": "Film"
+ },
+ {
+ "category": "business",
+ "content": "Diversification spreads investment risk across different assets, so that a poor outcome in one area does not sink an entire portfolio.",
+ "attribution": "Business"
+ },
+ {
+ "category": "quotes",
+ "content": "It is not the critic who counts; not the man who points out how the strong man stumbles. The credit belongs to the man who is actually in the arena, whose face is marred by dust and sweat and blood.",
+ "attribution": "Theodore Roosevelt, Citizenship in a Republic"
+ },
+ {
+ "category": "programming",
+ "content": "let mut counts: HashMap<String, usize> = HashMap::new();",
+ "attribution": "Rust",
+ "language": "rust"
+ },
+ {
+ "category": "quotes",
+ "content": "The two most important days in your life are the day you are born and the day you find out why.",
+ "attribution": "Mark Twain"
+ },
+ {
+ "category": "philosophy",
+ "content": "The Socratic method uses a series of questions and answers to expose contradictions in a person's beliefs, encouraging deeper critical thinking rather than simply providing answers.",
+ "attribution": "Philosophy"
+ },
+ {
+ "category": "programming",
+ "content": "const total = items.reduce((acc, item) => acc + item.price, 0);",
+ "attribution": "JavaScript",
+ "language": "javascript"
+ },
+ {
+ "category": "programming",
+ "content": "counts = {word: text.count(word) for word in set(text.split())}",
+ "attribution": "Python",
+ "language": "python"
+ },
+ {
+ "category": "general",
+ "content": "Honey never spoils if it is stored properly. Archaeologists have found pots of honey in ancient Egyptian tombs that are thousands of years old and still perfectly edible, preserved by its low moisture content and natural acidity.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "quotes",
+ "content": "Failure is impossible.",
+ "attribution": "Susan B. Anthony"
+ },
+ {
+ "category": "literature",
+ "content": "Mr. Sherlock Holmes, who was usually very late in the mornings, save upon those not infrequent occasions when he was up all night, was seated at the breakfast table.",
+ "attribution": "Arthur Conan Doyle, The Hound of the Baskervilles"
+ },
+ {
+ "category": "general",
+ "content": "Bananas are botanically classified as berries, while strawberries are not. The classification depends on how the fruit develops from the flower, not on taste or size.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "science",
+ "content": "Evolution by natural selection describes how organisms with traits better suited to their environment tend to survive and reproduce more successfully, gradually shaping a population over many generations.",
+ "attribution": "Science, after Charles Darwin"
+ },
+ {
+ "category": "quotes",
+ "content": "Success is to be measured not so much by the position that one has reached in life as by the obstacles which he has overcome while trying to succeed.",
+ "attribution": "Booker T. Washington"
+ },
+ {
+ "category": "programming",
+ "content": "impl Display for Token { fn fmt(&self, f: &mut Formatter) -> Result { write!(f, \"{}\", self.0) } }",
+ "attribution": "Rust",
+ "language": "rust"
+ },
+ {
+ "category": "history",
+ "content": "The invention of the wheel around 3500 BCE is considered one of the most important technological breakthroughs in human history, transforming transportation and trade.",
+ "attribution": "History"
+ },
+ {
+ "category": "nature",
+ "content": "The world is big and I want to have a good look at it before it gets dark.",
+ "attribution": "John Muir"
+ },
+ {
+ "category": "general",
+ "content": "The first computer programmer is widely considered to be Ada Lovelace, who wrote an algorithm for an early mechanical computer in the eighteen forties, decades before any machine capable of running it was ever built.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "philosophy",
+ "content": "Stoicism is a school of ancient Greek philosophy that teaches the practice of virtue as the only true good, and encourages focusing only on what lies within one's own control.",
+ "attribution": "Philosophy"
+ },
+ {
+ "category": "programming",
+ "content": "grep -rn \"TODO\" src/ | awk -F: '{print $1}' | sort -u",
+ "attribution": "Shell",
+ "language": "shell"
+ },
+ {
+ "category": "general",
+ "content": "Antarctica is technically the largest desert on the planet. Despite being covered in ice, it receives very little precipitation each year, which is the defining characteristic of a desert rather than temperature alone.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "general",
+ "content": "The shortest war in recorded history was fought between Britain and Zanzibar in 1896. It lasted around forty minutes before Zanzibar surrendered.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "nature",
+ "content": "A single mature tree can absorb around twenty two kilograms of carbon dioxide per year, making forests an important part of regulating the planet's climate.",
+ "attribution": "Nature"
+ },
+ {
+ "category": "business",
+ "content": "Cash flow tracks the actual movement of money into and out of a business, which can differ significantly from reported profit due to timing differences in payments.",
+ "attribution": "Business"
+ },
+ {
+ "category": "programming",
+ "content": "for (int i = 0; i < n; i++) { sum += a[i] * b[i]; }",
+ "attribution": "C",
+ "language": "clike"
+ },
+ {
+ "category": "business",
+ "content": "Price is what you pay, and value is what you get.",
+ "attribution": "Warren Buffett"
+ },
+ {
+ "category": "philosophy",
+ "content": "The only true wisdom is in knowing you know nothing.",
+ "attribution": "Socrates"
+ },
+ {
+ "category": "general",
+ "content": "The Eiffel Tower grows about six inches taller in the summer, as the iron structure expands slightly in the heat and contracts again once the weather cools.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "business",
+ "content": "Return on investment measures the profitability of an investment relative to its cost, expressed as a percentage to make it easy to compare different opportunities.",
+ "attribution": "Business"
+ },
+ {
+ "category": "movies",
+ "content": "The Cannes Film Festival, held annually in France since 1946, is considered one of the most prestigious film festivals in the world.",
+ "attribution": "Film"
+ },
+ {
+ "category": "history",
+ "content": "The signing of the Magna Carta in 1215 limited the power of the English king and established the principle that even a monarch was subject to the law.",
+ "attribution": "History"
+ },
+ {
+ "category": "movies",
+ "content": "A film's runtime is typically decided during editing, where an editor works with hours of raw footage to shape a coherent story within a manageable length.",
+ "attribution": "Film"
+ },
+ {
+ "category": "philosophy",
+ "content": "All men by nature desire to know.",
+ "attribution": "Aristotle, Metaphysics"
+ },
+ {
+ "category": "philosophy",
+ "content": "Waste no more time arguing about what a good man should be. Be one.",
+ "attribution": "Marcus Aurelius, Meditations"
+ },
+ {
+ "category": "technology",
+ "content": "A firewall monitors and controls incoming and outgoing network traffic based on predetermined security rules, acting as a barrier between a trusted network and an untrusted one.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "science",
+ "content": "The theory of plate tectonics explains how the Earth's outer shell is broken into large plates that slowly drift over the mantle, causing earthquakes, volcanoes, and mountain formation.",
+ "attribution": "Science"
+ },
+ {
+ "category": "history",
+ "content": "The Great Depression of the 1930s began with the stock market crash of 1929 and led to widespread unemployment and economic hardship across the globe.",
+ "attribution": "History"
+ },
+ {
+ "category": "movies",
+ "content": "The Wizard of Oz was one of the first major films to use Technicolor extensively, transitioning from black and white to vivid color once Dorothy arrives in Oz.",
+ "attribution": "Film"
+ },
+ {
+ "category": "quotes",
+ "content": "You have power over your mind, not outside events. Realize this, and you will find strength.",
+ "attribution": "Marcus Aurelius, Meditations"
+ },
+ {
+ "category": "literature",
+ "content": "The studio was filled with the rich odour of roses, and when the light summer wind stirred amidst the trees of the garden, there came through the open door the heavy scent of the lilac, or the more delicate perfume of the pink-flowering thorn.",
+ "attribution": "Oscar Wilde, The Picture of Dorian Gray"
+ },
+ {
+ "category": "science",
+ "content": "The periodic table arranges chemical elements by increasing atomic number, grouping elements with similar properties into the same columns.",
+ "attribution": "Science"
+ },
+ {
+ "category": "programming",
+ "content": "for i, v := range items { fmt.Printf(\"%d: %v\\n\", i, v) }",
+ "attribution": "Go",
+ "language": "clike"
+ },
+ {
+ "category": "history",
+ "content": "I will fight no more forever.",
+ "attribution": "Chief Joseph, Nez Perce, 1877"
+ },
+ {
+ "category": "programming",
+ "content": "try: value = int(raw) except ValueError: value = 0",
+ "attribution": "Python",
+ "language": "python"
+ },
+ {
+ "category": "programming",
+ "content": "fn main() { println!(\"{}\", args().skip(1).collect::<Vec<_>>().join(\" \")); }",
+ "attribution": "Rust",
+ "language": "rust"
+ },
+ {
+ "category": "technology",
+ "content": "Machine learning is a branch of artificial intelligence in which a system improves its performance on a task by learning patterns from data rather than following explicit instructions.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "programming",
+ "content": "document.querySelectorAll('.row').forEach(el => el.classList.toggle('active'));",
+ "attribution": "JavaScript",
+ "language": "javascript"
+ },
+ {
+ "category": "nature",
+ "content": "Heaven is under our feet as well as over our heads.",
+ "attribution": "Henry David Thoreau, Walden"
+ },
+ {
+ "category": "business",
+ "content": "A monopoly exists when a single company dominates an entire market with no significant competition, often leading regulators to intervene to protect consumers.",
+ "attribution": "Business"
+ },
+ {
+ "category": "philosophy",
+ "content": "Utilitarianism holds that the right action is the one that produces the greatest good for the greatest number of people, judging morality by outcomes rather than intentions.",
+ "attribution": "Philosophy"
+ },
+ {
+ "category": "history",
+ "content": "The Roman Empire at its height stretched from Britain in the north to Egypt in the south, connected by a vast network of roads that still exist in parts of Europe today.",
+ "attribution": "History"
+ },
+ {
+ "category": "literature",
+ "content": "Christmas won't be Christmas without any presents, grumbled Jo, lying on the rug. It's so dreadful to be poor, sighed Meg, looking down at her old dress.",
+ "attribution": "Louisa May Alcott, Little Women"
+ },
+ {
+ "category": "technology",
+ "content": "Artificial intelligence refers to computer systems designed to perform tasks that normally require human intelligence, such as recognizing speech, images, or patterns in data.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "literature",
+ "content": "Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do. Once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it.",
+ "attribution": "Lewis Carroll, Alice's Adventures in Wonderland"
+ },
+ {
+ "category": "literature",
+ "content": "A throng of bearded men, in sad-colored garments and gray, steeple-crowned hats, intermixed with women, some wearing hoods, and others bareheaded, was assembled in front of a wooden edifice.",
+ "attribution": "Nathaniel Hawthorne, The Scarlet Letter"
+ },
+ {
+ "category": "movies",
+ "content": "A film's score is often composed after the movie has been edited, allowing the composer to match the music precisely to the pacing and mood of each scene.",
+ "attribution": "Film"
+ },
+ {
+ "category": "technology",
+ "content": "The first practical smartphones combined a mobile phone with computing features such as email, web browsing, and applications, changing how people communicate and work.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "quotes",
+ "content": "To be great is to be misunderstood.",
+ "attribution": "Ralph Waldo Emerson, Self-Reliance"
+ },
+ {
+ "category": "technology",
+ "content": "Robotics combines mechanical engineering, electronics, and computer science to design machines capable of performing tasks autonomously or with minimal human control.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "movies",
+ "content": "The word cinema comes from the Greek word for movement, reflecting how early filmmakers were fascinated by the illusion of motion created by a sequence of still images.",
+ "attribution": "Film"
+ },
+ {
+ "category": "literature",
+ "content": "Not all those who wander are lost.",
+ "attribution": "J.R.R. Tolkien"
+ },
+ {
+ "category": "nature",
+ "content": "A rainforest canopy can be so dense that less than two percent of sunlight reaches the forest floor below.",
+ "attribution": "Nature"
+ },
+ {
+ "category": "literature",
+ "content": "Buck did not read the newspapers, or he would have known that trouble was brewing, not alone for himself, but for every tide-water dog, strong of muscle and with warm, long hair, from Puget Sound to San Diego.",
+ "attribution": "Jack London, The Call of the Wild"
+ },
+ {
+ "category": "literature",
+ "content": "The Nellie, a cruising yawl, swung to her anchor without a flutter of the sails, and was at rest. The flood had made, the wind was nearly calm, and being bound down the river, the only thing for it was to come to and wait for the turn of the tide.",
+ "attribution": "Joseph Conrad, Heart of Darkness"
+ },
+ {
+ "category": "general",
+ "content": "Sharks existed long before trees. Fossil evidence suggests that sharks have been swimming in the oceans for more than four hundred million years, while the earliest trees did not appear until tens of millions of years later.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "history",
+ "content": "The Renaissance began in Italy in the fourteenth century, sparking a renewed interest in classical art, science, and learning that eventually spread across Europe.",
+ "attribution": "History"
+ },
+ {
+ "category": "philosophy",
+ "content": "The beginning is the most important part of the work.",
+ "attribution": "Plato, The Republic"
+ },
+ {
+ "category": "quotes",
+ "content": "I went to the woods because I wished to live deliberately, to front only the essential facts of life, and see if I could not learn what it had to teach, and not, when I came to die, discover that I had not lived.",
+ "attribution": "Henry David Thoreau, Walden"
+ },
+ {
+ "category": "history",
+ "content": "The Silk Road was not a single road but a network of trade routes connecting China to the Mediterranean, carrying silk, spices, and ideas across the ancient world.",
+ "attribution": "History"
+ },
+ {
+ "category": "nature",
+ "content": "Migratory birds can travel thousands of miles between breeding and wintering grounds, navigating using the sun, stars, and the Earth's magnetic field.",
+ "attribution": "Nature"
+ },
+ {
+ "category": "literature",
+ "content": "You don't know about me without you have read a book by the name of The Adventures of Tom Sawyer, but that ain't no matter. That book was made by Mr. Mark Twain, and he told the truth, mainly.",
+ "attribution": "Mark Twain, Adventures of Huckleberry Finn"
+ },
+ {
+ "category": "philosophy",
+ "content": "The impediment to action advances action. What stands in the way becomes the way.",
+ "attribution": "Marcus Aurelius, Meditations"
+ },
+ {
+ "category": "nature",
+ "content": "Rivers shape the landscape around them over thousands of years, carving canyons and depositing sediment that forms fertile floodplains.",
+ "attribution": "Nature"
+ },
+ {
+ "category": "movies",
+ "content": "The first feature length narrative film, The Story of the Kelly Gang, was made in Australia in 1906 and ran for about an hour.",
+ "attribution": "Film"
+ },
+ {
+ "category": "programming",
+ "content": "const { id, name = 'anonymous', ...rest } = await request.json();",
+ "attribution": "JavaScript",
+ "language": "javascript"
+ },
+ {
+ "category": "science",
+ "content": "DNA carries the genetic instructions for every living organism, encoded in a sequence of four chemical bases that pair together to form the famous double helix shape.",
+ "attribution": "Science"
+ },
+ {
+ "category": "literature",
+ "content": "The Time Traveller, for so it will be convenient to speak of him, was expounding a recondite matter to us. His grey eyes shone and twinkled, and his usually pale face was flushed and animated.",
+ "attribution": "H.G. Wells, The Time Machine"
+ },
+ {
+ "category": "quotes",
+ "content": "Stay hungry, stay foolish.",
+ "attribution": "Steve Jobs"
+ },
+ {
+ "category": "technology",
+ "content": "Moore's Law observed that the number of transistors on a computer chip tends to double roughly every two years, driving decades of rapid improvement in computing power.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "science",
+ "content": "Photosynthesis is the process by which plants convert light energy into chemical energy, using carbon dioxide and water to produce glucose and release oxygen as a byproduct.",
+ "attribution": "Science"
+ },
+ {
+ "category": "philosophy",
+ "content": "A journey of a thousand miles begins with a single step.",
+ "attribution": "Lao Tzu, Tao Te Ching"
+ },
+ {
+ "category": "literature",
+ "content": "1801. I have just returned from a visit to my landlord, the solitary neighbour that I shall be troubled with. This is certainly a beautiful country. In all England, I do not believe that I could have fixed on a situation so completely removed from the stir of society.",
+ "attribution": "Emily Bronte, Wuthering Heights"
+ },
+ {
+ "category": "literature",
+ "content": "There was no possibility of taking a walk that day. We had been wandering, indeed, in the leafless shrubbery an hour in the morning, but since dinner the cold winter wind had brought with it clouds so sombre, and a rain so penetrating.",
+ "attribution": "Charlotte Bronte, Jane Eyre"
+ },
+ {
+ "category": "general",
+ "content": "Simplicity is the soul of efficiency.",
+ "attribution": "Austin Freeman"
+ },
+ {
+ "category": "nature",
+ "content": "Mycorrhizal fungi form partnerships with the roots of most plants, helping them absorb water and nutrients in exchange for sugars produced by photosynthesis.",
+ "attribution": "Nature"
+ },
+ {
+ "category": "science",
+ "content": "A black hole forms when a massive star collapses under its own gravity, packing so much mass into so small a space that not even light can escape its pull.",
+ "attribution": "Science"
+ },
+ {
+ "category": "quotes",
+ "content": "That man over there says that women need to be helped into carriages, and lifted over ditches, and to have the best place everywhere. Nobody ever helps me into carriages, or over mud puddles, or gives me any best place, and ain't I a woman?",
+ "attribution": "Sojourner Truth, Ain't I a Woman?"
+ },
+ {
+ "category": "general",
+ "content": "The quick brown fox jumps over the lazy dog.",
+ "attribution": "Traditional pangram"
+ },
+ {
+ "category": "general",
+ "content": "Venus is the hottest planet in the solar system, even though Mercury sits closer to the sun. A thick blanket of carbon dioxide traps heat at the surface.",
+ "attribution": "General knowledge"
+ },
+ {
+ "category": "programming",
+ "content": "int *buf = malloc(len * sizeof(int)); if (!buf) return -1;",
+ "attribution": "C",
+ "language": "clike"
+ },
+ {
+ "category": "movies",
+ "content": "Citizen Kane, released in 1941, is frequently cited by critics and filmmakers as one of the greatest films ever made, praised for its innovative camera work and narrative structure.",
+ "attribution": "Film"
+ },
+ {
+ "category": "literature",
+ "content": "It was on a dreary night of November that I beheld the accomplishment of my toils. With an anxiety that almost amounted to agony, I collected the instruments of life around me, that I might infuse a spark of being into the lifeless thing that lay at my feet.",
+ "attribution": "Mary Shelley, Frankenstein"
+ },
+ {
+ "category": "technology",
+ "content": "Quantum computing uses the principles of quantum mechanics to process information in ways that classical computers cannot, potentially solving certain problems far faster.",
+ "attribution": "Technology"
+ },
+ {
+ "category": "philosophy",
+ "content": "It does not matter how slowly you go as long as you do not stop.",
+ "attribution": "Confucius, Analects"
+ },
+ {
+ "category": "programming",
+ "content": "public static void main(String[] args) { System.out.println(\"ready\"); }",
+ "attribution": "Java",
+ "language": "clike"
+ },
+ {
+ "category": "quotes",
+ "content": "Our greatest glory is not in never falling, but in rising every time we fall.",
+ "attribution": "Confucius"
+ },
+ {
+ "category": "history",
+ "content": "The Apollo 11 mission landed the first humans on the Moon in July of 1969, when astronauts Neil Armstrong and Buzz Aldrin walked on the lunar surface.",
+ "attribution": "History"
+ },
+ {
+ "category": "literature",
+ "content": "The year 1866 was signalised by a remarkable incident, a mysterious and inexplicable phenomenon, which doubtless no one has yet forgotten. Ships had met with a huge thing, a long object, spindle-shaped, and at times phosphorescent.",
+ "attribution": "Jules Verne, Twenty Thousand Leagues Under the Sea"
+ },
+ {
+ "category": "programming",
+ "content": "squares = [n * n for n in range(1, 21) if n % 3 != 0]",
+ "attribution": "Python",
+ "language": "python"
+ },
+ {
+ "category": "philosophy",
+ "content": "It's not what happens to you, but how you react to it that matters.",
+ "attribution": "Epictetus"
+ },
+ {
+ "category": "nature",
+ "content": "Coral reefs cover less than one percent of the ocean floor, yet they support roughly a quarter of all marine species.",
+ "attribution": "Nature"
+ },
+ {
+ "category": "science",
+ "content": "Newton's first law of motion states that an object at rest stays at rest, and an object in motion stays in motion, unless acted on by an outside force.",
+ "attribution": "Science"
+ },
+ {
+ "category": "technology",
+ "content": "Fiber optic cables transmit data as pulses of light through thin strands of glass, allowing information to travel at extremely high speeds over long distances.",
+ "attribution": "Technology"
+ }
+] \ No newline at end of file
diff --git a/web/src/deviceDetect.js b/web/src/deviceDetect.js
new file mode 100644
index 0000000..bef49dc
--- /dev/null
+++ b/web/src/deviceDetect.js
@@ -0,0 +1,10 @@
+// Coarse touch/mobile detection for the "match/rank against desktop only"
+// preference - not trying to be a precise device fingerprint, just enough
+// to separate "typed on a physical keyboard" from "typed on a touchscreen"
+// for fairness purposes (see anticheat.rs's comment on why this is opt-in
+// tagging, not a default exclusion).
+export function detectDeviceType() {
+ const coarsePointer = typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
+ const touchPoints = navigator.maxTouchPoints > 0;
+ return coarsePointer && touchPoints ? 'mobile' : 'desktop';
+}
diff --git a/web/src/game.js b/web/src/game.js
new file mode 100644
index 0000000..2e8681c
--- /dev/null
+++ b/web/src/game.js
@@ -0,0 +1,17 @@
+import init, { TyperPunkGame } from '../wasm/typerpunk_wasm.js';
+
+const ready = init();
+
+export async function createGame() {
+ await ready;
+ return new TyperPunkGame('');
+}
+
+export function freeGame(game) {
+ if (!game) return;
+ try {
+ game.free();
+ } catch (err) {
+ console.error('Error freeing game:', err);
+ }
+}
diff --git a/web/src/keySounds.js b/web/src/keySounds.js
new file mode 100644
index 0000000..1a20087
--- /dev/null
+++ b/web/src/keySounds.js
@@ -0,0 +1,42 @@
+import { getSettings } from './settings.js';
+
+// Synthesized rather than sampled - a couple of short tones via the Web
+// Audio API instead of shipping/loading actual click-sample audio files,
+// so the feature has zero asset dependencies.
+const THEMES = {
+ click: { freq: 1800, duration: 0.03, type: 'square' },
+ mech: { freq: 180, duration: 0.05, type: 'triangle' },
+};
+
+let audioCtx = null;
+function getContext() {
+ if (!audioCtx) {
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
+ audioCtx = new AudioContextClass();
+ }
+ return audioCtx;
+}
+
+// Browsers refuse to start an AudioContext before a user gesture - the
+// first keystroke that reaches here always follows one (focusing/typing
+// into the input), so resuming inline rather than requiring a separate
+// "enable sound" click.
+export function playKeySound() {
+ const { soundTheme } = getSettings();
+ const theme = THEMES[soundTheme];
+ if (!theme) return;
+
+ const ctx = getContext();
+ if (ctx.state === 'suspended') ctx.resume();
+
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.type = theme.type;
+ osc.frequency.value = theme.freq;
+ gain.gain.setValueAtTime(0.12, ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + theme.duration);
+ osc.connect(gain);
+ gain.connect(ctx.destination);
+ osc.start();
+ osc.stop(ctx.currentTime + theme.duration);
+}
diff --git a/web/src/keyStats.js b/web/src/keyStats.js
new file mode 100644
index 0000000..9db0106
--- /dev/null
+++ b/web/src/keyStats.js
@@ -0,0 +1,59 @@
+const STORAGE_KEY = 'typerpunk:keystats';
+// Below this many samples a character's average latency/error rate is too
+// noisy to act on - a key typed twice that happened to be wrong once looks
+// identical to a genuinely hard key without this floor.
+const MIN_SAMPLES = 5;
+
+function load() {
+ try {
+ const raw = JSON.parse(localStorage.getItem(STORAGE_KEY));
+ return raw && typeof raw === 'object' ? raw : {};
+ } catch {
+ return {};
+ }
+}
+
+function save(data) {
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {}
+}
+
+// Called once per typed character (correct or not) with how long it took
+// since the previous keystroke. Skips whitespace - word gaps have highly
+// variable timing for reasons unrelated to how hard a character is to type
+// (thinking about the next word, punctuation pauses), and would otherwise
+// dominate the latency signal for every character adjacent to a space.
+export function recordKeystroke(char, isCorrect, latencyMs) {
+ if (!char || /\s/.test(char)) return;
+ const data = load();
+ const entry = data[char] || { attempts: 0, errors: 0, totalLatency: 0 };
+ entry.attempts += 1;
+ if (!isCorrect) entry.errors += 1;
+ if (latencyMs != null && latencyMs >= 0 && latencyMs < 5000) entry.totalLatency += latencyMs;
+ data[char] = entry;
+ save(data);
+}
+
+// Ranks every character with enough samples by a weak-ness score (error
+// rate weighted heaviest, since a mistake matters more than being merely
+// slow) and returns the worst `limit`. Empty until enough real typing has
+// happened - callers should fall back to plain random text until then.
+export function getWeakChars(limit = 12) {
+ const data = load();
+ const scored = Object.entries(data)
+ .filter(([, e]) => e.attempts >= MIN_SAMPLES)
+ .map(([char, e]) => ({
+ char,
+ errorRate: e.errors / e.attempts,
+ avgLatency: e.totalLatency / e.attempts,
+ }));
+ if (scored.length === 0) return [];
+
+ const maxLatency = Math.max(...scored.map(s => s.avgLatency), 1);
+ scored.forEach(s => { s.score = s.errorRate * 0.7 + (s.avgLatency / maxLatency) * 0.3; });
+ scored.sort((a, b) => b.score - a.score);
+ return scored.slice(0, limit).map(s => s.char);
+}
+
+export function hasEnoughData() {
+ return getWeakChars(1).length > 0;
+}
diff --git a/web/src/languages.js b/web/src/languages.js
new file mode 100644
index 0000000..b5bbba9
--- /dev/null
+++ b/web/src/languages.js
@@ -0,0 +1,205 @@
+// Typing languages: which vocabulary the generated-word modes (Words, Timed,
+// Zen, Practice) draw from. This is the text being typed, not the language of
+// the interface - the same distinction MonkeyType and 10FastFingers make.
+//
+// Each list is that language's own high-frequency vocabulary, not a
+// translation of the English one. Accented characters are kept as written;
+// typing them is the point of picking the language.
+//
+// Adding a language means adding an entry here and nothing else.
+
+const EN = [
+ 'the', 'be', 'to', 'of', 'and', 'a', 'in', 'that', 'have', 'it',
+ 'for', 'not', 'on', 'with', 'he', 'as', 'you', 'do', 'at', 'this',
+ 'but', 'his', 'by', 'from', 'they', 'we', 'say', 'her', 'she', 'or',
+ 'an', 'will', 'my', 'one', 'all', 'would', 'there', 'their', 'what', 'so',
+ 'up', 'out', 'if', 'about', 'who', 'get', 'which', 'go', 'me', 'when',
+ 'make', 'can', 'like', 'time', 'no', 'just', 'him', 'know', 'take', 'people',
+ 'into', 'year', 'your', 'good', 'some', 'could', 'them', 'see', 'other', 'than',
+ 'then', 'now', 'look', 'only', 'come', 'its', 'over', 'think', 'also', 'back',
+ 'after', 'use', 'two', 'how', 'our', 'work', 'first', 'well', 'way', 'even',
+ 'new', 'want', 'because', 'any', 'these', 'give', 'day', 'most', 'us', 'water',
+];
+
+const ES = [
+ 'el', 'la', 'de', 'que', 'y', 'a', 'en', 'un', 'ser', 'se',
+ 'no', 'haber', 'por', 'con', 'su', 'para', 'como', 'estar', 'tener', 'le',
+ 'lo', 'todo', 'pero', 'más', 'hacer', 'o', 'poder', 'decir', 'este', 'ir',
+ 'otro', 'ese', 'si', 'me', 'ya', 'ver', 'porque', 'dar', 'cuando', 'muy',
+ 'sin', 'vez', 'mucho', 'saber', 'qué', 'sobre', 'mi', 'alguno', 'mismo', 'yo',
+ 'también', 'hasta', 'año', 'dos', 'querer', 'entre', 'así', 'primero', 'desde', 'grande',
+ 'eso', 'ni', 'nos', 'llegar', 'pasar', 'tiempo', 'ella', 'sí', 'día', 'uno',
+ 'bien', 'poco', 'deber', 'entonces', 'poner', 'cosa', 'tanto', 'hombre', 'parecer', 'nuestro',
+ 'tan', 'donde', 'ahora', 'parte', 'después', 'vida', 'quedar', 'siempre', 'creer', 'hablar',
+ 'llevar', 'dejar', 'nada', 'cada', 'seguir', 'menos', 'nuevo', 'encontrar', 'algo', 'sólo',
+];
+
+const FR = [
+ 'le', 'de', 'un', 'être', 'et', 'à', 'il', 'avoir', 'ne', 'je',
+ 'son', 'que', 'se', 'qui', 'ce', 'dans', 'en', 'du', 'elle', 'au',
+ 'pour', 'pas', 'que', 'vous', 'par', 'sur', 'faire', 'plus', 'dire', 'me',
+ 'on', 'mon', 'lui', 'nous', 'comme', 'mais', 'pouvoir', 'avec', 'tout', 'y',
+ 'aller', 'voir', 'bien', 'où', 'sans', 'tu', 'ou', 'leur', 'homme', 'si',
+ 'deux', 'mari', 'moi', 'vouloir', 'te', 'même', 'venir', 'quand', 'grand', 'celui',
+ 'si', 'notre', 'devoir', 'là', 'jour', 'prendre', 'très', 'peu', 'encore', 'aussi',
+ 'quelque', 'dont', 'tout', 'mer', 'trouver', 'donner', 'temps', 'ça', 'peut', 'chose',
+ 'vie', 'année', 'monde', 'main', 'jamais', 'sous', 'passer', 'toujours', 'rien', 'falloir',
+ 'parler', 'sembler', 'depuis', 'moment', 'partir', 'penser', 'enfant', 'entre', 'aimer', 'mot',
+];
+
+const DE = [
+ 'der', 'die', 'und', 'in', 'den', 'von', 'zu', 'das', 'mit', 'sich',
+ 'des', 'auf', 'für', 'ist', 'im', 'dem', 'nicht', 'ein', 'eine', 'als',
+ 'auch', 'es', 'an', 'werden', 'aus', 'er', 'hat', 'dass', 'sie', 'nach',
+ 'bei', 'um', 'noch', 'wie', 'über', 'nur', 'oder', 'aber', 'vor', 'bis',
+ 'mehr', 'durch', 'man', 'sein', 'wurde', 'sei', 'wir', 'was', 'wenn', 'haben',
+ 'kann', 'ihre', 'dann', 'unter', 'wieder', 'zwei', 'schon', 'jahr', 'diese', 'wird',
+ 'kein', 'immer', 'zeit', 'doch', 'ihr', 'weil', 'ganz', 'zwischen', 'leben', 'sehr',
+ 'selbst', 'gegen', 'stadt', 'drei', 'hier', 'gut', 'wo', 'ohne', 'seine', 'jetzt',
+ 'weiter', 'menschen', 'land', 'welt', 'tag', 'hand', 'frage', 'seit', 'gehen', 'kommen',
+ 'große', 'teil', 'arbeit', 'ende', 'haus', 'frau', 'mann', 'kind', 'wasser', 'wort',
+];
+
+const IT = [
+ 'il', 'di', 'che', 'e', 'la', 'in', 'un', 'essere', 'a', 'per',
+ 'non', 'avere', 'con', 'si', 'da', 'come', 'io', 'questo', 'lo', 'ma',
+ 'del', 'più', 'fare', 'al', 'tutto', 'anche', 'su', 'lui', 'se', 'dire',
+ 'me', 'quando', 'lei', 'suo', 'poi', 'o', 'perché', 'cosa', 'ora', 'solo',
+ 'molto', 'bene', 'dove', 'ancora', 'sempre', 'niente', 'volta', 'grande', 'due', 'vita',
+ 'uomo', 'anno', 'giorno', 'casa', 'tempo', 'mondo', 'parte', 'mano', 'occhio', 'donna',
+ 'padre', 'madre', 'lavoro', 'città', 'paese', 'strada', 'notte', 'acqua', 'parola', 'amico',
+];
+
+const PT = [
+ 'o', 'de', 'que', 'e', 'a', 'do', 'da', 'em', 'um', 'para',
+ 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as',
+ 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'à', 'seu', 'sua', 'ou',
+ 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'até', 'isso',
+ 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'seus', 'quem', 'nas', 'me',
+ 'ano', 'dia', 'vez', 'casa', 'tempo', 'vida', 'homem', 'mundo', 'parte', 'lugar',
+ 'trabalho', 'cidade', 'noite', 'água', 'palavra', 'olho', 'mão', 'pessoa', 'coisa', 'forma',
+];
+
+const NL = [
+ 'de', 'en', 'van', 'het', 'een', 'in', 'is', 'dat', 'op', 'te',
+ 'zijn', 'met', 'voor', 'niet', 'aan', 'er', 'maar', 'om', 'ook', 'als',
+ 'dan', 'je', 'die', 'was', 'uit', 'door', 'over', 'ze', 'nog', 'naar',
+ 'heeft', 'hij', 'kan', 'zich', 'bij', 'onder', 'wij', 'wat', 'meer', 'deze',
+ 'jaar', 'dag', 'tijd', 'mens', 'werk', 'land', 'huis', 'stad', 'water', 'kind',
+ 'vrouw', 'man', 'hand', 'weg', 'woord', 'leven', 'wereld', 'nacht', 'oog', 'deel',
+];
+
+const SV = [
+ 'och', 'att', 'det', 'i', 'en', 'som', 'är', 'på', 'för', 'med',
+ 'av', 'den', 'till', 'har', 'de', 'inte', 'om', 'ett', 'han', 'men',
+ 'var', 'jag', 'sig', 'från', 'vi', 'så', 'kan', 'man', 'när', 'du',
+ 'ut', 'över', 'efter', 'bara', 'mycket', 'nu', 'här', 'där', 'eller', 'också',
+ 'år', 'dag', 'tid', 'liv', 'hand', 'land', 'hus', 'stad', 'vatten', 'barn',
+ 'kvinna', 'man', 'arbete', 'värld', 'natt', 'ord', 'väg', 'del', 'plats', 'öga',
+];
+
+const PL = [
+ 'w', 'i', 'z', 'na', 'do', 'się', 'nie', 'to', 'że', 'a',
+ 'o', 'jak', 'po', 'ale', 'za', 'jest', 'co', 'tak', 'od', 'przez',
+ 'przy', 'ten', 'już', 'tylko', 'bardzo', 'gdy', 'może', 'być', 'mieć', 'ona',
+ 'oni', 'my', 'wy', 'ja', 'ty', 'jego', 'jej', 'nasz', 'kiedy', 'gdzie',
+ 'rok', 'dzień', 'czas', 'praca', 'dom', 'człowiek', 'życie', 'woda', 'miasto', 'kraj',
+ 'ręka', 'słowo', 'noc', 'droga', 'świat', 'część', 'miejsce', 'oko', 'dziecko', 'kobieta',
+];
+
+const TR = [
+ 've', 'bir', 'bu', 'için', 'ile', 'de', 'da', 'ne', 'o', 'çok',
+ 'daha', 'var', 'gibi', 'ama', 'her', 'sonra', 'kadar', 'en', 'ben', 'sen',
+ 'biz', 'siz', 'onlar', 'olarak', 'değil', 'kendi', 'zaman', 'göre', 'şey', 'yok',
+ 'iyi', 'büyük', 'küçük', 'yeni', 'eski', 'uzun', 'kısa', 'önce', 'şimdi', 'burada',
+ 'yıl', 'gün', 'insan', 'ev', 'su', 'şehir', 'ülke', 'el', 'söz', 'gece',
+ 'yol', 'dünya', 'yer', 'göz', 'çocuk', 'kadın', 'adam', 'iş', 'hayat', 'baba',
+];
+
+const ID = [
+ 'yang', 'dan', 'di', 'itu', 'dengan', 'untuk', 'tidak', 'ini', 'dari', 'dalam',
+ 'akan', 'pada', 'juga', 'ke', 'karena', 'ada', 'oleh', 'bisa', 'saya', 'kami',
+ 'mereka', 'dia', 'kita', 'sudah', 'atau', 'saat', 'lebih', 'tetapi', 'bagi', 'hanya',
+ 'banyak', 'besar', 'kecil', 'baru', 'lama', 'baik', 'orang', 'tahun', 'hari', 'waktu',
+ 'rumah', 'air', 'kota', 'negara', 'tangan', 'kata', 'malam', 'jalan', 'dunia', 'bagian',
+ 'tempat', 'mata', 'anak', 'perempuan', 'laki', 'kerja', 'hidup', 'nama', 'jika', 'sangat',
+];
+
+const RO = [
+ 'de', 'și', 'la', 'în', 'un', 'o', 'cu', 'este', 'pe', 'nu',
+ 'care', 'ce', 'din', 'se', 'pentru', 'că', 'sunt', 'mai', 'dar', 'sau',
+ 'am', 'ai', 'are', 'eu', 'tu', 'el', 'ea', 'noi', 'voi', 'ei',
+ 'când', 'unde', 'cum', 'foarte', 'bine', 'acum', 'aici', 'după', 'fără', 'între',
+ 'an', 'zi', 'timp', 'casă', 'apă', 'oraș', 'țară', 'mână', 'cuvânt', 'noapte',
+ 'drum', 'lume', 'parte', 'loc', 'ochi', 'copil', 'femeie', 'om', 'muncă', 'viață',
+];
+
+const DA = [
+ 'og', 'i', 'at', 'det', 'en', 'den', 'til', 'er', 'som', 'på',
+ 'de', 'med', 'han', 'af', 'for', 'ikke', 'der', 'var', 'mig', 'sig',
+ 'men', 'et', 'har', 'om', 'vi', 'min', 'havde', 'ham', 'hun', 'nu',
+ 'over', 'da', 'fra', 'du', 'ud', 'sin', 'dem', 'os', 'op', 'man',
+ 'år', 'dag', 'tid', 'liv', 'hånd', 'land', 'hus', 'by', 'vand', 'barn',
+ 'kvinde', 'mand', 'arbejde', 'verden', 'nat', 'ord', 'vej', 'del', 'sted', 'øje',
+];
+
+const CS = [
+ 'a', 'v', 'se', 'na', 'je', 'že', 'o', 's', 'z', 'do',
+ 'to', 'ale', 'i', 'za', 'po', 'jako', 'jsem', 'jsou', 'byl', 'může',
+ 'který', 'jeho', 'její', 'nebo', 'když', 'kde', 'jak', 'velmi', 'jen', 'už',
+ 'tak', 'ještě', 'také', 'nic', 'vše', 'proto', 'první', 'nový', 'dobrý', 'velký',
+ 'rok', 'den', 'čas', 'práce', 'dům', 'člověk', 'život', 'voda', 'město', 'země',
+ 'ruka', 'slovo', 'noc', 'cesta', 'svět', 'část', 'místo', 'oko', 'dítě', 'žena',
+];
+
+const FI = [
+ 'ja', 'on', 'ei', 'että', 'se', 'oli', 'hän', 'ovat', 'mutta', 'kuin',
+ 'niin', 'jos', 'kun', 'myös', 'vain', 'sitä', 'tai', 'nyt', 'vielä', 'jo',
+ 'tämä', 'ne', 'me', 'te', 'minä', 'sinä', 'hyvä', 'suuri', 'pieni', 'uusi',
+ 'vanha', 'pitkä', 'paljon', 'missä', 'mitä', 'miten', 'koska', 'ennen', 'jälkeen', 'kaikki',
+ 'vuosi', 'päivä', 'aika', 'talo', 'vesi', 'kaupunki', 'maa', 'käsi', 'sana', 'yö',
+ 'tie', 'maailma', 'osa', 'paikka', 'silmä', 'lapsi', 'nainen', 'mies', 'työ', 'elämä',
+];
+
+// Ordered by how widely they are typed, English first as the default.
+const AF = [
+ 'die', 'en', 'van', 'is', 'in', 'het', 'nie', 'te', 'dat', 'op',
+ 'vir', 'met', 'was', 'sy', 'hy', 'ek', 'ons', 'jy', 'julle', 'hulle',
+ 'om', 'as', 'maar', 'ook', 'aan', 'by', 'uit', 'oor', 'na', 'deur',
+ 'kan', 'moet', 'sal', 'wil', 'weet', 'maak', 'gaan', 'kom', 'sien', 'gee',
+ 'baie', 'nog', 'weer', 'altyd', 'nooit', 'net', 'so', 'dan', 'toe', 'waar',
+ 'wat', 'wie', 'hoe', 'wanneer', 'omdat', 'sonder', 'tussen', 'onder', 'voor', 'agter',
+ 'jaar', 'dag', 'tyd', 'mens', 'werk', 'land', 'huis', 'stad', 'water', 'kind',
+ 'vrou', 'man', 'hand', 'pad', 'woord', 'lewe', 'wêreld', 'nag', 'oog', 'deel',
+ 'goed', 'groot', 'klein', 'nuwe', 'ou', 'lank', 'kort', 'eerste', 'plek', 'saak',
+ 'vriend', 'familie', 'skool', 'boek', 'môre', 'gister', 'vandag', 'mooi', 'sleg', 'help',
+];
+
+export const LANGUAGES = [
+ { id: 'en', label: 'English', words: EN },
+ { id: 'es', label: 'Español', words: ES },
+ { id: 'fr', label: 'Français', words: FR },
+ { id: 'de', label: 'Deutsch', words: DE },
+ { id: 'it', label: 'Italiano', words: IT },
+ { id: 'pt', label: 'Português', words: PT },
+ { id: 'nl', label: 'Nederlands', words: NL },
+ { id: 'sv', label: 'Svenska', words: SV },
+ { id: 'da', label: 'Dansk', words: DA },
+ { id: 'fi', label: 'Suomi', words: FI },
+ { id: 'pl', label: 'Polski', words: PL },
+ { id: 'cs', label: 'Čeština', words: CS },
+ { id: 'ro', label: 'Română', words: RO },
+ { id: 'tr', label: 'Türkçe', words: TR },
+ { id: 'id', label: 'Bahasa Indonesia', words: ID },
+ { id: 'af', label: 'Afrikaans', words: AF },
+];
+
+export function languageLabel(id) {
+ return LANGUAGES.find(l => l.id === id)?.label || 'English';
+}
+
+// Falls back to English for an unknown id - a language removed from this
+// file must not leave a stored setting pointing at nothing.
+export function languageWords(id) {
+ return (LANGUAGES.find(l => l.id === id) || LANGUAGES[0]).words;
+}
diff --git a/web/src/main.js b/web/src/main.js
new file mode 100644
index 0000000..3967db4
--- /dev/null
+++ b/web/src/main.js
@@ -0,0 +1,16 @@
+import { startApp } from './app.js';
+
+async function loadLocalTexts() {
+ try {
+ const res = await fetch(new URL('./data/texts.json', import.meta.url));
+ const data = await res.json();
+ return Array.isArray(data) ? data : [];
+ } catch (err) {
+ console.error('Failed to load local texts:', err);
+ return [];
+ }
+}
+
+loadLocalTexts().then(texts => {
+ startApp(document.getElementById('root'), texts);
+});
diff --git a/web/src/multiplayer.js b/web/src/multiplayer.js
new file mode 100644
index 0000000..28dba0a
--- /dev/null
+++ b/web/src/multiplayer.js
@@ -0,0 +1,81 @@
+import { api } from './api.js';
+import { detectDeviceType } from './deviceDetect.js';
+
+// Same base-URL resolution as api.js (window.TYPERPUNK_API_URL override for
+// local dev, same-origin otherwise), just converted to a ws:// URL instead
+// of being handed to fetch.
+function wsBaseUrl() {
+ const httpBase = (typeof window !== 'undefined' && window.TYPERPUNK_API_URL) || window.location.origin;
+ return httpBase.replace(/^http/, 'ws');
+}
+
+export async function createRoom(deviceFilter) {
+ const { room_code } = await api.post('/api/multiplayer/rooms', deviceFilter ? { device_filter: deviceFilter } : {});
+ return room_code;
+}
+
+// Live count of people sitting in multiplayer rooms, for the header's
+// ambient "is anyone around to race" indicator. Unauthenticated, like the
+// endpoint behind it.
+export async function getOnlineCount() {
+ return api.get('/api/multiplayer/online');
+}
+
+// Auto-matchmaking. The server hands back whichever room is still filling,
+// or opens a new one when none is - so the player never sees a room code.
+// createRoom stays for the deliberate "race my friends on this code" path.
+export async function quickMatch(deviceFilter) {
+ const { room_code } = await api.post('/api/multiplayer/quickmatch', deviceFilter ? { device_filter: deviceFilter } : {});
+ return room_code;
+}
+
+// Pub-sub rather than fixed callbacks passed to connectToRoom - the lobby
+// screen and the typing screen both need to react to the same connection's
+// messages (player list / countdown during the lobby phase, progress /
+// finished during the race), and they're two different pieces of code that
+// each attach their own listeners as the room moves between phases, rather
+// than one caller owning every handler up front.
+export function connectToRoom(roomCode, playerName) {
+ const ws = new WebSocket(`${wsBaseUrl()}/ws/multiplayer/${encodeURIComponent(roomCode)}`);
+ const listeners = new Map();
+ const connection = { playerId: null };
+
+ function emit(event, ...args) {
+ for (const fn of listeners.get(event) || []) fn(...args);
+ }
+
+ ws.addEventListener('open', () => {
+ ws.send(JSON.stringify({ type: 'Join', name: playerName, device_type: detectDeviceType() }));
+ });
+
+ ws.addEventListener('message', event => {
+ let msg;
+ try { msg = JSON.parse(event.data); } catch { return; }
+ switch (msg.type) {
+ case 'Joined': connection.playerId = msg.player_id; emit('joined', msg.player_id); break;
+ case 'PlayerList': emit('playerList', msg.players); break;
+ case 'Countdown': emit('countdown', msg.seconds); break;
+ case 'Start': emit('start', msg.text); break;
+ case 'PlayerProgress': emit('playerProgress', msg.player_id, msg.percent, msg.wpm); break;
+ case 'PlayerFinished': emit('playerFinished', msg.player_id, msg.wpm, msg.accuracy, msg.time, msg.place); break;
+ case 'RoomClosed': emit('roomClosed', msg.reason); break;
+ case 'Error': emit('error', msg.message); break;
+ }
+ });
+
+ ws.addEventListener('close', () => emit('close'));
+ ws.addEventListener('error', () => emit('error', 'connection error'));
+
+ Object.assign(connection, {
+ on(event, fn) {
+ if (!listeners.has(event)) listeners.set(event, new Set());
+ listeners.get(event).add(fn);
+ return () => listeners.get(event)?.delete(fn);
+ },
+ ready: () => ws.send(JSON.stringify({ type: 'Ready' })),
+ sendProgress: (percent, wpm) => ws.send(JSON.stringify({ type: 'Progress', percent, wpm })),
+ sendFinish: (wpm, accuracy, time) => ws.send(JSON.stringify({ type: 'Finish', wpm, accuracy, time })),
+ close: () => ws.close(),
+ });
+ return connection;
+}
diff --git a/web/src/pb.js b/web/src/pb.js
new file mode 100644
index 0000000..cd4413c
--- /dev/null
+++ b/web/src/pb.js
@@ -0,0 +1,56 @@
+const STORAGE_KEY = 'typerpunk:pb';
+
+function load() {
+ try {
+ const raw = JSON.parse(localStorage.getItem(STORAGE_KEY));
+ return raw && typeof raw === 'object' ? raw : {};
+ } catch {
+ return {};
+ }
+}
+
+export function getPersonalBest(modeKey) {
+ if (!modeKey) return null;
+ return load()[modeKey] || null;
+}
+
+// All recorded bests, best-WPM first, for a full stats/profile view rather
+// than a single mode's lookup.
+export function getAllPersonalBests() {
+ const all = load();
+ return Object.entries(all)
+ .map(([modeKey, record]) => ({ modeKey, ...record }))
+ .sort((a, b) => b.wpm - a.wpm);
+}
+
+// Reconciles local bests with the server's on sign-in - takes max(local,
+// remote) per mode rather than overwriting, so a best set on this device
+// before ever signing in isn't lost just because the server hadn't seen it
+// yet. Guests never call this; the account has to exist first.
+export function mergeRemoteBests(remoteBests) {
+ if (!Array.isArray(remoteBests) || remoteBests.length === 0) return;
+ const all = load();
+ for (const { mode_key: modeKey, wpm, date } of remoteBests) {
+ const local = all[modeKey];
+ if (!local || wpm > local.wpm) {
+ all[modeKey] = { wpm, date: date ? Date.parse(date) || Date.now() : Date.now() };
+ }
+ }
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); } catch {}
+}
+
+// Records a finished test's WPM against its mode's best, updating storage
+// only when it's actually a new best. Returns the previous best (or null)
+// and whether this run just beat it, so the end screen can show both in one
+// read without a separate lookup.
+export function recordResult(modeKey, wpm) {
+ if (!modeKey) return { isNewBest: false, previous: null };
+ const all = load();
+ const previous = all[modeKey] || null;
+ const isNewBest = !previous || wpm > previous.wpm;
+ if (isNewBest) {
+ all[modeKey] = { wpm, date: Date.now() };
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); } catch {}
+ }
+ return { isNewBest, previous: previous ? previous.wpm : null };
+}
diff --git a/web/src/profileStats.js b/web/src/profileStats.js
new file mode 100644
index 0000000..c88da15
--- /dev/null
+++ b/web/src/profileStats.js
@@ -0,0 +1,62 @@
+const STORAGE_KEY = 'typerpunk:profile';
+// Caps how many past results the progress chart can show - unbounded
+// growth would slowly bloat localStorage for a long-lived install with no
+// benefit, since the chart only ever renders the most recent entries anyway.
+const MAX_HISTORY = 100;
+
+function defaults() {
+ return {
+ testsCompleted: 0,
+ totalTimeSeconds: 0,
+ totalWpm: 0,
+ totalAccuracy: 0,
+ bestWpm: 0,
+ lastTestDate: null,
+ streakDays: 0,
+ history: [],
+ };
+}
+
+function load() {
+ try {
+ const raw = JSON.parse(localStorage.getItem(STORAGE_KEY));
+ return raw && typeof raw === 'object' ? { ...defaults(), ...raw } : defaults();
+ } catch {
+ return defaults();
+ }
+}
+
+export function getProfileStats() {
+ const s = load();
+ return {
+ ...s,
+ averageWpm: s.testsCompleted ? Math.round(s.totalWpm / s.testsCompleted) : 0,
+ averageAccuracy: s.testsCompleted ? Math.round(s.totalAccuracy / s.testsCompleted) : 0,
+ };
+}
+
+// Called once per finished test (any mode, including custom text) - these
+// are lifetime activity totals, not per-mode bests, so unlike pb.js every
+// completed test counts, not just new records.
+export function recordTest({ wpm, accuracy, time }) {
+ const s = load();
+ s.testsCompleted += 1;
+ s.totalTimeSeconds += time;
+ s.totalWpm += wpm;
+ s.totalAccuracy += accuracy;
+ s.bestWpm = Math.max(s.bestWpm, wpm);
+
+ const today = new Date().toDateString();
+ const lastDay = s.lastTestDate ? new Date(s.lastTestDate).toDateString() : null;
+ if (lastDay !== today) {
+ const yesterday = new Date(Date.now() - 86400000).toDateString();
+ s.streakDays = lastDay === yesterday ? s.streakDays + 1 : 1;
+ }
+ s.lastTestDate = Date.now();
+
+ s.history.push({ wpm, accuracy, date: s.lastTestDate });
+ if (s.history.length > MAX_HISTORY) s.history = s.history.slice(-MAX_HISTORY);
+
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch {}
+ return s;
+}
diff --git a/web/src/screens/accountScreen.js b/web/src/screens/accountScreen.js
new file mode 100644
index 0000000..30161d9
--- /dev/null
+++ b/web/src/screens/accountScreen.js
@@ -0,0 +1,121 @@
+import { escapeHtml } from '../util.js';
+import { renderCornerRail } from '../cornerRail.js';
+import { attachTooltips } from '../tooltip.js';
+import { renderTopRail } from '../topRail.js';
+import { getUser, onAuthChange, register, login, logout, ApiError } from '../auth.js';
+import { api, apiUrl } from '../api.js';
+
+// onShowAccount is accepted but intentionally not supplied by app.js for this
+// screen: the top rail's identity control is already pointing here, so it has
+// nothing to navigate to. Declared so the shared rail call below resolves.
+export function renderAccountScreen(root, { onBack, onShowAccount, onShowStats, onShowPlaceholder, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) {
+ let mode = 'login'; // 'login' | 'register'
+ let stopped = false;
+ // null = checking, true/false once the now-playing probe resolves --
+ // there's no dedicated "am I connected" endpoint, but now-playing
+ // itself 404s specifically when there's no stored Spotify connection,
+ // so it doubles as the status check.
+ let spotifyConnected = null;
+ let spotifyCheckedForUserId = null;
+
+ function checkSpotifyStatus(userId) {
+ spotifyCheckedForUserId = userId;
+ spotifyConnected = null;
+ api.get('/api/spotify/now-playing')
+ .then(() => { spotifyConnected = true; rerender(); })
+ .catch(err => { spotifyConnected = !(err instanceof ApiError && err.status === 404); rerender(); });
+ }
+
+ function spotifyStatusMarkup() {
+ if (spotifyConnected === null) return `<div class="account-spotify-status">Checking...</div>`;
+ if (spotifyConnected) return `<div class="account-spotify-status">Connected</div>`;
+ return `<a class="menu-button small" href="${apiUrl('/api/spotify/login')}">Connect Spotify</a>`;
+ }
+
+ function formMarkup(user) {
+ if (user) {
+ return `
+ <div class="account-signed-in">Signed in as <strong>${escapeHtml(user.username)}</strong></div>
+ <button class="menu-button" data-action="logout">Log Out</button>
+ <div class="settings-hint">Spotify</div>
+ ${spotifyStatusMarkup()}
+ `;
+ }
+ return `
+ <div class="account-tabs">
+ <button class="menu-button small${mode === 'login' ? '' : ' ghost'}" data-action="tab-login">Log In</button>
+ <button class="menu-button small${mode === 'register' ? '' : ' ghost'}" data-action="tab-register">Register</button>
+ </div>
+ <form class="account-form">
+ <input class="account-input" type="text" name="username" placeholder="Username" autocomplete="username" required>
+ <input class="account-input" type="password" name="password" placeholder="Password" autocomplete="${mode === 'login' ? 'current-password' : 'new-password'}" required>
+ <button class="menu-button" type="submit">${mode === 'login' ? 'Log In' : 'Create Account'}</button>
+ <div class="custom-error account-error"></div>
+ </form>
+ `;
+ }
+
+ function render() {
+ const user = getUser();
+ root.innerHTML = `
+ <div class="stats-screen">
+ <div class="logo" data-action="menu">TyperPunk</div>
+ <h2>Account</h2>
+ <div class="account-panel">${formMarkup(user)}</div>
+ <button class="menu-button small ghost" data-action="menu">Back</button>
+ </div>
+ `;
+
+ root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
+ attachTooltips(root);
+ const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+
+ const logoutBtn = root.querySelector('[data-action="logout"]');
+ if (logoutBtn) logoutBtn.addEventListener('click', async () => { await logout(); });
+
+ const tabLogin = root.querySelector('[data-action="tab-login"]');
+ const tabRegister = root.querySelector('[data-action="tab-register"]');
+ if (tabLogin) tabLogin.addEventListener('click', () => { mode = 'login'; rerender(); });
+ if (tabRegister) tabRegister.addEventListener('click', () => { mode = 'register'; rerender(); });
+
+ const form = root.querySelector('.account-form');
+ if (form) {
+ form.addEventListener('submit', async e => {
+ e.preventDefault();
+ const errorEl = form.querySelector('.account-error');
+ errorEl.textContent = '';
+ const username = form.username.value.trim();
+ const password = form.password.value;
+ try {
+ if (mode === 'login') await login(username, password);
+ else await register(username, password);
+ } catch (err) {
+ errorEl.textContent = err instanceof ApiError ? err.message : 'Something went wrong - try again.';
+ }
+ });
+ }
+
+ if (user && spotifyCheckedForUserId !== user.id) checkSpotifyStatus(user.id);
+ if (!user) spotifyCheckedForUserId = null;
+
+ const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore });
+ return () => {
+ cleanupTheme();
+ cleanupRail();
+ };
+ }
+
+ let cleanup = render();
+ function rerender() {
+ if (stopped) return;
+ cleanup();
+ cleanup = render();
+ }
+ const unsubscribeAuth = onAuthChange(rerender);
+
+ return () => {
+ stopped = true;
+ unsubscribeAuth();
+ cleanup();
+ };
+}
diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js
new file mode 100644
index 0000000..cd2a41d
--- /dev/null
+++ b/web/src/screens/endScreen.js
@@ -0,0 +1,226 @@
+import { buildGraphPoints, buildErrorPoints, drawChart, hitTestError, hitTestLine, calculateConsistency } from '../chart.js';
+import { escapeHtml } from '../util.js';
+import { attachTooltips } from '../tooltip.js';
+import { recordResult } from '../pb.js';
+import { recordTest } from '../profileStats.js';
+import { renderCornerRail } from '../cornerRail.js';
+import { renderTopRail } from '../topRail.js';
+import { submitTestResult } from '../auth.js';
+import { detectDeviceType } from '../deviceDetect.js';
+
+export function renderEndScreen(root, { stats, text, attribution, userInput, charTimings, keypressHistory, modeKey, onPlayAgain, onMainMenu, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) {
+ // Monkeytype's four-way split. "Extra" is anything typed past the end of
+ // the passage, "missed" is passage left untyped - neither is visible in
+ // a plain correct/incorrect pair, and the two mean very different things
+ // about a run that ended early.
+ const typedLen = (userInput || '').length;
+ const extraChars = Math.max(0, typedLen - (text || '').length);
+ const missedChars = Math.max(0, (text || '').length - typedLen);
+ const graphPoints = buildGraphPoints(charTimings, stats, keypressHistory);
+ const consistency = calculateConsistency(graphPoints);
+ const { isNewBest, previous } = recordResult(modeKey, stats.wpm);
+ recordTest({ wpm: stats.wpm, accuracy: stats.accuracy, time: stats.time });
+ // Same exclusion as recordResult above: custom text/Zen have no stable,
+ // repeatable challenge, so there's no meaningful leaderboard entry for
+ // them either.
+ if (modeKey) {
+ // Inter-keystroke gaps (not raw timestamps) - the server's
+ // anti-cheat check only cares about the *variance* between
+ // consecutive keystrokes, not when the test started.
+ const source = keypressHistory && keypressHistory.length > 1 ? keypressHistory : null;
+ const keystrokeIntervalsMs = source
+ ? source.slice(1).map((k, i) => (k.time - source[i].time) * 1000).filter(ms => ms >= 0)
+ : undefined;
+ submitTestResult({
+ mode_key: modeKey,
+ wpm: stats.wpm,
+ raw_wpm: stats.rawWpm,
+ accuracy: stats.accuracy,
+ time_seconds: stats.time,
+ device_type: detectDeviceType(),
+ keystroke_intervals_ms: keystrokeIntervalsMs,
+ });
+ }
+ // Don't announce "new best" on a mode's very first-ever run - there's
+ // nothing to have beaten yet, so it just silently becomes the baseline.
+ const pbLine = !modeKey || previous == null ? '' : isNewBest
+ ? '<div class="pb-badge pb-new">NEW BEST</div>'
+ : `<div class="pb-badge">PB ${Math.round(previous)}</div>`;
+
+ root.innerHTML = `
+ <div class="end-screen">
+ <div class="logo" data-action="menu">TyperPunk</div>
+ <div class="end-screen-text"><div class="text-display"></div></div>
+ ${attribution ? `<div class="attribution end-screen-attribution">&mdash; ${escapeHtml(attribution)}</div>` : ''}
+ <div class="end-screen-graph-row">
+ <div class="endscreen-side-stat wpm">
+ <div class="stat-label">WPM</div><div class="stat-value">${Math.round(stats.wpm)}</div>
+ ${pbLine}
+ </div>
+ <div class="graph-container"><canvas></canvas></div>
+ <div class="endscreen-side-stat acc">
+ <div class="stat-label">ACC</div><div class="stat-value">${Math.round(stats.accuracy)}%</div>
+ </div>
+ </div>
+ <div class="end-screen-stat-row">
+ <div class="end-stat"><div class="stat-label">RAW</div><div class="stat-value">${Math.round(stats.rawWpm)}</div></div>
+ <div class="end-stat"><div class="stat-label">CONSISTENCY</div><div class="stat-value">${consistency}%</div></div>
+ <div class="end-stat"><div class="stat-label">KEYSTROKES</div><div class="stat-value">${stats.keystrokes}</div></div>
+ <div class="end-stat" data-tooltip="Correct / wrong / typed past the end / left untyped">
+ <div class="stat-label">CHARACTERS</div><div class="stat-value">${stats.correctChars}/${stats.incorrectChars}/${extraChars}/${missedChars}</div>
+ </div>
+ <div class="end-stat"><div class="stat-label">TIME</div><div class="stat-value">${stats.time.toFixed(1)}s</div></div>
+ </div>
+ <div class="end-screen-buttons">
+ <button class="end-screen-button" data-action="again">Play Again</button>
+ </div>
+ </div>
+ `;
+
+ const textDisplay = root.querySelector('.text-display');
+ const inputChars = userInput ? userInput.split('') : [];
+ const wordMatches = text.match(/[^\s]+\s*/g) || [];
+ let charIndex = 0;
+ const html = [];
+ for (const word of wordMatches) {
+ html.push('<span>');
+ for (const char of word) {
+ const inputChar = inputChars[charIndex];
+ let className = 'neutral';
+ let displayChar = char;
+ if (charIndex < inputChars.length) {
+ className = inputChar === char ? 'correct' : 'incorrect';
+ if (className === 'incorrect') displayChar = inputChar;
+ }
+ html.push(`<span class="${className}">${escapeHtml(displayChar)}</span>`);
+ charIndex++;
+ }
+ html.push('</span>');
+ }
+ textDisplay.innerHTML = html.join('');
+
+ attachTooltips(root.querySelector('.end-screen-stat-row'));
+ const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+ root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onMainMenu));
+ root.querySelector('[data-action="again"]').addEventListener('click', onPlayAgain);
+
+ const canvas = root.querySelector('canvas');
+ const errorPoints = buildErrorPoints(graphPoints, keypressHistory, charTimings);
+ const xMax = Math.max(1, Math.ceil(stats.time));
+ const redraw = () => drawChart(canvas, { graphPoints, errorPoints, xMax });
+ redraw();
+ window.addEventListener('resize', redraw);
+
+ // A long passage wraps to more lines than a short one, growing
+ // .end-screen-text's height by an amount no fixed CSS margin/graph-height
+ // budget can account for - a static tuning only ever fits whichever
+ // passage happened to be on screen when it was measured. This claws the
+ // room back in stages instead, each one only kicking in if the last
+ // wasn't enough, so the page itself never needs to scroll to reach
+ // Play Again: shrink the graph down to a floor, then trim the button's
+ // top margin, and only as a last resort (a passage long enough that even
+ // both of those can't absorb it - realistically only reachable by
+ // pasting the full text-mode word buffer instead of actually typing it
+ // in the time given) let the passage preview itself scroll internally,
+ // which keeps every control below it reachable without the page
+ // scrolling. Runs once after layout settles.
+ requestAnimationFrame(() => {
+ const doc = document.documentElement;
+ // Re-measured after every stage rather than tracked as a running
+ // subtraction - margin-collapse and sub-pixel rounding meant an
+ // assumed "reduced height X means Y px less overflow" estimate
+ // drifted from the DOM's actual scrollHeight, consistently
+ // undershooting by a fixed amount however much was cut.
+ const overflowNow = () => doc.scrollHeight - doc.clientHeight;
+ if (overflowNow() <= 0) return;
+
+ const graphBox = root.querySelector('.graph-container');
+ const MIN_GRAPH_HEIGHT = 120;
+ const graphHeight = graphBox.getBoundingClientRect().height;
+ if (graphHeight > MIN_GRAPH_HEIGHT) {
+ const reduceBy = Math.min(overflowNow(), graphHeight - MIN_GRAPH_HEIGHT);
+ graphBox.style.height = `${graphHeight - reduceBy}px`;
+ redraw();
+ }
+ if (overflowNow() <= 0) return;
+
+ const buttons = root.querySelector('.end-screen-buttons');
+ const buttonsMargin = parseFloat(getComputedStyle(buttons).marginTop);
+ if (buttonsMargin > 8) {
+ const reduceBy = Math.min(overflowNow(), buttonsMargin - 8);
+ buttons.style.marginTop = `${buttonsMargin - reduceBy}px`;
+ }
+ if (overflowNow() <= 0) return;
+
+ const textBox = root.querySelector('.end-screen-text');
+ const textHeight = textBox.getBoundingClientRect().height;
+ textBox.style.maxHeight = `${Math.max(80, textHeight - overflowNow() - 4)}px`;
+ textBox.style.overflowY = 'auto';
+ });
+
+ const tooltip = document.createElement('div');
+ tooltip.className = 'chart-tooltip';
+ root.querySelector('.graph-container').appendChild(tooltip);
+
+ const handleMove = e => {
+ const rect = canvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ const errorHit = hitTestError(canvas, errorPoints, graphPoints, xMax, mx, my);
+ if (errorHit) {
+ canvas.style.cursor = 'pointer';
+ const word = errorHit.index != null ? wordAt(text, errorHit.index) : '';
+ const n = errorHit.count || 1;
+ tooltip.textContent = n > 1
+ ? `${n} errors${word ? ` · first at "${word}"` : ''}`
+ : (word ? `Error: "${word}"` : 'Error');
+ tooltip.style.left = `${mx}px`;
+ tooltip.style.top = `${my}px`;
+ tooltip.style.display = 'block';
+ return;
+ }
+ const linePoint = hitTestLine(canvas, graphPoints, xMax, mx);
+ if (linePoint) {
+ canvas.style.cursor = 'crosshair';
+ tooltip.textContent = `${linePoint.time}s · wpm ${Math.round(linePoint.wpm)} · raw ${Math.round(linePoint.raw)}`;
+ tooltip.style.left = `${mx}px`;
+ tooltip.style.top = `${my}px`;
+ tooltip.style.display = 'block';
+ } else {
+ canvas.style.cursor = 'default';
+ tooltip.style.display = 'none';
+ }
+ };
+ const handleLeave = () => { tooltip.style.display = 'none'; };
+ canvas.addEventListener('mousemove', handleMove);
+ canvas.addEventListener('mouseleave', handleLeave);
+
+ // Quick restart: Tab on the results screen is the keyboard equivalent
+ // of clicking Play Again.
+ const handleKeydown = e => {
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ onPlayAgain();
+ }
+ };
+ document.addEventListener('keydown', handleKeydown);
+
+ const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore });
+
+ return () => {
+ cleanupTheme();
+ window.removeEventListener('resize', redraw);
+ canvas.removeEventListener('mousemove', handleMove);
+ canvas.removeEventListener('mouseleave', handleLeave);
+ document.removeEventListener('keydown', handleKeydown);
+ cleanupRail();
+ };
+}
+
+function wordAt(text, index) {
+ let start = index;
+ let end = index;
+ while (start > 0 && text[start - 1] !== ' ') start--;
+ while (end < text.length && text[end] !== ' ') end++;
+ return text.slice(start, end).trim();
+}
diff --git a/web/src/screens/friendsScreen.js b/web/src/screens/friendsScreen.js
new file mode 100644
index 0000000..89317ad
--- /dev/null
+++ b/web/src/screens/friendsScreen.js
@@ -0,0 +1,149 @@
+import { escapeHtml } from '../util.js';
+import { renderCornerRail } from '../cornerRail.js';
+import { attachTooltips } from '../tooltip.js';
+import { renderTopRail } from '../topRail.js';
+import { api, ApiError } from '../api.js';
+import { getUser, onAuthChange } from '../auth.js';
+
+// onShowFriends is accepted but not supplied by app.js for this screen - the
+// top rail's Friends control already points here, so it has nowhere to go.
+// Declared so the shared rail call below resolves.
+export function renderFriendsScreen(root, { onBack, onShowFriends, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowMultiplayer, onShowStore }) {
+ let data = { friends: [], incoming_requests: [], outgoing_requests: [] };
+ let loadError = null;
+
+ async function load() {
+ if (!getUser()) return;
+ try {
+ data = await api.get('/api/friends');
+ loadError = null;
+ } catch (err) {
+ loadError = err instanceof ApiError ? err.message : 'network error';
+ }
+ }
+
+ function signedOutMarkup() {
+ return `<div class="stats-empty">Sign in to add friends and compare stats.</div>
+ <button class="menu-button" data-action="go-account">Sign In</button>`;
+ }
+
+ function signedInMarkup() {
+ return `
+ <form class="account-form friends-add-form">
+ <input class="account-input" type="text" name="username" placeholder="Add by username" required>
+ <button class="menu-button small" type="submit">Send Request</button>
+ <div class="custom-error friends-add-error"></div>
+ </form>
+
+ ${data.incoming_requests.length > 0 ? `
+ <h3>Requests</h3>
+ <div class="leaderboard-list">
+ ${data.incoming_requests.map(r => `
+ <div class="leaderboard-row">
+ <div class="leaderboard-name">${escapeHtml(r.username)}</div>
+ <button class="menu-button small" data-action="accept" data-id="${r.friendship_id}">Accept</button>
+ <button class="menu-button small ghost" data-action="decline" data-id="${r.friendship_id}">Decline</button>
+ </div>
+ `).join('')}
+ </div>` : ''}
+
+ ${data.outgoing_requests.length > 0 ? `
+ <h3>Sent</h3>
+ <div class="leaderboard-list">
+ ${data.outgoing_requests.map(r => `
+ <div class="leaderboard-row">
+ <div class="leaderboard-name">${escapeHtml(r.username)}</div>
+ <div class="leaderboard-acc">Pending</div>
+ <button class="menu-button small ghost" data-action="cancel" data-id="${r.friendship_id}">Cancel</button>
+ </div>
+ `).join('')}
+ </div>` : ''}
+
+ <h3>Friends</h3>
+ ${data.friends.length > 0 ? `
+ <div class="leaderboard-list">
+ ${data.friends.map(f => `
+ <div class="leaderboard-row">
+ <div class="leaderboard-name">${escapeHtml(f.username)}</div>
+ <button class="menu-button small ghost" data-action="unfriend" data-id="${f.friendship_id}">Remove</button>
+ </div>
+ `).join('')}
+ </div>` : `<div class="stats-empty">No friends yet - add one by username above.</div>`}
+
+ ${loadError ? `<div class="custom-error">${escapeHtml(loadError)}</div>` : ''}
+ `;
+ }
+
+ function render() {
+ const user = getUser();
+ root.innerHTML = `
+ <div class="stats-screen">
+ <div class="logo" data-action="menu">TyperPunk</div>
+ <h2>Friends</h2>
+ ${user ? signedInMarkup() : signedOutMarkup()}
+ <button class="menu-button small ghost" data-action="menu">Back</button>
+ </div>
+ `;
+
+ root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
+ attachTooltips(root);
+ const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+
+ const goAccount = root.querySelector('[data-action="go-account"]');
+ if (goAccount) goAccount.addEventListener('click', onShowAccount);
+
+ const addForm = root.querySelector('.friends-add-form');
+ if (addForm) {
+ addForm.addEventListener('submit', async e => {
+ e.preventDefault();
+ const errorEl = addForm.querySelector('.friends-add-error');
+ errorEl.textContent = '';
+ const username = addForm.username.value.trim();
+ try {
+ await api.post('/api/friends/request', { username });
+ addForm.username.value = '';
+ await load();
+ rerender();
+ } catch (err) {
+ errorEl.textContent = err instanceof ApiError ? err.message : 'Something went wrong - try again.';
+ }
+ });
+ }
+
+ root.querySelectorAll('[data-action="accept"]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ await api.post(`/api/friends/${btn.dataset.id}/accept`).catch(() => {});
+ await load();
+ rerender();
+ });
+ });
+ root.querySelectorAll('[data-action="decline"], [data-action="cancel"], [data-action="unfriend"]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ await api.delete(`/api/friends/${btn.dataset.id}`).catch(() => {});
+ await load();
+ rerender();
+ });
+ });
+
+ const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowMultiplayer, onShowStore });
+ return () => {
+ cleanupTheme();
+ cleanupRail();
+ };
+ }
+
+ let cleanup;
+ function rerender() {
+ if (cleanup) cleanup();
+ cleanup = render();
+ }
+
+ rerender();
+ load().then(rerender);
+ const unsubscribeAuth = onAuthChange(() => { load().then(rerender); });
+
+ return () => {
+ unsubscribeAuth();
+ if (cleanup) cleanup();
+ };
+}
diff --git a/web/src/screens/icons.js b/web/src/screens/icons.js
new file mode 100644
index 0000000..c088c0d
--- /dev/null
+++ b/web/src/screens/icons.js
@@ -0,0 +1,109 @@
+import { isDarkTheme } from '../theme.js';
+
+// Angular, straight-line glyphs to match the site's sharp-cornered,
+// terminal-styled UI - rounded/organic icons (circles, bezier crescents)
+// read as off-brand next to bordered rectangular buttons and monospace text.
+const SUN = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <rect x="8.6" y="8.6" width="6.8" height="6.8" transform="rotate(45 12 12)"/>
+ <path d="M12 1v3M12 20v3M1 12h3M20 12h3M4.75 4.75l2.1 2.1M17.15 17.15l2.1 2.1M19.25 4.75l-2.1 2.1M7.15 17.15l-2.1 2.1"/>
+</svg>`;
+
+// A true cut-out crescent (fill-rule evenodd punches a hexagonal hole out of
+// a larger hexagon) rather than a color-matched overlay, so it renders
+// correctly against every theme's background without special-casing.
+const MOON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M12 2L20 6.5V17.5L12 22L4 17.5V6.5L12 2ZM14.3 5.6L19 8.3V15.7L14.3 18.4L16.2 12L14.3 5.6Z"/>
+</svg>`;
+
+// Shows the icon for the theme currently active (moon while in any
+// dark-family theme, sun while in Light) rather than just checking for the
+// literal string "dark" - that missed every gallery theme (Dracula, Nord,
+// etc.), which are all dark-background but not literally named "dark".
+export function themeIcon(theme) {
+ return isDarkTheme(theme) ? MOON : SUN;
+}
+
+export const STATS_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
+ <rect x="3" y="13" width="4" height="8"/>
+ <rect x="10" y="8" width="4" height="13"/>
+ <rect x="17" y="3" width="4" height="18"/>
+</svg>`;
+
+// Three sliders (an equalizer/preferences glyph) - the hex-nut-with-teeth
+// gear this replaced still read too much like the hexagon family shared by
+// Moon/Account/Friends at a glance despite the teeth. This is unambiguous as
+// "settings" on its own and shares no shape language with anything else in
+// the icon set.
+export const SETTINGS_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
+ <line x1="3" y1="6" x2="21" y2="6"/>
+ <rect x="13" y="4" width="4" height="4" fill="currentColor" stroke="none"/>
+ <line x1="3" y1="12" x2="21" y2="12"/>
+ <rect x="6" y="10" width="4" height="4" fill="currentColor" stroke="none"/>
+ <line x1="3" y1="18" x2="21" y2="18"/>
+ <rect x="16" y="16" width="4" height="4" fill="currentColor" stroke="none"/>
+</svg>`;
+
+// Two overlapping triangle "figures" - same flat-geometric language as the
+// other corner icons, reads as "two players" without a literal person glyph.
+export const MULTIPLAYER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
+ <path d="M7 3L12.5 13H1.5L7 3Z"/>
+ <path d="M17 3L22.5 13H11.5L17 3Z" opacity="0.55"/>
+</svg>`;
+
+export const LEADERBOARD_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
+ <path d="M6 2H18V9C18 13 15.5 15.5 12 15.5C8.5 15.5 6 13 6 9V2Z"/>
+ <rect x="10" y="15.5" width="4" height="3"/>
+ <rect x="6.5" y="18.5" width="11" height="2.5"/>
+</svg>`;
+
+// Two overlapping copies of ACCOUNT_ICON's head-and-shoulders figure - two
+// small bare hexagons (an earlier draft) blurred into two indistinct dots at
+// this icon's actual render size; a head+shoulders silhouette keeps reading
+// as "people" even that small.
+export const FRIENDS_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
+ <path d="M8 2L11 3.9V7.5L8 9.4L5 7.5V3.9L8 2Z"/>
+ <path d="M2 21L3.6 14.5H12.4L14 21H2Z"/>
+ <path d="M16.5 5L19.3 6.7V10L16.5 11.7L13.7 10V6.7L16.5 5Z" opacity="0.55"/>
+ <path d="M11 22L12.4 16.2H20.6L22 22H11Z" opacity="0.55"/>
+</svg>`;
+
+export const ACCOUNT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
+ <path d="M12 2L16.5 4.6V9.8L12 12.4L7.5 9.8V4.6L12 2Z"/>
+ <path d="M4 22L6.5 15H17.5L20 22H4Z"/>
+</svg>`;
+
+// A price tag - angular throughout (straight edges, a punched square hole
+// instead of a round one), matching the rest of the set.
+export const STORE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M11 2H21V12L12 21L3 12L11 2ZM14.5 6H17.5V9H14.5V6Z"/>
+</svg>`;
+
+// A stroked chevron - an SVG path centers and scales identically everywhere,
+// unlike a unicode "▾" glyph, whose vertical metrics vary by font and don't
+// sit centered in a fixed-size box the way this does.
+export const CHEVRON_DOWN_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M5 9L12 16L19 9"/>
+</svg>`;
+
+// Cosmetic flair badges (see cosmetics.rs) - angular throughout like every
+// other icon here, a 4-point sparkle instead of a rounded star so it
+// doesn't stand out as the one curved glyph in the set.
+export const FLAIR_ICONS = {
+ star: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L14.2 9.8L22 12L14.2 14.2L12 22L9.8 14.2L2 12L9.8 9.8Z"/></svg>`,
+ bolt: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13 2L4 14H11L10 22L20 9H13L14 2Z"/></svg>`,
+ skull: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L20 6.5V14.5C20 17 18.5 19 16.5 20L15 22H9L7.5 20C5.5 19 4 17 4 14.5V6.5L12 2Z"/><rect x="8" y="12" width="3" height="3"/><rect x="13" y="12" width="3" height="3"/></svg>`,
+};
+
+// Typing-language picker. The convention for this control is a glyph pair --
+// one letterform from a non-Latin script beside a Latin "A" - which is what
+// 10FastFingers, MonkeyType and browser translate buttons all use. Drawn as
+// straight-edged strokes rather than set in a font, so it matches the rest of
+// this angular set and does not depend on a CJK face being installed.
+export const LANGUAGE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square">
+ <path d="M3 5H11"/>
+ <path d="M7 3V5"/>
+ <path d="M9.5 8.5C8.5 12 6 14 3.5 15.5"/>
+ <path d="M5 11C6.5 13.5 8.5 15 10.5 16"/>
+ <path d="M13 21L17 11L21 21"/>
+ <path d="M14.4 18H19.6"/>
+</svg>`;
diff --git a/web/src/screens/leaderboardScreen.js b/web/src/screens/leaderboardScreen.js
new file mode 100644
index 0000000..2777b1c
--- /dev/null
+++ b/web/src/screens/leaderboardScreen.js
@@ -0,0 +1,120 @@
+import { escapeHtml } from '../util.js';
+import { renderCornerRail } from '../cornerRail.js';
+import { attachTooltips } from '../tooltip.js';
+import { renderTopRail } from '../topRail.js';
+import { api, ApiError } from '../api.js';
+import { FLAIR_ICONS } from './icons.js';
+
+const WORD_COUNTS = [10, 25, 50, 100];
+const TIME_DURATIONS = [15, 30, 60, 120];
+
+// Scoped to Words/Time only, not the open-ended quote categories - those
+// grow with the text pack and don't make a stable, comparable leaderboard
+// mode the way a fixed word count or time duration does.
+const MODES = [
+ ...TIME_DURATIONS.map(d => ({ key: `time-${d}`, label: `Time ${d}s` })),
+ ...WORD_COUNTS.map(w => ({ key: `words-${w}`, label: `Words ${w}` })),
+];
+
+function formatDate(iso) {
+ const t = Date.parse(iso);
+ return Number.isNaN(t) ? '' : new Date(t).toLocaleDateString();
+}
+
+export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onShowStats, onShowPlaceholder, onShowAccount, onShowFriends, onShowMultiplayer, onShowStore }) {
+ let selectedMode = MODES[1].key; // time-30, matches the app's own default mode
+ let desktopOnly = false;
+
+ function rowsMarkup(state) {
+ if (state.status === 'loading') return `<div class="stats-empty">Loading...</div>`;
+ if (state.status === 'error') return `<div class="stats-empty">Could not load the leaderboard - ${escapeHtml(state.message)}</div>`;
+ if (state.entries.length === 0) return `<div class="stats-empty">No results yet for this mode - be the first.</div>`;
+ return `
+ <div class="leaderboard-list">
+ ${state.entries.map((e, i) => `
+ <div class="leaderboard-row">
+ <div class="leaderboard-rank">#${i + 1}</div>
+ <button class="leaderboard-name" data-username="${escapeHtml(e.username)}">${escapeHtml(e.username)}${e.flair && FLAIR_ICONS[e.flair] ? `<span class="leaderboard-flair" data-tooltip="Equipped flair">${FLAIR_ICONS[e.flair]}</span>` : ''}</button>
+ ${e.device_type === 'mobile' ? `<span class="leaderboard-device-badge" data-tooltip="Typed on a touchscreen device">mobile</span>` : ''}
+ <div class="leaderboard-wpm">${Math.round(e.wpm)} wpm</div>
+ <div class="leaderboard-acc">${Math.round(e.accuracy)}%</div>
+ <div class="leaderboard-date">${formatDate(e.date)}</div>
+ </div>
+ `).join('')}
+ </div>
+ `;
+ }
+
+ root.innerHTML = `
+ <div class="stats-screen">
+ <div class="logo" data-action="menu">TyperPunk</div>
+ <h2>Leaderboard</h2>
+ <div class="leaderboard-modes">
+ ${MODES.map(m => `<button class="menu-button small${m.key === selectedMode ? '' : ' ghost'}" data-mode="${m.key}">${escapeHtml(m.label)}</button>`).join('')}
+ </div>
+ <button class="menu-button small ghost" data-action="toggle-device-filter" data-tooltip="Mobile results still count for personal stats - this only filters what's shown here.">Devices: All</button>
+ <div class="leaderboard-results"></div>
+ <button class="menu-button small ghost" data-action="menu">Back</button>
+ </div>
+ `;
+
+ root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
+ attachTooltips(root);
+ const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+
+ const resultsEl = root.querySelector('.leaderboard-results');
+ let requestToken = 0;
+ async function load() {
+ const token = ++requestToken;
+ resultsEl.innerHTML = rowsMarkup({ status: 'loading' });
+ try {
+ const params = new URLSearchParams({ mode: selectedMode });
+ if (desktopOnly) params.set('device', 'desktop');
+ const entries = await api.get(`/api/leaderboard?${params.toString()}`);
+ if (token !== requestToken) return; // a newer mode pick already superseded this request
+ resultsEl.innerHTML = rowsMarkup({ status: 'ok', entries });
+ // Scoped to resultsEl, not the whole root - attachTooltips
+ // already ran once for the rest of the screen at render time,
+ // and re-running it there would double-bind those elements'
+ // listeners on every reload.
+ attachTooltips(resultsEl);
+ } catch (err) {
+ if (token !== requestToken) return;
+ const message = err instanceof ApiError ? err.message : 'network error';
+ resultsEl.innerHTML = rowsMarkup({ status: 'error', message });
+ }
+ }
+
+ root.querySelectorAll('.leaderboard-modes [data-mode]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ selectedMode = btn.dataset.mode;
+ root.querySelectorAll('.leaderboard-modes [data-mode]').forEach(b => b.classList.toggle('ghost', b !== btn));
+ load();
+ });
+ });
+
+ const deviceFilterBtn = root.querySelector('[data-action="toggle-device-filter"]');
+ deviceFilterBtn.addEventListener('click', () => {
+ desktopOnly = !desktopOnly;
+ deviceFilterBtn.textContent = `Devices: ${desktopOnly ? 'Desktop only' : 'All'}`;
+ deviceFilterBtn.classList.toggle('ghost', !desktopOnly);
+ load();
+ });
+
+ // Delegated rather than attached per-row - resultsEl's rows get
+ // replaced wholesale on every load(), so a listener attached to a
+ // specific row would be gone the next time the mode changes.
+ resultsEl.addEventListener('click', e => {
+ const btn = e.target.closest('[data-username]');
+ if (btn) onShowPublicProfile(btn.dataset.username);
+ });
+
+ load();
+
+ const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowFriends, onShowMultiplayer, onShowStore });
+
+ return () => {
+ cleanupTheme();
+ cleanupRail();
+ };
+}
diff --git a/web/src/screens/lyricsScreen.js b/web/src/screens/lyricsScreen.js
new file mode 100644
index 0000000..ac8b4f4
--- /dev/null
+++ b/web/src/screens/lyricsScreen.js
@@ -0,0 +1,127 @@
+import { renderCornerRail } from '../cornerRail.js';
+import { attachTooltips } from '../tooltip.js';
+import { renderTopRail } from '../topRail.js';
+import { api, apiUrl, ApiError } from '../api.js';
+import { getUser } from '../auth.js';
+import { parseCustomContent } from '../customText.js';
+import { escapeHtml } from '../util.js';
+
+const POLL_INTERVAL_MS = 4000;
+
+// Connect Spotify -> poll what's currently playing -> fetch synced lyrics
+// for it -> hand the result to onLyricsReady, which app.js implements by
+// setting it as the active custom text and starting a game exactly like a
+// manually-uploaded .lrc file would - this screen only produces that same
+// {name, chunks, language, timed} shape, it doesn't play anything itself.
+export function renderLyricsScreen(root, { onBack, onLyricsReady, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) {
+ let status = 'checking';
+ let message = 'Checking sign-in...';
+ let lastTrackKey = null;
+ let pollTimer = null;
+ let stopped = false;
+
+ function bodyText() {
+ if (status === 'signed-out') return 'Sign in first - the Spotify connection is tied to your account.';
+ if (status === 'not-connected') return 'Connect your Spotify account to type along with whatever you\'re playing.';
+ if (status === 'not-configured') return message;
+ return message;
+ }
+
+ function render() {
+ root.innerHTML = `
+ <div class="stats-screen">
+ <div class="logo" data-action="menu">TyperPunk</div>
+ <h2>Lyrics</h2>
+ <div class="stats-placeholder">${escapeHtml(bodyText())}</div>
+ ${status === 'signed-out' ? `<button class="menu-button" data-action="go-account">Sign In</button>` : ''}
+ ${status === 'not-connected' ? `<a class="menu-button" href="${apiUrl('/api/spotify/login')}">Connect Spotify</a>` : ''}
+ <button class="menu-button small ghost" data-action="menu">Back</button>
+ </div>
+ `;
+ root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', () => { stopped = true; onBack(); }));
+ attachTooltips(root);
+ const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+ const goAccount = root.querySelector('[data-action="go-account"]');
+ if (goAccount) goAccount.addEventListener('click', () => { stopped = true; onShowAccount(); });
+ const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore });
+ return () => { cleanupTheme(); cleanupRail(); };
+ }
+
+ let cleanup = render();
+ function rerender() {
+ if (stopped) return;
+ cleanup();
+ cleanup = render();
+ }
+
+ async function poll() {
+ if (stopped) return;
+ if (!getUser()) {
+ status = 'signed-out';
+ rerender();
+ return;
+ }
+ try {
+ const playing = await api.get('/api/spotify/now-playing');
+ if (!playing.is_playing || !playing.track) {
+ status = 'waiting';
+ message = 'Play something on Spotify to get started.';
+ rerender();
+ } else {
+ const trackKey = `${playing.artist}::${playing.track}`;
+ status = 'waiting';
+ message = `Now playing: ${playing.track} - ${playing.artist}. Looking for lyrics...`;
+ rerender();
+ if (trackKey !== lastTrackKey) {
+ lastTrackKey = trackKey;
+ await fetchLyrics(playing);
+ }
+ }
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 404) {
+ status = 'not-connected';
+ } else if (err instanceof ApiError && err.status === 401) {
+ status = 'signed-out';
+ } else if (err instanceof ApiError && err.status === 501) {
+ status = 'not-configured';
+ message = err.message;
+ } else {
+ status = 'waiting';
+ message = 'Could not reach Spotify - retrying...';
+ }
+ rerender();
+ }
+ if (!stopped && status !== 'not-configured') {
+ pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
+ }
+ }
+
+ async function fetchLyrics(playing) {
+ try {
+ const durationSeconds = playing.duration_ms ? Math.round(playing.duration_ms / 1000) : undefined;
+ const query = new URLSearchParams({ artist: playing.artist, track: playing.track });
+ if (durationSeconds) query.set('duration', String(durationSeconds));
+ const result = await api.get(`/api/lyrics?${query.toString()}`);
+ const raw = result.lrc || result.plain;
+ const filename = result.lrc ? 'lyrics.lrc' : 'lyrics.txt';
+ const { chunks, language, timed } = parseCustomContent(raw, filename);
+ if (chunks.length === 0) return;
+ stopped = true;
+ if (pollTimer) clearTimeout(pollTimer);
+ onLyricsReady({ name: `${playing.track} - ${playing.artist}`, chunks, language, timed });
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 404) {
+ message = `No lyrics found for "${playing.track}" - try a different song.`;
+ rerender();
+ }
+ }
+ }
+
+ poll();
+
+ return () => {
+ stopped = true;
+ if (pollTimer) clearTimeout(pollTimer);
+ cleanup();
+ };
+}
diff --git a/web/src/screens/mainMenu.js b/web/src/screens/mainMenu.js
new file mode 100644
index 0000000..909740d
--- /dev/null
+++ b/web/src/screens/mainMenu.js
@@ -0,0 +1,531 @@
+import { STATS_ICON, SETTINGS_ICON, CHEVRON_DOWN_ICON, LEADERBOARD_ICON, STORE_ICON } from './icons.js';
+import { parseCustomContent } from '../customText.js';
+import { escapeHtml } from '../util.js';
+import { getSettings, updateSettings } from '../settings.js';
+import { getProfileStats } from '../profileStats.js';
+import { attachTooltips } from '../tooltip.js';
+import { wordListTiers } from '../wordGenerator.js';
+import { renderTopRail } from '../topRail.js';
+import { onCountsChange, getCounts } from '../counts.js';
+
+function label(m) {
+ if (m === 'random') return 'Random';
+ if (m === 'words') return 'Words';
+ // "Time" read as a duplicate of the Rules panel's "Time: 30s" duration
+ // control - this is the mode (type until the clock runs out), that's a
+ // setting (how long the clock runs), so they needed visibly different
+ // names.
+ if (m === 'time') return 'Timed';
+ if (m === 'custom') return 'Custom';
+ return m.charAt(0).toUpperCase() + m.slice(1);
+}
+
+const MODE_DESCRIPTIONS = {
+ random: 'A random passage from any category below.',
+ words: 'Type a fixed number of random words (set in Rules).',
+ time: 'Type as many words as you can before the clock runs out (duration set in Rules).',
+ general: 'Random passages from the general text collection.',
+ literature: 'Passages from literature.',
+ programming: 'Passages of real code.',
+ quotes: 'Short standalone quotes.',
+ custom: 'Paste or upload your own text to type.',
+ zen: 'No timer, no word limit - type for as long as you want, then finish whenever you\'re ready.',
+ practice: 'Words chosen to target the letters you personally mistype or slow down on most, based on your recent tests.',
+};
+
+function modeDescription(m) {
+ return MODE_DESCRIPTIONS[m] || '';
+}
+
+// Rough binary-file detector: a file that decoded to mostly control chars or
+// Unicode replacement characters (U+FFFD, inserted for invalid UTF-8 byte
+// sequences) is almost certainly not text, even though `.text()` never
+// throws on it.
+function looksLikeText(str) {
+ if (!str) return false;
+ const sample = str.slice(0, 2000);
+ let suspicious = 0;
+ for (const ch of sample) {
+ const code = ch.codePointAt(0);
+ if (code === 0xfffd || (code < 32 && code !== 9 && code !== 10 && code !== 13)) suspicious++;
+ }
+ return suspicious / sample.length < 0.05;
+}
+
+const WORD_COUNTS = [10, 25, 50, 100];
+const TIME_DURATIONS = [15, 30, 60, 120];
+const SOUND_THEMES = ['off', 'click', 'mech'];
+function soundThemeLabel(theme) {
+ if (theme === 'click') return 'Click';
+ if (theme === 'mech') return 'Mechanical';
+ return 'Off';
+}
+
+function wordListTierLabel(tier) {
+ if (tier === 'extended') return 'Extended';
+ if (tier === 'hard') return 'Hard';
+ return 'Common';
+}
+
+export function renderMainMenu(root, props) {
+ const { onStartGame, categories, selectedCategory, onSelectCategory, customText, onLoadCustom, onClearCustom, onStartCustom, onStartPassive, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowLyrics, onShowStore, onSimulateTest } = props;
+ const modes = ['random', 'words', 'time', 'zen', 'practice', ...categories, 'custom'];
+ // The picker used to be one flat list of 18 entries mixing two unrelated
+ // things: how a test is generated (Random/Words/Timed/Zen/Practice/Custom)
+ // and which body of text it draws from (Literature, Science, ...). Split
+ // and shown side by side, it fits without scrolling and can be scanned.
+ const GAME_MODES = ['random', 'words', 'time', 'zen', 'practice'];
+ const gameModes = [...GAME_MODES.filter(m => modes.includes(m)), 'custom'];
+ const textCategories = modes.filter(m => !gameModes.includes(m));
+ const modeItem = m => `<button class="mode-popover-item${m === currentMode ? ' active' : ''}" data-mode="${escapeHtml(m)}" data-tooltip="${escapeHtml(modeDescription(m))}">${escapeHtml(label(m))}</button>`;
+ const currentIndex = Math.max(0, modes.indexOf(selectedCategory || 'random'));
+ const currentMode = modes[currentIndex] || 'random';
+ const isCustom = currentMode === 'custom';
+ const isWords = currentMode === 'words';
+ const isTime = currentMode === 'time';
+
+ // No "Start:" prefix - it's the one big button in the middle of the
+ // screen, clicking it obviously starts things; the label's job is just
+ // to say which mode/text it'll start.
+ let startLabel = label(currentMode);
+ if (isCustom) {
+ startLabel = customText ? customText.name : 'Load Custom Text';
+ }
+
+ const settings = getSettings();
+ const isFavorite = settings.favoriteMode === currentMode;
+ const wordCount = WORD_COUNTS.includes(settings.wordCount) ? settings.wordCount : 25;
+ const timeDuration = TIME_DURATIONS.includes(settings.timeDuration) ? settings.timeDuration : 30;
+ const profile = getProfileStats();
+
+ // Shows what Start will actually generate without opening Settings --
+ // only Words/Timed have rules to summarize; the quote-based modes and
+ // Custom don't use word count/punctuation/numbers at all.
+ let rulesSummary = '';
+ if (isWords || isTime) {
+ const parts = [isWords ? `${wordCount} words` : `${timeDuration}s`];
+ if (settings.wordsPunctuation) parts.push('punctuation');
+ if (settings.wordsNumbers) parts.push('numbers');
+ rulesSummary = parts.join(' · ');
+ }
+
+ root.innerHTML = `
+ <div class="main-menu">
+ <h1>TyperPunk</h1>
+ <div class="menu-options">
+ <button class="mode-pill sp-chevron" data-tooltip="Choose what to type">
+ <span class="mode-pill-label">${escapeHtml(startLabel)}${rulesSummary ? ` &middot; ${escapeHtml(rulesSummary)}` : ''}</span>
+ ${CHEVRON_DOWN_ICON}
+ </button>
+ <div class="start-group sp-group">
+ <button class="menu-button start-main" data-action="start">Single Player</button>
+ <div class="mode-popover sp-popover" hidden>
+ <div class="mode-popover-group">
+ <div class="mode-popover-heading">Modes</div>
+ ${gameModes.map(modeItem).join('')}
+ <button class="mode-popover-item" data-action="mode-lyrics" data-tooltip="Connect Spotify and type along to whatever's playing.">Lyrics</button>
+ </div>
+ <div class="mode-popover-group">
+ <div class="mode-popover-heading">Text</div>
+ ${textCategories.map(modeItem).join('')}
+ </div>
+ </div>
+ </div>
+ <div class="start-group mp-group">
+ <button class="menu-button start-main" data-action="mode-multiplayer" data-tooltip="Race other typists live.">Multiplayer</button>
+ </div>
+ <div class="start-caption mp-caption" hidden></div>
+ ${isCustom && customText && customText.timed ? `<button class="menu-button" data-action="start-passive">Passive Mode</button>` : ''}
+ ${isCustom && customText ? `<button class="menu-button" data-action="clear-custom">Clear Custom Text</button>` : ''}
+ </div>
+
+ <div class="settings-panel" hidden>
+ <label class="settings-row">
+ <input type="checkbox" class="settings-skip-menu"${settings.skipMenu ? ' checked' : ''} />
+ Start directly into a game, skip this menu
+ </label>
+ <button class="menu-button small" data-action="toggle-favorite">
+ ${isFavorite ? `★ Favorite (${escapeHtml(label(currentMode))})` : `☆ Set "${escapeHtml(label(currentMode))}" as favorite`}
+ </button>
+ <div class="settings-columns">
+ ${(isWords || isTime) ? `
+ <div class="settings-column">
+ <div class="settings-hint">Rules</div>
+ <div class="settings-row-group">
+ ${isWords ? `<button class="menu-button small" data-action="cycle-word-count" data-tooltip="How many words the Words mode generates. Click to cycle.">Words: ${wordCount}</button>` : ''}
+ ${isTime ? `<button class="menu-button small" data-action="cycle-time-duration" data-tooltip="How long the clock runs in Timed mode. Click to cycle.">Time: ${timeDuration}s</button>` : ''}
+ <button class="menu-button small" data-action="toggle-punctuation" data-tooltip="Include punctuation (commas, periods) in generated text.">Punctuation: ${settings.wordsPunctuation ? 'On' : 'Off'}</button>
+ <button class="menu-button small" data-action="toggle-numbers" data-tooltip="Include numbers in generated text.">Numbers: ${settings.wordsNumbers ? 'On' : 'Off'}</button>
+ <button class="menu-button small" data-action="cycle-word-list-tier" data-tooltip="Common (short, everyday words), Extended (adds longer/less frequent words), or Hard (technical and irregularly-spelled words). Click to cycle.">Word List: ${wordListTierLabel(settings.wordListTier)}</button>
+ </div>
+ </div>` : ''}
+ <div class="settings-column">
+ <div class="settings-hint">Typing</div>
+ <div class="settings-row-group">
+ <button class="menu-button small" data-action="toggle-live-stats" data-tooltip="Show WPM/ACC while you type, or only reveal them on the end screen.">Live Stats: ${settings.hideLiveStats ? 'Off' : 'On'}</button>
+ <button class="menu-button small" data-action="toggle-caret-blink" data-tooltip="Make the current-character caret blink, or keep it solid.">Blink Caret: ${settings.caretBlink ? 'On' : 'Off'}</button>
+ <button class="menu-button small" data-action="toggle-blind-mode" data-tooltip="Hide correct/incorrect coloring while typing - only revealed on the end screen.">Blind Mode: ${settings.blindMode ? 'On' : 'Off'}</button>
+ <button class="menu-button small" data-action="cycle-sound-theme" data-tooltip="Play a sound on each keystroke. Click to cycle.">Sound: ${escapeHtml(soundThemeLabel(settings.soundTheme))}</button>
+ </div>
+ </div>
+ </div>
+ <div class="settings-hint">Dev</div>
+ <button class="menu-button small" data-action="simulate-test" data-tooltip="Jump straight to the end screen with fake results, no typing required.">Simulate Test &rarr; End Screen</button>
+ <button class="menu-button small ghost" data-action="close-settings">Close</button>
+ </div>
+
+ <div class="custom-panel" hidden>
+ <div class="custom-hint">Paste text, notes, code, subtitles (.srt/.vtt), or lyrics (.lrc) - typed in order, not randomized. Drag a file in, or:</div>
+ <textarea class="custom-textarea" placeholder="Paste text, notes, code, or lyrics here... (or drop a file anywhere in this box)"></textarea>
+ <div class="custom-stats" hidden></div>
+ <div class="custom-panel-row">
+ <label class="menu-button small file-label">
+ Choose file
+ <input type="file" class="custom-file-input" accept=".txt,.md,.srt,.vtt,.lrc,.rs,.js,.ts,.jsx,.tsx,.py,.c,.h,.cpp,.hpp,.go,.java,.cs,.sh" hidden />
+ </label>
+ <span class="custom-file-name"></span>
+ </div>
+ <div class="custom-panel-row">
+ <button class="menu-button small" data-action="use-pasted">Use this text</button>
+ <button class="menu-button small ghost" data-action="close-custom">Cancel</button>
+ </div>
+ <div class="custom-error"></div>
+ </div>
+
+ <div class="logo">TyperPunk</div>
+
+ <div class="menu-key-hint">Enter to start &middot; Esc for the menu</div>
+
+ <div class="corner-rail-left">
+ <button class="corner-icon-button" data-action="settings" aria-label="Settings" data-tooltip="Settings">${SETTINGS_ICON}</button>
+ <button class="corner-icon-button" data-action="store" aria-label="Store" data-tooltip="Store">${STORE_ICON}</button>
+ </div>
+
+ <div class="corner-rail-right">
+ ${profile.testsCompleted > 0 ? `<div class="stats-corner-figures" data-tooltip="Your average WPM across every test, and your best single result.">
+ <div class="stats-corner-avg">avg ${profile.averageWpm}</div>
+ <div class="stats-corner-pb">pb ${Math.round(profile.bestWpm)}</div>
+ </div>` : ''}
+ <button class="corner-icon-button" data-action="stats" aria-label="Stats" data-tooltip="Stats">${STATS_ICON}</button>
+ <button class="corner-icon-button" data-action="leaderboard" aria-label="Leaderboard" data-tooltip="Leaderboard">${LEADERBOARD_ICON}</button>
+ </div>
+
+ </div>
+ `;
+
+ attachTooltips(root);
+ const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+
+ // Live count under the Multiplayer button. The Friends badge is the top
+ // rail's, painted there.
+ const mpCaptionEl = root.querySelector('.mp-caption');
+ function paintCounts({ online }) {
+ if (mpCaptionEl) {
+ mpCaptionEl.hidden = !online;
+ mpCaptionEl.textContent = online === 1 ? '1 racing now' : `${online} racing now`;
+ }
+ }
+ paintCounts(getCounts());
+ const unsubscribeCounts = onCountsChange(paintCounts);
+
+ // Clicking a chevron reveals every mode as a direct pick, rather than
+ // cycling blind through them one click at a time - Custom and the text
+ // categories were otherwise invisible unless you clicked through the
+ // whole list first. Shared between the Singleplayer and Multiplayer
+ // split-buttons instead of duplicated, since both need the exact same
+ // "position, open downward, cap to available height" behavior.
+ const outsideClickHandlers = [];
+ function setupDropdown(groupEl, chevronEl, popoverEl, hideElWhileOpen) {
+ function position() {
+ const rect = groupEl.getBoundingClientRect();
+ const gap = 8;
+ const spaceBelow = window.innerHeight - rect.bottom - gap;
+ popoverEl.style.left = `${rect.left}px`;
+ popoverEl.style.width = `${rect.width}px`;
+ popoverEl.style.top = `${rect.bottom + gap}px`;
+ popoverEl.style.bottom = 'auto';
+ popoverEl.style.maxHeight = `${Math.max(100, spaceBelow)}px`;
+ }
+ chevronEl.addEventListener('click', e => {
+ e.stopPropagation();
+ const opening = popoverEl.hidden;
+ popoverEl.hidden = !popoverEl.hidden;
+ if (opening) position();
+ // The popover opens flush against the button's own bottom edge,
+ // the same spot this occupies in normal flow - left showing,
+ // the two sat directly on top of each other.
+ if (hideElWhileOpen) hideElWhileOpen.style.visibility = opening ? 'hidden' : '';
+ });
+ const handleOutsideClick = e => {
+ if (!popoverEl.hidden && !groupEl.contains(e.target)) {
+ popoverEl.hidden = true;
+ if (hideElWhileOpen) hideElWhileOpen.style.visibility = '';
+ }
+ };
+ document.addEventListener('click', handleOutsideClick);
+ outsideClickHandlers.push(handleOutsideClick);
+ }
+
+ setupDropdown(
+ root.querySelector('.sp-group'),
+ root.querySelector('.sp-chevron'),
+ root.querySelector('.sp-popover'),
+ null,
+ );
+ root.querySelectorAll('.sp-popover .mode-popover-item[data-mode]').forEach(item => {
+ item.addEventListener('click', () => onSelectCategory(item.dataset.mode));
+ });
+ root.querySelector('[data-action="mode-lyrics"]').addEventListener('click', onShowLyrics);
+
+ root.querySelector('[data-action="mode-multiplayer"]').addEventListener('click', onShowMultiplayer);
+
+ const startBtn = root.querySelector('.sp-group .start-main');
+ const panel = root.querySelector('.custom-panel');
+ startBtn.addEventListener('click', () => {
+ if (isCustom && !customText) {
+ panel.hidden = false;
+ return;
+ }
+ if (isCustom) {
+ onStartCustom();
+ } else {
+ onStartGame();
+ }
+ });
+
+ // Matches the "Enter to start" hint in the bottom-right corner. Skipped
+ // while typing into a text field or with any popover/panel open, so it
+ // doesn't hijack Enter from the custom-text textarea, a form, or a mode
+ // picker where Enter has no obvious meaning.
+ const handleEnterToStart = e => {
+ if (e.key !== 'Enter') return;
+ const tag = document.activeElement?.tagName;
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
+ if (root.querySelector('.mode-popover:not([hidden])')) return;
+ // The theme gallery is owned by renderThemeButton now, not this
+ // screen's own state - re-queried fresh rather than kept as a
+ // local reference, since it doesn't exist until that call runs.
+ const themePopover = root.querySelector('.theme-gallery-popover');
+ if (!settingsPanel.hidden || (themePopover && !themePopover.hidden) || !panel.hidden) return;
+ startBtn.click();
+ };
+ document.addEventListener('keydown', handleEnterToStart);
+
+ root.querySelector('[data-action="stats"]').addEventListener('click', onShowStats);
+
+ root.querySelector('[data-action="leaderboard"]').addEventListener('click', onShowLeaderboard);
+ root.querySelector('[data-action="store"]').addEventListener('click', onShowStore);
+ // Friends and Account are the top rail's controls now, wired there.
+
+ const settingsPanel = root.querySelector('.settings-panel');
+ root.querySelector('[data-action="settings"]').addEventListener('click', () => {
+ settingsPanel.hidden = false;
+ });
+ root.querySelector('[data-action="close-settings"]').addEventListener('click', () => {
+ settingsPanel.hidden = true;
+ });
+ root.querySelector('.settings-skip-menu').addEventListener('change', e => {
+ updateSettings({ skipMenu: e.target.checked });
+ });
+
+ const favoriteBtn = root.querySelector('[data-action="toggle-favorite"]');
+ favoriteBtn.addEventListener('click', () => {
+ const nowFavorite = getSettings().favoriteMode !== currentMode;
+ updateSettings({ favoriteMode: nowFavorite ? currentMode : null });
+ favoriteBtn.textContent = nowFavorite
+ ? `★ Favorite (${label(currentMode)})`
+ : `☆ Set "${label(currentMode)}" as favorite`;
+ });
+
+ // These update settings in place (text content patched directly) rather
+ // than going through onSelectCategory's full menu re-render - none of
+ // them change the selected mode, and a full re-render was resetting
+ // .settings-panel back to hidden on every single click, forcing you to
+ // reopen Settings after each change.
+ // The mode pill above the buttons carries the mode name and its rules.
+ const startSubEl = root.querySelector('.mode-pill-label');
+ function refreshRulesSummary() {
+ if (!startSubEl) return;
+ const s = getSettings();
+ const parts = [isWords ? `${liveWordCount} words` : `${liveTimeDuration}s`];
+ if (s.wordsPunctuation) parts.push('punctuation');
+ if (s.wordsNumbers) parts.push('numbers');
+ startSubEl.textContent = `${startLabel} · ${parts.join(' · ')}`;
+ }
+
+ let liveWordCount = wordCount;
+ const wordCountBtn = root.querySelector('[data-action="cycle-word-count"]');
+ if (wordCountBtn) {
+ wordCountBtn.addEventListener('click', () => {
+ liveWordCount = WORD_COUNTS[(WORD_COUNTS.indexOf(liveWordCount) + 1) % WORD_COUNTS.length];
+ updateSettings({ wordCount: liveWordCount });
+ wordCountBtn.textContent = `Words: ${liveWordCount}`;
+ refreshRulesSummary();
+ });
+ }
+
+ let liveTimeDuration = timeDuration;
+ const timeDurationBtn = root.querySelector('[data-action="cycle-time-duration"]');
+ if (timeDurationBtn) {
+ timeDurationBtn.addEventListener('click', () => {
+ liveTimeDuration = TIME_DURATIONS[(TIME_DURATIONS.indexOf(liveTimeDuration) + 1) % TIME_DURATIONS.length];
+ updateSettings({ timeDuration: liveTimeDuration });
+ timeDurationBtn.textContent = `Time: ${liveTimeDuration}s`;
+ refreshRulesSummary();
+ });
+ }
+
+ const punctuationBtn = root.querySelector('[data-action="toggle-punctuation"]');
+ if (punctuationBtn) {
+ punctuationBtn.addEventListener('click', () => {
+ const next = !getSettings().wordsPunctuation;
+ updateSettings({ wordsPunctuation: next });
+ punctuationBtn.textContent = `Punctuation: ${next ? 'On' : 'Off'}`;
+ refreshRulesSummary();
+ });
+ }
+ const numbersBtn = root.querySelector('[data-action="toggle-numbers"]');
+ if (numbersBtn) {
+ numbersBtn.addEventListener('click', () => {
+ const next = !getSettings().wordsNumbers;
+ updateSettings({ wordsNumbers: next });
+ numbersBtn.textContent = `Numbers: ${next ? 'On' : 'Off'}`;
+ refreshRulesSummary();
+ });
+ }
+ const wordListTierBtn = root.querySelector('[data-action="cycle-word-list-tier"]');
+ if (wordListTierBtn) {
+ const tiers = wordListTiers();
+ wordListTierBtn.addEventListener('click', () => {
+ const current = getSettings().wordListTier;
+ const next = tiers[(tiers.indexOf(current) + 1) % tiers.length];
+ updateSettings({ wordListTier: next });
+ wordListTierBtn.textContent = `Word List: ${wordListTierLabel(next)}`;
+ });
+ }
+
+ const liveStatsBtn = root.querySelector('[data-action="toggle-live-stats"]');
+ liveStatsBtn.addEventListener('click', () => {
+ const next = !getSettings().hideLiveStats;
+ updateSettings({ hideLiveStats: next });
+ liveStatsBtn.textContent = `Live Stats: ${next ? 'Off' : 'On'}`;
+ });
+ const caretBlinkBtn = root.querySelector('[data-action="toggle-caret-blink"]');
+ caretBlinkBtn.addEventListener('click', () => {
+ const next = !getSettings().caretBlink;
+ updateSettings({ caretBlink: next });
+ caretBlinkBtn.textContent = `Blink Caret: ${next ? 'On' : 'Off'}`;
+ });
+ const blindModeBtn = root.querySelector('[data-action="toggle-blind-mode"]');
+ blindModeBtn.addEventListener('click', () => {
+ const next = !getSettings().blindMode;
+ updateSettings({ blindMode: next });
+ blindModeBtn.textContent = `Blind Mode: ${next ? 'On' : 'Off'}`;
+ });
+ const soundThemeBtn = root.querySelector('[data-action="cycle-sound-theme"]');
+ soundThemeBtn.addEventListener('click', () => {
+ const current = getSettings().soundTheme;
+ const next = SOUND_THEMES[(SOUND_THEMES.indexOf(current) + 1) % SOUND_THEMES.length];
+ updateSettings({ soundTheme: next });
+ soundThemeBtn.textContent = `Sound: ${soundThemeLabel(next)}`;
+ });
+
+ root.querySelector('[data-action="simulate-test"]').addEventListener('click', onSimulateTest);
+
+ const passiveBtn = root.querySelector('[data-action="start-passive"]');
+ if (passiveBtn) passiveBtn.addEventListener('click', onStartPassive);
+
+ const clearBtn = root.querySelector('[data-action="clear-custom"]');
+ if (clearBtn) clearBtn.addEventListener('click', onClearCustom);
+
+ const closeBtn = root.querySelector('[data-action="close-custom"]');
+ closeBtn.addEventListener('click', () => { panel.hidden = true; });
+
+ const fileInput = root.querySelector('.custom-file-input');
+ const fileNameLabel = root.querySelector('.custom-file-name');
+ const textarea = root.querySelector('.custom-textarea');
+ const customStats = root.querySelector('.custom-stats');
+ const errorBox = root.querySelector('.custom-error');
+ let pickedFile = null;
+
+ // Word/character count of whatever's currently loaded (pasted or a
+ // picked file) - shown before you commit to it, since a file's content
+ // isn't otherwise visible until you've already started typing it.
+ async function refreshCustomStats() {
+ let raw = pickedFile ? await pickedFile.text() : textarea.value;
+ if (!raw || !raw.trim()) {
+ customStats.hidden = true;
+ return;
+ }
+ const words = raw.trim().split(/\s+/).filter(Boolean).length;
+ const chars = raw.length;
+ customStats.hidden = false;
+ customStats.textContent = `${words} words · ${chars} characters`;
+ }
+
+ function setPickedFile(file) {
+ pickedFile = file || null;
+ fileNameLabel.textContent = pickedFile ? pickedFile.name : '';
+ if (pickedFile) textarea.value = '';
+ refreshCustomStats();
+ }
+
+ fileInput.addEventListener('change', () => setPickedFile(fileInput.files[0]));
+ textarea.addEventListener('input', () => {
+ if (pickedFile) setPickedFile(null);
+ else refreshCustomStats();
+ });
+
+ // Drag-and-drop a file directly onto the textarea, instead of only
+ // through the file picker button.
+ textarea.addEventListener('dragover', e => {
+ e.preventDefault();
+ textarea.classList.add('drag-over');
+ });
+ textarea.addEventListener('dragleave', () => textarea.classList.remove('drag-over'));
+ textarea.addEventListener('drop', e => {
+ e.preventDefault();
+ textarea.classList.remove('drag-over');
+ const file = e.dataTransfer.files && e.dataTransfer.files[0];
+ if (file) setPickedFile(file);
+ });
+
+ root.querySelector('[data-action="use-pasted"]').addEventListener('click', async () => {
+ errorBox.textContent = '';
+ try {
+ let raw;
+ let name;
+ if (pickedFile) {
+ raw = await pickedFile.text();
+ name = pickedFile.name;
+ } else {
+ raw = textarea.value;
+ name = 'Pasted text';
+ }
+ if (!raw || !raw.trim()) {
+ errorBox.textContent = 'Paste some text or choose a file first.';
+ return;
+ }
+ if (pickedFile && !looksLikeText(raw)) {
+ errorBox.textContent = "That file doesn't look like text - try a .txt, .md, or code file.";
+ return;
+ }
+ const { chunks, language, timed } = parseCustomContent(raw, name);
+ if (chunks.length === 0) {
+ errorBox.textContent = 'Could not find any typeable text in that content.';
+ return;
+ }
+ onLoadCustom({ name, chunks, language, timed });
+ } catch (err) {
+ console.error('Failed to load custom text:', err);
+ errorBox.textContent = 'Failed to read that file.';
+ }
+ });
+
+ return () => {
+ cleanupTheme();
+ unsubscribeCounts();
+ document.removeEventListener('keydown', handleEnterToStart);
+ outsideClickHandlers.forEach(h => document.removeEventListener('click', h));
+ };
+}
diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js
new file mode 100644
index 0000000..0de0182
--- /dev/null
+++ b/web/src/screens/multiplayerScreen.js
@@ -0,0 +1,265 @@
+import { escapeHtml } from '../util.js';
+import { renderCornerRail } from '../cornerRail.js';
+import { attachTooltips } from '../tooltip.js';
+import { renderTopRail } from '../topRail.js';
+import { createRoom, quickMatch, connectToRoom } from '../multiplayer.js';
+import { getUser } from '../auth.js';
+import { createGame, freeGame } from '../game.js';
+import { renderTypingGame } from './typingGame.js';
+
+// You are always the theme's own accent; opponents take the --player-N hues
+// in the order the server lists them, so the mapping is stable for everyone
+// in the room and the same person keeps their colour from lobby to race.
+const OPPONENT_COLORS = ['var(--player-2)', 'var(--player-3)', 'var(--player-4)', 'var(--player-5)'];
+
+function racerColors(players, myId) {
+ const colors = {};
+ let next = 0;
+ for (const p of players) {
+ if (p.id === myId) {
+ colors[p.id] = 'var(--primary-color)';
+ } else {
+ colors[p.id] = OPPONENT_COLORS[next % OPPONENT_COLORS.length];
+ next += 1;
+ }
+ }
+ return colors;
+}
+
+// No account required - a quick race with friends shouldn't need signing
+// in, so this only prefills the name field when one exists rather than
+// gating the whole feature behind it (unlike Friends/Leaderboard, which
+// inherently need an identity to mean anything).
+export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowStore }) {
+ let connection = null;
+ let cleanupInner = null;
+ let players = [];
+ let myId = null;
+ let game = null;
+
+ function teardownInner() {
+ if (cleanupInner) { cleanupInner(); cleanupInner = null; }
+ }
+
+ function leaveRoom() {
+ connection?.close();
+ connection = null;
+ }
+
+ function renderLanding(landingError) {
+ teardownInner();
+ let deviceFilter = 'everyone';
+ root.innerHTML = `
+ <div class="stats-screen">
+ <div class="logo" data-action="menu">TyperPunk</div>
+ <h2>Multiplayer</h2>
+ <div class="account-panel">
+ <input class="account-input" type="text" id="mp-name" placeholder="Your name" value="${escapeHtml(getUser()?.username || '')}">
+ <button class="menu-button small ghost" data-action="toggle-device-filter" data-tooltip="Who you get matched with, and the setting any room you open uses. Joining by code always uses that room's setting.">Match: Everyone</button>
+ <button class="menu-button" data-action="quick" data-tooltip="Drops you straight into a race with whoever else is looking. No code to share.">Find a Race</button>
+ <div class="settings-hint">or race specific people</div>
+ <div class="mp-code-row">
+ <input class="account-input" type="text" id="mp-room-code" placeholder="Room code">
+ <button class="menu-button small ghost" data-action="join">Join</button>
+ </div>
+ <button class="menu-button small ghost" data-action="create" data-tooltip="Opens an empty room and gives you a code to share.">Create a Room</button>
+ <div class="custom-error mp-error">${escapeHtml(landingError || '')}</div>
+ </div>
+ <button class="menu-button small ghost" data-action="menu">Back</button>
+ </div>
+ `;
+ root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
+ attachTooltips(root);
+ const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+ const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowStore });
+
+ const nameInput = root.querySelector('#mp-name');
+ const errorEl = root.querySelector('.mp-error');
+
+ const deviceFilterBtn = root.querySelector('[data-action="toggle-device-filter"]');
+ deviceFilterBtn.addEventListener('click', () => {
+ deviceFilter = deviceFilter === 'everyone' ? 'desktop_only' : 'everyone';
+ deviceFilterBtn.textContent = `Match: ${deviceFilter === 'everyone' ? 'Everyone' : 'Desktop only'}`;
+ });
+
+ const quickBtn = root.querySelector('[data-action="quick"]');
+ quickBtn.addEventListener('click', async () => {
+ const name = nameInput.value.trim() || 'Player';
+ quickBtn.disabled = true;
+ quickBtn.textContent = 'Finding a race...';
+ try {
+ const roomCode = await quickMatch(deviceFilter);
+ joinRoom(roomCode, name, true);
+ } catch {
+ quickBtn.disabled = false;
+ quickBtn.textContent = 'Find a Race';
+ errorEl.textContent = 'Could not reach matchmaking - try again.';
+ }
+ });
+
+ root.querySelector('[data-action="create"]').addEventListener('click', async () => {
+ const name = nameInput.value.trim() || 'Player';
+ try {
+ const roomCode = await createRoom(deviceFilter);
+ joinRoom(roomCode, name);
+ } catch {
+ errorEl.textContent = 'Could not create a room - try again.';
+ }
+ });
+ root.querySelector('[data-action="join"]').addEventListener('click', () => {
+ const name = nameInput.value.trim() || 'Player';
+ const code = root.querySelector('#mp-room-code').value.trim().toUpperCase();
+ if (!code) { errorEl.textContent = 'Enter a room code.'; return; }
+ joinRoom(code, name);
+ });
+
+ cleanupInner = () => { cleanupTheme(); cleanupRail(); };
+ }
+
+ // `auto` marks a quick-match join: the player asked to be put in a race,
+ // not to sit in a lobby, so they are readied up as soon as the server
+ // acknowledges the join and the race starts on its own once someone else
+ // is in. A join by room code stays manual - there the lobby is the point,
+ // since you are waiting for specific people to arrive.
+ function joinRoom(roomCode, name, auto = false) {
+ teardownInner();
+ players = [];
+ myId = null;
+ connection = connectToRoom(roomCode, name);
+ renderLobby(roomCode, auto);
+ }
+
+ function renderLobby(roomCode, auto = false) {
+ root.innerHTML = `
+ <div class="stats-screen">
+ <div class="logo" data-action="menu">TyperPunk</div>
+ <h2>Room ${escapeHtml(roomCode)}</h2>
+ <div class="settings-hint">Share this code with whoever you're racing.</div>
+ <div class="leaderboard-list mp-player-list"></div>
+ <button class="menu-button" data-action="ready">Ready</button>
+ <div class="mp-countdown"></div>
+ <button class="menu-button ghost" data-action="leave">Leave Room</button>
+ </div>
+ `;
+ root.querySelector('[data-action="menu"]').addEventListener('click', () => { leaveRoom(); onBack(); });
+ attachTooltips(root);
+ const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+ const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowStore });
+
+ const readyBtn = root.querySelector('[data-action="ready"]');
+ const listEl = root.querySelector('.mp-player-list');
+ function paintPlayers() {
+ const colors = racerColors(players, myId);
+ listEl.innerHTML = players.map(p => `
+ <div class="leaderboard-row" style="--racer-color: ${colors[p.id]}">
+ <div class="leaderboard-name"><span class="mp-racer-dot"></span>${escapeHtml(p.name)}${p.id === myId ? ' (you)' : ''}</div>
+ <div class="leaderboard-acc">${p.ready ? 'Ready' : 'Not ready'}</div>
+ </div>
+ `).join('');
+ }
+ paintPlayers();
+
+ const offJoined = connection.on('joined', id => {
+ myId = id;
+ paintPlayers();
+ // Ready is sent once, here, rather than on every playerList --
+ // the server starts a countdown as soon as everyone present is
+ // ready, and re-sending would restart it.
+ if (auto) {
+ connection.ready();
+ readyBtn.textContent = 'Waiting for players...';
+ readyBtn.disabled = true;
+ }
+ });
+ const offPlayerList = connection.on('playerList', list => { players = list; paintPlayers(); });
+ const offCountdown = connection.on('countdown', s => {
+ root.querySelector('.mp-countdown').textContent = s;
+ });
+ const offStart = connection.on('start', text => startRace(text));
+ // The server closes the connection right after an Error (e.g. a
+ // desktop-only room rejecting a mobile joiner) - 'close' always
+ // follows, so it's what actually routes back to the landing screen;
+ // this just remembers the message so that screen can show it
+ // instead of silently dropping the player back with no explanation.
+ let lastError = null;
+ const offError = connection.on('error', msg => { lastError = msg; });
+ const offClose = connection.on('close', () => renderLanding(lastError));
+
+ readyBtn.addEventListener('click', () => {
+ connection.ready();
+ readyBtn.textContent = 'Waiting for players...';
+ readyBtn.disabled = true;
+ });
+ root.querySelector('[data-action="leave"]').addEventListener('click', () => { leaveRoom(); renderLanding(); });
+
+ cleanupInner = () => {
+ offJoined(); offPlayerList(); offCountdown(); offStart(); offError(); offClose();
+ cleanupTheme(); cleanupRail();
+ };
+ }
+
+ async function startRace(text) {
+ teardownInner();
+ try {
+ game = await createGame();
+ game.set_text(text);
+ } catch (err) {
+ console.error('Failed to start multiplayer race:', err);
+ renderLanding();
+ return;
+ }
+
+ const opponents = document.createElement('div');
+ opponents.className = 'mp-opponents';
+ const progressById = {};
+ function paintOpponents() {
+ const colors = racerColors(players, myId);
+ opponents.innerHTML = players.filter(p => p.id !== myId).map(p => `
+ <div class="mp-opponent-row" style="--racer-color: ${colors[p.id]}">
+ <span class="mp-opponent-name">${escapeHtml(p.name)}</span>
+ <div class="mp-opponent-bar"><div class="mp-opponent-bar-fill" style="width:${(progressById[p.id]?.percent || 0)}%"></div></div>
+ <span class="mp-opponent-wpm">${Math.round(progressById[p.id]?.wpm || 0)}</span>
+ </div>
+ `).join('');
+ }
+ paintOpponents();
+
+ const offProgress = connection.on('playerProgress', (id, percent, wpm) => {
+ progressById[id] = { percent, wpm };
+ paintOpponents();
+ });
+ // Reserved for a future shared-standings view - for now the local
+ // player just falls through to the normal end screen on their own
+ // finish, same as any other mode.
+ const offFinished = connection.on('playerFinished', () => {});
+
+ const cleanupTyping = renderTypingGame(root, {
+ game, text, modeKey: undefined,
+ multiplayer: { connection },
+ onFinish: result => {
+ offProgress(); offFinished();
+ freeGame(game);
+ game = null;
+ leaveRoom();
+ onFinish(result);
+ },
+ onMainMenu: () => { leaveRoom(); onBack(); },
+ onRestart: null,
+ onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends,
+ });
+ root.appendChild(opponents);
+
+ cleanupInner = () => {
+ offProgress(); offFinished();
+ cleanupTyping();
+ opponents.remove();
+ };
+ }
+
+ renderLanding();
+ return () => {
+ teardownInner();
+ leaveRoom();
+ freeGame(game);
+ };
+}
diff --git a/web/src/screens/passiveMode.js b/web/src/screens/passiveMode.js
new file mode 100644
index 0000000..e690d3c
--- /dev/null
+++ b/web/src/screens/passiveMode.js
@@ -0,0 +1,159 @@
+import { getTheme, toggleTheme, onThemeChange } from '../theme.js';
+import { themeIcon } from './icons.js';
+import { escapeHtml } from '../util.js';
+
+const PACE_WPM = 130;
+const LINE_GAP_SECONDS = 0.6;
+
+function buildSchedule(chunks) {
+ const allTimed = chunks.every(c => c.time !== null && c.time !== undefined);
+ if (allTimed) {
+ const base = chunks[0].time;
+ return chunks.map(c => c.time - base);
+ }
+ let t = 0;
+ const offsets = [];
+ for (const c of chunks) {
+ offsets.push(t);
+ const duration = (c.content.length / 5) / (PACE_WPM / 60);
+ t += duration + LINE_GAP_SECONDS;
+ }
+ return offsets;
+}
+
+function renderLine(content, typed) {
+ const inputChars = typed.split('');
+ let html = '';
+ for (let i = 0; i < content.length; i++) {
+ const char = content[i];
+ const inputChar = inputChars[i];
+ let cls = 'neutral';
+ let displayChar = char;
+ if (i < inputChars.length) {
+ cls = inputChar === char ? 'correct' : 'incorrect';
+ if (cls === 'incorrect') displayChar = inputChar;
+ }
+ html += `<span class="${cls}">${escapeHtml(displayChar)}</span>`;
+ }
+ return html;
+}
+
+export function renderPassiveMode(root, { name, chunks, onExit }) {
+ const schedule = buildSchedule(chunks);
+ const totalDuration = schedule[schedule.length - 1] + 4; // let the last line linger
+
+ root.innerHTML = `
+ <div class="passive-mode">
+ <div class="logo" data-action="menu">TyperPunk</div>
+ <div class="passive-source">${escapeHtml(name)} · passive mode</div>
+ <div class="passive-lines">
+ <div class="passive-line prev"></div>
+ <div class="passive-line active"></div>
+ <div class="passive-line next"></div>
+ </div>
+ <input class="typing-input passive-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" />
+ <div class="passive-stats">
+ <div class="stat-label">ACCURACY</div>
+ <div class="stat-value" data-field="acc">100%</div>
+ </div>
+ <div class="passive-summary" hidden>
+ <div class="passive-summary-title">Session complete</div>
+ <div class="passive-summary-stats">
+ <div><span class="stat-label">LINES</span><span class="stat-value" data-field="lines">0</span></div>
+ <div><span class="stat-label">ACCURACY</span><span class="stat-value" data-field="final-acc">100%</span></div>
+ </div>
+ <button class="menu-button" data-action="back">Back to Menu</button>
+ </div>
+ <button class="theme-toggle" data-action="theme" aria-label="Toggle theme"></button>
+ </div>
+ `;
+
+ const prevLine = root.querySelector('.passive-line.prev');
+ const activeLine = root.querySelector('.passive-line.active');
+ const nextLine = root.querySelector('.passive-line.next');
+ const input = root.querySelector('.passive-input');
+ const accField = root.querySelector('[data-field="acc"]');
+ const summary = root.querySelector('.passive-summary');
+ const themeButton = root.querySelector('.theme-toggle');
+
+ const paintTheme = () => { themeButton.innerHTML = themeIcon(getTheme()); };
+ paintTheme();
+ const unsubscribeTheme = onThemeChange(paintTheme);
+ themeButton.addEventListener('click', toggleTheme);
+ root.querySelector('[data-action="menu"]').addEventListener('click', onExit);
+
+ let activeIndex = -1;
+ let typedForActive = '';
+ let totalCorrect = 0;
+ let totalTyped = 0;
+ let done = false;
+ const startedAt = Date.now();
+ let intervalId = null;
+
+ function paintLines() {
+ prevLine.textContent = activeIndex > 0 ? chunks[activeIndex - 1].content : '';
+ activeLine.innerHTML = activeIndex >= 0 ? renderLine(chunks[activeIndex].content, typedForActive) : '';
+ nextLine.textContent = activeIndex + 1 < chunks.length ? chunks[activeIndex + 1].content : '';
+ }
+
+ function paintAccuracy() {
+ const acc = totalTyped === 0 ? 100 : Math.round((totalCorrect / totalTyped) * 100);
+ accField.textContent = `${acc}%`;
+ return acc;
+ }
+
+ function finishSession() {
+ if (done) return;
+ done = true;
+ if (intervalId) clearInterval(intervalId);
+ input.disabled = true;
+ const acc = paintAccuracy();
+ summary.querySelector('[data-field="lines"]').textContent = String(chunks.length);
+ summary.querySelector('[data-field="final-acc"]').textContent = `${acc}%`;
+ summary.hidden = false;
+ }
+
+ function advanceTo(index) {
+ // Score whatever was typed for the outgoing line before moving on.
+ if (activeIndex >= 0) {
+ const target = chunks[activeIndex].content;
+ for (let i = 0; i < typedForActive.length; i++) {
+ totalTyped++;
+ if (typedForActive[i] === target[i]) totalCorrect++;
+ }
+ }
+ activeIndex = index;
+ typedForActive = '';
+ input.value = '';
+ paintLines();
+ paintAccuracy();
+ }
+
+ input.addEventListener('input', () => {
+ if (done || activeIndex < 0) return;
+ typedForActive = input.value;
+ activeLine.innerHTML = renderLine(chunks[activeIndex].content, typedForActive);
+ });
+ input.addEventListener('blur', () => {
+ if (!done) setTimeout(() => input.focus(), 10);
+ });
+
+ summary.querySelector('[data-action="back"]').addEventListener('click', onExit);
+
+ setTimeout(() => input.focus(), 50);
+ advanceTo(0);
+
+ intervalId = setInterval(() => {
+ if (done) return;
+ const elapsed = (Date.now() - startedAt) / 1000;
+ let target = activeIndex;
+ while (target + 1 < schedule.length && elapsed >= schedule[target + 1]) target++;
+ if (target !== activeIndex) advanceTo(target);
+ if (elapsed >= totalDuration) finishSession();
+ }, 150);
+
+ return () => {
+ unsubscribeTheme();
+ if (intervalId) clearInterval(intervalId);
+ };
+}
diff --git a/web/src/screens/placeholderScreen.js b/web/src/screens/placeholderScreen.js
new file mode 100644
index 0000000..5d652ce
--- /dev/null
+++ b/web/src/screens/placeholderScreen.js
@@ -0,0 +1,31 @@
+import { escapeHtml } from '../util.js';
+import { renderCornerRail } from '../cornerRail.js';
+import { attachTooltips } from '../tooltip.js';
+import { renderTopRail } from '../topRail.js';
+
+// Shared shell for any feature that has a nav entry point but no real screen
+// yet (Multiplayer, Friends - Account and Leaderboard have since become
+// real screens and moved out of here) - one implementation to swap out per
+// feature as each gets built, instead of a copy of the same "coming soon"
+// markup per feature.
+export function renderPlaceholderScreen(root, { title, description, onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) {
+ root.innerHTML = `
+ <div class="stats-screen">
+ <div class="logo" data-action="menu">TyperPunk</div>
+ <h2>${escapeHtml(title)}</h2>
+ <div class="stats-placeholder">${escapeHtml(description)}</div>
+ <button class="menu-button small ghost" data-action="menu">Back</button>
+ </div>
+ `;
+
+ root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
+ attachTooltips(root);
+ const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+
+ const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore });
+
+ return () => {
+ cleanupTheme();
+ cleanupRail();
+ };
+}
diff --git a/web/src/screens/publicProfileScreen.js b/web/src/screens/publicProfileScreen.js
new file mode 100644
index 0000000..5ce50ed
--- /dev/null
+++ b/web/src/screens/publicProfileScreen.js
@@ -0,0 +1,92 @@
+import { escapeHtml } from '../util.js';
+import { renderCornerRail } from '../cornerRail.js';
+import { attachTooltips } from '../tooltip.js';
+import { renderTopRail } from '../topRail.js';
+import { api, ApiError } from '../api.js';
+import { FLAIR_ICONS } from './icons.js';
+
+// modeKey format is set in app.js: "words-25", "time-30", "quote-<category>".
+function modeLabel(modeKey) {
+ const [kind, arg] = modeKey.split('-');
+ if (kind === 'words') return `Words · ${arg}`;
+ if (kind === 'time') return `Time · ${arg}s`;
+ if (kind === 'quote') return arg === 'random' ? 'Random' : arg.charAt(0).toUpperCase() + arg.slice(1);
+ return modeKey;
+}
+
+function formatDate(iso) {
+ const t = Date.parse(iso);
+ return Number.isNaN(t) ? '' : new Date(t).toLocaleDateString();
+}
+
+// Read-only - no settings, no actions on someone else's data, just what
+// /api/users/:username/public hands back (the same aggregate shape
+// /api/stats/me uses for yourself, minus anything private).
+export function renderPublicProfileScreen(root, { username, onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) {
+ function bodyMarkup(state) {
+ if (state.status === 'loading') return `<div class="stats-empty">Loading...</div>`;
+ if (state.status === 'error') return `<div class="stats-empty">${escapeHtml(state.message)}</div>`;
+ const p = state.profile;
+ return `
+ <div class="stats-summary">
+ <div class="menu-stat"><div class="stat-label">Tests</div><div class="stat-value">${p.tests_completed}</div></div>
+ <div class="menu-stat"><div class="stat-label">Avg WPM</div><div class="stat-value">${Math.round(p.average_wpm)}</div></div>
+ <div class="menu-stat"><div class="stat-label">Best WPM</div><div class="stat-value">${Math.round(p.best_wpm)}</div></div>
+ <div class="menu-stat"><div class="stat-label">Avg Acc</div><div class="stat-value">${Math.round(p.average_accuracy)}%</div></div>
+ </div>
+ <div class="stats-placeholder">Joined ${formatDate(p.joined_at)}</div>
+ <h3>Personal bests</h3>
+ ${p.personal_bests.length > 0 ? `
+ <div class="stats-pb-list">
+ ${p.personal_bests.map(b => `
+ <div class="stats-pb-row">
+ <div class="stats-pb-mode">${escapeHtml(modeLabel(b.mode_key))}</div>
+ <div class="stats-pb-wpm">${Math.round(b.wpm)} wpm</div>
+ <div class="stats-pb-date">${formatDate(b.date)}</div>
+ </div>
+ `).join('')}
+ </div>
+ ` : `<div class="stats-empty">No personal bests recorded yet.</div>`}
+ `;
+ }
+
+ root.innerHTML = `
+ <div class="stats-screen">
+ <div class="logo" data-action="menu">TyperPunk</div>
+ <h2 class="public-profile-heading">${escapeHtml(username)}</h2>
+ <div class="public-profile-body"></div>
+ <button class="menu-button small ghost" data-action="menu">Back</button>
+ </div>
+ `;
+
+ root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
+ attachTooltips(root);
+ const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+
+ const bodyEl = root.querySelector('.public-profile-body');
+ const headingEl = root.querySelector('.public-profile-heading');
+ bodyEl.innerHTML = bodyMarkup({ status: 'loading' });
+ api.get(`/api/users/${encodeURIComponent(username)}/public`)
+ .then(profile => {
+ bodyEl.innerHTML = bodyMarkup({ status: 'ok', profile });
+ if (profile.flair && FLAIR_ICONS[profile.flair]) {
+ headingEl.innerHTML = `${escapeHtml(username)}<span class="leaderboard-flair" data-tooltip="Equipped flair">${FLAIR_ICONS[profile.flair]}</span>`;
+ // Scoped to headingEl, not the whole root - see the same
+ // note in leaderboardScreen.js's load().
+ attachTooltips(headingEl);
+ }
+ })
+ .catch(err => {
+ const message = err instanceof ApiError && err.status === 404
+ ? `No profile found for "${username}".`
+ : 'Could not load this profile - try again.';
+ bodyEl.innerHTML = bodyMarkup({ status: 'error', message });
+ });
+
+ const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore });
+
+ return () => {
+ cleanupTheme();
+ cleanupRail();
+ };
+}
diff --git a/web/src/screens/statsScreen.js b/web/src/screens/statsScreen.js
new file mode 100644
index 0000000..a38e193
--- /dev/null
+++ b/web/src/screens/statsScreen.js
@@ -0,0 +1,85 @@
+import { getProfileStats } from '../profileStats.js';
+import { getAllPersonalBests } from '../pb.js';
+import { escapeHtml } from '../util.js';
+import { drawProgressChart } from '../chart.js';
+import { renderCornerRail } from '../cornerRail.js';
+import { attachTooltips } from '../tooltip.js';
+import { renderTopRail } from '../topRail.js';
+
+// modeKey format is set in app.js: "words-25", "time-30", "quote-<category>".
+function modeLabel(modeKey) {
+ const [kind, arg] = modeKey.split('-');
+ if (kind === 'words') return `Words · ${arg}`;
+ if (kind === 'time') return `Time · ${arg}s`;
+ if (kind === 'quote') return arg === 'random' ? 'Random' : arg.charAt(0).toUpperCase() + arg.slice(1);
+ return modeKey;
+}
+
+function formatDate(ts) {
+ if (!ts) return '';
+ return new Date(ts).toLocaleDateString();
+}
+
+export function renderStatsScreen(root, { onBack, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) {
+ const profile = getProfileStats();
+ const bests = getAllPersonalBests();
+
+ root.innerHTML = `
+ <div class="stats-screen">
+ <div class="logo" data-action="menu">TyperPunk</div>
+ <h2>Stats</h2>
+
+ ${profile.testsCompleted > 0 ? `
+ <div class="stats-summary">
+ <div class="menu-stat"><div class="stat-label">Tests</div><div class="stat-value">${profile.testsCompleted}</div></div>
+ <div class="menu-stat"><div class="stat-label">Avg WPM</div><div class="stat-value">${profile.averageWpm}</div></div>
+ <div class="menu-stat"><div class="stat-label">Best WPM</div><div class="stat-value">${Math.round(profile.bestWpm)}</div></div>
+ <div class="menu-stat"><div class="stat-label">Avg Acc</div><div class="stat-value">${profile.averageAccuracy}%</div></div>
+ <div class="menu-stat"><div class="stat-label">Streak</div><div class="stat-value">${profile.streakDays}d</div></div>
+ <div class="menu-stat"><div class="stat-label">Time Typed</div><div class="stat-value">${Math.round(profile.totalTimeSeconds / 60)}m</div></div>
+ </div>
+ ` : `<div class="stats-empty">No tests completed yet - finish a test to start tracking stats.</div>`}
+
+ <h3>Personal bests</h3>
+ ${bests.length > 0 ? `
+ <div class="stats-pb-list">
+ ${bests.map(b => `
+ <div class="stats-pb-row">
+ <div class="stats-pb-mode">${escapeHtml(modeLabel(b.modeKey))}</div>
+ <div class="stats-pb-wpm">${Math.round(b.wpm)} wpm</div>
+ <div class="stats-pb-date">${formatDate(b.date)}</div>
+ </div>
+ `).join('')}
+ </div>
+ ` : `<div class="stats-empty">No personal bests recorded yet.</div>`}
+
+ <h3>Progress</h3>
+ ${profile.history.length > 1
+ ? `<div class="stats-chart"><canvas></canvas></div>`
+ : `<div class="stats-placeholder">Complete a few more tests to see a progress chart.</div>`}
+
+ <button class="menu-button small ghost" data-action="menu">Back</button>
+ </div>
+ `;
+
+ root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
+ attachTooltips(root);
+ const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+
+ const canvas = root.querySelector('.stats-chart canvas');
+ let cleanupResize = () => {};
+ if (canvas) {
+ const redraw = () => drawProgressChart(canvas, profile.history);
+ redraw();
+ window.addEventListener('resize', redraw);
+ cleanupResize = () => window.removeEventListener('resize', redraw);
+ }
+
+ const cleanupRail = renderCornerRail(root, { onShowStats: null, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore });
+
+ return () => {
+ cleanupResize();
+ cleanupTheme();
+ cleanupRail();
+ };
+}
diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js
new file mode 100644
index 0000000..1ab2f84
--- /dev/null
+++ b/web/src/screens/storeScreen.js
@@ -0,0 +1,145 @@
+import { escapeHtml } from '../util.js';
+import { renderCornerRail } from '../cornerRail.js';
+import { attachTooltips } from '../tooltip.js';
+import { renderTopRail } from '../topRail.js';
+import { api, ApiError } from '../api.js';
+import { getUser } from '../auth.js';
+import { FLAIR_ICONS } from './icons.js';
+
+function formatPrice(cents) {
+ return `$${(cents / 100).toFixed(2)}`;
+}
+
+function categoryLabel(category) {
+ return category === 'caret' ? 'Caret Colors' : 'Flair';
+}
+
+// Real purchase/equip flow against the catalog, but purchase is a stub --
+// it grants ownership immediately with no actual charge, since real payment
+// processing needs the project owner's own Stripe (or other processor)
+// account. Parked as a question rather than assumed: see nightshift
+// questions.
+export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer }) {
+ let catalog = [];
+ let mine = { owned: [], equipped_caret: null, equipped_flair: null };
+ let status = 'loading';
+ let message = '';
+ let stopped = false;
+
+ function itemMarkup(item) {
+ const owned = mine.owned.includes(item.id);
+ const equipped = mine.equipped_caret === item.id || mine.equipped_flair === item.id;
+ const swatch = item.category === 'caret'
+ ? `<span class="store-swatch" style="background:${escapeHtml(item.value)}"></span>`
+ : `<span class="store-swatch store-flair-swatch">${FLAIR_ICONS[item.value] || ''}</span>`;
+ return `
+ <div class="leaderboard-row store-item-row">
+ ${swatch}
+ <div class="leaderboard-name">${escapeHtml(item.name)}</div>
+ ${!owned ? `<div class="leaderboard-acc">${formatPrice(item.price_cents)}</div>` : ''}
+ ${owned
+ ? `<button class="menu-button small${equipped ? '' : ' ghost'}" data-action="${equipped ? 'unequip' : 'equip'}" data-id="${item.id}" data-category="${item.category}" data-tooltip="${equipped ? 'Unequip - back to the default look' : `Replaces whichever ${categoryLabel(item.category).toLowerCase()} you have equipped now`}">${equipped ? 'Equipped' : 'Equip'}</button>`
+ : `<button class="menu-button small" data-action="buy" data-id="${item.id}">Buy</button>`}
+ </div>
+ `;
+ }
+
+ function bodyMarkup() {
+ if (status === 'signed-out') return `<div class="stats-empty">Sign in to buy and equip cosmetics.</div><button class="menu-button" data-action="go-account">Sign In</button>`;
+ if (status === 'loading') return `<div class="stats-empty">Loading...</div>`;
+ if (status === 'error') return `<div class="stats-empty">${escapeHtml(message)}</div>`;
+
+ const categories = ['caret', 'flair'];
+ return categories.map(cat => {
+ const items = catalog.filter(i => i.category === cat);
+ if (items.length === 0) return '';
+ return `<h3>${categoryLabel(cat)}</h3><div class="leaderboard-list">${items.map(itemMarkup).join('')}</div>`;
+ }).join('') + `<div class="custom-error store-error"></div>`;
+ }
+
+ function render() {
+ root.innerHTML = `
+ <div class="stats-screen">
+ <div class="logo" data-action="menu">TyperPunk</div>
+ <h2>Store</h2>
+ ${bodyMarkup()}
+ <button class="menu-button small ghost" data-action="menu">Back</button>
+ </div>
+ `;
+ root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
+ attachTooltips(root);
+ const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+
+ const goAccount = root.querySelector('[data-action="go-account"]');
+ if (goAccount) goAccount.addEventListener('click', onShowAccount);
+
+ const errorEl = root.querySelector('.store-error');
+ root.querySelectorAll('[data-action="buy"]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ try {
+ await api.post(`/api/cosmetics/${btn.dataset.id}/purchase`);
+ await loadMine();
+ rerender();
+ } catch (err) {
+ if (errorEl) errorEl.textContent = err instanceof ApiError ? err.message : 'Could not complete the purchase - try again.';
+ }
+ });
+ });
+ root.querySelectorAll('[data-action="equip"]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ await api.post(`/api/cosmetics/${btn.dataset.id}/equip`).catch(() => {});
+ await loadMine();
+ rerender();
+ });
+ });
+ root.querySelectorAll('[data-action="unequip"]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ await api.post('/api/cosmetics/unequip', { category: btn.dataset.category }).catch(() => {});
+ await loadMine();
+ rerender();
+ });
+ });
+
+ const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer });
+ return () => { cleanupTheme(); cleanupRail(); };
+ }
+
+ let cleanup;
+ function rerender() {
+ if (stopped) return;
+ if (cleanup) cleanup();
+ cleanup = render();
+ }
+
+ async function loadCatalog() {
+ try {
+ catalog = await api.get('/api/cosmetics');
+ } catch {
+ status = 'error';
+ message = 'Could not load the store - try again.';
+ }
+ }
+
+ async function loadMine() {
+ if (!getUser()) { status = 'signed-out'; return; }
+ try {
+ mine = await api.get('/api/cosmetics/me');
+ status = 'ok';
+ } catch {
+ status = 'error';
+ message = 'Could not load your cosmetics - try again.';
+ }
+ }
+
+ cleanup = render();
+ (async () => {
+ await loadCatalog();
+ await loadMine();
+ rerender();
+ })();
+
+ return () => {
+ stopped = true;
+ if (cleanup) cleanup();
+ };
+}
diff --git a/web/src/screens/typingGame.js b/web/src/screens/typingGame.js
new file mode 100644
index 0000000..72442a2
--- /dev/null
+++ b/web/src/screens/typingGame.js
@@ -0,0 +1,309 @@
+import { calculateStats, emptyStats } from '../stats.js';
+import { escapeHtml } from '../util.js';
+import { highlightClasses } from '../customText.js';
+import { getSettings } from '../settings.js';
+import { renderCornerRail } from '../cornerRail.js';
+import { renderTopRail } from '../topRail.js';
+import { attachTooltips } from '../tooltip.js';
+import { playKeySound } from '../keySounds.js';
+import { recordKeystroke } from '../keyStats.js';
+import { getUser } from '../auth.js';
+import { api } from '../api.js';
+
+export function renderTypingGame(root, { game, text, attribution, language, progress, timeLimit, modeKey, zenMode, multiplayer, onFinish, onMainMenu, onRestart, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) {
+ const syntaxClasses = language ? highlightClasses(text, language) : null;
+ const { hideLiveStats, caretBlink, blindMode } = getSettings();
+ root.innerHTML = `
+ <div class="typing-game${hideLiveStats ? ' hide-live-stats' : ''}${caretBlink ? '' : ' no-caret-blink'}">
+ <div class="logo" data-action="menu">TyperPunk</div>
+ ${progress ? `<div class="segment-progress">${escapeHtml(progress)}</div>` : ''}
+ <div class="text-container">
+ <div class="text-display${language ? ' code' : ''}"></div>
+ <input class="typing-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" />
+ </div>
+ ${attribution ? `<div class="attribution">- ${escapeHtml(attribution)}</div>` : ''}
+ <div class="wpm-stat"><div class="stat-label">WPM</div><div class="stat-value" data-field="wpm">0</div></div>
+ <div class="acc-stat"><div class="stat-label">ACC</div><div class="stat-value" data-field="acc">100%</div></div>
+ <div class="time-stat"><div class="stat-label">TIME</div><div class="stat-value" data-field="time">0.0</div></div>
+ ${zenMode ? `<button class="zen-finish-button" data-action="zen-finish">Finish</button>` : ''}
+ ${onRestart ? `<button class="restart-toggle" data-action="restart" aria-label="Restart" data-tooltip="Restart (Tab)">&#8635;</button>` : ''}
+ </div>
+ `;
+
+ const textDisplay = root.querySelector('.text-display');
+ const input = root.querySelector('.typing-input');
+ const wpmField = root.querySelector('[data-field="wpm"]');
+ const accField = root.querySelector('[data-field="acc"]');
+ const timeField = root.querySelector('[data-field="time"]');
+
+ attachTooltips(root);
+ // Restart and Zen Finish used to fixed-position themselves at hardcoded
+ // right offsets (4.25rem and 7.5rem) that assumed the theme toggle was the
+ // only thing to their right. The top rail is wider than that now, so they
+ // are handed to it as row members instead of guessing where the row ends.
+ const railExtras = [];
+ const zenBtn = root.querySelector('[data-action="zen-finish"]');
+ if (zenBtn) railExtras.push(zenBtn);
+ const restartBtn = root.querySelector('[data-action="restart"]');
+ if (restartBtn) railExtras.push(restartBtn);
+ const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends, extras: railExtras });
+
+ // Equipped caret color (see cosmetics.rs) overrides the theme's default
+ // --caret-color on this screen only, via the CSS custom property the
+ // caret's ::after rule already reads - no equipped caret just leaves
+ // the theme default in place.
+ let caretColorTorndown = false;
+ if (getUser()) {
+ Promise.all([api.get('/api/cosmetics/me'), api.get('/api/cosmetics')])
+ .then(([mine, catalog]) => {
+ if (caretColorTorndown || !mine.equipped_caret) return;
+ const item = catalog.find(i => i.id === mine.equipped_caret);
+ if (item && item.value) {
+ root.querySelector('.typing-game')?.style.setProperty('--caret-color', item.value);
+ }
+ })
+ .catch(() => {});
+ }
+
+ root.querySelector('[data-action="menu"]').addEventListener('click', onMainMenu);
+ root.querySelector('[data-action="restart"]')?.addEventListener('click', () => onRestart());
+
+ let finished = false;
+ let startTime = null;
+ let keystrokes = [];
+ let charTimings = [];
+ let intervalId = null;
+ // Persistent, never-decremented count of every forward keystroke made,
+ // including ones later backspaced away - feeds raw WPM (see stats.js).
+ let totalKeystrokes = 0;
+ let lastInputLength = 0;
+ // Accuracy from the wasm engine is cumulative (tracks every mistake ever
+ // made, even ones since backspaced away) - the interval below has no
+ // fresh keystroke to read it from, so it needs its own last-known copy
+ // instead of falling back to stats.js's current-input-only calculation,
+ // which forgets a mistake the moment it's corrected.
+ let lastAccuracy = 100;
+ // Idle/AFK timeout: a test left running with no keystrokes for this long
+ // auto-finishes with whatever was typed so far, instead of sitting open
+ // indefinitely. Applies to every standard mode (quote/words/time) --
+ // there's no reason a stalled tab should keep accumulating elapsed time
+ // and (before the chart's own bucket cap) inflating the graph.
+ const IDLE_TIMEOUT_MS = 180000;
+ let lastActivityTime = null;
+
+ function renderText(currentInput) {
+ const inputChars = currentInput.split('');
+ const wordMatches = text.match(/[^\s]+\s*/g) || [];
+ let charIndex = 0;
+ const html = [];
+ for (const word of wordMatches) {
+ html.push('<span>');
+ for (const char of word) {
+ const inputChar = inputChars[charIndex];
+ let className = 'neutral';
+ let displayChar = char;
+ if (charIndex < inputChars.length) {
+ // Blind Mode: no correct/incorrect feedback while typing
+ // (not even which character was actually typed for a
+ // mistake) - the WASM engine still tracks correctness
+ // normally underneath for accuracy/stats, this only
+ // changes what gets rendered live. Revealed as usual on
+ // the end screen, which does its own independent diff.
+ if (blindMode) {
+ className = 'typed';
+ } else {
+ className = inputChar === char ? 'correct' : 'incorrect';
+ if (className === 'incorrect') displayChar = inputChar;
+ }
+ } else if (syntaxClasses && syntaxClasses[charIndex]) {
+ className += ` ${syntaxClasses[charIndex]}`;
+ }
+ if (!finished && charIndex === currentInput.length) className += ' current';
+ html.push(`<span class="${className}">${escapeHtml(displayChar)}</span>`);
+ charIndex++;
+ }
+ html.push('</span>');
+ }
+ textDisplay.innerHTML = html.join('');
+ }
+
+ let lastKeystrokeAt = null;
+ function recordChar(index, isCorrect, time) {
+ keystrokes.push({ time, index, isCorrect });
+ charTimings = charTimings.slice(0, index);
+ charTimings.push({ time, isCorrect, index });
+
+ // Skips the very first keystroke of the test - "time since start"
+ // isn't a meaningful per-key latency the way "time since the last
+ // keystroke" is for every one after it.
+ if (lastKeystrokeAt != null) {
+ recordKeystroke(text[index], isCorrect, (time - lastKeystrokeAt) * 1000);
+ }
+ lastKeystrokeAt = time;
+ }
+
+ function liveStats(currentInput) {
+ if (!startTime) return emptyStats();
+ return calculateStats(currentInput, text, (Date.now() - startTime) / 1000, totalKeystrokes);
+ }
+
+ function paintStats(stats, accuracy) {
+ wpmField.textContent = String(Math.round(stats.wpm));
+ accField.textContent = `${Math.round(accuracy ?? stats.accuracy)}%`;
+ // Time mode counts down to zero instead of counting up, since the
+ // test ends on the clock rather than at the end of the text.
+ timeField.textContent = timeLimit
+ ? Math.max(0, timeLimit - stats.time).toFixed(1)
+ : stats.time.toFixed(1);
+
+ // Single hook point for every caller of paintStats (each keystroke,
+ // the 100ms live-stats interval, backspace) rather than scattering a
+ // sendProgress call at each call site.
+ if (multiplayer && stats.totalChars > 0) {
+ const percent = ((stats.correctChars + stats.incorrectChars) / stats.totalChars) * 100;
+ multiplayer.connection.sendProgress(percent, stats.wpm);
+ }
+ }
+
+ function finish(accuracy, mistakes, finalInput) {
+ finished = true;
+ input.disabled = true;
+ if (intervalId) clearInterval(intervalId);
+ const elapsed = startTime ? (Date.now() - startTime) / 1000 : 0;
+ // Time/Zen mode's text is a long word buffer sized to outlast the
+ // clock (or, for Zen, just sized generously since there's no clock
+ // at all) - trim it to what was actually reached so the end screen
+ // doesn't try to render hundreds of untyped words.
+ const finalText = (timeLimit || zenMode) ? text.slice(0, finalInput.length) : text;
+ const stats = calculateStats(finalInput, finalText, elapsed, totalKeystrokes);
+ stats.accuracy = accuracy;
+ stats.incorrectChars = mistakes;
+ if (multiplayer) multiplayer.connection.sendFinish(stats.wpm, accuracy, elapsed);
+ onFinish({ stats, text: finalText, attribution, userInput: finalInput, charTimings, keypressHistory: keystrokes, modeKey });
+ }
+
+ function handleChange() {
+ if (finished || !game) return;
+ const value = input.value;
+ if (value.length > text.length) {
+ input.value = value.slice(0, text.length);
+ return;
+ }
+ if (!startTime) startTime = Date.now();
+ lastActivityTime = Date.now();
+ const elapsed = (Date.now() - startTime) / 1000;
+
+ try {
+ game.handle_input(value);
+ const [wasmInput, accuracy, mistakes] = game.get_stats_and_input();
+ input.value = wasmInput;
+ if (wasmInput.length > lastInputLength) {
+ totalKeystrokes += wasmInput.length - lastInputLength;
+ playKeySound();
+ }
+ lastInputLength = wasmInput.length;
+ if (wasmInput.length > 0) {
+ const idx = wasmInput.length - 1;
+ recordChar(idx, wasmInput[idx] === text[idx], elapsed);
+ }
+ renderText(wasmInput);
+ lastAccuracy = accuracy;
+ paintStats(liveStats(wasmInput), accuracy);
+
+ if (game.is_finished()) {
+ finish(accuracy, mistakes, wasmInput);
+ }
+ } catch (err) {
+ console.error('WASM input error:', err);
+ }
+ }
+
+ async function handleKeydown(e) {
+ if (finished || !game) return;
+ if (e.key === 'Tab') {
+ // Quick restart: abandon the current attempt and start a fresh
+ // one of the same mode, without reaching for the mouse.
+ e.preventDefault();
+ if (onRestart) onRestart();
+ return;
+ }
+ const ctrlOrMeta = e.ctrlKey || e.metaKey;
+ // Some platforms bind native readline-style line-editing to text
+ // inputs (Ctrl+U "clear to start of line", Ctrl+K "clear to end").
+ // Left unblocked, these silently wipe the whole typed input via the
+ // browser's own text-field editing, bypassing our backspace rules
+ // entirely - block them outright rather than let them fire.
+ if (ctrlOrMeta && (e.key === 'u' || e.key === 'U' || e.key === 'k' || e.key === 'K')) {
+ e.preventDefault();
+ return;
+ }
+ if (e.key !== 'Backspace') return;
+ e.preventDefault();
+ try {
+ const ctrl = ctrlOrMeta;
+ const success = await game.handle_backspace(ctrl);
+ if (success) {
+ lastActivityTime = Date.now();
+ const [wasmInput, accuracy] = await game.get_stats_and_input();
+ input.value = wasmInput;
+ lastInputLength = wasmInput.length;
+ charTimings = charTimings.slice(0, wasmInput.length);
+ renderText(wasmInput);
+ lastAccuracy = accuracy;
+ paintStats(liveStats(wasmInput), accuracy);
+ }
+ } catch (err) {
+ console.error('WASM backspace error:', err);
+ }
+ }
+
+ input.addEventListener('input', handleChange);
+ input.addEventListener('keydown', handleKeydown);
+ input.addEventListener('blur', () => {
+ if (!finished) setTimeout(() => input.focus(), 10);
+ });
+
+ // Zen has no timer and no fixed length, so unlike every other mode it
+ // never ends on its own - this is the only way to actually finish one.
+ const zenFinishBtn = root.querySelector('[data-action="zen-finish"]');
+ if (zenFinishBtn) {
+ zenFinishBtn.addEventListener('click', () => {
+ if (finished || !game || !startTime) return;
+ const [wasmInput, accuracy, mistakes] = game.get_stats_and_input();
+ finish(accuracy, mistakes, wasmInput);
+ });
+ }
+
+ renderText('');
+ paintStats(emptyStats(), 100);
+ setTimeout(() => input.focus(), 50);
+
+ intervalId = setInterval(() => {
+ if (finished || !startTime) return;
+ const elapsed = (Date.now() - startTime) / 1000;
+ if (timeLimit && elapsed >= timeLimit) {
+ const [wasmInput, accuracy, mistakes] = game.get_stats_and_input();
+ finish(accuracy, mistakes, wasmInput);
+ return;
+ }
+ if (lastActivityTime && Date.now() - lastActivityTime >= IDLE_TIMEOUT_MS) {
+ const [wasmInput, accuracy, mistakes] = game.get_stats_and_input();
+ finish(accuracy, mistakes, wasmInput);
+ return;
+ }
+ paintStats(liveStats(input.value), lastAccuracy);
+ }, 100);
+
+ const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore });
+ // No Tab-restart shortcut mid-multiplayer-race (onRestart is null there,
+ // same guard the restart-toggle button itself uses) - falls back to
+ // the plain version stamp instead of advertising a shortcut that does
+ // nothing.
+
+ return () => {
+ caretColorTorndown = true;
+ cleanupTheme();
+ if (intervalId) clearInterval(intervalId);
+ cleanupRail();
+ };
+}
diff --git a/web/src/settings.js b/web/src/settings.js
new file mode 100644
index 0000000..37e10f0
--- /dev/null
+++ b/web/src/settings.js
@@ -0,0 +1,40 @@
+const STORAGE_KEY = 'typerpunk:settings';
+
+function load() {
+ try {
+ const raw = JSON.parse(localStorage.getItem(STORAGE_KEY));
+ return raw && typeof raw === 'object' ? raw : {};
+ } catch {
+ return {};
+ }
+}
+
+let settings = {
+ skipMenu: false,
+ favoriteMode: null,
+ wordCount: 25,
+ timeDuration: 30,
+ wordsPunctuation: false,
+ wordsNumbers: false,
+ caretBlink: true,
+ hideLiveStats: false,
+ blindMode: false,
+ soundTheme: 'off',
+ wordListTier: 'common',
+ // Which vocabulary the generated-word modes draw from (see languages.js).
+ language: 'en',
+ ...load(),
+};
+
+function save() {
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } catch {}
+}
+
+export function getSettings() {
+ return { ...settings };
+}
+
+export function updateSettings(patch) {
+ settings = { ...settings, ...patch };
+ save();
+}
diff --git a/web/src/stats.js b/web/src/stats.js
new file mode 100644
index 0000000..00d4db6
--- /dev/null
+++ b/web/src/stats.js
@@ -0,0 +1,66 @@
+export function emptyStats() {
+ return {
+ wpm: 0,
+ rawWpm: 0,
+ keystrokes: 0,
+ accuracy: 100,
+ time: 0,
+ correctChars: 0,
+ incorrectChars: 0,
+ totalChars: 0,
+ currentStreak: 0,
+ bestStreak: 0,
+ };
+}
+
+// `totalKeystrokes` is a persistent, never-decremented count of every forward
+// keystroke made during the test, including ones later backspaced away. It
+// must be tracked externally (see typingGame.js) because a backspaced
+// mistake is no longer present in `input` by the time this runs - without
+// it, raw WPM would silently collapse to equal WPM whenever the final input
+// happens to be fully correct, hiding every corrected mistake. Falls back to
+// `input.length` (final-state only) when the caller doesn't track it.
+export function calculateStats(input, text, elapsedTime, totalKeystrokes) {
+ let correct = 0;
+ let totalErrors = 0;
+ let currentStreak = 0;
+ let bestStreak = 0;
+ const hasStartedTyping = input.length > 0;
+
+ for (let i = 0; i < input.length; i++) {
+ if (i < text.length && input[i] === text[i]) {
+ correct++;
+ currentStreak++;
+ bestStreak = Math.max(bestStreak, currentStreak);
+ } else {
+ totalErrors++;
+ currentStreak = 0;
+ }
+ }
+
+ const totalTyped = input.length;
+ const accuracy = !hasStartedTyping ? 100 : Math.max(0, Math.min(100, (correct / totalTyped) * 100));
+ const wpm = elapsedTime === 0 ? 0 : (correct / 5) / (elapsedTime / 60);
+ // Raw WPM: same formula as WPM, but counting every keystroke ever made
+ // (right, wrong, or later corrected) instead of only correct ones --
+ // this is how MonkeyType/TypeRacer define it: raw speed ignores accuracy
+ // entirely, including keystrokes you fixed with backspace.
+ const rawCount = totalKeystrokes ?? totalTyped;
+ const rawWpm = elapsedTime === 0 ? 0 : (rawCount / 5) / (elapsedTime / 60);
+
+ return {
+ wpm,
+ rawWpm,
+ accuracy,
+ time: elapsedTime,
+ correctChars: correct,
+ incorrectChars: totalErrors,
+ totalChars: text.length,
+ // Every forward keystroke of the test, corrected-away ones included --
+ // the same figure rawWpm is derived from. Reported on the end screen
+ // in its own right, the way 10FastFingers does.
+ keystrokes: rawCount,
+ currentStreak,
+ bestStreak,
+ };
+}
diff --git a/web/src/styles.css b/web/src/styles.css
new file mode 100644
index 0000000..b556038
--- /dev/null
+++ b/web/src/styles.css
@@ -0,0 +1,2440 @@
+:root {
+ --primary-color: #00ff9d;
+ --secondary-color: #00cc8f;
+ --background-color: #000000;
+ --text-color: #c8ccd0;
+ --error-color: #ca4754;
+ --correct-color: #00ff9d;
+ --neutral-color: #646669;
+ --caret-color: #00ff9d;
+ --sub-color: #8b8e92;
+ /* Unified top offset below fixed header/toggle */
+ --header-offset: 3.5rem;
+ /* App padding variable so we can compensate where needed */
+ --app-padding: 1rem;
+ --record-color: #ffd166;
+ --warning-color: #ffb454;
+ --player-2: #4ea3ff;
+ --player-3: #ff9f43;
+ --player-4: #c678dd;
+ --player-5: #ff6b8a;
+}
+
+[data-theme="light"] {
+ --primary-color: #067a4e;
+ --secondary-color: #05663f;
+ --background-color: #f6f6f4;
+ --text-color: #2b2b2b;
+ --error-color: #b3323f;
+ --correct-color: #067a4e;
+ --neutral-color: #8a8d90;
+ --caret-color: #067a4e;
+ --sub-color: #5c5f61;
+ --record-color: #9a6b00;
+ --warning-color: #a35b00;
+ --player-2: #1667c2;
+ --player-3: #b45309;
+ --player-4: #7c3aed;
+ --player-5: #be123c;
+}
+
+[data-theme="dark"] {
+ --primary-color: #00ff9d;
+ --secondary-color: #00cc8f;
+ --background-color: #000000;
+ --text-color: #c8ccd0;
+ --error-color: #ca4754;
+ --correct-color: #00ff9d;
+ --neutral-color: #646669;
+ --caret-color: #00ff9d;
+ --sub-color: #8b8e92;
+ --record-color: #ffd166;
+ --warning-color: #ffb454;
+ --player-2: #4ea3ff;
+ --player-3: #ff9f43;
+ --player-4: #c678dd;
+ --player-5: #ff6b8a;
+}
+
+[data-theme="dracula"] {
+ --primary-color: #bd93f9;
+ --secondary-color: #ff79c6;
+ --background-color: #282a36;
+ --text-color: #f8f8f2;
+ --error-color: #ff5555;
+ --correct-color: #bd93f9;
+ --neutral-color: #6272a4;
+ --caret-color: #bd93f9;
+ --sub-color: #9aa5c4;
+ --record-color: #ffd166;
+ --warning-color: #ffb454;
+ --player-2: #4ea3ff;
+ --player-3: #ff9f43;
+ --player-4: #c678dd;
+ --player-5: #ff6b8a;
+}
+
+[data-theme="nord"] {
+ --primary-color: #88c0d0;
+ --secondary-color: #81a1c1;
+ --background-color: #2e3440;
+ --text-color: #d8dee9;
+ --error-color: #bf616a;
+ --correct-color: #88c0d0;
+ --neutral-color: #4c566a;
+ --caret-color: #88c0d0;
+ --sub-color: #93a1b8;
+ --record-color: #ffd166;
+ --warning-color: #ffb454;
+ --player-2: #4ea3ff;
+ --player-3: #ff9f43;
+ --player-4: #c678dd;
+ --player-5: #ff6b8a;
+}
+
+[data-theme="solarized"] {
+ --primary-color: #268bd2;
+ --secondary-color: #2aa198;
+ --background-color: #002b36;
+ --text-color: #93a1a1;
+ --error-color: #dc322f;
+ --correct-color: #268bd2;
+ --neutral-color: #586e75;
+ --caret-color: #268bd2;
+ --sub-color: #839496;
+ --record-color: #ffd166;
+ --warning-color: #ffb454;
+ --player-2: #4ea3ff;
+ --player-3: #ff9f43;
+ --player-4: #c678dd;
+ --player-5: #ff6b8a;
+}
+
+[data-theme="gruvbox"] {
+ --primary-color: #fabd2f;
+ --secondary-color: #b8bb26;
+ --background-color: #282828;
+ --text-color: #ebdbb2;
+ --error-color: #fb4934;
+ --correct-color: #fabd2f;
+ --neutral-color: #928374;
+ --caret-color: #fabd2f;
+ --sub-color: #bdae93;
+ --record-color: #ffd166;
+ --warning-color: #ffb454;
+ --player-2: #4ea3ff;
+ --player-3: #ff9f43;
+ --player-4: #c678dd;
+ --player-5: #ff6b8a;
+}
+
+[data-theme="monokai"] {
+ --primary-color: #a6e22e;
+ --secondary-color: #66d9ef;
+ --background-color: #272822;
+ --text-color: #f8f8f2;
+ --error-color: #f92672;
+ --correct-color: #a6e22e;
+ --neutral-color: #75715e;
+ --caret-color: #a6e22e;
+ --sub-color: #b0a98d;
+ --record-color: #ffd166;
+ --warning-color: #ffb454;
+ --player-2: #4ea3ff;
+ --player-3: #ff9f43;
+ --player-4: #c678dd;
+ --player-5: #ff6b8a;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+body {
+ margin: 0;
+ padding: 0;
+ font-family: 'JetBrains Mono', monospace;
+ background-color: var(--background-color);
+ color: var(--text-color);
+ height: 100vh;
+ /* Vertical scroll is allowed (and only appears when actually needed) --
+ a fixed "overflow: hidden" here silently clipped any screen whose
+ content got taller than the viewport (a wide-but-short window, a
+ longer passage, extra stat rows) with literally no way to reach the
+ clipped part. Horizontal stays clipped since nothing should ever
+ need to scroll sideways. */
+ overflow-x: hidden;
+ overflow-y: auto;
+ /* Cursor is set per-theme in JS (theme.js) - a hardcoded green crosshair
+ here would look wrong on Dracula/Nord/etc. */
+}
+
+.app {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start; /* avoid vertical centering that pushes content down */
+ height: 100vh;
+ padding: var(--app-padding);
+ overflow: hidden;
+}
+
+.logo {
+ position: fixed;
+ top: 1rem;
+ left: 1rem;
+ font-size: 1.2rem;
+ color: var(--primary-color);
+ font-family: 'JetBrains Mono', monospace;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ cursor: pointer;
+ transition: color 0.2s ease;
+}
+
+.logo:hover {
+ color: var(--secondary-color);
+}
+
+/* Main Menu */
+.main-menu {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ /* Pinned near the top rather than vertically centered - centered left
+ barely more room below the Start button than the mode popover needs,
+ so on anything but a tall viewport the list ran past the bottom edge.
+ Fixed higher, there's always real room underneath it. */
+ justify-content: flex-start;
+ padding-top: 6rem;
+ height: 100vh;
+ width: 100%;
+ max-width: 700px;
+ margin: 0 auto;
+ padding-left: 2rem;
+ padding-right: 2rem;
+ padding-bottom: 2rem;
+ overflow-y: auto;
+ box-sizing: border-box;
+}
+
+.main-menu h1 {
+ font-size: 4rem;
+ color: var(--primary-color);
+ margin-bottom: 2rem;
+ text-transform: uppercase;
+ letter-spacing: 4px;
+}
+
+.settings-row-group {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.settings-row-group .menu-button.small {
+ width: 230px;
+}
+
+.menu-stat .stat-label {
+ font-size: 0.7rem;
+ color: var(--sub-color);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-bottom: 0.2rem;
+}
+
+.menu-stat .stat-value {
+ font-size: 1.3rem;
+ color: var(--primary-color);
+ font-weight: bold;
+}
+
+/* Stats Screen */
+.stats-screen {
+ width: 100%;
+ max-width: 700px;
+ margin: 0 auto;
+ padding: 2rem;
+ margin-top: calc(var(--header-offset) - var(--app-padding));
+ min-height: calc(100vh - var(--header-offset));
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ /* Vertically centred when the content is short - these screens were
+ top-heavy, ending around y=510 on a 900px viewport with the rest of
+ the page empty. "safe" keeps it flex-start once the content is taller
+ than the viewport, so a long list is never cut off above the scroll
+ origin; browsers that do not know the keyword ignore the declaration
+ and keep the old flex-start behaviour. */
+ justify-content: safe center;
+ overflow-y: auto;
+}
+
+.stats-screen h2 {
+ font-size: 1.8rem;
+ color: var(--primary-color);
+ text-transform: uppercase;
+ letter-spacing: 3px;
+ margin: 1.5rem 0 1.5rem;
+}
+
+.stats-screen h3 {
+ font-size: 1rem;
+ color: var(--sub-color);
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ align-self: flex-start;
+ margin: 2rem 0 0.75rem;
+}
+
+.stats-summary {
+ width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 1.5rem;
+ border: 1px solid var(--neutral-color);
+ border-radius: 8px;
+ padding: 1.25rem 1rem;
+ text-align: center;
+}
+
+.stats-empty {
+ color: var(--sub-color);
+ font-size: 0.9rem;
+ align-self: flex-start;
+}
+
+.stats-pb-list {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.stats-pb-row {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ border: 1px solid var(--neutral-color);
+ border-radius: 6px;
+ padding: 0.6rem 1rem;
+}
+
+.stats-pb-mode {
+ flex: 1;
+ text-align: left;
+}
+
+.stats-pb-wpm {
+ color: var(--primary-color);
+ font-weight: bold;
+}
+
+.stats-pb-date {
+ color: var(--sub-color);
+ font-size: 0.8rem;
+ min-width: 90px;
+ text-align: right;
+}
+
+.leaderboard-modes {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 0.4rem;
+ margin-bottom: 0.5rem;
+}
+
+.leaderboard-list {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.leaderboard-row {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ border: 1px solid var(--neutral-color);
+ border-radius: 6px;
+ padding: 0.6rem 1rem;
+}
+
+.leaderboard-rank {
+ color: var(--sub-color);
+ min-width: 2.5rem;
+}
+
+.leaderboard-name {
+ flex: 1;
+ text-align: left;
+ /* A <button> now (links to the player's public profile) - reset away
+ the browser's default button chrome so it still just reads as text. */
+ background: none;
+ border: none;
+ color: inherit;
+ font: inherit;
+ padding: 0;
+ cursor: pointer;
+}
+
+.leaderboard-name:hover {
+ color: var(--primary-color);
+ text-decoration: underline;
+}
+
+.leaderboard-wpm {
+ color: var(--primary-color);
+ font-weight: bold;
+}
+
+.leaderboard-acc {
+ color: var(--sub-color);
+ min-width: 3.5rem;
+ text-align: right;
+}
+
+.leaderboard-date {
+ color: var(--sub-color);
+ font-size: 0.8rem;
+ min-width: 90px;
+ text-align: right;
+}
+
+.store-swatch {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.5rem;
+ height: 1.5rem;
+ border-radius: 4px;
+ border: 1px solid var(--neutral-color);
+}
+
+.store-flair-swatch {
+ color: var(--caret-color);
+ padding: 0.25rem;
+}
+
+.store-flair-swatch svg {
+ width: 100%;
+ height: 100%;
+}
+
+/* Marks a result typed on a touchscreen, which is not comparable with a
+ desktop one. It sat in the same grey as ordinary secondary text, so the
+ caveat read as decoration - this is the second and last use of
+ --warning-color. */
+.leaderboard-device-badge {
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--warning-color);
+ border: 1px solid color-mix(in srgb, var(--warning-color) 45%, transparent);
+ border-radius: 4px;
+ padding: 0.1rem 0.35rem;
+}
+
+.leaderboard-flair {
+ display: inline-flex;
+ width: 0.9em;
+ height: 0.9em;
+ margin-left: 0.35em;
+ vertical-align: -0.05em;
+ color: var(--caret-color);
+}
+
+.leaderboard-flair svg {
+ width: 100%;
+ height: 100%;
+}
+
+.stats-placeholder {
+ width: 100%;
+ border: 1px dashed var(--neutral-color);
+ border-radius: 8px;
+ padding: 2rem 1rem;
+ text-align: center;
+ color: var(--sub-color);
+ font-size: 0.85rem;
+}
+
+.stats-chart {
+ width: 100%;
+ height: 200px;
+ border: 1px solid var(--neutral-color);
+ border-radius: 8px;
+ padding: 0.5rem;
+ box-sizing: border-box;
+}
+
+.stats-chart canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+.stats-screen .menu-button {
+ margin-top: 2rem;
+}
+
+.menu-options {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+}
+
+.start-group {
+ position: relative;
+ /* Fixed, not content-sized - a content-sized button changed width (and
+ shifted everything else in the centered column sideways with it)
+ every time you picked a mode with a longer or shorter name. */
+ width: 360px;
+}
+
+/* Sits directly above the Start button it describes, replacing the old
+ bottom-right corner hint - the keyboard shortcut is more useful read
+ right next to the button it triggers than in a corner of the screen. */
+/* Fixed width via .start-group above - ellipsis covers a very long custom
+ text name that would otherwise force the box wider. The chevron overlays
+ the button's own right edge rather than squeezing the label into a
+ smaller box, so padding-right reserves space for it - without this, a
+ long mode name ("Start: Literature") ran its text straight under the
+ chevron instead of stopping short of it. */
+/* Bigger and bolder than the other menu buttons - now that its label is
+ just the mode name ("Timed", not "Start: Timed"), its size has to carry
+ "this is the one you click" on its own instead of the word "Start". */
+/* The two entry points. Single-line labels naming the action, nothing else
+ inside the box - what a Single Player run will actually start (mode plus
+ its rules) sits in the .mode-pill above, so the button reads as one clear
+ word and the mode stays changeable without redrawing the button. */
+.start-main {
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ /* Symmetric. Nothing overlays either button now that the mode picker is
+ its own control above them, so both labels centre on the same axis. */
+ padding-left: 2rem;
+ padding-right: 2rem;
+ font-size: 1.5rem;
+ padding-top: 1.3rem;
+ padding-bottom: 1.3rem;
+ border-width: 3px;
+ letter-spacing: 3px;
+}
+
+/* Mode + rules for the Single Player button. Hidden (not removed) while the
+ mode popover is open, so the layout does not jump. */
+.start-caption {
+ font-size: 0.85rem;
+ color: var(--sub-color);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-top: -0.35rem;
+ text-align: center;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Split button: most of the width starts the game, the chevron on the right
+ edge (overlaid, not squeezing the label) opens the mode list. Mode used to
+ be its own separate full-width button - merged here since Start already
+ displays the current mode in its own label ("Start: Timed"). */
+/* The mode picker, above the two start buttons rather than notched into the
+ Single Player button's right edge. As a chevron inside the button it cut a
+ 3px divider through the outline and made the button look clipped, and it
+ forced the label off-centre to avoid it. As its own quiet pill it also
+ removes the caption that used to sit between Single Player and Multiplayer,
+ separating the two things you actually came here to press. */
+.mode-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ max-width: 360px;
+ background: none;
+ border: 1px solid color-mix(in srgb, var(--primary-color) 35%, transparent);
+ border-radius: 999px;
+ color: var(--sub-color);
+ font-family: inherit;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ padding: 0.45rem 0.9rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.mode-pill:hover {
+ border-color: var(--primary-color);
+ color: var(--text-color);
+}
+
+.mode-pill-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.mode-pill svg {
+ width: 0.9rem;
+ height: 0.9rem;
+ flex-shrink: 0;
+}
+
+
+
+
+
+/* What Start will actually generate, visible without opening Settings. */
+/* Every mode listed as a direct pick when the button is clicked, instead of
+ cycling blind through them one click at a time - Custom and the text
+ categories were otherwise invisible unless you happened to click through
+ the whole list first.
+
+ position:fixed with JS-computed coordinates (not CSS position:absolute
+ below the button) - with 8 modes listed, the panel is taller than the
+ gap between the button and the bottom of the viewport on an ordinary
+ 900px-tall screen, and a fixed "opens downward" placement ran the last
+ 1-2 items off the bottom edge with no way to reach them. JS picks
+ whichever of above/below actually has room, and this only falls back to
+ its own internal scroll (never the page's) if neither direction has
+ enough space at all. */
+/* Two columns, one per kind of entry (see mainMenu.js). As a single column
+ this was 585px of 18 undifferentiated rows on a 900px viewport - it ran
+ off the bottom into a scroll region and covered the Multiplayer button and
+ the icon rail. Side by side it is roughly half that and needs no scrolling
+ at any normal window height. */
+.mode-popover {
+ position: fixed;
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 0.5rem;
+ background: var(--background-color);
+ border: 1px solid var(--neutral-color);
+ border-radius: 8px;
+ padding: 0.5rem;
+ z-index: 10;
+ max-height: calc(100vh - 2rem);
+ overflow-y: auto;
+}
+
+.mode-popover-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+/* Same small dim caption as the Settings panel's "Rules"/"Typing" headings,
+ so the two panels read as the same kind of surface. */
+.mode-popover-heading {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--sub-color);
+ padding: 0.25rem 0.7rem 0.35rem;
+ border-bottom: 1px solid var(--neutral-color);
+ margin-bottom: 0.25rem;
+}
+
+.mode-popover-item {
+ background: none;
+ border: none;
+ color: var(--text-color);
+ font-family: inherit;
+ font-size: 0.9rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ padding: 0.45rem 0.7rem;
+ border-radius: 4px;
+ cursor: pointer;
+ text-align: left;
+ flex-shrink: 0;
+}
+
+.mode-popover-item:hover {
+ background: rgba(128, 128, 128, 0.15);
+}
+
+.mode-popover-item.active {
+ color: var(--primary-color);
+ font-weight: bold;
+}
+
+.menu-button {
+ background: none;
+ border: 2px solid var(--primary-color);
+ color: var(--text-color);
+ padding: 1rem 2rem;
+ font-size: 1.2rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-family: inherit;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ min-width: 250px;
+ /* min-width has no effect on an inline element - a plain <button>
+ defaults to inline-block already, but this class is also used on the
+ Spotify "Connect" <a> link (an OAuth login has to be a real page
+ navigation, not a fetch), which needs these spelled out explicitly. */
+ display: inline-block;
+ text-align: center;
+ text-decoration: none;
+ box-sizing: border-box;
+}
+
+.menu-button:hover {
+ background-color: var(--primary-color);
+ color: var(--background-color);
+}
+
+.menu-button.small {
+ padding: 0.6rem 1.2rem;
+ font-size: 0.9rem;
+ min-width: 0;
+}
+
+/* Secondary actions. A dashed outline conventionally means "placeholder" or
+ "drop target", so Join / Create a Room / the leaderboard filter chips all
+ read as unfinished. Solid at reduced strength says "secondary" instead,
+ and still inverts to the filled state on hover like every other button. */
+.menu-button.ghost {
+ border-color: color-mix(in srgb, var(--primary-color) 45%, transparent);
+ color: var(--sub-color);
+}
+
+.menu-button.ghost:hover {
+ border-color: var(--primary-color);
+ color: var(--background-color);
+}
+
+.custom-hint {
+ font-size: 0.8rem;
+ color: var(--neutral-color);
+ text-align: center;
+ max-width: 340px;
+}
+
+.settings-panel {
+ width: 100%;
+ max-width: 620px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ /* Tightened alongside the two-column split: .app is overflow:hidden, so
+ anything past the viewport bottom is clipped rather than scrollable. */
+ gap: 0.55rem;
+ border: 1px solid var(--neutral-color);
+ border-radius: 8px;
+ padding: 0.85rem;
+ margin: 1rem auto 0 auto;
+ text-align: center;
+}
+
+.settings-row {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ font-size: 0.9rem;
+ cursor: pointer;
+}
+
+.settings-hint {
+ font-size: 0.75rem;
+ color: var(--neutral-color);
+ max-width: 340px;
+}
+
+.theme-gallery {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
+ gap: 0.5rem;
+ width: 100%;
+}
+
+.theme-swatch {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.35rem;
+ background: transparent;
+ border: 1px solid var(--neutral-color);
+ border-radius: 6px;
+ padding: 0.5rem;
+ cursor: pointer;
+ font-family: inherit;
+}
+
+.theme-swatch.active {
+ border-color: var(--primary-color);
+}
+
+.theme-swatch-preview {
+ display: block;
+ width: 100%;
+ height: 1.5rem;
+ border-radius: 4px;
+}
+
+.theme-swatch-label {
+ font-size: 0.7rem;
+ color: var(--text-color);
+}
+
+.theme-swatch-preview[data-swatch="dark"] { background: linear-gradient(90deg, #000000 60%, #00ff9d 60%); }
+.theme-swatch-preview[data-swatch="light"] { background: linear-gradient(90deg, #f6f6f4 60%, #067a4e 60%); }
+.theme-swatch-preview[data-swatch="dracula"] { background: linear-gradient(90deg, #282a36 60%, #bd93f9 60%); }
+.theme-swatch-preview[data-swatch="nord"] { background: linear-gradient(90deg, #2e3440 60%, #88c0d0 60%); }
+.theme-swatch-preview[data-swatch="solarized"] { background: linear-gradient(90deg, #002b36 60%, #268bd2 60%); }
+.theme-swatch-preview[data-swatch="gruvbox"] { background: linear-gradient(90deg, #282828 60%, #fabd2f 60%); }
+.theme-swatch-preview[data-swatch="monokai"] { background: linear-gradient(90deg, #272822 60%, #a6e22e 60%); }
+
+.custom-panel {
+ width: 100%;
+ max-width: 480px;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ border: 1px solid var(--neutral-color);
+ border-radius: 8px;
+ padding: 1rem;
+ margin: 1.5rem auto 0 auto;
+}
+
+.custom-textarea {
+ width: 100%;
+ min-height: 120px;
+ resize: vertical;
+ background: transparent;
+ color: var(--text-color);
+ border: 1px solid var(--neutral-color);
+ border-radius: 6px;
+ padding: 0.75rem;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.9rem;
+ transition: border-color 0.15s ease;
+}
+
+.custom-textarea.drag-over {
+ border-color: var(--primary-color);
+ border-style: dashed;
+}
+
+.custom-stats {
+ font-size: 0.8rem;
+ color: var(--sub-color);
+ text-align: center;
+}
+
+.custom-panel-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.file-label {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.custom-file-name {
+ font-size: 0.8rem;
+ color: var(--neutral-color);
+ word-break: break-all;
+}
+
+.custom-error {
+ font-size: 0.8rem;
+ color: var(--error-color);
+ min-height: 1em;
+}
+
+/* Account screen */
+.account-panel {
+ width: 100%;
+ max-width: 360px;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin: 1rem auto 0 auto;
+}
+
+.account-tabs {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: center;
+}
+
+.account-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+
+.account-input {
+ background: transparent;
+ color: var(--text-color);
+ border: 1px solid var(--neutral-color);
+ border-radius: 6px;
+ padding: 0.65rem 0.75rem;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.9rem;
+}
+
+.account-input:focus {
+ outline: none;
+ border-color: var(--primary-color);
+}
+
+.account-signed-in {
+ text-align: center;
+ font-size: 1rem;
+}
+
+.account-spotify-status {
+ font-size: 0.85rem;
+ color: var(--sub-color);
+ text-align: center;
+}
+
+/* Typing Game */
+.typing-game {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ max-width: 1300px;
+ margin: 0 auto;
+ padding: 1rem;
+ position: relative;
+ height: 100vh;
+ overflow: hidden;
+}
+
+.text-container {
+ position: relative;
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 1rem 2rem 0 2rem;
+ margin-top: var(--header-offset); /* unified: just below header/toggle */
+}
+
+.text-display {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 1.5rem;
+ line-height: 1.5;
+ min-height: 6rem;
+ color: var(--text-color);
+ position: relative;
+ white-space: pre-wrap;
+ word-break: keep-all;
+ overflow-wrap: break-word;
+ /* A whole number of characters wide, not fit-content. When the passage
+ wraps, fit-content resolves to the full available width - which was
+ 78.9 character cells, so the leftover 0.9 cell could never be filled
+ and always sat on the right. The box measured perfectly centred while
+ the text inside it read as right-heavy. An exact character count means
+ a full line reaches both edges. (border-box is global, hence the
+ padding in the calc.) */
+ width: min(100%, calc(76ch + 2rem));
+ margin: 0 auto;
+ tab-size: 4;
+ hyphens: none;
+ text-align: left;
+ padding: 1rem;
+}
+
+.text-display span {
+ white-space: pre;
+ position: relative;
+ display: inline-block;
+}
+
+.text-display span.correct {
+ color: var(--correct-color);
+}
+
+.text-display span.incorrect {
+ color: var(--error-color);
+}
+
+.text-display span.neutral {
+ color: var(--neutral-color);
+}
+
+/* Blind Mode: a typed character just looks "passed", no red/green judgment
+ - distinct from .neutral (not yet reached) so the caret's progress is
+ still visible, just without correctness feedback. */
+.text-display span.typed {
+ color: var(--text-color);
+}
+
+.text-display span.current {
+ position: relative;
+}
+
+.text-display span.current::after {
+ content: '▏';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ color: var(--caret-color);
+ animation: blink 1s step-end infinite;
+}
+
+.typing-input {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ z-index: 1;
+ cursor: text;
+ caret-color: transparent;
+ background: transparent !important;
+ color: transparent !important;
+ border: none !important;
+ outline: none !important;
+ box-shadow: none !important;
+}
+
+.typing-input:focus {
+ background: transparent !important;
+ color: transparent !important;
+ border: none !important;
+ outline: none !important;
+ box-shadow: none !important;
+}
+
+.theme-toggle {
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ background: transparent;
+ border: 2px solid var(--primary-color);
+ outline: none;
+ box-shadow: none;
+ color: var(--primary-color);
+ width: 2.5rem;
+ height: 2.5rem;
+ padding: 0;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ border-radius: 4px;
+ backdrop-filter: none !important;
+ -webkit-backdrop-filter: none !important;
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+}
+
+.theme-toggle:focus,
+.theme-toggle:active {
+ outline: none;
+ box-shadow: none;
+}
+
+.theme-toggle:hover {
+ background-color: var(--primary-color);
+ color: var(--background-color);
+}
+
+/* Wrapper for themeButton.js's standalone control (every screen besides the
+ main menu, which builds its own .theme-corner-group inline). Fixed itself
+ so its child button can drop to position:static and still lay out
+ correctly as the popover's positioned ancestor - same reasoning as
+ .corner-rail-left .corner-icon-button. */
+/* Kept fixed for any caller that still mounts it standalone; inside .top-rail
+ it drops to static so the row lays it out (see .top-rail .theme-control). */
+.theme-control {
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ z-index: 100;
+}
+
+.theme-control .theme-toggle {
+ position: static;
+}
+
+/* Same box as .theme-toggle, sitting just to its left - Tab already
+ restarts the current test, but that shortcut isn't discoverable on its
+ own, so it gets a visible button too. */
+.restart-toggle {
+ background: transparent;
+ border: 2px solid var(--primary-color);
+ outline: none;
+ box-shadow: none;
+ color: var(--primary-color);
+ width: 2.5rem;
+ height: 2.5rem;
+ padding: 0;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ border-radius: 4px;
+ font-size: 1.3rem;
+ line-height: 1;
+ appearance: none;
+}
+
+.restart-toggle:hover {
+ background-color: var(--primary-color);
+ color: var(--background-color);
+}
+
+/* Zen has no timer and no fixed length, so unlike every other mode nothing
+ ever ends it automatically - this is the only way to finish one, so it
+ gets a real labeled button (not just another small icon square) sitting
+ in the same top-right cluster as Restart/Theme. Deliberately not placed
+ near TIME at bottom-center: that space is reserved to stay visually
+ consistent with the end screen's graph position, not a place to add
+ controls. */
+.zen-finish-button {
+ background: transparent;
+ border: 2px solid var(--primary-color);
+ outline: none;
+ box-shadow: none;
+ color: var(--primary-color);
+ height: 2.5rem;
+ padding: 0 1rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ border-radius: 4px;
+ font-family: inherit;
+ font-size: 0.9rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ appearance: none;
+}
+
+.zen-finish-button:hover {
+ background-color: var(--primary-color);
+ color: var(--background-color);
+}
+
+.theme-toggle svg {
+ width: 1.5rem;
+ height: 1.5rem;
+ transition: color 0.2s;
+}
+
+/* Same visual language as .theme-toggle (which stays a separate,
+ self-contained class since it's also used stand-alone on the typing/end
+ screens) - these are the main menu's other two corner buttons. */
+.corner-icon-button {
+ position: fixed;
+ background: transparent;
+ border: 2px solid var(--primary-color);
+ outline: none;
+ box-shadow: none;
+ color: var(--primary-color);
+ width: 2.5rem;
+ height: 2.5rem;
+ padding: 0;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ border-radius: 4px;
+ appearance: none;
+}
+
+.corner-icon-button:hover {
+ background-color: var(--primary-color);
+ color: var(--background-color);
+}
+
+.corner-icon-button svg {
+ width: 1.5rem;
+ height: 1.5rem;
+}
+
+/* The icon rail. Two bottom corners, identical on every screen - the main
+ menu builds the same two clusters inline (it owns its own Settings panel),
+ cornerRail.js injects them everywhere else. This used to be two different
+ layouts: bottom corners on the menu, a vertical top-right stack elsewhere,
+ so the icons jumped position on every navigation.
+
+ Left cluster is identity and behavior (Account, Settings, then Friends and
+ Store); right cluster is numbers (Stats, Leaderboard, Multiplayer).
+ Horizontal, not vertical - a bottom corner has little headroom before
+ hitting the screen edge. */
+/* Top-left, stacked under the wordmark: what customises the app. It used to
+ be a second bottom cluster, which left the bottom edge carrying navigation
+ on both sides while the top-left held nothing but the logo. A column, not a
+ row - there is vertical room here and the passage starts below it. */
+.corner-rail-left {
+ position: fixed;
+ top: 3.4rem;
+ left: 1rem;
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.75rem;
+}
+
+.corner-rail-right {
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ z-index: 100;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+/* .corner-icon-button is position:fixed by default - inside these flex rows
+ it needs to drop back to a normal flow position, or every button ignores
+ the layout entirely and stacks on top of the others at whatever its
+ unstyled static position happens to be. */
+.corner-rail-left .corner-icon-button,
+.corner-rail-right .corner-icon-button {
+ position: static;
+}
+
+/* Only the main menu still draws one, between its two top-left icons. A
+ horizontal hairline, since that cluster is a column. */
+.rail-divider {
+ align-self: stretch;
+ height: 1px;
+ background: var(--neutral-color);
+ opacity: 0.4;
+}
+
+.rail-settings-group {
+ position: relative;
+}
+
+.rail-settings-panel {
+ position: absolute;
+ top: calc(100% + 0.6rem);
+ left: 0;
+ background: var(--background-color);
+ border: 1px solid var(--neutral-color);
+ border-radius: 8px;
+ padding: 0.75rem;
+ width: 280px;
+}
+
+.theme-gallery-popover {
+ position: absolute;
+ top: calc(100% + 0.6rem);
+ right: 0;
+ background: var(--background-color);
+ border: 1px solid var(--neutral-color);
+ border-radius: 8px;
+ padding: 0.75rem;
+ width: 320px;
+}
+
+/* Lifetime average over best single result, stacked so both read as one
+ figure block beside the Stats icon rather than competing for corner room. */
+.stats-corner-figures {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.05rem;
+ line-height: 1.2;
+}
+
+.stats-corner-avg,
+.stats-corner-pb {
+ font-size: 0.85rem;
+ color: var(--sub-color);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ white-space: nowrap;
+}
+
+
+@keyframes blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+/* Mobile adjustments */
+@media (max-width: 768px) {
+ .main-menu h1 {
+ font-size: 2.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ .menu-button {
+ padding: 0.8rem 1.5rem;
+ font-size: 1rem;
+ min-width: 200px;
+ }
+
+ .typing-game {
+ padding: 1rem;
+ }
+
+ .text-container {
+ padding: 1rem;
+ margin-top: var(--header-offset);
+ }
+
+ .wpm-stat {
+ left: 1rem;
+ }
+
+ .acc-stat {
+ right: 1rem;
+ }
+
+ .time-stat {
+ bottom: 0.5rem;
+ font-size: 1rem;
+ }
+
+ .text-display {
+ font-size: 1.2rem;
+ }
+
+ .graph-container {
+ height: 200px;
+ padding: 1rem;
+ }
+
+ .graph-axis.x {
+ left: 1rem;
+ right: 1rem;
+ }
+
+ .graph-axis.y {
+ bottom: 1rem;
+ }
+
+ .end-screen-buttons {
+ bottom: 1rem;
+ }
+}
+
+@media (max-height: 600px) {
+ .wpm-stat,
+ .acc-stat {
+ top: 50%;
+ }
+
+ .time-stat {
+ bottom: 0.25rem;
+ font-size: 0.9rem;
+ }
+
+ .text-container {
+ margin-top: 4.5rem;
+ padding: 1rem;
+ }
+
+ .text-display {
+ font-size: 1.2rem;
+ min-height: 4.5rem;
+ }
+
+ .graph-container {
+ height: 200px;
+ }
+}
+
+@media (max-width: 600px) {
+ .typing-game {
+ padding: 0.5rem;
+ height: 100vh;
+ min-width: 0;
+ }
+ .text-container {
+ padding: 0.5rem 0.5rem 0 0.5rem;
+ margin-top: var(--header-offset);
+ min-width: 0;
+ }
+ .text-display {
+ font-size: 1rem;
+ padding: 0.5rem;
+ min-height: 3rem;
+ max-width: 100vw;
+ word-break: break-word;
+ }
+}
+
+.attribution {
+ max-width: 700px;
+ width: 100%;
+ margin: 0.5rem auto 1.75rem auto;
+ text-align: center;
+ color: var(--neutral-color);
+ font-size: 0.95rem;
+}
+
+.segment-progress {
+ position: fixed;
+ top: 1rem;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 0.8rem;
+ color: var(--neutral-color);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.text-display.code {
+ text-align: left;
+ background: rgba(127, 127, 127, 0.06);
+ border-radius: 8px;
+}
+
+.text-display span.neutral.syn-keyword { color: var(--secondary-color); }
+.text-display span.neutral.syn-string { color: #d9a441; }
+.text-display span.neutral.syn-comment { color: var(--sub-color); font-style: italic; }
+.text-display span.neutral.syn-number { color: #6f9ee8; }
+
+/* Passive mode */
+.passive-mode {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ width: 100%;
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 2rem;
+ position: relative;
+ text-align: center;
+}
+
+.passive-source {
+ color: var(--neutral-color);
+ font-size: 0.85rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-bottom: 2rem;
+}
+
+.passive-lines {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ width: 100%;
+ margin-bottom: 2rem;
+}
+
+.passive-line {
+ font-family: 'JetBrains Mono', monospace;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.passive-line.prev,
+.passive-line.next {
+ color: var(--neutral-color);
+ opacity: 0.5;
+ font-size: 1.1rem;
+}
+
+.passive-line.active {
+ font-size: 1.6rem;
+ line-height: 1.5;
+ min-height: 2.4rem;
+}
+
+.passive-input {
+ position: relative;
+ width: 100%;
+ max-width: 500px;
+ background: transparent;
+ color: var(--text-color);
+ border: none;
+ border-bottom: 2px solid var(--neutral-color);
+ outline: none;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 1.1rem;
+ text-align: center;
+ padding: 0.5rem;
+ caret-color: var(--caret-color);
+}
+
+.passive-input:focus {
+ border-bottom-color: var(--primary-color);
+}
+
+.passive-stats {
+ margin-top: 1.5rem;
+}
+
+.passive-summary {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1.5rem;
+ margin-top: 2rem;
+}
+
+.passive-summary-title {
+ font-size: 1.3rem;
+ color: var(--primary-color);
+ text-transform: uppercase;
+ letter-spacing: 2px;
+}
+
+.passive-summary-stats {
+ display: flex;
+ gap: 3rem;
+}
+
+.passive-summary-stats > div {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+/* Fixed, matching width on both sides - "CONSISTENCY" is a much longer
+ label than "CHARS"/"ERR", so left to auto-size these two blocks end up
+ different widths, which pushes the graph between them (a flex-grow child
+ filling the remaining space-between gap) visibly off-center. */
+.endscreen-side-stat.wpm {
+ flex: 0 0 170px;
+ text-align: left;
+}
+
+.endscreen-side-stat.acc {
+ flex: 0 0 170px;
+ text-align: right;
+}
+
+.pb-badge {
+ font-size: 0.75rem;
+ letter-spacing: 0.05em;
+ color: var(--sub-color);
+ margin: -0.1rem 0 0.3rem;
+}
+
+/* A new personal best is the payoff moment of a test and had no colour of
+ its own - it rendered in the same accent as every other figure on the
+ screen. --record-color is used here and nowhere else, so the gold reads
+ as "you beat something", not as decoration. */
+.pb-badge.pb-new {
+ color: var(--record-color);
+ font-weight: 700;
+}
+
+.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ font-size: 1.5rem;
+ color: var(--primary-color);
+ font-family: 'JetBrains Mono', monospace;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+}
+
+#root {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+/* Typing Game */
+.stats-bar {
+ display: none;
+}
+
+.end-screen {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ width: 100%;
+ max-width: 1300px;
+ margin: 0 auto;
+ /* Same padding and same --header-offset margin as .typing-game +
+ .text-container, so the passage lands on the identical baseline on
+ both screens and does not jump when the test ends. This claimed to
+ "match .text-container exactly" while subtracting --app-padding and
+ using a 0.25rem pad, which put the end screen's passage 1.75rem
+ higher than the one the player had just been reading. */
+ padding: 1rem;
+ margin-top: var(--header-offset);
+ /* min-height must subtract the margin-top above, not just be 100vh --
+ margin sits outside the box, so 100vh + this margin always overflowed
+ the viewport by exactly the margin amount. Invisible before (body was
+ overflow:hidden), but a real, permanent scrollbar once that changed
+ to allow scrolling for genuinely tall content. */
+ min-height: calc(100vh - var(--header-offset));
+ position: relative;
+ /* Without this, Play Again sits flush against the very bottom edge of
+ the viewport whenever the passage/graph/time content happens to fill
+ most of the screen - the auto-fit cascade below still shrinks this
+ away first if a genuinely tall passage would otherwise force a
+ scrollbar, so it never fights that logic, it just guarantees normal
+ breathing room in the common case. */
+ padding-bottom: 2rem;
+ box-sizing: border-box;
+}
+
+.end-screen-buttons {
+ /* Normal flow, not fixed-to-viewport - fixed positioning stranded this
+ right at the bottom of the viewport regardless of content height,
+ leaving a large dead gap above it whenever the passage/graph/time
+ block didn't happen to fill the screen. */
+ margin-top: 1.5rem;
+ display: flex;
+ gap: 1.5rem;
+}
+
+.end-screen-button {
+ background: none;
+ border: 2px solid var(--primary-color);
+ color: var(--text-color);
+ padding: 1rem 2rem;
+ font-size: 1.2rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-family: inherit;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ min-width: 250px;
+ border-radius: 8px;
+}
+
+.end-screen-button:hover {
+ background-color: var(--primary-color);
+ color: var(--background-color);
+ transform: translateY(-2px);
+}
+
+/* Character styling */
+.correct {
+ color: var(--correct-color);
+}
+
+.incorrect {
+ color: var(--error-color);
+ text-decoration: underline;
+}
+
+.current {
+ /* A hardcoded rgba(0,0,0,...) darkening tint is a no-op against dark
+ mode's pure-black background (you can't darken black further) --
+ mixing --text-color (already the correct direction per theme: dark
+ tint on a light background, light tint on a dark one) into
+ transparent instead makes this actually visible in every theme. */
+ background-color: color-mix(in srgb, var(--text-color) 18%, transparent);
+ border-radius: 2px;
+}
+
+.error-screen {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 2rem;
+ text-align: center;
+}
+
+.error-screen h2 {
+ margin-bottom: 1rem;
+}
+
+.error-screen p {
+ margin-bottom: 2rem;
+}
+
+/* WPM/TIME/ACC used to each be position:fixed to a different edge of the
+ viewport (left-middle, bottom-center, right-middle) - three related
+ numbers scattered to opposite corners with the text floating alone above
+ a canyon of empty space between them. They're one dashboard, so they sit
+ in normal flow as one row directly under the text instead. */
+/* WPM sits left, ACC sits right, TIME sits bottom-center, leaving the
+ center/center-bottom of the screen clear - that's deliberate, not
+ incidental: it's the same zone the end screen's graph occupies (flanked
+ left/right by its own WPM/ACC), so the layout doesn't jump around when a
+ test finishes and the graph appears there. Don't "fix" this into one
+ grouped row; the empty center is reserved space, not dead space. */
+.wpm-stat {
+ position: fixed;
+ left: 2rem;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ width: 6rem;
+}
+
+/* "Live Stats: Off" - WPM/ACC hidden while typing, revealed on the end
+ screen as normal. TIME stays visible since time mode needs it to know
+ when the test ends. */
+.typing-game.hide-live-stats .wpm-stat,
+.typing-game.hide-live-stats .acc-stat {
+ display: none;
+}
+
+/* "Blink Caret: Off" - the current-character caret stays solid instead of
+ blinking (see .text-display span.current::after's animation). */
+.typing-game.no-caret-blink .text-display span.current::after {
+ animation: none;
+}
+
+.acc-stat {
+ position: fixed;
+ right: 2rem;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ width: 6rem;
+}
+
+.time-stat {
+ position: fixed;
+ left: 50%;
+ transform: translateX(-50%);
+ bottom: 1rem;
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+}
+
+/* Multiplayer: opponents' live progress during a race. Anchored below the
+ logo (the only thing already in the top-left corner) rather than the
+ center, which the typing/end screens keep empty on purpose to match the
+ end screen's graph zone. */
+.mp-opponents {
+ position: fixed;
+ top: 4.5rem;
+ left: 1rem;
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ width: 200px;
+}
+
+.mp-opponent-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.75rem;
+ color: var(--sub-color);
+}
+
+.mp-opponent-name {
+ color: var(--racer-color, var(--sub-color));
+ min-width: 4rem;
+ max-width: 4rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.mp-opponent-bar {
+ flex: 1;
+ height: 6px;
+ background: var(--neutral-color);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+/* Every racer's bar used the one accent colour, so in a five-player quick
+ match you could not tell which bar was whose. Each row carries its own
+ --racer-color (assigned in multiplayerScreen.js); you are always the theme
+ primary, opponents take the --player-N hues in join order. */
+.mp-opponent-bar-fill {
+ height: 100%;
+ background: var(--racer-color, var(--primary-color));
+ transition: width 0.15s ease;
+}
+
+/* Same colour key in the lobby list, so the mapping is learned before the
+ race starts rather than during it. */
+.leaderboard-row .mp-racer-dot {
+ display: inline-block;
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 2px;
+ margin-right: 0.5rem;
+ background: var(--racer-color, var(--primary-color));
+ flex-shrink: 0;
+}
+
+.mp-opponent-wpm {
+ min-width: 2rem;
+ text-align: right;
+}
+
+/* The one genuinely time-critical state in the app: seconds left to get your
+ hands on the keys. It read in the same accent as every idle figure on the
+ screen, so it carried no urgency. --warning-color is used here and on the
+ device badge, nowhere else. */
+.mp-countdown {
+ font-size: 3rem;
+ color: var(--warning-color);
+ text-align: center;
+ min-height: 4rem;
+}
+
+.stat-label {
+ font-size: 0.8rem;
+ color: var(--sub-color);
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ margin-bottom: 0.5rem;
+}
+
+.stat-value {
+ font-size: 2rem;
+ color: var(--primary-color);
+ font-weight: bold;
+}
+
+/* Custom tooltip for [data-tooltip] elements (see tooltip.js) - replaces
+ the browser's own native title="" popup, which can't be styled at all and
+ reads as an unpolished default sitting next to an otherwise fully custom
+ terminal-styled UI. */
+/* Floats over the page, so it has to read as a layer rather than as part of
+ the layout. It shared the page background with a hairline border, which
+ made it look like a panel that belonged where it landed - particularly
+ when it covered the control above the one it describes. The lifted
+ background and shadow separate it from whatever it overlaps. */
+.app-tooltip {
+ position: fixed;
+ background: color-mix(in srgb, var(--text-color) 10%, var(--background-color));
+ color: var(--text-color);
+ border: 1px solid var(--primary-color);
+ border-radius: 4px;
+ padding: 0.4rem 0.6rem;
+ font-size: 0.8rem;
+ font-family: 'JetBrains Mono', monospace;
+ max-width: 260px;
+ line-height: 1.4;
+ pointer-events: none;
+ z-index: 1000;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.55);
+}
+
+.chart-tooltip {
+ position: absolute;
+ display: none;
+ transform: translate(-50%, -100%);
+ margin-top: -10px;
+ background: rgba(20, 20, 20, 0.95);
+ color: #fff;
+ border: 1px solid var(--primary-color);
+ border-radius: 4px;
+ padding: 0.3rem 0.6rem;
+ font-size: 0.8rem;
+ font-family: 'JetBrains Mono', monospace;
+ pointer-events: none;
+ white-space: nowrap;
+ z-index: 20;
+}
+
+.graph-container canvas {
+ width: 100%;
+ height: 100%;
+}
+
+.graph-axis {
+ position: absolute;
+ color: var(--text-color);
+ font-size: 0.8rem;
+ font-family: 'JetBrains Mono', monospace;
+}
+
+.graph-axis.x {
+ bottom: 0;
+ left: 2rem;
+ right: 2rem;
+ display: flex;
+ justify-content: space-between;
+ padding: 0.5rem 0;
+}
+
+.graph-axis.y {
+ top: 0;
+ bottom: 2rem;
+ left: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ padding: 0 0.5rem;
+ width: 3rem;
+ text-align: right;
+}
+
+body, .app, #root {
+ overflow-x: hidden;
+ scrollbar-width: none; /* Firefox */
+}
+body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
+ display: none; /* Chrome, Safari, Opera */
+}
+
+.future-modes-placeholder {
+ width: 100%;
+ min-height: 3rem;
+ background: transparent;
+ margin-top: 1.5rem;
+}
+
+.end-screen-text {
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto 0.5rem auto;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 1.2rem;
+ color: var(--text-color);
+ /* See .current's comment - a flat rgba(0,0,0,...) tint only darkens,
+ so it did nothing at all against dark mode's pure-black background. */
+ background: color-mix(in srgb, var(--text-color) 6%, transparent);
+ border-radius: 6px;
+ /* Side padding matches .text-container's 2rem so the centred passage
+ lands on the same left edge as it had on the typing screen. */
+ padding: 1rem 2rem;
+ text-align: left;
+ word-break: break-word;
+}
+
+.end-screen-graph-row {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto 0.5rem auto;
+ /* Matches .end-screen-text's own 2rem side padding, so the WPM/ACC
+ blocks line up with the actual text edges above them, not with the
+ text box's outer (padded) edge. Right side gets extra clearance on
+ top of that - unlike the typing screen's WPM/ACC blocks (fixed
+ position with their own deliberate margin), this row is a normal-flow
+ flex layout, so on any viewport narrower than max-width its own right
+ edge can land directly under the fixed corner icon rail otherwise. */
+ padding: 0 2rem;
+ padding-right: 4.5rem;
+}
+
+.graph-container {
+ flex: 1 1 0;
+ min-width: 0;
+ margin: 0;
+ padding: 1rem 0;
+ height: 240px;
+ position: relative;
+ background: color-mix(in srgb, var(--text-color) 3%, transparent);
+ border-radius: 8px;
+}
+
+.end-screen-graph-row.end-screen-time-row {
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ margin: 0 auto 2rem auto;
+}
+
+.end-screen-rawwpm {
+ width: 100%;
+ max-width: 900px;
+ margin: 0 auto 2rem auto;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ gap: 1.5rem;
+ font-size: 1.3rem;
+}
+.end-screen-rawwpm .stat-label {
+ font-size: 1rem;
+ color: var(--sub-color);
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ margin-right: 0.5rem;
+}
+.end-screen-rawwpm .stat-value {
+ font-size: 2rem;
+ color: var(--primary-color);
+ font-weight: bold;
+}
+
+.end-screen-time {
+ width: 100%;
+ max-width: 1100px;
+ margin: 0 auto;
+ text-align: center;
+ padding: 1rem 0;
+}
+
+.end-screen-time .stat-label {
+ font-size: 0.9rem;
+ margin-bottom: 0.3rem;
+}
+
+.end-screen-time .stat-value {
+ font-size: 1.5rem;
+}
+
+/* Width-only now - this used to also trigger on short viewports (max-height:
+ 800px) to keep normal-flow content clear of the Play Again button, back
+ when that button was pinned with position:fixed and could overlap it. It's
+ normal flow now (see .end-screen-buttons), and the page scrolls when
+ content is taller than the viewport, so there's nothing left for a height
+ trigger to protect against - it was only ever collapsing the side-by-side
+ stat layout on perfectly normal laptop screens (1366x768, 1280x800 both
+ hit max-height:800px despite being completely ordinary resolutions). */
+@media (max-width: 900px) {
+ .end-screen-graph-row {
+ flex-direction: column !important;
+ align-items: stretch !important;
+ max-width: 100vw !important;
+ position: relative !important;
+ /* The extra right clearance is only needed for the desktop
+ side-by-side layout sharing horizontal space with the fixed icon
+ rail - stacked vertically here, nothing competes for that
+ space, so keep it symmetric like every other stacked block. */
+ padding-right: 1.5rem !important;
+ }
+ .endscreen-side-stat {
+ position: static !important;
+ left: unset !important;
+ right: unset !important;
+ top: unset !important;
+ transform: none !important;
+ width: 100% !important;
+ margin-bottom: 0.75rem !important;
+ z-index: 10;
+ /* Each block is 3 label+value pairs (WPM/RAW/CONSISTENCY or
+ ACC/ERR/CHARS) - row direction here was flattening them into one
+ spread-out line (WPM ... 805 ... RAW ... 805 ...) edge to edge.
+ They need to stay stacked, just centered instead of the desktop
+ layout's left/right-anchored alignment. */
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: center !important;
+ text-align: center !important;
+ }
+ .graph-container {
+ flex: none !important;
+ max-width: 100vw !important;
+ min-width: 0 !important;
+ width: 100% !important;
+ overflow-x: auto !important;
+ margin: 0 auto 1rem auto !important;
+ height: 160px !important;
+ }
+ .end-screen-buttons {
+ position: static !important;
+ left: unset !important;
+ bottom: unset !important;
+ transform: none !important;
+ width: 100% !important;
+ margin: 1.5rem 0 0 0 !important;
+ flex-direction: column !important;
+ gap: 1rem !important;
+ z-index: 10;
+ }
+ .end-screen-time {
+ position: static !important;
+ left: unset !important;
+ transform: none !important;
+ bottom: unset !important;
+ width: 100% !important;
+ margin-top: 1rem !important;
+ }
+}
+
+@media (max-width: 600px) {
+ .end-screen-graph-row {
+ padding: 0 0.5rem !important;
+ }
+ .graph-container {
+ height: 180px !important;
+ padding: 0.5rem !important;
+ }
+ .endscreen-side-stat.wpm, .endscreen-side-stat.acc {
+ font-size: 1rem !important;
+ padding: 0.5rem !important;
+ }
+ .end-screen-time .stat-value {
+ font-size: 1.2rem !important;
+ }
+}
+
+@media (max-width: 900px) {
+ .text-container {
+ padding: 0.5rem !important;
+ min-height: 120px !important;
+ }
+}
+@media (max-width: 600px) {
+ .text-container {
+ min-height: 80px !important;
+ font-size: 1rem !important;
+ }
+}
+
+@media (max-width: 700px) {
+ :root {
+ --header-offset: 2.5rem;
+ }
+ body, #root, .app, .end-screen {
+ overflow-y: auto !important;
+ overflow-x: hidden !important;
+ }
+ .end-screen {
+ min-height: 0 !important;
+ height: auto !important;
+ padding: 0.5rem 0.75rem !important;
+ padding-top: var(--header-offset) !important; /* align with game text */
+ padding-bottom: 0.5rem !important;
+ display: flex !important;
+ flex-direction: column !important;
+ }
+ .end-screen-buttons {
+ margin-top: auto !important;
+ margin-bottom: 0 !important;
+ width: 100% !important;
+ }
+}
+/* Room code + Join on one line: they are a single action split across two
+ controls, and stacking them read as two unrelated steps. */
+.mp-code-row {
+ display: flex;
+ gap: 0.5rem;
+ width: 100%;
+ align-items: stretch;
+}
+
+.mp-code-row .account-input {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.mp-code-row .menu-button {
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
+
+/* The passage's attribution, repeated on the end screen so a quote still
+ says who wrote it once the test is over. Same centred look as the typing
+ screen's, just tighter - the graph row follows immediately below. */
+.end-screen-attribution {
+ max-width: 1200px;
+ margin: 0.4rem auto 0.5rem auto;
+ padding: 0 2rem;
+}
+
+/* Rules and Typing side by side. Stacked, the panel ran past the bottom of a
+ 900px viewport and had to be scrolled to reach the last toggle, with every
+ row an identically-weighted full-width button. */
+.settings-columns {
+ display: flex;
+ gap: 1.25rem;
+ width: 100%;
+ align-items: flex-start;
+}
+
+.settings-column {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+.settings-column .menu-button.small {
+ min-width: 0;
+ width: 100%;
+}
+
+/* One column on narrow windows, where side-by-side would squeeze the labels. */
+@media (max-width: 700px) {
+ .settings-columns {
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+}
+
+/* Correct/incorrect split under a headline figure - the keystroke count is
+ the interesting number, the breakdown is the footnote. */
+.stat-breakdown {
+ display: block;
+ font-size: 0.7rem;
+ color: var(--sub-color);
+ letter-spacing: 1px;
+ margin-top: 0.15rem;
+}
+
+
+/* The top-right rail: typing language, theme, and identity. Everything here
+ configures the app or says who you are, as opposed to the bottom rail's
+ navigation. Screens with their own top-right controls (Restart, Zen Finish)
+ hand them in as `extras` so they join this row rather than fixed-positioning
+ themselves into it. */
+.top-rail {
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ z-index: 100;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.6rem;
+}
+
+.top-rail .theme-control,
+.top-rail .corner-icon-button,
+.top-rail .restart-toggle,
+.top-rail .zen-finish-button {
+ position: static;
+}
+
+.lang-group {
+ position: relative;
+}
+
+/* Opens downward from a top-anchored control, unlike the bottom rail's
+ Settings panel which opens upward. */
+.lang-popover {
+ position: absolute;
+ top: calc(100% + 0.6rem);
+ right: 0;
+ background: var(--background-color);
+ border: 1px solid var(--neutral-color);
+ border-radius: 8px;
+ padding: 0.5rem;
+ width: 210px;
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ z-index: 120;
+ /* Scrolls once the list outgrows the viewport - the heading and the note
+ stay put because only the item list between them scrolls. */
+ max-height: calc(100vh - 6rem);
+ overflow-y: auto;
+}
+
+.lang-heading {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--sub-color);
+ padding: 0.25rem 0.6rem 0.35rem;
+ border-bottom: 1px solid var(--neutral-color);
+ margin-bottom: 0.25rem;
+}
+
+.lang-item {
+ background: none;
+ border: none;
+ color: var(--text-color);
+ font-family: inherit;
+ font-size: 0.9rem;
+ text-align: left;
+ padding: 0.4rem 0.6rem;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.lang-item:hover {
+ background: rgba(128, 128, 128, 0.15);
+}
+
+.lang-item.active {
+ color: var(--primary-color);
+ font-weight: bold;
+}
+
+.lang-note {
+ font-size: 0.7rem;
+ color: var(--sub-color);
+ padding: 0.45rem 0.6rem 0.15rem;
+ line-height: 1.35;
+}
+
+.auth-control {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+/* Text links, not bordered boxes - three outlined squares plus two outlined
+ buttons in one row was far too much chrome for a corner. */
+.top-rail-link {
+ background: none;
+ border: none;
+ color: var(--sub-color);
+ font-family: inherit;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ cursor: pointer;
+ padding: 0.5rem 0.4rem;
+}
+
+.top-rail-link:hover {
+ color: var(--text-color);
+}
+
+.top-rail-link.accent,
+.top-rail-link.signed-in {
+ color: var(--primary-color);
+}
+
+.top-rail-link.accent:hover,
+.top-rail-link.signed-in:hover {
+ color: var(--secondary-color);
+}
+
+/* Live counts on the Friends and Multiplayer icons. Positioned against the
+ button's own box, which is why those buttons opt in with .has-badge. */
+.corner-icon-button.has-badge {
+ position: relative;
+ overflow: visible;
+}
+
+.corner-rail-left .corner-icon-button.has-badge,
+.corner-rail-right .corner-icon-button.has-badge {
+ position: relative;
+}
+
+.rail-badge {
+ position: absolute;
+ top: -0.45rem;
+ right: -0.45rem;
+ min-width: 1.05rem;
+ height: 1.05rem;
+ padding: 0 0.2rem;
+ border-radius: 0.55rem;
+ background: var(--background-color);
+ border: 1px solid var(--neutral-color);
+ color: var(--sub-color);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.6rem;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+}
+
+/* People available to race right now is the one count worth drawing the eye. */
+.rail-badge.accent {
+ border-color: var(--primary-color);
+ color: var(--primary-color);
+}
+
+.mp-caption {
+ color: var(--primary-color);
+}
+
+
+.friends-control {
+ display: flex;
+ align-items: center;
+}
+
+
+/* Keyboard shortcuts, at the foot of the menu rather than wedged between the
+ title and the buttons where it competed with them for attention. */
+.menu-key-hint {
+ position: fixed;
+ bottom: 1.25rem;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 0.75rem;
+ color: var(--sub-color);
+ letter-spacing: 1px;
+ z-index: 90;
+}
+
+
+/* Secondary figures in one row under the graph, the way 10FastFingers and
+ MonkeyType both lay them out. Stacked in the two side columns they were a
+ dim 1.2rem list flanking the chart, competing with WPM and ACC for the same
+ space and hard to read at a glance. The side columns now carry only the two
+ headline numbers. */
+.end-screen-stat-row {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: flex-start;
+ gap: 2.5rem;
+ margin-top: 1.25rem;
+ width: 100%;
+ max-width: 1200px;
+ padding: 0 2rem;
+}
+
+.end-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.end-stat .stat-label {
+ font-size: 0.7rem;
+ letter-spacing: 1px;
+ color: var(--sub-color);
+}
+
+.end-stat .stat-value {
+ font-size: 1.35rem;
+ color: var(--primary-color);
+ font-family: 'JetBrains Mono', monospace;
+}
+
+@media (max-width: 900px) {
+ .end-screen-stat-row {
+ gap: 1.25rem 1.75rem;
+ padding: 0 1rem;
+ }
+}
diff --git a/web/src/theme.js b/web/src/theme.js
new file mode 100644
index 0000000..686572b
--- /dev/null
+++ b/web/src/theme.js
@@ -0,0 +1,66 @@
+export const Theme = { Light: 'light', Dark: 'dark' };
+
+// Full theme catalog for the gallery. Light/Dark stay the two the corner
+// toggle button cycles between; the rest are only reachable from the
+// gallery, matching how most typing sites treat "light vs dark" as the
+// quick switch and everything else as a deliberate pick.
+export const THEMES = [
+ { id: 'dark', label: 'Dark', dark: true, primary: '#00ff9d' },
+ { id: 'light', label: 'Light', dark: false, primary: '#067a4e' },
+ { id: 'dracula', label: 'Dracula', dark: true, primary: '#bd93f9' },
+ { id: 'nord', label: 'Nord', dark: true, primary: '#88c0d0' },
+ { id: 'solarized', label: 'Solarized', dark: true, primary: '#268bd2' },
+ { id: 'gruvbox', label: 'Gruvbox', dark: true, primary: '#fabd2f' },
+ { id: 'monokai', label: 'Monokai', dark: true, primary: '#a6e22e' },
+];
+
+export function isDarkTheme(id) {
+ return THEMES.find(t => t.id === id)?.dark ?? true;
+}
+
+const STORAGE_KEY = 'theme';
+const listeners = new Set();
+
+let theme = localStorage.getItem(STORAGE_KEY) || Theme.Dark;
+
+// The custom cursor is a per-theme accent, not a fixed brand mark - a
+// hardcoded color would sit oddly on Dracula/Nord/etc, so it's regenerated
+// from each theme's own primary color instead. Plus-reticle crosshair, not a
+// circle - the rest of the UI (buttons, icons.js's angular sun/moon) is all
+// sharp corners, and a round cursor read as off-brand next to it. The gap at
+// the center (arms don't meet) is what makes it read as a scope reticle
+// rather than a plain plus sign.
+function cursorDataUri(color) {
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><path d="M10 1V8 M10 12V19 M1 10H8 M12 10H19" stroke="${color}" stroke-width="1.6"/><rect x="9.3" y="9.3" width="1.4" height="1.4" fill="${color}"/></svg>`;
+ return `url("data:image/svg+xml;base64,${btoa(svg)}") 10 10, auto`;
+}
+
+function apply(value) {
+ document.documentElement.setAttribute('data-theme', value);
+ if (document.body) {
+ const t = THEMES.find(x => x.id === value);
+ document.body.style.cursor = cursorDataUri(t ? t.primary : '#00ff9d');
+ }
+}
+apply(theme);
+
+export function getTheme() {
+ return theme;
+}
+
+export function setTheme(id) {
+ if (!THEMES.some(t => t.id === id)) return;
+ theme = id;
+ localStorage.setItem(STORAGE_KEY, theme);
+ apply(theme);
+ for (const fn of listeners) fn(theme);
+}
+
+export function toggleTheme() {
+ setTheme(theme === Theme.Light ? Theme.Dark : Theme.Light);
+}
+
+export function onThemeChange(fn) {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+}
diff --git a/web/src/themeButton.js b/web/src/themeButton.js
new file mode 100644
index 0000000..81ac64e
--- /dev/null
+++ b/web/src/themeButton.js
@@ -0,0 +1,70 @@
+import { getTheme, setTheme, toggleTheme, onThemeChange, THEMES } from './theme.js';
+import { themeIcon } from './screens/icons.js';
+import { escapeHtml } from './util.js';
+import { attachTooltips } from './tooltip.js';
+
+const HOLD_MS = 450;
+
+// Every screen showed a theme button advertising "Hold for more themes" in
+// its tooltip, but only the main menu actually wired up the hold gesture --
+// everywhere else it was a plain click-only toggle, so the tooltip was
+// lying. One shared implementation instead of six copies of the same
+// click/hold/popover logic, so the behavior can't drift out of sync again.
+export function renderThemeButton(root) {
+ const wrap = document.createElement('div');
+ wrap.className = 'theme-control';
+ wrap.innerHTML = `
+ <button class="theme-toggle" data-tooltip="Hold for more themes" aria-label="Toggle theme"></button>
+ <div class="theme-gallery-popover" hidden>
+ <div class="theme-gallery">
+ ${THEMES.map(t => `
+ <button class="theme-swatch${getTheme() === t.id ? ' active' : ''}" data-theme-id="${t.id}">
+ <span class="theme-swatch-preview" data-swatch="${t.id}"></span>
+ <span class="theme-swatch-label">${escapeHtml(t.label)}</span>
+ </button>
+ `).join('')}
+ </div>
+ </div>
+ `;
+ root.appendChild(wrap);
+ attachTooltips(wrap);
+
+ const button = wrap.querySelector('.theme-toggle');
+ const popover = wrap.querySelector('.theme-gallery-popover');
+ const paint = () => { button.innerHTML = themeIcon(getTheme()); };
+ paint();
+ const unsubscribeTheme = onThemeChange(paint);
+
+ let holdTimer = null;
+ let heldOpen = false;
+ button.addEventListener('pointerdown', () => {
+ heldOpen = false;
+ holdTimer = setTimeout(() => { heldOpen = true; popover.hidden = false; }, HOLD_MS);
+ });
+ const cancelHold = () => clearTimeout(holdTimer);
+ button.addEventListener('pointerup', cancelHold);
+ button.addEventListener('pointerleave', cancelHold);
+ button.addEventListener('click', () => {
+ if (heldOpen) { heldOpen = false; return; }
+ toggleTheme();
+ });
+
+ wrap.querySelectorAll('.theme-swatch').forEach(swatch => {
+ swatch.addEventListener('click', () => {
+ setTheme(swatch.dataset.themeId);
+ wrap.querySelectorAll('.theme-swatch').forEach(b => b.classList.toggle('active', b === swatch));
+ popover.hidden = true;
+ });
+ });
+
+ const handleOutsideClick = e => {
+ if (!popover.hidden && !wrap.contains(e.target)) popover.hidden = true;
+ };
+ document.addEventListener('click', handleOutsideClick);
+
+ return () => {
+ unsubscribeTheme();
+ document.removeEventListener('click', handleOutsideClick);
+ wrap.remove();
+ };
+}
diff --git a/web/src/tooltip.js b/web/src/tooltip.js
new file mode 100644
index 0000000..e432ac3
--- /dev/null
+++ b/web/src/tooltip.js
@@ -0,0 +1,66 @@
+// Custom-styled tooltip, replacing the browser's own native title="" popup --
+// that one can't be styled at all (OS-drawn box, inconsistent font/delay
+// across browsers), which reads as an unpolished, "browser default" element
+// sitting next to an otherwise fully custom UI.
+let tooltipEl = null;
+
+function getTooltipEl() {
+ if (!tooltipEl) {
+ tooltipEl = document.createElement('div');
+ tooltipEl.className = 'app-tooltip';
+ tooltipEl.hidden = true;
+ document.body.appendChild(tooltipEl);
+ }
+ return tooltipEl;
+}
+
+// Short delay before showing. Without one, sweeping the pointer across the
+// icon rail fires a tooltip per icon passed over, which flickers.
+const SHOW_DELAY_MS = 350;
+let showTimer = null;
+
+function showTooltip(target) {
+ const text = target.dataset.tooltip;
+ if (!text) return;
+ const tip = getTooltipEl();
+ tip.textContent = text;
+ tip.hidden = false;
+
+ const rect = target.getBoundingClientRect();
+ const tipRect = tip.getBoundingClientRect();
+ let left = rect.left + rect.width / 2 - tipRect.width / 2;
+ left = Math.max(8, Math.min(left, window.innerWidth - tipRect.width - 8));
+ let top = rect.top - tipRect.height - 8;
+ if (top < 8) top = rect.bottom + 8;
+ tip.style.left = `${left}px`;
+ tip.style.top = `${top}px`;
+}
+
+function scheduleTooltip(target) {
+ clearTimeout(showTimer);
+ showTimer = setTimeout(() => showTooltip(target), SHOW_DELAY_MS);
+}
+
+function hideTooltip() {
+ clearTimeout(showTimer);
+ if (tooltipEl) tooltipEl.hidden = true;
+}
+
+// Wires every [data-tooltip] element under root - call once after the
+// screen's markup is in the DOM. Hides on scroll too, since a fixed-position
+// tooltip left open would otherwise drift away from the element it's
+// pointing at.
+export function attachTooltips(root) {
+ root.querySelectorAll('[data-tooltip]').forEach(el => {
+ el.addEventListener('mouseenter', () => scheduleTooltip(el));
+ el.addEventListener('mouseleave', hideTooltip);
+ // Acting on a control dismisses its tooltip. Without this it stays up
+ // over whatever the click just opened - the mode popover, or the
+ // control sitting above the button that was pressed.
+ el.addEventListener('mousedown', hideTooltip);
+ el.addEventListener('click', hideTooltip);
+ el.addEventListener('focus', () => showTooltip(el));
+ el.addEventListener('blur', hideTooltip);
+ });
+ window.addEventListener('scroll', hideTooltip, { capture: true, passive: true });
+}
diff --git a/web/src/topRail.js b/web/src/topRail.js
new file mode 100644
index 0000000..b6b50d7
--- /dev/null
+++ b/web/src/topRail.js
@@ -0,0 +1,114 @@
+import { LANGUAGE_ICON, FRIENDS_ICON } from './screens/icons.js';
+import { LANGUAGES, languageLabel } from './languages.js';
+import { getSettings, updateSettings } from './settings.js';
+import { renderThemeButton } from './themeButton.js';
+import { getUser, onAuthChange } from './auth.js';
+import { onCountsChange, getCounts } from './counts.js';
+import { attachTooltips } from './tooltip.js';
+import { escapeHtml } from './util.js';
+
+// The top-right cluster: the typing language, the theme, and who you are --
+// Friends sits directly beside the account control because the two are the
+// same thing, your identity and who it connects you to. These are the app's own chrome --
+// they belong together and away from the bottom rail's per-screen
+// navigation. Screens with their own top-right controls (the typing screen's
+// Restart and Zen Finish) pass them in as `extras` so everything shares one
+// row instead of each fixed-positioning itself into a collision.
+export function renderTopRail(root, { onShowAccount, onShowFriends, extras = [] } = {}) {
+ const wrap = document.createElement('div');
+ wrap.className = 'top-rail';
+ root.appendChild(wrap);
+
+ for (const el of extras) wrap.appendChild(el);
+
+ const langGroup = document.createElement('div');
+ langGroup.className = 'lang-group';
+ langGroup.innerHTML = `
+ <button class="corner-icon-button" data-action="lang" aria-label="Typing language" data-tooltip="Typing language">${LANGUAGE_ICON}</button>
+ <div class="lang-popover" hidden>
+ <div class="lang-heading">Typing language</div>
+ ${LANGUAGES.map(l => `
+ <button class="lang-item" data-lang="${escapeHtml(l.id)}">${escapeHtml(l.label)}</button>
+ `).join('')}
+ <div class="lang-note">Applies to Words, Timed, Zen and Practice.</div>
+ </div>
+ `;
+ wrap.appendChild(langGroup);
+
+ const cleanupTheme = renderThemeButton(wrap);
+
+ const friends = document.createElement('div');
+ friends.className = 'friends-control';
+ if (onShowFriends) {
+ friends.innerHTML = `<button class="corner-icon-button has-badge" data-action="rail-friends" aria-label="Friends" data-tooltip="Friends">${FRIENDS_ICON}<span class="rail-badge" data-badge="friends" hidden></span></button>`;
+ }
+ wrap.appendChild(friends);
+
+ const auth = document.createElement('div');
+ auth.className = 'auth-control';
+ wrap.appendChild(auth);
+
+ attachTooltips(wrap);
+
+ // Language ------------------------------------------------------------
+ const langPopover = langGroup.querySelector('.lang-popover');
+ function paintLang() {
+ const current = getSettings().language || 'en';
+ langGroup.querySelectorAll('.lang-item').forEach(item => {
+ item.classList.toggle('active', item.dataset.lang === current);
+ });
+ langGroup.querySelector('[data-action="lang"]').dataset.tooltip = `Typing language: ${languageLabel(current)}`;
+ }
+ paintLang();
+ langGroup.querySelector('[data-action="lang"]').addEventListener('click', e => {
+ e.stopPropagation();
+ langPopover.hidden = !langPopover.hidden;
+ });
+ langGroup.querySelectorAll('.lang-item').forEach(item => {
+ item.addEventListener('click', () => {
+ updateSettings({ language: item.dataset.lang });
+ paintLang();
+ langPopover.hidden = true;
+ });
+ });
+
+ // Identity ------------------------------------------------------------
+ function paintAuth() {
+ const user = getUser();
+ auth.innerHTML = user
+ ? `<button class="top-rail-link signed-in" data-action="account" data-tooltip="Your account">${escapeHtml(user.username)}</button>`
+ : `<button class="top-rail-link" data-action="signin" data-tooltip="Sign in to save stats and join the leaderboard">Sign In</button>
+ <button class="top-rail-link accent" data-action="signup" data-tooltip="Create an account">Sign Up</button>`;
+ attachTooltips(auth);
+ auth.querySelectorAll('button').forEach(btn => {
+ btn.addEventListener('click', () => onShowAccount?.());
+ });
+ }
+ paintAuth();
+ const unsubscribeAuth = onAuthChange(paintAuth);
+
+ // Friends ------------------------------------------------------------
+ const friendsBtn = friends.querySelector('[data-action="rail-friends"]');
+ if (friendsBtn) friendsBtn.addEventListener('click', onShowFriends);
+ const friendsBadge = friends.querySelector('[data-badge="friends"]');
+ function paintCounts({ friends: friendCount }) {
+ if (!friendsBadge) return;
+ friendsBadge.hidden = !friendCount;
+ friendsBadge.textContent = String(friendCount);
+ }
+ paintCounts(getCounts());
+ const unsubscribeCounts = onCountsChange(paintCounts);
+
+ const handleOutsideClick = e => {
+ if (!langPopover.hidden && !langGroup.contains(e.target)) langPopover.hidden = true;
+ };
+ document.addEventListener('click', handleOutsideClick);
+
+ return () => {
+ unsubscribeCounts();
+ document.removeEventListener('click', handleOutsideClick);
+ unsubscribeAuth();
+ cleanupTheme();
+ wrap.remove();
+ };
+}
diff --git a/web/src/util.js b/web/src/util.js
new file mode 100644
index 0000000..65c718b
--- /dev/null
+++ b/web/src/util.js
@@ -0,0 +1,5 @@
+const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
+
+export function escapeHtml(str) {
+ return str.replace(/[&<>"']/g, c => ESCAPES[c]);
+}
diff --git a/web/src/wordGenerator.js b/web/src/wordGenerator.js
new file mode 100644
index 0000000..d97ea8e
--- /dev/null
+++ b/web/src/wordGenerator.js
@@ -0,0 +1,185 @@
+import { languageWords } from './languages.js';
+
+const COMMON_WORDS = [
+ 'the', 'be', 'to', 'of', 'and', 'a', 'in', 'that', 'have', 'it',
+ 'for', 'not', 'on', 'with', 'he', 'as', 'you', 'do', 'at', 'this',
+ 'but', 'his', 'by', 'from', 'they', 'we', 'say', 'her', 'she', 'or',
+ 'an', 'will', 'my', 'one', 'all', 'would', 'there', 'their', 'what', 'so',
+ 'up', 'out', 'if', 'about', 'who', 'get', 'which', 'go', 'me', 'when',
+ 'make', 'can', 'like', 'time', 'no', 'just', 'him', 'know', 'take', 'people',
+ 'into', 'year', 'your', 'good', 'some', 'could', 'them', 'see', 'other', 'than',
+ 'then', 'now', 'look', 'only', 'come', 'its', 'over', 'think', 'also', 'back',
+ 'after', 'use', 'two', 'how', 'our', 'work', 'first', 'well', 'way', 'even',
+ 'new', 'want', 'because', 'any', 'these', 'give', 'day', 'most', 'us', 'water',
+ 'long', 'find', 'here', 'thing', 'place', 'hand', 'part', 'child', 'eye', 'life',
+ 'world', 'school', 'state', 'family', 'student', 'group', 'country', 'problem', 'fact', 'month',
+ 'right', 'study', 'book', 'word', 'business', 'issue', 'side', 'kind', 'head', 'house',
+ 'service', 'friend', 'father', 'power', 'hour', 'game', 'line', 'end', 'member', 'law',
+ 'car', 'city', 'community', 'name', 'president', 'team', 'minute', 'idea', 'body', 'information',
+ 'parent', 'face', 'others', 'level', 'office', 'door', 'health', 'person', 'art', 'war',
+ 'history', 'party', 'result', 'change', 'morning', 'reason', 'research', 'girl', 'guy', 'moment',
+ 'air', 'teacher', 'force', 'education', 'foot', 'boy', 'age', 'policy', 'process', 'music',
+ 'market', 'sense', 'nation', 'plan', 'college', 'interest', 'death', 'experience', 'effect', 'model',
+];
+
+// Longer, less-frequent words - layered on top of COMMON_WORDS for the
+// "extended" tier rather than replacing it, so that tier is strictly harder
+// (more variety, longer words) instead of just different.
+const EXTENDED_WORDS = [
+ 'available', 'benefit', 'community', 'consider', 'determine', 'discover', 'economic', 'establish',
+ 'evidence', 'example', 'experience', 'feature', 'function', 'general', 'government', 'however',
+ 'important', 'include', 'increase', 'individual', 'information', 'international', 'involve', 'language',
+ 'material', 'measure', 'medical', 'military', 'natural', 'necessary', 'opportunity', 'organization',
+ 'particular', 'perform', 'physical', 'political', 'population', 'position', 'positive', 'possible',
+ 'practice', 'prepare', 'present', 'pressure', 'probably', 'produce', 'product', 'program',
+ 'protect', 'provide', 'quality', 'question', 'reality', 'receive', 'recognize', 'record',
+ 'relationship', 'remember', 'require', 'resource', 'response', 'result', 'security', 'several',
+ 'significant', 'similar', 'situation', 'social', 'society', 'special', 'specific', 'standard',
+ 'structure', 'suggest', 'support', 'system', 'technology', 'therefore', 'together', 'traditional',
+ 'training', 'travel', 'treatment', 'understand', 'university', 'various', 'through', 'without',
+];
+
+// Uncommon, technical, or irregularly-spelled words - deliberately picks
+// for awkward letter combinations and length rather than just obscurity, so
+// "hard" is actually harder to type, not just harder to recognize.
+const HARD_WORDS = [
+ 'rhythm', 'bureaucracy', 'conscience', 'entrepreneur', 'idiosyncrasy', 'juxtaposition', 'onomatopoeia',
+ 'paradigm', 'pharaoh', 'phenomenon', 'protagonist', 'quintessential', 'silhouette', 'subtlety',
+ 'synchronize', 'unprecedented', 'vacuum', 'wednesday', 'xylophone', 'zeitgeist', 'acquaintance',
+ 'archipelago', 'camaraderie', 'chrysanthemum', 'circumference', 'colonel', 'conscientious', 'exacerbate',
+ 'exaggerate', 'extraordinary', 'gargantuan', 'hierarchical', 'hypothesis', 'inconsequential', 'labyrinth',
+ 'maneuver', 'mischievous', 'nostalgia', 'omniscient', 'perpendicular', 'questionnaire', 'reconnaissance',
+ 'rendezvous', 'sovereignty', 'surveillance', 'threshold', 'tuberculosis', 'ubiquitous', 'vicissitude',
+ 'wherewithal',
+];
+
+const WORD_POOLS = {
+ common: COMMON_WORDS,
+ extended: [...COMMON_WORDS, ...EXTENDED_WORDS],
+ hard: [...EXTENDED_WORDS, ...HARD_WORDS],
+};
+
+export function wordListTiers() {
+ return Object.keys(WORD_POOLS);
+}
+
+// The tier ladder (common/extended/hard) is built from the English lists, so
+// it only applies to English. Every other language ships one high-frequency
+// list, and asking for a harder tier in Spanish must not silently drop you
+// back into English words - so a non-English language uses its own list at
+// every tier rather than falling through to WORD_POOLS.
+function poolFor(tier, language) {
+ if (language && language !== 'en') return languageWords(language);
+ return WORD_POOLS[tier] || COMMON_WORDS;
+}
+
+function pick(tier, language) {
+ const pool = poolFor(tier, language);
+ return pool[Math.floor(Math.random() * pool.length)];
+}
+
+function capitalize(word) {
+ return word.charAt(0).toUpperCase() + word.slice(1);
+}
+
+// Builds a random passage of the given word count, MonkeyType-style: a flat
+// stream of common words with no fixed meaning, used for count- or
+// time-based practice instead of a memorized quote. `numbers` occasionally
+// swaps a word for a random number; `punctuation` breaks the stream into
+// capitalized, comma- and period-punctuated "sentences".
+export function generateWordStream(count, options = {}) {
+ const { punctuation = false, numbers = false, tier = 'common', language = 'en' } = options;
+ const words = [];
+ let sinceComma = 0;
+ let sinceSentenceStart = 0;
+ for (let i = 0; i < count; i++) {
+ let word = numbers && Math.random() < 0.12
+ ? String(Math.floor(Math.random() * 1000))
+ : pick(tier, language);
+
+ if (punctuation) {
+ if (sinceSentenceStart === 0) word = capitalize(word);
+ sinceComma++;
+ sinceSentenceStart++;
+
+ const atEnd = i === count - 1;
+ if (atEnd) {
+ word += '.';
+ } else if (sinceSentenceStart >= 6 && Math.random() < 0.15) {
+ word += '.';
+ sinceSentenceStart = 0;
+ } else if (sinceComma >= 4 && Math.random() < 0.2) {
+ word += ',';
+ sinceComma = 0;
+ }
+ }
+
+ words.push(word);
+ }
+ return words.join(' ');
+}
+
+// Weighted toward words containing the caller's weak characters (see
+// keyStats.js) instead of a flat random pick - a word with 3 weak letters
+// is far more likely to come up than one with none, but every word still
+// has some chance (the +1 baseline), so the output stays readable text
+// instead of a narrow loop of the same few words.
+function pickWeighted(pool, weakSet) {
+ const weights = pool.map(word => {
+ let score = 1;
+ for (const ch of word) if (weakSet.has(ch)) score += 2;
+ return score;
+ });
+ const total = weights.reduce((a, b) => a + b, 0);
+ let roll = Math.random() * total;
+ for (let i = 0; i < pool.length; i++) {
+ roll -= weights[i];
+ if (roll <= 0) return pool[i];
+ }
+ return pool[pool.length - 1];
+}
+
+// Falls back to a plain generateWordStream when there isn't enough data
+// yet to know what's actually hard for this person (a brand-new install,
+// or someone who hasn't typed enough for MIN_SAMPLES to kick in).
+export function generateWeakKeyStream(count, weakChars, options = {}) {
+ if (!weakChars || weakChars.length === 0) return generateWordStream(count, options);
+ const { punctuation = false, numbers = false } = options;
+ const weakSet = new Set(weakChars.map(c => c.toLowerCase()));
+ const pool = WORD_POOLS.extended;
+
+ const words = [];
+ let sinceComma = 0;
+ let sinceSentenceStart = 0;
+ for (let i = 0; i < count; i++) {
+ let word = numbers && Math.random() < 0.12
+ ? String(Math.floor(Math.random() * 1000))
+ : pickWeighted(pool, weakSet);
+
+ if (punctuation) {
+ if (sinceSentenceStart === 0) word = capitalize(word);
+ sinceComma++;
+ sinceSentenceStart++;
+ const atEnd = i === count - 1;
+ if (atEnd) {
+ word += '.';
+ } else if (sinceSentenceStart >= 6 && Math.random() < 0.15) {
+ word += '.';
+ sinceSentenceStart = 0;
+ } else if (sinceComma >= 4 && Math.random() < 0.2) {
+ word += ',';
+ sinceComma = 0;
+ }
+ }
+ words.push(word);
+ }
+ return words.join(' ');
+}
+
+// For time-based mode the buffer has to outlast the timer regardless of how
+// fast the typist is. 6 words/sec (360 WPM) is well past the sustained
+// world-record typing speed (~216 WPM), so this is generous headroom rather
+// than a tight fit.
+export function wordCountForDuration(seconds) {
+ return Math.max(20, Math.round(seconds * 6));
+}
diff --git a/web/tests/README.md b/web/tests/README.md
new file mode 100644
index 0000000..fadc5b9
--- /dev/null
+++ b/web/tests/README.md
@@ -0,0 +1,42 @@
+# TyperPunk web test suite
+
+Playwright end-to-end tests covering the major user-facing flows: typing a
+test through to the end screen, account/store/cosmetics, and
+leaderboard/friends. These replace the ad-hoc scripts written into `/tmp`
+during development - checked in here so they survive past one session and
+can be re-run for regressions.
+
+## Prerequisites
+
+- Python 3 with the `playwright` package installed (`pip install
+ playwright && playwright install chromium`, or on Arch,
+ `pacman -S python-playwright`).
+- The web app's dev server running on port 4173:
+ `cd web && node serve.mjs`
+- The backend running on port 8787, pointed at a throwaway database (do not
+ run tests against a production database):
+ `DATABASE_URL="sqlite:///tmp/typerpunk_test.db" PORT=8787 COOKIE_SECURE=0 \
+ cargo run --package typerpunk-server`
+
+Both servers must already be running before starting the suite - it does
+not launch or manage them itself.
+
+## Running
+
+```
+cd web/tests
+python3 run_all.py
+```
+
+Each `test_*.py` file exposes a single `run()` function that raises
+`AssertionError` on failure. `run_all.py` imports and calls each one in
+turn, continuing after a failure so one broken test doesn't hide the
+others, and exits non-zero if anything failed.
+
+To run a single file directly: `python3 test_typing_flow.py`.
+
+## Adding a test
+
+Add a new `test_*.py` file with a `run()` function, using Playwright's sync
+API (see the existing files for the pattern). `run_all.py` discovers files
+by the `test_*.py` name, so no registration step is needed.
diff --git a/web/tests/helpers.py b/web/tests/helpers.py
new file mode 100644
index 0000000..8aca3f6
--- /dev/null
+++ b/web/tests/helpers.py
@@ -0,0 +1,38 @@
+"""Shared helpers for the Playwright test suite. See README.md for how to
+run these tests and what needs to already be running.
+"""
+import random
+import string
+
+FRONTEND_URL = "http://localhost:4173"
+
+
+def random_username(prefix="test"):
+ suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
+ return f"{prefix}_{suffix}"
+
+
+def random_password():
+ """A fresh password per run. These accounts are throwaway, so nothing is
+ gained by a fixed literal - and a hardcoded one in a checked-in file has
+ the shape of a real credential, which trips secret scanners."""
+ return "Tp" + "".join(random.choices(string.ascii_letters + string.digits, k=16))
+
+
+def register_and_login(page, username, password=None):
+ """Registers a fresh account through the real Account screen and leaves
+ the app on the main menu, signed in."""
+ password = password or random_password()
+ page.goto(FRONTEND_URL, timeout=40000)
+ # The account entry point is the top-right identity control: "Sign In"
+ # when signed out, your username once signed in. It used to be an icon in
+ # the main menu's bottom-left cluster.
+ page.click('.auth-control button')
+ page.wait_for_timeout(300)
+ page.click('[data-action="tab-register"]')
+ page.fill('input[name="username"]', username)
+ page.fill('input[name="password"]', password)
+ page.click('.account-form button[type="submit"]')
+ page.wait_for_selector('.account-signed-in', timeout=10000)
+ page.click('[data-action="menu"]')
+ page.wait_for_timeout(300)
diff --git a/web/tests/run_all.py b/web/tests/run_all.py
new file mode 100644
index 0000000..91064da
--- /dev/null
+++ b/web/tests/run_all.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+"""Runs every test_*.py file in this directory and reports pass/fail. See
+README.md for prerequisites (both servers must already be running).
+"""
+import glob
+import importlib
+import os
+import sys
+import traceback
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+
+def main():
+ test_dir = os.path.dirname(os.path.abspath(__file__))
+ files = sorted(glob.glob(os.path.join(test_dir, "test_*.py")))
+ if not files:
+ print("No test_*.py files found.")
+ return 1
+
+ failures = []
+ for path in files:
+ name = os.path.splitext(os.path.basename(path))[0]
+ module = importlib.import_module(name)
+ try:
+ module.run()
+ except Exception:
+ failures.append(name)
+ print(f"{name}: FAIL")
+ traceback.print_exc()
+
+ total = len(files)
+ passed = total - len(failures)
+ print(f"\n{passed}/{total} passed")
+ if failures:
+ print("Failed: " + ", ".join(failures))
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/web/tests/test_account_and_store.py b/web/tests/test_account_and_store.py
new file mode 100644
index 0000000..ce1b9c9
--- /dev/null
+++ b/web/tests/test_account_and_store.py
@@ -0,0 +1,56 @@
+"""Account registration, and the cosmetics store's buy/equip flow --
+including that an equipped caret color and flair icon actually show up,
+not just that the API calls succeed. See README.md to run.
+"""
+from playwright.sync_api import sync_playwright
+from helpers import FRONTEND_URL, random_username, register_and_login
+
+
+def run():
+ with sync_playwright() as p:
+ browser = p.chromium.launch()
+ try:
+ page = browser.new_page(viewport={"width": 1280, "height": 900})
+ username = random_username("store")
+ register_and_login(page, username)
+ assert page.locator('.auth-control button').count() > 0
+
+ page.click('[data-action="store"]')
+ page.wait_for_selector(".store-item-row", timeout=10000)
+ rows = page.locator(".store-item-row")
+ assert rows.count() > 0, "store catalog did not render"
+
+ def buy_and_equip(item_name):
+ row = page.locator(".store-item-row", has=page.locator(".leaderboard-name", has_text=item_name))
+ buy_btn = row.locator('[data-action="buy"]')
+ if buy_btn.count() > 0:
+ buy_btn.click()
+ page.wait_for_timeout(500)
+ row = page.locator(".store-item-row", has=page.locator(".leaderboard-name", has_text=item_name))
+ row.locator('[data-action="equip"]').click(timeout=10000)
+ page.wait_for_timeout(500)
+
+ buy_and_equip("Cyan Caret")
+ buy_and_equip("Bolt")
+
+ equipped_rows = page.locator(".store-item-row", has=page.get_by_text("EQUIPPED"))
+ assert equipped_rows.count() >= 2, "expected caret and flair to both show as equipped"
+
+ # Equipped caret color should now override --caret-color on the
+ # typing screen itself, not just in the store's own UI.
+ page.click('[data-action="menu"]')
+ page.wait_for_timeout(300)
+ page.click('[data-action="start"]')
+ page.wait_for_timeout(500)
+ caret_color = page.evaluate(
+ "() => getComputedStyle(document.querySelector('.typing-game')).getPropertyValue('--caret-color').trim()"
+ )
+ assert caret_color.lower() == "#00e5ff", f"expected cyan caret override, got {caret_color!r}"
+
+ print("test_account_and_store: PASS")
+ finally:
+ browser.close()
+
+
+if __name__ == "__main__":
+ run()
diff --git a/web/tests/test_leaderboard_and_friends.py b/web/tests/test_leaderboard_and_friends.py
new file mode 100644
index 0000000..fc8de3e
--- /dev/null
+++ b/web/tests/test_leaderboard_and_friends.py
@@ -0,0 +1,60 @@
+"""Leaderboard mode/device filtering, and the friends request -> accept
+flow between two real accounts. See README.md to run.
+"""
+from playwright.sync_api import sync_playwright
+from helpers import FRONTEND_URL, random_username, register_and_login
+
+
+def run():
+ with sync_playwright() as p:
+ browser = p.chromium.launch()
+ try:
+ page_a = browser.new_page(viewport={"width": 1280, "height": 900})
+ page_b = browser.new_page(viewport={"width": 1280, "height": 900})
+
+ user_a = random_username("friend_a")
+ user_b = random_username("friend_b")
+ register_and_login(page_a, user_a)
+ register_and_login(page_b, user_b)
+
+ # Leaderboard: mode picker and device filter both render and
+ # don't error, even with no results yet for a fresh mode.
+ page_a.click('[data-action="leaderboard"]')
+ page_a.wait_for_selector(".leaderboard-modes", timeout=10000)
+ page_a.click('[data-action="toggle-device-filter"]')
+ page_a.wait_for_timeout(400)
+ # Case-insensitive: this button's text may render uppercase via
+ # CSS text-transform, which inner_text() reflects.
+ assert "desktop only" in page_a.locator('[data-action="toggle-device-filter"]').inner_text().lower()
+ assert page_a.locator(".stats-empty, .leaderboard-row").count() > 0, "leaderboard body did not render"
+ page_a.click('[data-action="menu"]')
+ page_a.wait_for_timeout(300)
+
+ # Friends: A sends a request to B, B accepts, both sides settle
+ # into the Friends list.
+ page_a.click('[data-action="rail-friends"]')
+ page_a.wait_for_selector(".friends-add-form", timeout=10000)
+ page_a.fill('.friends-add-form input[name="username"]', user_b)
+ page_a.click('.friends-add-form button[type="submit"]')
+ page_a.wait_for_timeout(600)
+
+ page_b.click('[data-action="rail-friends"]')
+ page_b.wait_for_selector('[data-action="accept"]', timeout=10000)
+ page_b.click('[data-action="accept"]')
+ page_b.wait_for_timeout(600)
+
+ assert page_b.locator(".leaderboard-row", has_text=user_a).count() > 0, "B does not see A as a friend"
+
+ page_a.reload()
+ page_a.wait_for_timeout(500)
+ page_a.click('[data-action="rail-friends"]')
+ page_a.wait_for_timeout(600)
+ assert page_a.locator(".leaderboard-row", has_text=user_b).count() > 0, "A does not see B as a friend"
+
+ print("test_leaderboard_and_friends: PASS")
+ finally:
+ browser.close()
+
+
+if __name__ == "__main__":
+ run()
diff --git a/web/tests/test_typing_flow.py b/web/tests/test_typing_flow.py
new file mode 100644
index 0000000..251ff56
--- /dev/null
+++ b/web/tests/test_typing_flow.py
@@ -0,0 +1,54 @@
+"""Core golden path: main menu loads, a typing test can be started, typed
+characters show correct/incorrect feedback and live stats, and finishing
+reaches the end screen with a results graph. See README.md to run.
+"""
+from playwright.sync_api import sync_playwright
+from helpers import FRONTEND_URL
+
+
+def run():
+ with sync_playwright() as p:
+ browser = p.chromium.launch()
+ try:
+ page = browser.new_page(viewport={"width": 1280, "height": 900})
+ page.goto(FRONTEND_URL, timeout=40000)
+
+ assert page.locator(".main-menu").is_visible(), "main menu did not render"
+ # inner_text() reflects CSS text-transform (this heading may be
+ # rendered uppercase) - compare case-insensitively rather than
+ # asserting on the exact source casing.
+ assert "typerpunk" in page.locator(".main-menu h1").inner_text().lower()
+
+ page.click('[data-action="start"]')
+ page.wait_for_selector(".typing-game", timeout=10000)
+
+ input_el = page.locator(".typing-input")
+ input_el.click()
+ page.keyboard.type("abcdefghij", delay=30)
+ page.wait_for_timeout(300)
+
+ wpm_text = page.locator('[data-field="wpm"]').inner_text()
+ assert wpm_text.strip() != "", "live WPM stat did not render"
+
+ classes = page.locator(".text-display span[class]").first.get_attribute("class")
+ assert classes is not None, "typed characters have no highlight class"
+
+ # Finish the test by typing enough characters to exhaust the
+ # visible text sample - Tab is the quick-restart shortcut, not
+ # a way to finish, so this has to actually type to completion.
+ text_len = len(page.locator(".text-display").inner_text().replace("\n", " "))
+ remaining = max(0, text_len - 10)
+ if remaining > 0:
+ page.keyboard.type("a" * remaining, delay=1)
+
+ page.wait_for_selector(".end-screen", timeout=15000)
+ assert page.locator(".end-screen-graph-row canvas").is_visible(), "results graph did not render"
+ assert page.locator(".end-screen .stat-value").first.is_visible(), "end screen stats missing"
+
+ print("test_typing_flow: PASS")
+ finally:
+ browser.close()
+
+
+if __name__ == "__main__":
+ run()