srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'web/src/screens')
-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
14 files changed, 2469 insertions, 0 deletions
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();
+ };
+}