srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/core/src/multiplayer.rs4
-rw-r--r--crates/server/src/main.rs19
-rw-r--r--crates/server/src/multiplayer.rs28
-rw-r--r--crates/server/src/state.rs13
-rw-r--r--crates/steam/src/multiplayer_plugin.rs1
-rw-r--r--crates/steam/src/net.rs9
-rw-r--r--crates/tui/src/main.rs1
-rw-r--r--crates/tui/src/multiplayer_net.rs9
-rw-r--r--web/src/multiplayer.js1
-rw-r--r--web/src/screens/accountScreen.js3
-rw-r--r--web/src/screens/endScreen.js6
-rw-r--r--web/src/screens/friendsScreen.js3
-rw-r--r--web/src/screens/icons.js7
-rw-r--r--web/src/screens/leaderboardScreen.js6
-rw-r--r--web/src/screens/lyricsScreen.js3
-rw-r--r--web/src/screens/multiplayerScreen.js67
-rw-r--r--web/src/screens/placeholderScreen.js5
-rw-r--r--web/src/screens/publicProfileScreen.js6
-rw-r--r--web/src/screens/statsScreen.js5
-rw-r--r--web/src/screens/storeScreen.js26
-rw-r--r--web/src/styles.css130
21 files changed, 306 insertions, 46 deletions
diff --git a/crates/core/src/multiplayer.rs b/crates/core/src/multiplayer.rs
index a25a659..14d59e9 100644
--- a/crates/core/src/multiplayer.rs
+++ b/crates/core/src/multiplayer.rs
@@ -34,6 +34,10 @@ pub enum ServerMessage {
/// assigned server-side per connection.
Joined { player_id: String },
PlayerList { players: Vec<PlayerInfo> },
+ /// Sent once, just before the countdown begins, so every client can put
+ /// the passage on screen and let players read it while they wait. Start
+ /// still carries the text, for clients that do not use this.
+ RaceText { text: String, attribution: Option<String> },
Countdown { seconds: u32 },
Start { text: String },
PlayerProgress { player_id: String, percent: f32, wpm: f32 },
diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs
index 7e9e9a8..2769d4a 100644
--- a/crates/server/src/main.rs
+++ b/crates/server/src/main.rs
@@ -10,6 +10,7 @@ mod spotify;
mod state;
mod stats;
+use crate::state::RaceText;
use axum::http::{HeaderValue, Method};
use axum::routing::get;
use axum::Router;
@@ -24,6 +25,8 @@ use tower_http::trace::TraceLayer;
#[derive(Deserialize)]
struct TextEntry {
content: String,
+ #[serde(default)]
+ attribution: Option<String>,
}
// Every multiplayer room draws from this same pool so every player in a
@@ -32,15 +35,18 @@ struct TextEntry {
// falls back to a couple of plain sentences if the file isn't reachable
// (e.g. the server binary run from somewhere other than the repo root),
// so a room can still start rather than erroring on an empty pool.
-fn load_race_texts() -> Vec<String> {
+fn load_race_texts() -> Vec<RaceText> {
let path = std::env::var("TEXTS_JSON_PATH").unwrap_or_else(|_| "texts.json".to_string());
match std::fs::read_to_string(&path).ok().and_then(|raw| serde_json::from_str::<Vec<TextEntry>>(&raw).ok()) {
- Some(entries) if !entries.is_empty() => entries.into_iter().map(|e| e.content).collect(),
+ Some(entries) if !entries.is_empty() => entries
+ .into_iter()
+ .map(|e| RaceText { text: e.content, attribution: e.attribution })
+ .collect(),
_ => {
tracing::warn!("could not load race texts from {path} - using a small built-in fallback pool");
vec![
- "The quick brown fox jumps over the lazy dog.".to_string(),
- "Pack my box with five dozen liquor jugs.".to_string(),
+ RaceText { text: "The quick brown fox jumps over the lazy dog.".to_string(), attribution: None },
+ RaceText { text: "Pack my box with five dozen liquor jugs.".to_string(), attribution: None },
]
}
}
@@ -136,7 +142,10 @@ mod tests {
let app_state = Arc::new(AppState::new(
db,
false,
- vec!["The quick brown fox jumps over the lazy dog.".to_string()],
+ vec![RaceText {
+ text: "The quick brown fox jumps over the lazy dog.".to_string(),
+ attribution: None,
+ }],
SpotifyConfig::default(),
"http://localhost:4173".to_string(),
));
diff --git a/crates/server/src/multiplayer.rs b/crates/server/src/multiplayer.rs
index 8974c8c..7fca7c3 100644
--- a/crates/server/src/multiplayer.rs
+++ b/crates/server/src/multiplayer.rs
@@ -56,6 +56,10 @@ const BOT_NAMES: &[&str] = &[
"Pixel", "Quartz", "Sigil", "Tessa", "Umbra",
];
+// Long enough to read the passage's opening words and get your hands in
+// position. Three seconds was barely enough to register that a race had
+// started, let alone look at the text.
+const COUNTDOWN_SECONDS: u32 = 5;
const ROOM_CODE_CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I - easy to misread aloud
#[derive(Debug, Clone, PartialEq)]
@@ -318,16 +322,30 @@ async fn start_race(room_arc: Arc<Mutex<Room>>, state: Arc<AppState>) {
return;
}
room.status = RoomStatus::Countdown;
- let text = state
+ let picked = state
.race_texts
.get(rand::thread_rng().gen_range(0..state.race_texts.len()))
.cloned()
- .unwrap_or_default();
- room.text = Some(text.clone());
- text
+ .unwrap_or_else(|| crate::state::RaceText {
+ text: String::new(),
+ attribution: None,
+ });
+ room.text = Some(picked.text.clone());
+ // Sent before the countdown so every client can put the passage on
+ // screen and let players read it while they wait, the way TypeRacer
+ // and 10FastFingers both do.
+ broadcast(
+ &room,
+ &ServerMessage::RaceText {
+ text: picked.text.clone(),
+ attribution: picked.attribution.clone(),
+ },
+ )
+ .await;
+ picked.text
};
- for seconds in (1..=3).rev() {
+ for seconds in (1..=COUNTDOWN_SECONDS).rev() {
{
let room = room_arc.lock().await;
broadcast(&room, &ServerMessage::Countdown { seconds }).await;
diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs
index 267b6fb..7244955 100644
--- a/crates/server/src/state.rs
+++ b/crates/server/src/state.rs
@@ -17,6 +17,15 @@ impl SpotifyConfig {
}
}
+/// A passage a race can be run on, with whoever wrote it. The attribution
+/// travels with the text so every racer can see where the passage came from,
+/// the same way single player does.
+#[derive(Debug, Clone)]
+pub struct RaceText {
+ pub text: String,
+ pub attribution: Option<String>,
+}
+
#[derive(Clone)]
pub struct AppState {
pub db: SqlitePool,
@@ -33,7 +42,7 @@ pub struct AppState {
/// Race passages every multiplayer room draws from, so every player in
/// a room types the identical text - loaded once at startup rather
/// than per-room, since the pool itself never changes at runtime.
- pub race_texts: Vec<String>,
+ pub race_texts: Vec<RaceText>,
pub spotify: SpotifyConfig,
pub frontend_origin: String,
pub http: Client,
@@ -43,7 +52,7 @@ impl AppState {
pub fn new(
db: SqlitePool,
cookie_secure: bool,
- race_texts: Vec<String>,
+ race_texts: Vec<RaceText>,
spotify: SpotifyConfig,
frontend_origin: String,
) -> Self {
diff --git a/crates/steam/src/multiplayer_plugin.rs b/crates/steam/src/multiplayer_plugin.rs
index 05c1760..02bd2e7 100644
--- a/crates/steam/src/multiplayer_plugin.rs
+++ b/crates/steam/src/multiplayer_plugin.rs
@@ -80,6 +80,7 @@ fn poll_events(
) {
while let Some(event) = net.0.try_recv() {
match event {
+ NetEvent::Ignored => {}
NetEvent::RoomCreated { code } => {
info!("multiplayer room created - join it from the web app with code: {code}");
}
diff --git a/crates/steam/src/net.rs b/crates/steam/src/net.rs
index 4d213b4..e08e9fa 100644
--- a/crates/steam/src/net.rs
+++ b/crates/steam/src/net.rs
@@ -19,6 +19,10 @@ use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage};
#[derive(Debug, Clone)]
pub enum NetEvent {
+ /// A protocol message this client has no use for. Delivered rather than
+ /// dropped so the match on ServerMessage stays exhaustive and adding a
+ /// variant is a compile-time prompt rather than a silent parse failure.
+ Ignored,
RoomCreated { code: String },
Joined { player_id: String },
PlayerList(Vec<PlayerInfo>),
@@ -84,6 +88,11 @@ fn to_event(msg: ServerMessage) -> NetEvent {
match msg {
ServerMessage::Joined { player_id } => NetEvent::Joined { player_id },
ServerMessage::PlayerList { players } => NetEvent::PlayerList(players),
+ // The passage ahead of the countdown, so a client can show it while
+ // players wait. This one does not yet, and Start still carries the
+ // text, so it is accepted and ignored rather than dropped as an
+ // unparseable message.
+ ServerMessage::RaceText { .. } => NetEvent::Ignored,
ServerMessage::Countdown { seconds } => NetEvent::Countdown(seconds),
ServerMessage::Start { text } => NetEvent::Start(text),
ServerMessage::PlayerProgress { player_id, percent, wpm } => NetEvent::PlayerProgress { player_id, percent, wpm },
diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs
index f5c4a68..27ea983 100644
--- a/crates/tui/src/main.rs
+++ b/crates/tui/src/main.rs
@@ -104,6 +104,7 @@ fn merge_player_list(existing: &[MpPlayer], incoming: Vec<typerpunk_core::multip
fn apply_mp_event(app: &mut App, event: MpNetEvent) {
match event {
+ MpNetEvent::Ignored => {}
MpNetEvent::RoomCreated { code } => app.set_mp_room_created(code),
MpNetEvent::Joined { player_id } => app.set_mp_joined(player_id),
MpNetEvent::PlayerList(players) => {
diff --git a/crates/tui/src/multiplayer_net.rs b/crates/tui/src/multiplayer_net.rs
index 8c6c6b0..5d1b772 100644
--- a/crates/tui/src/multiplayer_net.rs
+++ b/crates/tui/src/multiplayer_net.rs
@@ -11,6 +11,10 @@ use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage};
#[derive(Debug, Clone)]
pub enum NetEvent {
+ /// A protocol message this client has no use for. Delivered rather than
+ /// dropped so the match on ServerMessage stays exhaustive and adding a
+ /// variant is a compile-time prompt rather than a silent parse failure.
+ Ignored,
RoomCreated { code: String },
Joined { player_id: String },
PlayerList(Vec<PlayerInfo>),
@@ -81,6 +85,11 @@ fn to_event(msg: ServerMessage) -> NetEvent {
match msg {
ServerMessage::Joined { player_id } => NetEvent::Joined { player_id },
ServerMessage::PlayerList { players } => NetEvent::PlayerList(players),
+ // The passage ahead of the countdown, so a client can show it while
+ // players wait. This one does not yet, and Start still carries the
+ // text, so it is accepted and ignored rather than dropped as an
+ // unparseable message.
+ ServerMessage::RaceText { .. } => NetEvent::Ignored,
ServerMessage::Countdown { seconds } => NetEvent::Countdown(seconds),
ServerMessage::Start { text } => NetEvent::Start(text),
ServerMessage::PlayerProgress { player_id, percent, wpm } => NetEvent::PlayerProgress { player_id, percent, wpm },
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 = `
<div class="stats-screen">
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
<div class="logo" data-action="menu">TyperPunk</div>
<h2>Account</h2>
<div class="account-panel">${formMarkup(user)}</div>
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
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 = `
<div class="stats-screen">
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
<div class="logo" data-action="menu">TyperPunk</div>
<h2>Friends</h2>
${user ? signedInMarkup() : signedOutMarkup()}
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0
<path d="M13 21L17 11L21 21"/>
<path d="M14.4 18H19.6"/>
</svg>`;
+
+// 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="square">
+ <path d="M5 5L19 19"/>
+ <path d="M19 5L5 19"/>
+</svg>`;
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 = `
<div class="stats-screen">
- <div class="logo" data-action="menu">TyperPunk</div>
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
+ <div class="logo" data-action="menu">TyperPunk</div>
<h2>Leaderboard</h2>
<div class="leaderboard-modes">
${MODES.map(m => `<button class="menu-button small${m.key === selectedMode ? '' : ' ghost'}" data-mode="${m.key}">${escapeHtml(m.label)}</button>`).join('')}
</div>
<button class="menu-button small ghost" data-action="toggle-device-filter" data-tooltip="Mobile results still count for personal stats - this only filters what's shown here.">Devices: All</button>
<div class="leaderboard-results"></div>
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
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 = `
<div class="stats-screen">
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
<div class="logo" data-action="menu">TyperPunk</div>
<h2>Lyrics</h2>
<div class="stats-placeholder">${escapeHtml(bodyText())}</div>
${status === 'signed-out' ? `<button class="menu-button" data-action="go-account">Sign In</button>` : ''}
${status === 'not-connected' ? `<a class="menu-button" href="${apiUrl('/api/spotify/login')}">Connect Spotify</a>` : ''}
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', () => { stopped = true; onBack(); }));
diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js
index a89bfd4..59c4b3f 100644
--- a/web/src/screens/multiplayerScreen.js
+++ b/web/src/screens/multiplayerScreen.js
@@ -64,15 +64,16 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
<input class="account-input" type="text" id="mp-name" placeholder="Your name" value="${escapeHtml(getUser()?.username || '')}">
<button class="menu-button small ghost" data-action="toggle-device-filter" data-tooltip="Who you get matched with, and the setting any room you open uses. Joining by code always uses that room's setting.">Match: Everyone</button>
<button class="menu-button" data-action="quick" data-tooltip="Drops you straight into a race with whoever else is looking. No code to share.">Find a Race</button>
- <div class="settings-hint">or race specific people</div>
+
+ <div class="mp-divider"><span>or race friends</span></div>
+
<div class="mp-code-row">
- <input class="account-input" type="text" id="mp-room-code" placeholder="Room code">
- <button class="menu-button small ghost" data-action="join">Join</button>
+ <input class="account-input" type="text" id="mp-room-code" placeholder="Enter a room code" maxlength="5" autocomplete="off" spellcheck="false">
+ <button class="menu-button" data-action="join">Join</button>
</div>
- <button class="menu-button small ghost" data-action="create" data-tooltip="Opens an empty room and gives you a code to share.">Create a Room</button>
+ <button class="menu-button ghost" data-action="create" data-tooltip="Opens an empty room and gives you a code to share.">Create a Room</button>
<div class="custom-error mp-error">${escapeHtml(landingError || '')}</div>
</div>
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
@@ -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 = `
<div class="stats-screen">
@@ -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 = '<div class="mp-race-gate-count"></div><div class="mp-race-gate-label">Get ready</div>';
+ 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 = `
<div class="stats-screen">
- <div class="logo" data-action="menu">TyperPunk</div>
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
+ <div class="logo" data-action="menu">TyperPunk</div>
<h2>${escapeHtml(title)}</h2>
<div class="stats-placeholder">${escapeHtml(description)}</div>
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
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-<category>".
function modeLabel(modeKey) {
@@ -52,10 +52,10 @@ export function renderPublicProfileScreen(root, { username, onBack, onShowStats,
root.innerHTML = `
<div class="stats-screen">
- <div class="logo" data-action="menu">TyperPunk</div>
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
+ <div class="logo" data-action="menu">TyperPunk</div>
<h2 class="public-profile-heading">${escapeHtml(username)}</h2>
<div class="public-profile-body"></div>
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
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 = `
<div class="stats-screen">
- <div class="logo" data-action="menu">TyperPunk</div>
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
+ <div class="logo" data-action="menu">TyperPunk</div>
<h2>Stats</h2>
${profile.testsCompleted > 0 ? `
@@ -58,7 +60,6 @@ export function renderStatsScreen(root, { onBack, onShowPlaceholder, onShowAccou
? `<div class="stats-chart"><canvas></canvas></div>`
: `<div class="stats-placeholder">Complete a few more tests to see a progress chart.</div>`}
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js
index 1ab2f84..5a533d9 100644
--- a/web/src/screens/storeScreen.js
+++ b/web/src/screens/storeScreen.js
@@ -4,7 +4,7 @@ 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';
+import { FLAIR_ICONS, CLOSE_ICON } from './icons.js';
function formatPrice(cents) {
return `$${(cents / 100).toFixed(2)}`;
@@ -27,8 +27,9 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
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 signedOut = status === 'signed-out';
+ const owned = !signedOut && mine.owned.includes(item.id);
+ const equipped = !signedOut && (mine.equipped_caret === item.id || mine.equipped_flair === item.id);
const swatch = item.category === 'caret'
? `<span class="store-swatch" style="background:${escapeHtml(item.value)}"></span>`
: `<span class="store-swatch store-flair-swatch">${FLAIR_ICONS[item.value] || ''}</span>`;
@@ -39,18 +40,22 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
${!owned ? `<div class="leaderboard-acc">${formatPrice(item.price_cents)}</div>` : ''}
${owned
? `<button class="menu-button small${equipped ? '' : ' ghost'}" data-action="${equipped ? 'unequip' : 'equip'}" data-id="${item.id}" data-category="${item.category}" data-tooltip="${equipped ? 'Unequip - back to the default look' : `Replaces whichever ${categoryLabel(item.category).toLowerCase()} you have equipped now`}">${equipped ? 'Equipped' : 'Equip'}</button>`
- : `<button class="menu-button small" data-action="buy" data-id="${item.id}">Buy</button>`}
+ : `<button class="menu-button small${signedOut ? ' ghost' : ''}" data-action="${signedOut ? 'go-account' : 'buy'}" data-id="${item.id}"${signedOut ? ' data-tooltip="Sign in to buy this"' : ''}>Buy</button>`}
</div>
`;
}
function bodyMarkup() {
- if (status === 'signed-out') return `<div class="stats-empty">Sign in to buy and equip cosmetics.</div><button class="menu-button" data-action="go-account">Sign In</button>`;
if (status === 'loading') return `<div class="stats-empty">Loading...</div>`;
if (status === 'error') return `<div class="stats-empty">${escapeHtml(message)}</div>`;
+ const signedOut = status === 'signed-out';
+ const banner = signedOut
+ ? `<div class="store-signin-note">Sign in to buy and equip these.
+ <button class="menu-button small" data-action="go-account">Sign In</button></div>`
+ : '';
const categories = ['caret', 'flair'];
- return categories.map(cat => {
+ return banner + categories.map(cat => {
const items = catalog.filter(i => i.category === cat);
if (items.length === 0) return '';
return `<h3>${categoryLabel(cat)}</h3><div class="leaderboard-list">${items.map(itemMarkup).join('')}</div>`;
@@ -60,10 +65,10 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
function render() {
root.innerHTML = `
<div class="stats-screen">
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
<div class="logo" data-action="menu">TyperPunk</div>
<h2>Store</h2>
${bodyMarkup()}
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
@@ -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;
+}