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/styles.css | 2440 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2440 insertions(+) create mode 100644 web/src/styles.css (limited to 'web/src/styles.css') diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..b556038 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,2440 @@ +:root { + --primary-color: #00ff9d; + --secondary-color: #00cc8f; + --background-color: #000000; + --text-color: #c8ccd0; + --error-color: #ca4754; + --correct-color: #00ff9d; + --neutral-color: #646669; + --caret-color: #00ff9d; + --sub-color: #8b8e92; + /* Unified top offset below fixed header/toggle */ + --header-offset: 3.5rem; + /* App padding variable so we can compensate where needed */ + --app-padding: 1rem; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +[data-theme="light"] { + --primary-color: #067a4e; + --secondary-color: #05663f; + --background-color: #f6f6f4; + --text-color: #2b2b2b; + --error-color: #b3323f; + --correct-color: #067a4e; + --neutral-color: #8a8d90; + --caret-color: #067a4e; + --sub-color: #5c5f61; + --record-color: #9a6b00; + --warning-color: #a35b00; + --player-2: #1667c2; + --player-3: #b45309; + --player-4: #7c3aed; + --player-5: #be123c; +} + +[data-theme="dark"] { + --primary-color: #00ff9d; + --secondary-color: #00cc8f; + --background-color: #000000; + --text-color: #c8ccd0; + --error-color: #ca4754; + --correct-color: #00ff9d; + --neutral-color: #646669; + --caret-color: #00ff9d; + --sub-color: #8b8e92; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +[data-theme="dracula"] { + --primary-color: #bd93f9; + --secondary-color: #ff79c6; + --background-color: #282a36; + --text-color: #f8f8f2; + --error-color: #ff5555; + --correct-color: #bd93f9; + --neutral-color: #6272a4; + --caret-color: #bd93f9; + --sub-color: #9aa5c4; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +[data-theme="nord"] { + --primary-color: #88c0d0; + --secondary-color: #81a1c1; + --background-color: #2e3440; + --text-color: #d8dee9; + --error-color: #bf616a; + --correct-color: #88c0d0; + --neutral-color: #4c566a; + --caret-color: #88c0d0; + --sub-color: #93a1b8; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +[data-theme="solarized"] { + --primary-color: #268bd2; + --secondary-color: #2aa198; + --background-color: #002b36; + --text-color: #93a1a1; + --error-color: #dc322f; + --correct-color: #268bd2; + --neutral-color: #586e75; + --caret-color: #268bd2; + --sub-color: #839496; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +[data-theme="gruvbox"] { + --primary-color: #fabd2f; + --secondary-color: #b8bb26; + --background-color: #282828; + --text-color: #ebdbb2; + --error-color: #fb4934; + --correct-color: #fabd2f; + --neutral-color: #928374; + --caret-color: #fabd2f; + --sub-color: #bdae93; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +[data-theme="monokai"] { + --primary-color: #a6e22e; + --secondary-color: #66d9ef; + --background-color: #272822; + --text-color: #f8f8f2; + --error-color: #f92672; + --correct-color: #a6e22e; + --neutral-color: #75715e; + --caret-color: #a6e22e; + --sub-color: #b0a98d; + --record-color: #ffd166; + --warning-color: #ffb454; + --player-2: #4ea3ff; + --player-3: #ff9f43; + --player-4: #c678dd; + --player-5: #ff6b8a; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + padding: 0; + font-family: 'JetBrains Mono', monospace; + background-color: var(--background-color); + color: var(--text-color); + height: 100vh; + /* Vertical scroll is allowed (and only appears when actually needed) -- + a fixed "overflow: hidden" here silently clipped any screen whose + content got taller than the viewport (a wide-but-short window, a + longer passage, extra stat rows) with literally no way to reach the + clipped part. Horizontal stays clipped since nothing should ever + need to scroll sideways. */ + overflow-x: hidden; + overflow-y: auto; + /* Cursor is set per-theme in JS (theme.js) - a hardcoded green crosshair + here would look wrong on Dracula/Nord/etc. */ +} + +.app { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; /* avoid vertical centering that pushes content down */ + height: 100vh; + padding: var(--app-padding); + overflow: hidden; +} + +.logo { + position: fixed; + top: 1rem; + left: 1rem; + font-size: 1.2rem; + color: var(--primary-color); + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 2px; + cursor: pointer; + transition: color 0.2s ease; +} + +.logo:hover { + color: var(--secondary-color); +} + +/* Main Menu */ +.main-menu { + display: flex; + flex-direction: column; + align-items: center; + /* Pinned near the top rather than vertically centered - centered left + barely more room below the Start button than the mode popover needs, + so on anything but a tall viewport the list ran past the bottom edge. + Fixed higher, there's always real room underneath it. */ + justify-content: flex-start; + padding-top: 6rem; + height: 100vh; + width: 100%; + max-width: 700px; + margin: 0 auto; + padding-left: 2rem; + padding-right: 2rem; + padding-bottom: 2rem; + overflow-y: auto; + box-sizing: border-box; +} + +.main-menu h1 { + font-size: 4rem; + color: var(--primary-color); + margin-bottom: 2rem; + text-transform: uppercase; + letter-spacing: 4px; +} + +.settings-row-group { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; +} + +.settings-row-group .menu-button.small { + width: 230px; +} + +.menu-stat .stat-label { + font-size: 0.7rem; + color: var(--sub-color); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.2rem; +} + +.menu-stat .stat-value { + font-size: 1.3rem; + color: var(--primary-color); + font-weight: bold; +} + +/* Stats Screen */ +.stats-screen { + width: 100%; + max-width: 700px; + margin: 0 auto; + padding: 2rem; + margin-top: calc(var(--header-offset) - var(--app-padding)); + min-height: calc(100vh - var(--header-offset)); + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + /* Vertically centred when the content is short - these screens were + top-heavy, ending around y=510 on a 900px viewport with the rest of + the page empty. "safe" keeps it flex-start once the content is taller + than the viewport, so a long list is never cut off above the scroll + origin; browsers that do not know the keyword ignore the declaration + and keep the old flex-start behaviour. */ + justify-content: safe center; + overflow-y: auto; +} + +.stats-screen h2 { + font-size: 1.8rem; + color: var(--primary-color); + text-transform: uppercase; + letter-spacing: 3px; + margin: 1.5rem 0 1.5rem; +} + +.stats-screen h3 { + font-size: 1rem; + color: var(--sub-color); + text-transform: uppercase; + letter-spacing: 2px; + align-self: flex-start; + margin: 2rem 0 0.75rem; +} + +.stats-summary { + width: 100%; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1.5rem; + border: 1px solid var(--neutral-color); + border-radius: 8px; + padding: 1.25rem 1rem; + text-align: center; +} + +.stats-empty { + color: var(--sub-color); + font-size: 0.9rem; + align-self: flex-start; +} + +.stats-pb-list { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stats-pb-row { + display: flex; + align-items: center; + gap: 1rem; + border: 1px solid var(--neutral-color); + border-radius: 6px; + padding: 0.6rem 1rem; +} + +.stats-pb-mode { + flex: 1; + text-align: left; +} + +.stats-pb-wpm { + color: var(--primary-color); + font-weight: bold; +} + +.stats-pb-date { + color: var(--sub-color); + font-size: 0.8rem; + min-width: 90px; + text-align: right; +} + +.leaderboard-modes { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.4rem; + margin-bottom: 0.5rem; +} + +.leaderboard-list { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.leaderboard-row { + display: flex; + align-items: center; + gap: 1rem; + border: 1px solid var(--neutral-color); + border-radius: 6px; + padding: 0.6rem 1rem; +} + +.leaderboard-rank { + color: var(--sub-color); + min-width: 2.5rem; +} + +.leaderboard-name { + flex: 1; + text-align: left; + /* A