From a726d9f5fb56e1fd7983c5ac806d408ad78daa86 Mon Sep 17 00:00:00 2001
From: srdusr <99972264+srdusr@users.noreply.github.com>
Date: Thu, 11 Sep 2025 21:53:00 +0200
Subject: Add multiplayer bots, typing languages, and rework the UI layout
Multiplayer
- Quick match: POST /api/multiplayer/quickmatch returns whichever room is
still filling, or opens one. Players never see a room code; joining by
code stays for racing specific people.
- Bots fill quick-match rooms after a short wait so a new game is never an
empty lobby. They only ever join quick-match rooms, never a room opened
by code. One or two per room, drawn from separate ~40 and ~80 WPM tiers so
two bots are never near each other's pace, and they stall to correct
mistakes rather than typing a clean straight line.
- Live player count via GET /api/multiplayer/online, shown on the
Multiplayer control and under the main menu's Multiplayer button.
- Per-racer colours: you are the theme accent, opponents take distinct hues
that stay the same from lobby to race.
- The countdown no longer holds the room lock for its full three seconds,
which is what reset clients mid-countdown.
Typing languages
- 16 languages for the generated-word modes, each with its own
high-frequency vocabulary rather than a translation of the English list.
- Picker in the top-right rail; non-English uses its own list at every
difficulty tier instead of falling back to English words.
Fix UTF-8 accuracy in the game core
- update_game_state mixed byte and character counts: total_characters_typed
accumulated byte-length deltas while total_correct_characters compared a
char index against that byte count. Equal on ASCII, so it went unnoticed;
a correctly typed Spanish passage scored 6%. The old byte slicing would
also have panicked if an index landed inside a multi-byte character.
Rewritten char-based, with regression tests.
Programming mode
- Replaced prose about programming with real code: 26 syntax-highlighted
snippets across JavaScript, Python, Rust, C/Go/Java and shell. Single-line
by necessity, since the typing input is a single-line field.
Layout and readability
- One icon rail arrangement on every screen: Settings/Store under the
wordmark, Language/Theme/Friends/Account top-right, Stats/Leaderboard/
Multiplayer bottom-right.
- Main menu: mode picker moved out of the Single Player button, which it was
notching a divider through and pushing the label off-centre.
- Escape returns to the menu, closing any open popover first, and confirms
before abandoning a live race.
- Split --text-color and --sub-color per theme; they shared one value that
measured 3.65:1 against the background, below the 4.5:1 body-text floor.
- Semantic colours used in exactly one place each: gold for a personal best,
amber for the race countdown and the mobile-result badge.
- Passage now sits in the same place on the typing and end screens, and its
column is a whole number of characters wide so wrapping cannot leave a
permanent gap on the right.
- End screen: keystrokes and a correct/wrong/extra/missed split, attribution
carried over from the typing screen, and a graph with a separate error
axis, axis titles including seconds, and smoothed lines.
---
web/src/screens/accountScreen.js | 121 ++++++++
web/src/screens/endScreen.js | 226 ++++++++++++++
web/src/screens/friendsScreen.js | 149 +++++++++
web/src/screens/icons.js | 109 +++++++
web/src/screens/leaderboardScreen.js | 120 ++++++++
web/src/screens/lyricsScreen.js | 127 ++++++++
web/src/screens/mainMenu.js | 531 +++++++++++++++++++++++++++++++++
web/src/screens/multiplayerScreen.js | 265 ++++++++++++++++
web/src/screens/passiveMode.js | 159 ++++++++++
web/src/screens/placeholderScreen.js | 31 ++
web/src/screens/publicProfileScreen.js | 92 ++++++
web/src/screens/statsScreen.js | 85 ++++++
web/src/screens/storeScreen.js | 145 +++++++++
web/src/screens/typingGame.js | 309 +++++++++++++++++++
14 files changed, 2469 insertions(+)
create mode 100644 web/src/screens/accountScreen.js
create mode 100644 web/src/screens/endScreen.js
create mode 100644 web/src/screens/friendsScreen.js
create mode 100644 web/src/screens/icons.js
create mode 100644 web/src/screens/leaderboardScreen.js
create mode 100644 web/src/screens/lyricsScreen.js
create mode 100644 web/src/screens/mainMenu.js
create mode 100644 web/src/screens/multiplayerScreen.js
create mode 100644 web/src/screens/passiveMode.js
create mode 100644 web/src/screens/placeholderScreen.js
create mode 100644 web/src/screens/publicProfileScreen.js
create mode 100644 web/src/screens/statsScreen.js
create mode 100644 web/src/screens/storeScreen.js
create mode 100644 web/src/screens/typingGame.js
(limited to 'web/src/screens')
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 `
Checking...
`;
+ if (spotifyConnected) return `Connected
`;
+ return ``;
+ }
+
+ function formMarkup(user) {
+ if (user) {
+ return `
+ Signed in as ${escapeHtml(user.username)}
+
+ Spotify
+ ${spotifyStatusMarkup()}
+ `;
+ }
+ return `
+
+
+
+
+
+ `;
+ }
+
+ function render() {
+ const user = getUser();
+ root.innerHTML = `
+
+
TyperPunk
+
Account
+
${formMarkup(user)}
+
+
+ `;
+
+ 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
+ ? 'NEW BEST
'
+ : `PB ${Math.round(previous)}
`;
+
+ root.innerHTML = `
+
+
TyperPunk
+
+ ${attribution ? `
— ${escapeHtml(attribution)}
` : ''}
+
+
+
WPM
${Math.round(stats.wpm)}
+ ${pbLine}
+
+
+
+
ACC
${Math.round(stats.accuracy)}%
+
+
+
+
RAW
${Math.round(stats.rawWpm)}
+
CONSISTENCY
${consistency}%
+
KEYSTROKES
${stats.keystrokes}
+
+
CHARACTERS
${stats.correctChars}/${stats.incorrectChars}/${extraChars}/${missedChars}
+
+
TIME
${stats.time.toFixed(1)}s
+
+
+ Play Again
+
+
+ `;
+
+ 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('');
+ 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(`${escapeHtml(displayChar)} `);
+ charIndex++;
+ }
+ html.push(' ');
+ }
+ 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 `Sign in to add friends and compare stats.
+ `;
+ }
+
+ function signedInMarkup() {
+ return `
+
+
+ ${data.incoming_requests.length > 0 ? `
+ Requests
+
+ ${data.incoming_requests.map(r => `
+
+
${escapeHtml(r.username)}
+
+
+
+ `).join('')}
+
` : ''}
+
+ ${data.outgoing_requests.length > 0 ? `
+ Sent
+
+ ${data.outgoing_requests.map(r => `
+
+
${escapeHtml(r.username)}
+
Pending
+
+
+ `).join('')}
+
` : ''}
+
+ Friends
+ ${data.friends.length > 0 ? `
+
+ ${data.friends.map(f => `
+
+
${escapeHtml(f.username)}
+
+
+ `).join('')}
+
` : `No friends yet - add one by username above.
`}
+
+ ${loadError ? `${escapeHtml(loadError)}
` : ''}
+ `;
+ }
+
+ function render() {
+ const user = getUser();
+ root.innerHTML = `
+
+
TyperPunk
+
Friends
+ ${user ? signedInMarkup() : signedOutMarkup()}
+
+
+ `;
+
+ 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 = `
+
+
+ `;
+
+// 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 = `
+
+ `;
+
+// 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 = `
+
+
+
+ `;
+
+// 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 = `
+
+
+
+
+
+
+ `;
+
+// 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 = `
+
+
+ `;
+
+export const LEADERBOARD_ICON = `
+
+
+
+ `;
+
+// 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 = `
+
+
+
+
+ `;
+
+export const ACCOUNT_ICON = `
+
+
+ `;
+
+// 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 = `
+
+ `;
+
+// 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 = `
+
+ `;
+
+// 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: ` `,
+ bolt: ` `,
+ skull: ` `,
+};
+
+// 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 = `
+
+
+
+
+
+
+ `;
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 `Loading...
`;
+ if (state.status === 'error') return `Could not load the leaderboard - ${escapeHtml(state.message)}
`;
+ if (state.entries.length === 0) return `No results yet for this mode - be the first.
`;
+ return `
+
+ ${state.entries.map((e, i) => `
+
+
#${i + 1}
+
${escapeHtml(e.username)}${e.flair && FLAIR_ICONS[e.flair] ? `${FLAIR_ICONS[e.flair]} ` : ''}
+ ${e.device_type === 'mobile' ? `
mobile ` : ''}
+
${Math.round(e.wpm)} wpm
+
${Math.round(e.accuracy)}%
+
${formatDate(e.date)}
+
+ `).join('')}
+
+ `;
+ }
+
+ root.innerHTML = `
+
+
TyperPunk
+
Leaderboard
+
+ ${MODES.map(m => ``).join('')}
+
+
+
+
+
+ `;
+
+ 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 = `
+
+
TyperPunk
+
Lyrics
+
${escapeHtml(bodyText())}
+ ${status === 'signed-out' ? `` : ''}
+ ${status === 'not-connected' ? `` : ''}
+
+
+ `;
+ 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 => `${escapeHtml(label(m))} `;
+ 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 = `
+
+
TyperPunk
+
+
+
+
+
+ Start directly into a game, skip this menu
+
+
+
+ ${(isWords || isTime) ? `
+
+
Rules
+
+ ${isWords ? `` : ''}
+ ${isTime ? `` : ''}
+
+
+
+
+
` : ''}
+
+
+
Dev
+
+
+
+
+
+
Paste text, notes, code, subtitles (.srt/.vtt), or lyrics (.lrc) - typed in order, not randomized. Drag a file in, or:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
TyperPunk
+
+
+
+
+ ${SETTINGS_ICON}
+ ${STORE_ICON}
+
+
+
+ ${profile.testsCompleted > 0 ? `
` : ''}
+
${STATS_ICON}
+
${LEADERBOARD_ICON}
+
+
+
+ `;
+
+ 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 = `
+
+
TyperPunk
+
Multiplayer
+
+
+
+
+
or race specific people
+
+
+
+
+
+
${escapeHtml(landingError || '')}
+
+
+
+ `;
+ 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 = `
+
+
TyperPunk
+
Room ${escapeHtml(roomCode)}
+
Share this code with whoever you're racing.
+
+
+
+
+
+ `;
+ 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 => `
+
+
${escapeHtml(p.name)}${p.id === myId ? ' (you)' : ''}
+
${p.ready ? 'Ready' : 'Not ready'}
+
+ `).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 => `
+
+
${escapeHtml(p.name)}
+
+
${Math.round(progressById[p.id]?.wpm || 0)}
+
+ `).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 += `${escapeHtml(displayChar)} `;
+ }
+ 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 = `
+
+
TyperPunk
+
${escapeHtml(name)} · passive mode
+
+
+
+
+
Session complete
+
+
LINES 0
+
ACCURACY 100%
+
+
+
+
+
+ `;
+
+ 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 = `
+
+
TyperPunk
+
${escapeHtml(title)}
+
${escapeHtml(description)}
+
+
+ `;
+
+ 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-".
+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 `Loading...
`;
+ if (state.status === 'error') return `${escapeHtml(state.message)}
`;
+ const p = state.profile;
+ return `
+
+
+
+
+
+
+ Joined ${formatDate(p.joined_at)}
+ Personal bests
+ ${p.personal_bests.length > 0 ? `
+
+ ${p.personal_bests.map(b => `
+
+
${escapeHtml(modeLabel(b.mode_key))}
+
${Math.round(b.wpm)} wpm
+
${formatDate(b.date)}
+
+ `).join('')}
+
+ ` : `No personal bests recorded yet.
`}
+ `;
+ }
+
+ root.innerHTML = `
+
+
TyperPunk
+
${escapeHtml(username)}
+
+
+
+ `;
+
+ 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)}${FLAIR_ICONS[profile.flair]} `;
+ // 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-".
+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 = `
+
+
TyperPunk
+
Stats
+
+ ${profile.testsCompleted > 0 ? `
+
+
+
+
+
+
+
+
+ ` : `
No tests completed yet - finish a test to start tracking stats.
`}
+
+
Personal bests
+ ${bests.length > 0 ? `
+
+ ${bests.map(b => `
+
+
${escapeHtml(modeLabel(b.modeKey))}
+
${Math.round(b.wpm)} wpm
+
${formatDate(b.date)}
+
+ `).join('')}
+
+ ` : `
No personal bests recorded yet.
`}
+
+
Progress
+ ${profile.history.length > 1
+ ? `
`
+ : `
Complete a few more tests to see a progress chart.
`}
+
+
+
+ `;
+
+ 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'
+ ? ` `
+ : `${FLAIR_ICONS[item.value] || ''} `;
+ return `
+
+ ${swatch}
+
${escapeHtml(item.name)}
+ ${!owned ? `
${formatPrice(item.price_cents)}
` : ''}
+ ${owned
+ ? ``
+ : ``}
+
+ `;
+ }
+
+ function bodyMarkup() {
+ if (status === 'signed-out') return `Sign in to buy and equip cosmetics.
`;
+ if (status === 'loading') return `Loading...
`;
+ if (status === 'error') return `${escapeHtml(message)}
`;
+
+ const categories = ['caret', 'flair'];
+ return categories.map(cat => {
+ const items = catalog.filter(i => i.category === cat);
+ if (items.length === 0) return '';
+ return `${categoryLabel(cat)} ${items.map(itemMarkup).join('')}
`;
+ }).join('') + `
`;
+ }
+
+ function render() {
+ root.innerHTML = `
+
+
TyperPunk
+
Store
+ ${bodyMarkup()}
+
+
+ `;
+ 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 = `
+
+
TyperPunk
+ ${progress ? `
${escapeHtml(progress)}
` : ''}
+
+ ${attribution ? `
- ${escapeHtml(attribution)}
` : ''}
+
+
+
+ ${zenMode ? `
Finish ` : ''}
+ ${onRestart ? `
↻ ` : ''}
+
+ `;
+
+ 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('');
+ 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(`${escapeHtml(displayChar)} `);
+ charIndex++;
+ }
+ html.push(' ');
+ }
+ 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();
+ };
+}
--
cgit v1.2.3