srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-09-13 21:11:00 +0200
committersrdusr <[email protected]>2025-09-13 21:11:00 +0200
commit0d3864bcebcdba1b1743367d0f84389fd53e37e1 (patch)
tree54a5c028b9fe9b965ffc0240f734b2b5ac81046b /web/src/screens
parent29992fe40703cf567177d70f152811bfedb0caad (diff)
downloadtyperpunk-0d3864bcebcdba1b1743367d0f84389fd53e37e1.tar.gz
typerpunk-0d3864bcebcdba1b1743367d0f84389fd53e37e1.zip
Rework the end screen figures, mode picker, corners and race view
End screen - Every figure is now a dim label directly over its value, the two centred on each other, grouped by what they qualify: RAW and PB under WPM, ERR and CONSISTENCY under ACC, KEYSTROKES and CHARACTERS either side of TIME. The row is a grid so TIME sits on the graph's exact centre line rather than drifting with its neighbours' widths. - Errors go back onto the wpm line. Their own y axis implied a magnitude a mistake does not have; what matters is when one happened. - Graph hover reads "wpm ... raw ... time" - the figures first, the second they happened in as the qualifier. Mode picker - Opens from the Single Player button itself, and choosing a mode starts it: picking what to type and starting it are one action. The control has now been a chevron notched into that button, a caption between the two buttons, a pill above them and a chip row below them; as the button's own menu it needs no separate real estate at all. Corners - Settings and Store move to the bottom-left. The top-left is the wordmark's alone. - The global racer count is gone from the home screen - it is not something you can act on there. The Friends control carries "N online" instead, which is. - Presence: users gain a last_seen column, touched at most once a minute per active user on any authenticated request, and the friends list reports who has been seen inside a five-minute window. Multiplayer race view - The standings move to the middle of the screen, the space the end screen's graph occupies, and now include your own row rather than opponents only. Pinned to the top-left corner they put what you are racing against in your peripheral vision and left out the one bar you most need to see. Programming mode - Snippets carry an explanation of what the code does. Shown under the passage while you type in single player; in multiplayer that space belongs to the standings, so it waits for the end screen, where it appears either way.
Diffstat (limited to 'web/src/screens')
-rw-r--r--web/src/screens/endScreen.js24
-rw-r--r--web/src/screens/mainMenu.js97
-rw-r--r--web/src/screens/multiplayerScreen.js24
-rw-r--r--web/src/screens/typingGame.js5
4 files changed, 69 insertions, 81 deletions
diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js
index cd2a41d..551a91c 100644
--- a/web/src/screens/endScreen.js
+++ b/web/src/screens/endScreen.js
@@ -8,7 +8,7 @@ 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 }) {
+export function renderEndScreen(root, { stats, text, attribution, explanation, 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
@@ -43,33 +43,37 @@ export function renderEndScreen(root, { stats, text, attribution, userInput, cha
}
// 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.
+ // Sits under WPM alongside RAW, in the same label-over-value shape as
+ // every other secondary figure rather than as a floating badge.
const pbLine = !modeKey || previous == null ? '' : isNewBest
- ? '<div class="pb-badge pb-new">NEW BEST</div>'
- : `<div class="pb-badge">PB ${Math.round(previous)}</div>`;
+ ? '<div class="end-stat pb-new"><div class="stat-label">PB</div><div class="stat-value">NEW BEST</div></div>'
+ : `<div class="end-stat"><div class="stat-label">PB</div><div class="stat-value">${Math.round(previous)}</div></div>`;
root.innerHTML = `
<div class="end-screen">
<div class="logo" data-action="menu">TyperPunk</div>
<div class="end-screen-text"><div class="text-display"></div></div>
${attribution ? `<div class="attribution end-screen-attribution">&mdash; ${escapeHtml(attribution)}</div>` : ''}
+ ${explanation ? `<div class="code-explainer"><span class="code-explainer-label">What this does</span>${escapeHtml(explanation)}</div>` : ''}
<div class="end-screen-graph-row">
<div class="endscreen-side-stat wpm">
- <div class="stat-label">WPM</div><div class="stat-value">${Math.round(stats.wpm)}</div>
+ <div class="end-stat headline"><div class="stat-label">WPM</div><div class="stat-value">${Math.round(stats.wpm)}</div></div>
+ <div class="end-stat"><div class="stat-label">RAW</div><div class="stat-value">${Math.round(stats.rawWpm)}</div></div>
${pbLine}
</div>
<div class="graph-container"><canvas></canvas></div>
<div class="endscreen-side-stat acc">
- <div class="stat-label">ACC</div><div class="stat-value">${Math.round(stats.accuracy)}%</div>
+ <div class="end-stat headline"><div class="stat-label">ACC</div><div class="stat-value">${Math.round(stats.accuracy)}%</div></div>
+ <div class="end-stat"><div class="stat-label">ERR</div><div class="stat-value">${stats.incorrectChars}</div></div>
+ <div class="end-stat"><div class="stat-label">CONSISTENCY</div><div class="stat-value">${consistency}%</div></div>
</div>
</div>
<div class="end-screen-stat-row">
- <div class="end-stat"><div class="stat-label">RAW</div><div class="stat-value">${Math.round(stats.rawWpm)}</div></div>
- <div class="end-stat"><div class="stat-label">CONSISTENCY</div><div class="stat-value">${consistency}%</div></div>
<div class="end-stat"><div class="stat-label">KEYSTROKES</div><div class="stat-value">${stats.keystrokes}</div></div>
+ <div class="end-stat headline"><div class="stat-label">TIME</div><div class="stat-value">${stats.time.toFixed(1)}s</div></div>
<div class="end-stat" data-tooltip="Correct / wrong / typed past the end / left untyped">
<div class="stat-label">CHARACTERS</div><div class="stat-value">${stats.correctChars}/${stats.incorrectChars}/${extraChars}/${missedChars}</div>
</div>
- <div class="end-stat"><div class="stat-label">TIME</div><div class="stat-value">${stats.time.toFixed(1)}s</div></div>
</div>
<div class="end-screen-buttons">
<button class="end-screen-button" data-action="again">Play Again</button>
@@ -182,7 +186,9 @@ export function renderEndScreen(root, { stats, text, attribution, userInput, cha
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)}`;
+ // Time last: the figures are what you are reading for, and the
+ // second it happened is the qualifier on them.
+ tooltip.textContent = `wpm ${Math.round(linePoint.wpm)} · raw ${Math.round(linePoint.raw)} · ${linePoint.time}s`;
tooltip.style.left = `${mx}px`;
tooltip.style.top = `${my}px`;
tooltip.style.display = 'block';
diff --git a/web/src/screens/mainMenu.js b/web/src/screens/mainMenu.js
index a5e9df1..d0553c3 100644
--- a/web/src/screens/mainMenu.js
+++ b/web/src/screens/mainMenu.js
@@ -6,8 +6,6 @@ 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';
-import { MULTIPLAYER_ICON } from './icons.js';
function label(m) {
if (m === 'random') return 'Random';
@@ -69,7 +67,7 @@ function wordListTierLabel(tier) {
}
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 { onStartGame, onPickAndStart, 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)
@@ -79,7 +77,6 @@ export function renderMainMenu(root, props) {
const gameModes = [...GAME_MODES.filter(m => modes.includes(m)), 'custom'];
const textCategories = modes.filter(m => !gameModes.includes(m));
const modeItem = m => `<button class="mode-popover-item${m === currentMode ? ' active' : ''}" data-mode="${escapeHtml(m)}" data-tooltip="${escapeHtml(modeDescription(m))}">${escapeHtml(label(m))}</button>`;
- const modeChip = m => `<button class="mode-chip${m === currentMode ? ' active' : ''}" data-mode="${escapeHtml(m)}" data-tooltip="${escapeHtml(modeDescription(m))}">${escapeHtml(label(m))}</button>`;
const currentIndex = Math.max(0, modes.indexOf(selectedCategory || 'random'));
const currentMode = modes[currentIndex] || 'random';
const isCustom = currentMode === 'custom';
@@ -116,28 +113,24 @@ export function renderMainMenu(root, props) {
<h1>TyperPunk</h1>
<div class="menu-options">
<div class="start-group sp-group">
- <button class="menu-button start-main" data-action="start">Single Player</button>
+ <button class="menu-button start-main" data-action="pick-mode">Single Player</button>
+ <div class="mode-popover sp-popover" hidden>
+ <div class="mode-popover-group">
+ <div class="mode-popover-heading">Modes</div>
+ ${gameModes.map(modeItem).join('')}
+ <button class="mode-popover-item" data-action="mode-lyrics" data-tooltip="Connect Spotify and type along to whatever's playing.">Lyrics</button>
+ </div>
+ <div class="mode-popover-group">
+ <div class="mode-popover-heading">Text</div>
+ ${textCategories.map(modeItem).join('')}
+ </div>
+ </div>
</div>
<div class="start-group mp-group">
<button class="menu-button start-main" data-action="mode-multiplayer" data-tooltip="Race other typists live.">Multi-Player</button>
</div>
- <div class="mode-bar">
- ${gameModes.map(modeChip).join('')}
- <button class="mode-chip" data-action="mode-lyrics" data-tooltip="Connect Spotify and type along to whatever's playing.">Lyrics</button>
- <div class="mode-chip-group">
- <button class="mode-chip sp-chevron${textCategories.includes(currentMode) ? ' active' : ''}" data-tooltip="Passages from a topic">
- ${textCategories.includes(currentMode) ? escapeHtml(label(currentMode)) : 'Text'} ${CHEVRON_DOWN_ICON}
- </button>
- <div class="mode-popover sp-popover" hidden>
- <div class="mode-popover-group">
- <div class="mode-popover-heading">Text</div>
- ${textCategories.map(modeItem).join('')}
- </div>
- </div>
- </div>
- </div>
- ${rulesSummary ? `<div class="start-caption sp-caption">${escapeHtml(rulesSummary)}</div>` : ''}
+ <div class="start-caption sp-caption">${escapeHtml(label(currentMode))}${rulesSummary ? ` &middot; ${escapeHtml(rulesSummary)}` : ''}</div>
${isCustom && customText && customText.timed ? `<button class="menu-button" data-action="start-passive">Passive Mode</button>` : ''}
${isCustom && customText ? `<button class="menu-button" data-action="clear-custom">Clear Custom Text</button>` : ''}
</div>
@@ -204,11 +197,6 @@ export function renderMainMenu(root, props) {
<button class="corner-icon-button" data-action="store" aria-label="Store" data-tooltip="Store">${STORE_ICON}</button>
</div>
- <div class="corner-rail-bottom-left">
- <button class="corner-icon-button" data-action="mode-multiplayer" aria-label="Multiplayer" data-tooltip="Multiplayer">${MULTIPLAYER_ICON}</button>
- <span class="online-count" data-badge="online" hidden></span>
- </div>
-
<div class="corner-rail-right">
${profile.testsCompleted > 0 ? `<div class="stats-corner-figures" data-tooltip="Your average WPM across every test, and your best single result.">
<div class="stats-corner-avg">avg ${profile.averageWpm}</div>
@@ -224,23 +212,7 @@ export function renderMainMenu(root, props) {
attachTooltips(root);
const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
- // The Friends badge belongs to the top rail. The player count is this
- // screen's own, since the menu builds its corner clusters inline.
- const onlineEl = root.querySelector('.corner-rail-bottom-left [data-badge="online"]');
- const mpIconBtn = root.querySelector('.corner-rail-bottom-left [data-action="mode-multiplayer"]');
- function paintCounts({ online }) {
- if (onlineEl) {
- onlineEl.hidden = !online;
- onlineEl.textContent = online === 1 ? '1 racing' : `${online} racing`;
- }
- if (mpIconBtn) {
- mpIconBtn.dataset.tooltip = !online
- ? 'Multiplayer'
- : (online === 1 ? 'Multiplayer - 1 player racing now' : `Multiplayer - ${online} players racing now`);
- }
- }
- paintCounts(getCounts());
- const unsubscribeCounts = onCountsChange(paintCounts);
+ // Friends-online lives in the top rail beside the Friends control.
// 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
@@ -282,32 +254,34 @@ export function renderMainMenu(root, props) {
setupDropdown(
root.querySelector('.sp-group'),
- root.querySelector('.sp-chevron'),
+ root.querySelector('[data-action="pick-mode"]'),
root.querySelector('.sp-popover'),
- null,
+ root.querySelector('.sp-caption'),
);
- root.querySelectorAll('.sp-popover .mode-popover-item[data-mode], .mode-bar .mode-chip[data-mode]').forEach(item => {
- item.addEventListener('click', () => onSelectCategory(item.dataset.mode));
+ root.querySelectorAll('.sp-popover .mode-popover-item[data-mode]').forEach(item => {
+ item.addEventListener('click', () => {
+ const mode = item.dataset.mode;
+ // Custom needs its text before it can start: with none loaded the
+ // picker hands off to the paste/upload panel instead.
+ if (mode === 'custom') {
+ onSelectCategory(mode);
+ return;
+ }
+ onPickAndStart(mode);
+ });
});
root.querySelector('[data-action="mode-lyrics"]').addEventListener('click', onShowLyrics);
- // Two of these now: the big button and the bottom-left icon beside the
- // live player count.
- root.querySelectorAll('[data-action="mode-multiplayer"]').forEach(el => el.addEventListener('click', onShowMultiplayer));
+ 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();
- }
- });
+ // No click handler of its own: setupDropdown above owns this button and
+ // opens the mode picker with it. Starting happens when a mode is chosen.
+
+ // Landing on Custom with nothing loaded goes straight to the paste/upload
+ // panel - there is nothing to start until text exists.
+ if (isCustom && !customText) panel.hidden = false;
// 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
@@ -540,7 +514,6 @@ export function renderMainMenu(root, props) {
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
index 0de0182..d2d2a2b 100644
--- a/web/src/screens/multiplayerScreen.js
+++ b/web/src/screens/multiplayerScreen.js
@@ -210,17 +210,25 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
}
const opponents = document.createElement('div');
- opponents.className = 'mp-opponents';
+ opponents.className = 'mp-racers';
const progressById = {};
+ // Everyone in the room, yourself included. Showing only opponents left
+ // you guessing where you actually stood: your own bar is the one you
+ // are measuring the others against. The server echoes your Progress
+ // back to you along with everyone else's, so your row is fed the same
+ // way theirs are.
function paintOpponents() {
const colors = racerColors(players, myId);
- opponents.innerHTML = players.filter(p => p.id !== myId).map(p => `
- <div class="mp-opponent-row" style="--racer-color: ${colors[p.id]}">
- <span class="mp-opponent-name">${escapeHtml(p.name)}</span>
- <div class="mp-opponent-bar"><div class="mp-opponent-bar-fill" style="width:${(progressById[p.id]?.percent || 0)}%"></div></div>
- <span class="mp-opponent-wpm">${Math.round(progressById[p.id]?.wpm || 0)}</span>
- </div>
- `).join('');
+ opponents.innerHTML = players.map(p => {
+ const me = p.id === myId;
+ const prog = progressById[p.id] || {};
+ return `
+ <div class="mp-racer-row${me ? ' me' : ''}" style="--racer-color: ${colors[p.id]}">
+ <span class="mp-racer-name">${escapeHtml(p.name)}${me ? ' (you)' : ''}</span>
+ <div class="mp-racer-bar"><div class="mp-racer-bar-fill" style="width:${prog.percent || 0}%"></div></div>
+ <span class="mp-racer-wpm">${Math.round(prog.wpm || 0)}</span>
+ </div>`;
+ }).join('');
}
paintOpponents();
diff --git a/web/src/screens/typingGame.js b/web/src/screens/typingGame.js
index 72442a2..d65d254 100644
--- a/web/src/screens/typingGame.js
+++ b/web/src/screens/typingGame.js
@@ -10,7 +10,7 @@ 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 }) {
+export function renderTypingGame(root, { game, text, attribution, explanation, 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 = `
@@ -22,6 +22,7 @@ export function renderTypingGame(root, { game, text, attribution, language, prog
<input class="typing-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" />
</div>
${attribution ? `<div class="attribution">- ${escapeHtml(attribution)}</div>` : ''}
+ ${explanation && !multiplayer ? `<div class="code-explainer"><span class="code-explainer-label">What this does</span>${escapeHtml(explanation)}</div>` : ''}
<div class="wpm-stat"><div class="stat-label">WPM</div><div class="stat-value" data-field="wpm">0</div></div>
<div class="acc-stat"><div class="stat-label">ACC</div><div class="stat-value" data-field="acc">100%</div></div>
<div class="time-stat"><div class="stat-label">TIME</div><div class="stat-value" data-field="time">0.0</div></div>
@@ -179,7 +180,7 @@ export function renderTypingGame(root, { game, text, attribution, language, prog
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 });
+ onFinish({ stats, text: finalText, attribution, explanation, userInput: finalInput, charTimings, keypressHistory: keystrokes, modeKey });
}
function handleChange() {