From 076f29369e51dfb5798f70099a330ebeb1b66a4e Mon Sep 17 00:00:00 2001
From: srdusr <99972264+srdusr@users.noreply.github.com>
Date: Fri, 5 Dec 2025 22:56:00 +0200
Subject: Show the passage during the countdown, and fix navigation across
screens
Countdown
- Five seconds instead of three, and shown over the passage on the race
screen rather than in an empty lobby. New ServerMessage::RaceText carries
the text (and its attribution) just before the countdown starts, so every
client can put it on screen and let players read the opening words while
the numbers run - what TypeRacer and 10FastFingers both do. Typing is
locked until Start lifts the gate.
- Two subscriptions had to move with it. Building the race view tears the
lobby down, and the lobby owned the only handlers for Countdown and Start,
so the count stopped arriving exactly when it was needed and the gate would
never have lifted.
- The TUI and Steam clients match ServerMessage exhaustively, so both gained
an arm for the new variant; whole workspace checks clean.
Multiplayer attribution
- Race passages now carry who wrote them, so a race shows its source the way
single player always has. Previously the server sent bare strings and both
the race and its end screen showed nothing.
Navigation
- Every screen's full-width "Back" button becomes a close control at the top
of the content. The wordmark and Escape already went back, so it was a
third way to do the same thing and the least reachable of them.
- The multiplayer screen had a Back button on top of that; gone.
Fixes
- The end screen graph is drawn into a canvas, so it cannot inherit a theme
change the way the rest of the page does. It now redraws on one - switching
theme used to leave the chart in the old palette.
- .stats-empty was align-self:flex-start inside a centred column, so "Sign in
to add friends" sat on a different axis from its own Sign In button.
- The store showed a sign-in prompt instead of its catalogue, so nobody could
see what signing in would get them. The catalogue now renders signed out,
with prices, behind a sign-in note.
- The room code field is sized for the five characters a code actually is,
and Join carries the same weight as the field it belongs to.
---
web/src/multiplayer.js | 1 +
web/src/screens/accountScreen.js | 3 +-
web/src/screens/endScreen.js | 6 ++
web/src/screens/friendsScreen.js | 3 +-
web/src/screens/icons.js | 7 ++
web/src/screens/leaderboardScreen.js | 6 +-
web/src/screens/lyricsScreen.js | 3 +-
web/src/screens/multiplayerScreen.js | 67 ++++++++++++++---
web/src/screens/placeholderScreen.js | 5 +-
web/src/screens/publicProfileScreen.js | 6 +-
web/src/screens/statsScreen.js | 5 +-
web/src/screens/storeScreen.js | 26 +++++--
web/src/styles.css | 130 ++++++++++++++++++++++++++++++++-
13 files changed, 234 insertions(+), 34 deletions(-)
(limited to 'web/src')
diff --git a/web/src/multiplayer.js b/web/src/multiplayer.js
index 903d6f9..8592285 100644
--- a/web/src/multiplayer.js
+++ b/web/src/multiplayer.js
@@ -47,6 +47,7 @@ export function connectToRoom(roomCode, playerName) {
switch (msg.type) {
case 'Joined': connection.playerId = msg.player_id; emit('joined', msg.player_id); break;
case 'PlayerList': emit('playerList', msg.players); break;
+ case 'RaceText': emit('raceText', msg.text, msg.attribution); break;
case 'Countdown': emit('countdown', msg.seconds); break;
case 'Start': emit('start', msg.text); break;
case 'PlayerProgress': emit('playerProgress', msg.player_id, msg.percent, msg.wpm); break;
diff --git a/web/src/screens/accountScreen.js b/web/src/screens/accountScreen.js
index 30161d9..5ae2bd0 100644
--- a/web/src/screens/accountScreen.js
+++ b/web/src/screens/accountScreen.js
@@ -1,3 +1,4 @@
+import { CLOSE_ICON } from './icons.js';
import { escapeHtml } from '../util.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
@@ -59,10 +60,10 @@ export function renderAccountScreen(root, { onBack, onShowAccount, onShowStats,
const user = getUser();
root.innerHTML = `
+
TyperPunk
Account
${formMarkup(user)}
-
`;
diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js
index aa4e29b..2b859bb 100644
--- a/web/src/screens/endScreen.js
+++ b/web/src/screens/endScreen.js
@@ -1,6 +1,7 @@
import { buildGraphPoints, buildErrorPoints, drawChart, hitTestError, hitTestLine, calculateConsistency } from '../chart.js';
import { escapeHtml } from '../util.js';
import { attachTooltips } from '../tooltip.js';
+import { onThemeChange } from '../theme.js';
import { recordResult } from '../pb.js';
import { recordTest } from '../profileStats.js';
import { renderCornerRail } from '../cornerRail.js';
@@ -137,6 +138,10 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s
const redraw = () => drawChart(canvas, { graphPoints, errorPoints, xMax });
redraw();
window.addEventListener('resize', redraw);
+ // The chart is drawn into a canvas, so it cannot inherit a theme change
+ // the way the rest of the page does - it has to be redrawn. Without this
+ // switching theme on the end screen left the graph in the old palette.
+ const offTheme = onThemeChange(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
@@ -243,6 +248,7 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s
onLeaveRace?.();
cleanupTheme();
window.removeEventListener('resize', redraw);
+ offTheme();
canvas.removeEventListener('mousemove', handleMove);
canvas.removeEventListener('mouseleave', handleLeave);
document.removeEventListener('keydown', handleKeydown);
diff --git a/web/src/screens/friendsScreen.js b/web/src/screens/friendsScreen.js
index 89317ad..14a0b2d 100644
--- a/web/src/screens/friendsScreen.js
+++ b/web/src/screens/friendsScreen.js
@@ -1,3 +1,4 @@
+import { CLOSE_ICON } from './icons.js';
import { escapeHtml } from '../util.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
@@ -78,10 +79,10 @@ export function renderFriendsScreen(root, { onBack, onShowFriends, onShowStats,
const user = getUser();
root.innerHTML = `
+
TyperPunk
Friends
${user ? signedInMarkup() : signedOutMarkup()}
-
`;
diff --git a/web/src/screens/icons.js b/web/src/screens/icons.js
index c088c0d..21c0548 100644
--- a/web/src/screens/icons.js
+++ b/web/src/screens/icons.js
@@ -107,3 +107,10 @@ export const LANGUAGE_ICON = `
`;
+
+// Close. A plain X drawn as two strokes rather than a "×" glyph, so it
+// matches the weight and corner style of the rest of this set at any size.
+export const CLOSE_ICON = ``;
diff --git a/web/src/screens/leaderboardScreen.js b/web/src/screens/leaderboardScreen.js
index 2777b1c..2288400 100644
--- a/web/src/screens/leaderboardScreen.js
+++ b/web/src/screens/leaderboardScreen.js
@@ -3,7 +3,7 @@ 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';
+import { FLAIR_ICONS, CLOSE_ICON } from './icons.js';
const WORD_COUNTS = [10, 25, 50, 100];
const TIME_DURATIONS = [15, 30, 60, 120];
@@ -47,14 +47,14 @@ export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onS
root.innerHTML = `
-
TyperPunk
+
+
TyperPunk
Leaderboard
${MODES.map(m => ``).join('')}
-
`;
diff --git a/web/src/screens/lyricsScreen.js b/web/src/screens/lyricsScreen.js
index ac8b4f4..95af6b2 100644
--- a/web/src/screens/lyricsScreen.js
+++ b/web/src/screens/lyricsScreen.js
@@ -1,3 +1,4 @@
+import { CLOSE_ICON } from './icons.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
import { renderTopRail } from '../topRail.js';
@@ -30,12 +31,12 @@ export function renderLyricsScreen(root, { onBack, onLyricsReady, onShowStats, o
function render() {
root.innerHTML = `
-
`;
root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
@@ -136,6 +137,11 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
renderLobby(roomCode, auto);
}
+ // Set by startRace so the lobby's countdown handler can hand off to the
+ // race view once the passage is on screen.
+ let showCountdown = null;
+ let releaseRace = null;
+
function renderLobby(roomCode, auto = false) {
root.innerHTML = `
@@ -184,14 +190,19 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
const offPlayerList = connection.on('playerList', list => { players = list; paintPlayers(); });
const countdownEl = root.querySelector('.mp-countdown');
const offCountdown = connection.on('countdown', s => {
- // Takes over the lobby while it runs: it is the only thing that
- // matters in those three seconds, and it used to be a small number
- // tucked underneath a large disabled button.
+ // Once the race view exists the count belongs over the passage, so
+ // players read the opening words while it runs. Before that (a
+ // slow RaceText) it still shows in the lobby rather than nowhere.
+ if (showCountdown) { showCountdown(s); return; }
countdownEl.hidden = false;
countdownEl.textContent = s;
readyBtn.hidden = true;
});
- const offStart = connection.on('start', text => startRace(text));
+ // The passage arrives before the countdown so it can be read while the
+ // numbers run. The race view is built here, with typing locked; Start
+ // only unlocks it.
+ const offRaceText = connection.on('raceText', (text, attribution) => startRace(text, attribution));
+ const offStart = connection.on('start', () => releaseRace?.());
// 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;
@@ -210,12 +221,12 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
root.querySelector('[data-action="leave"]').addEventListener('click', () => { leaveRoom(); renderLanding(); });
cleanupInner = () => {
- offJoined(); offPlayerList(); offCountdown(); offStart(); offError(); offClose();
+ offJoined(); offPlayerList(); offCountdown(); offRaceText(); offStart(); offError(); offClose();
cleanupTheme(); cleanupRail();
};
}
- async function startRace(text) {
+ async function startRace(text, attribution) {
teardownInner();
try {
game = await createGame();
@@ -293,7 +304,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
});
const cleanupTyping = renderTypingGame(root, {
- game, text, modeKey: undefined,
+ game, text, attribution, modeKey: undefined,
multiplayer: { connection },
onFinish: result => {
freeGame(game);
@@ -323,8 +334,40 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
});
root.appendChild(opponents);
+ // The passage is on screen but locked until Start. The count sits over
+ // it, so the seconds are spent reading the opening words rather than
+ // staring at an empty lobby.
+ const input = root.querySelector('.typing-input');
+ if (input) input.disabled = true;
+ const gate = document.createElement('div');
+ gate.className = 'mp-race-gate';
+ gate.innerHTML = '
Get ready
';
+ root.appendChild(gate);
+ const gateCount = gate.querySelector('.mp-race-gate-count');
+ // Its own subscription: building this view tears the lobby down, and
+ // the lobby owned the only countdown handler - so the count stopped
+ // arriving exactly when it was needed on screen.
+ const offGateCountdown = connection.on('countdown', seconds => { gateCount.textContent = seconds; });
+ // Start has the same problem the countdown did: the lobby's handler is
+ // gone by the time this view exists, so the gate would never lift.
+ const offGateStart = connection.on('start', () => releaseRace?.());
+ showCountdown = seconds => { gateCount.textContent = seconds; };
+ releaseRace = () => {
+ offGateCountdown();
+ offGateStart();
+ gate.remove();
+ if (input) { input.disabled = false; input.focus(); }
+ showCountdown = null;
+ releaseRace = null;
+ };
+
cleanupInner = () => {
if (!handedOff) { offProgress(); offFinished(); }
+ showCountdown = null;
+ releaseRace = null;
+ offGateCountdown();
+ offGateStart();
+ gate.remove();
cleanupTyping();
opponents.remove();
};
diff --git a/web/src/screens/placeholderScreen.js b/web/src/screens/placeholderScreen.js
index 5d652ce..4375b8d 100644
--- a/web/src/screens/placeholderScreen.js
+++ b/web/src/screens/placeholderScreen.js
@@ -1,3 +1,4 @@
+import { CLOSE_ICON } from './icons.js';
import { escapeHtml } from '../util.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
@@ -11,10 +12,10 @@ import { renderTopRail } from '../topRail.js';
export function renderPlaceholderScreen(root, { title, description, onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) {
root.innerHTML = `
-
TyperPunk
+
+
TyperPunk
${escapeHtml(title)}
${escapeHtml(description)}
-
`;
diff --git a/web/src/screens/publicProfileScreen.js b/web/src/screens/publicProfileScreen.js
index 5ce50ed..1479df6 100644
--- a/web/src/screens/publicProfileScreen.js
+++ b/web/src/screens/publicProfileScreen.js
@@ -3,7 +3,7 @@ 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';
+import { FLAIR_ICONS, CLOSE_ICON } from './icons.js';
// modeKey format is set in app.js: "words-25", "time-30", "quote-".
function modeLabel(modeKey) {
@@ -52,10 +52,10 @@ export function renderPublicProfileScreen(root, { username, onBack, onShowStats,
root.innerHTML = `
-
TyperPunk
+
+
TyperPunk
${escapeHtml(username)}
-
`;
diff --git a/web/src/screens/statsScreen.js b/web/src/screens/statsScreen.js
index a38e193..a76cbca 100644
--- a/web/src/screens/statsScreen.js
+++ b/web/src/screens/statsScreen.js
@@ -1,3 +1,4 @@
+import { CLOSE_ICON } from './icons.js';
import { getProfileStats } from '../profileStats.js';
import { getAllPersonalBests } from '../pb.js';
import { escapeHtml } from '../util.js';
@@ -26,7 +27,8 @@ export function renderStatsScreen(root, { onBack, onShowPlaceholder, onShowAccou
root.innerHTML = `
`;
@@ -60,10 +65,10 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
function render() {
root.innerHTML = `
+
TyperPunk
Store
${bodyMarkup()}
-
`;
root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
@@ -121,7 +126,12 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
}
async function loadMine() {
- if (!getUser()) { status = 'signed-out'; return; }
+ if (!getUser()) {
+ // The catalogue still renders - only ownership needs an account.
+ status = 'signed-out';
+ mine = { owned: [], equipped_caret: null, equipped_flair: null };
+ return;
+ }
try {
mine = await api.get('/api/cosmetics/me');
status = 'ok';
diff --git a/web/src/styles.css b/web/src/styles.css
index 8462fbf..f7879e6 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -308,10 +308,16 @@ body {
text-align: center;
}
+/* Centred, not flex-start. These screens are a centred column, so a
+ left-hugging empty state sat out of line with the button underneath it --
+ most visible on Friends, where "Sign in to add friends" and its Sign In
+ button were on two different axes. */
.stats-empty {
color: var(--sub-color);
font-size: 0.9rem;
- align-self: flex-start;
+ align-self: center;
+ text-align: center;
+ max-width: 32rem;
}
.stats-pb-list {
@@ -2123,9 +2129,48 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
min-width: 0;
}
+/* Sized to the five characters a room code actually is, rather than eating
+ whatever width was going. The Join button matches the field's height and
+ the weight of the other actions on the screen - as a `small ghost` it
+ read as an afterthought next to the field it belongs to. */
+.mp-code-row .account-input {
+ flex: 1 1 auto;
+ min-width: 0;
+ text-align: center;
+ text-transform: uppercase;
+ letter-spacing: 3px;
+ font-size: 1.1rem;
+}
+
.mp-code-row .menu-button {
flex: 0 0 auto;
+ min-width: 0;
white-space: nowrap;
+ padding-left: 1.75rem;
+ padding-right: 1.75rem;
+}
+
+/* A labelled rule, so the two ways into a race read as alternatives rather
+ than a list of buttons. */
+.mp-divider {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ width: 100%;
+ margin: 0.5rem 0;
+ color: var(--sub-color);
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.mp-divider::before,
+.mp-divider::after {
+ content: '';
+ flex: 1 1 auto;
+ height: 1px;
+ background: var(--neutral-color);
+ opacity: 0.4;
}
/* The passage's attribution, repeated on the end screen so a quote still
@@ -2617,3 +2662,86 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
.lang-item {
transition: background-color 0.12s ease, color 0.12s ease;
}
+
+/* The count, over the passage, while typing is locked. Sits above the text so
+ the seconds before a race are spent reading the opening words - the lobby
+ used to hold the count on a screen with nothing to read. */
+.mp-race-gate {
+ position: fixed;
+ inset: 0;
+ z-index: 95;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ pointer-events: none;
+ background: color-mix(in srgb, var(--background-color) 55%, transparent);
+}
+
+.mp-race-gate-count {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 7rem;
+ line-height: 1;
+ font-weight: 700;
+ color: var(--warning-color);
+}
+
+.mp-race-gate-label {
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 3px;
+ color: var(--sub-color);
+}
+
+/* Shown above the catalogue when signed out. The store used to be replaced
+ entirely by a sign-in prompt, so nobody could see what signing in would get
+ them. */
+.store-signin-note {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin-bottom: 1rem;
+ padding: 0.75rem 1rem;
+ border: 1px solid color-mix(in srgb, var(--primary-color) 35%, transparent);
+ border-radius: 8px;
+ font-size: 0.85rem;
+ color: var(--sub-color);
+}
+
+/* Close, at the top-right of the content. Replaces the full-width "Back"
+ button every screen carried at the bottom - a third way to do what the
+ wordmark and Escape already did, and the least reachable of the three. */
+.screen-close {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 2.25rem;
+ height: 2.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: none;
+ color: var(--sub-color);
+ cursor: pointer;
+ border-radius: 6px;
+ transition: color 0.15s ease, background-color 0.15s ease;
+}
+
+.screen-close:hover {
+ color: var(--text-color);
+ background: color-mix(in srgb, var(--text-color) 10%, transparent);
+}
+
+.screen-close svg {
+ width: 1.1rem;
+ height: 1.1rem;
+}
+
+/* The close button anchors to the content column. */
+.stats-screen {
+ position: relative;
+}
--
cgit v1.2.3