From 0d3864bcebcdba1b1743367d0f84389fd53e37e1 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Sat, 13 Sep 2025 21:11:00 +0200 Subject: 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. --- web/src/screens/endScreen.js | 24 +++++---- web/src/screens/mainMenu.js | 97 +++++++++++++----------------------- web/src/screens/multiplayerScreen.js | 24 ++++++--- web/src/screens/typingGame.js | 5 +- 4 files changed, 69 insertions(+), 81 deletions(-) (limited to 'web/src/screens') 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 - ? '
NEW BEST
' - : `
PB ${Math.round(previous)}
`; + ? '
PB
NEW BEST
' + : `
PB
${Math.round(previous)}
`; root.innerHTML = `
${attribution ? `
— ${escapeHtml(attribution)}
` : ''} + ${explanation ? `
What this does${escapeHtml(explanation)}
` : ''}
-
WPM
${Math.round(stats.wpm)}
+
WPM
${Math.round(stats.wpm)}
+
RAW
${Math.round(stats.rawWpm)}
${pbLine}
-
ACC
${Math.round(stats.accuracy)}%
+
ACC
${Math.round(stats.accuracy)}%
+
ERR
${stats.incorrectChars}
+
CONSISTENCY
${consistency}%
-
RAW
${Math.round(stats.rawWpm)}
-
CONSISTENCY
${consistency}%
KEYSTROKES
${stats.keystrokes}
+
TIME
${stats.time.toFixed(1)}s
CHARACTERS
${stats.correctChars}/${stats.incorrectChars}/${extraChars}/${missedChars}
-
TIME
${stats.time.toFixed(1)}s
@@ -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 => ``; - const modeChip = m => ``; 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) {

TyperPunk

@@ -204,11 +197,6 @@ export function renderMainMenu(root, props) {
-
- - -
-
${profile.testsCompleted > 0 ? `
avg ${profile.averageWpm}
@@ -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 => ` -
- ${escapeHtml(p.name)} -
- ${Math.round(progressById[p.id]?.wpm || 0)} -
- `).join(''); + opponents.innerHTML = players.map(p => { + const me = p.id === myId; + const prog = progressById[p.id] || {}; + return ` +
+ ${escapeHtml(p.name)}${me ? ' (you)' : ''} +
+ ${Math.round(prog.wpm || 0)} +
`; + }).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
${attribution ? `
- ${escapeHtml(attribution)}
` : ''} + ${explanation && !multiplayer ? `
What this does${escapeHtml(explanation)}
` : ''}
WPM
0
ACC
100%
TIME
0.0
@@ -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() { -- cgit v1.2.3