diff options
| -rw-r--r-- | web/src/cornerRail.js | 47 | ||||
| -rw-r--r-- | web/src/screens/mainMenu.js | 72 | ||||
| -rw-r--r-- | web/src/styles.css | 131 |
3 files changed, 165 insertions, 85 deletions
diff --git a/web/src/cornerRail.js b/web/src/cornerRail.js index 6859a3b..9bd24a2 100644 --- a/web/src/cornerRail.js +++ b/web/src/cornerRail.js @@ -14,14 +14,12 @@ function soundThemeLabel(theme) { // the identical clusters inline (see mainMenu.js) because it owns its own // Settings panel, so the two must stay visually in step. // -// Top-left, under the wordmark: what customises the app (Settings, Store). -// Bottom-right: numbers and play (Stats, Leaderboard, Multiplayer). Identity -// - Account and Friends - lives in the top-right rail beside the language -// and theme controls (see topRail.js), and the bottom-left corner is -// deliberately left empty so the passage and its stats have room. -// -// Multiplayer carries a live count, so you can see whether anyone is around -// to race without opening the screen. +// Top-left, on the wordmark's own line: what customises the app (Settings, +// Store). Bottom-left: Multiplayer, carrying the live count of people racing +// right now - a number needs room to sit beside its icon, which a corner +// shared with two other buttons does not have. Bottom-right: your numbers +// (Stats, Leaderboard). Identity - Account and Friends - lives in the +// top-right rail beside the language and theme controls (see topRail.js). export function renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowLeaderboard, onShowMultiplayer, onShowStore }) { const settings = getSettings(); const left = document.createElement('div'); @@ -43,17 +41,25 @@ export function renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowL ${onShowStore ? `<button class="corner-icon-button" data-action="rail-store" aria-label="Store" data-tooltip="Store">${STORE_ICON}</button>` : ''} `; + const bottomLeft = document.createElement('div'); + bottomLeft.className = 'corner-rail-bottom-left'; + bottomLeft.innerHTML = onShowMultiplayer + ? `<button class="corner-icon-button" data-action="rail-multiplayer" aria-label="Multiplayer" data-tooltip="Multiplayer">${MULTIPLAYER_ICON}</button> + <span class="online-count" data-badge="online" hidden></span>` + : ''; + const right = document.createElement('div'); right.className = 'corner-rail-right'; right.innerHTML = ` ${onShowStats ? `<button class="corner-icon-button" data-action="rail-stats" aria-label="Stats" data-tooltip="Stats">${STATS_ICON}</button>` : ''} ${onShowLeaderboard ? `<button class="corner-icon-button" data-action="rail-leaderboard" aria-label="Leaderboard" data-tooltip="Leaderboard">${LEADERBOARD_ICON}</button>` : ''} - ${onShowMultiplayer ? `<button class="corner-icon-button has-badge" data-action="rail-multiplayer" aria-label="Multiplayer" data-tooltip="Multiplayer">${MULTIPLAYER_ICON}<span class="rail-badge accent" data-badge="online" hidden></span></button>` : ''} `; root.appendChild(left); + root.appendChild(bottomLeft); root.appendChild(right); attachTooltips(left); + attachTooltips(bottomLeft); attachTooltips(right); const statsBtn = right.querySelector('[data-action="rail-stats"]'); @@ -91,21 +97,27 @@ export function renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowL soundThemeBtn.textContent = `Sound: ${soundThemeLabel(next)}`; }); - const multiplayerBtn = right.querySelector('[data-action="rail-multiplayer"]'); + const multiplayerBtn = bottomLeft.querySelector('[data-action="rail-multiplayer"]'); if (multiplayerBtn) multiplayerBtn.addEventListener('click', onShowMultiplayer); const leaderboardBtn = right.querySelector('[data-action="rail-leaderboard"]'); if (leaderboardBtn) leaderboardBtn.addEventListener('click', onShowLeaderboard); const storeBtn = left.querySelector('[data-action="rail-store"]'); if (storeBtn) storeBtn.addEventListener('click', onShowStore); - // Badges ----------------------------------------------------------- - const onlineBadge = right.querySelector('[data-badge="online"]'); + // Live count --------------------------------------------------------- + // Beside the icon rather than pinned to its corner as a badge: the number + // has room to be read here, and it can say what it counts. + const onlineEl = bottomLeft.querySelector('[data-badge="online"]'); + const mpBtn = bottomLeft.querySelector('[data-action="rail-multiplayer"]'); function paintBadges({ online }) { - if (onlineBadge) { - onlineBadge.hidden = !online; - onlineBadge.textContent = String(online); - const btn = onlineBadge.closest('.corner-icon-button'); - if (btn) btn.dataset.tooltip = online === 1 ? '1 player racing now' : `${online} players racing now`; + if (onlineEl) { + onlineEl.hidden = !online; + onlineEl.textContent = online === 1 ? '1 racing' : `${online} racing`; + } + if (mpBtn) { + mpBtn.dataset.tooltip = !online + ? 'Multiplayer' + : (online === 1 ? 'Multiplayer - 1 player racing now' : `Multiplayer - ${online} players racing now`); } } paintBadges(getCounts()); @@ -120,6 +132,7 @@ export function renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowL unsubscribeCounts(); document.removeEventListener('click', handleOutsideClick); left.remove(); + bottomLeft.remove(); right.remove(); }; } diff --git a/web/src/screens/mainMenu.js b/web/src/screens/mainMenu.js index 909740d..a5e9df1 100644 --- a/web/src/screens/mainMenu.js +++ b/web/src/screens/mainMenu.js @@ -7,6 +7,7 @@ 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'; @@ -78,6 +79,7 @@ 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'; @@ -113,28 +115,29 @@ export function renderMainMenu(root, props) { <div class="main-menu"> <h1>TyperPunk</h1> <div class="menu-options"> - <button class="mode-pill sp-chevron" data-tooltip="Choose what to type"> - <span class="mode-pill-label">${escapeHtml(startLabel)}${rulesSummary ? ` · ${escapeHtml(rulesSummary)}` : ''}</span> - ${CHEVRON_DOWN_ICON} - </button> <div class="start-group sp-group"> <button class="menu-button start-main" data-action="start">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.">Multiplayer</button> + <button class="menu-button start-main" data-action="mode-multiplayer" data-tooltip="Race other typists live.">Multi-Player</button> </div> - <div class="start-caption mp-caption" hidden></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>` : ''} ${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> @@ -201,6 +204,11 @@ 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> @@ -216,13 +224,19 @@ export function renderMainMenu(root, props) { attachTooltips(root); const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); - // Live count under the Multiplayer button. The Friends badge is the top - // rail's, painted there. - const mpCaptionEl = root.querySelector('.mp-caption'); + // 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 (mpCaptionEl) { - mpCaptionEl.hidden = !online; - mpCaptionEl.textContent = online === 1 ? '1 racing now' : `${online} racing now`; + 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()); @@ -272,12 +286,14 @@ export function renderMainMenu(root, props) { root.querySelector('.sp-popover'), null, ); - root.querySelectorAll('.sp-popover .mode-popover-item[data-mode]').forEach(item => { + 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.querySelector('[data-action="mode-lyrics"]').addEventListener('click', onShowLyrics); - root.querySelector('[data-action="mode-multiplayer"]').addEventListener('click', onShowMultiplayer); + // 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)); const startBtn = root.querySelector('.sp-group .start-main'); const panel = root.querySelector('.custom-panel'); @@ -342,15 +358,15 @@ export function renderMainMenu(root, props) { // them change the selected mode, and a full re-render was resetting // .settings-panel back to hidden on every single click, forcing you to // reopen Settings after each change. - // The mode pill above the buttons carries the mode name and its rules. - const startSubEl = root.querySelector('.mode-pill-label'); + // The rules line under the mode bar, for the modes that have rules. + const startSubEl = root.querySelector('.sp-caption'); function refreshRulesSummary() { if (!startSubEl) return; const s = getSettings(); const parts = [isWords ? `${liveWordCount} words` : `${liveTimeDuration}s`]; if (s.wordsPunctuation) parts.push('punctuation'); if (s.wordsNumbers) parts.push('numbers'); - startSubEl.textContent = `${startLabel} · ${parts.join(' · ')}`; + startSubEl.textContent = parts.join(' · '); } let liveWordCount = wordCount; diff --git a/web/src/styles.css b/web/src/styles.css index b556038..8f84f93 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -519,7 +519,7 @@ body { "this is the one you click" on its own instead of the word "Start". */ /* The two entry points. Single-line labels naming the action, nothing else inside the box - what a Single Player run will actually start (mode plus - its rules) sits in the .mode-pill above, so the button reads as one clear + its rules) sits in the .mode-bar below, so the button reads as one clear word and the mode stays changeable without redrawing the button. */ .start-main { width: 100%; @@ -540,11 +540,11 @@ body { /* Mode + rules for the Single Player button. Hidden (not removed) while the mode popover is open, so the layout does not jump. */ .start-caption { + margin-top: 0.6rem; font-size: 0.85rem; color: var(--sub-color); text-transform: uppercase; letter-spacing: 1px; - margin-top: -0.35rem; text-align: center; max-width: 100%; overflow: hidden; @@ -556,46 +556,62 @@ body { edge (overlaid, not squeezing the label) opens the mode list. Mode used to be its own separate full-width button - merged here since Start already displays the current mode in its own label ("Start: Timed"). */ -/* The mode picker, above the two start buttons rather than notched into the - Single Player button's right edge. As a chevron inside the button it cut a - 3px divider through the outline and made the button look clipped, and it - forced the label off-centre to avoid it. As its own quiet pill it also - removes the caption that used to sit between Single Player and Multiplayer, - separating the two things you actually came here to press. */ -.mode-pill { - display: inline-flex; - align-items: center; +/* The mode picker, as a row of chips below both start buttons. It has now + been three things: a chevron notched into the Single Player button (which + cut a divider through its outline and shoved the label off-centre), then a + caption wedged between Single Player and Multi-Player (which separated the + two things you came here to press), then a pill sitting directly on top of + the button. As its own row underneath, it is clearly a setting for the run + rather than part of the button stack, and every common mode is one click + instead of a dropdown away. The eleven topic packs stay behind the "Text" + chip - they are a long list and a rarer choice. */ +.mode-bar { + display: flex; + flex-wrap: wrap; justify-content: center; - gap: 0.5rem; - max-width: 360px; + align-items: center; + gap: 0.4rem; + margin-top: 2.25rem; + /* Wide enough for all eight chips on one line; it still wraps rather than + overflowing on a narrow window. */ + max-width: 660px; +} + +.mode-chip { background: none; - border: 1px solid color-mix(in srgb, var(--primary-color) 35%, transparent); + border: 1px solid transparent; border-radius: 999px; color: var(--sub-color); font-family: inherit; - font-size: 0.8rem; + font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; - padding: 0.45rem 0.9rem; + padding: 0.35rem 0.75rem; cursor: pointer; - transition: all 0.2s ease; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 0.35rem; } -.mode-pill:hover { - border-color: var(--primary-color); +.mode-chip:hover { color: var(--text-color); + border-color: color-mix(in srgb, var(--primary-color) 35%, transparent); } -.mode-pill-label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.mode-chip.active { + color: var(--primary-color); + border-color: var(--primary-color); } -.mode-pill svg { - width: 0.9rem; - height: 0.9rem; - flex-shrink: 0; +.mode-chip svg { + width: 0.75rem; + height: 0.75rem; +} + +.mode-chip-group { + position: relative; + display: inline-flex; } @@ -616,11 +632,9 @@ body { whichever of above/below actually has room, and this only falls back to its own internal scroll (never the page's) if neither direction has enough space at all. */ -/* Two columns, one per kind of entry (see mainMenu.js). As a single column - this was 585px of 18 undifferentiated rows on a 900px viewport - it ran - off the bottom into a scroll region and covered the Multiplayer button and - the icon rail. Side by side it is roughly half that and needs no scrolling - at any normal window height. */ +/* Just the topic packs now - the generated modes are chips in .mode-bar, so + this is one column of eleven rather than the 585px wall of eighteen mixed + entries it used to be. */ .mode-popover { position: fixed; display: flex; @@ -1196,19 +1210,56 @@ body { Store); right cluster is numbers (Stats, Leaderboard, Multiplayer). Horizontal, not vertical - a bottom corner has little headroom before hitting the screen edge. */ -/* Top-left, stacked under the wordmark: what customises the app. It used to - be a second bottom cluster, which left the bottom edge carrying navigation - on both sides while the top-left held nothing but the logo. A column, not a - row - there is vertical room here and the passage starts below it. */ +/* Top-left, on the wordmark's own line rather than stacked beneath it -- + below the logo they pushed into the space the passage needs, and read as a + second, unrelated column. `left` clears the wordmark; the buttons are + slightly smaller here so they sit against 1.2rem text without dwarfing it. */ .corner-rail-left { position: fixed; - top: 3.4rem; + top: 0.85rem; + left: 11.5rem; + z-index: 100; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5rem; +} + +.corner-rail-left .corner-icon-button { + width: 2rem; + height: 2rem; +} + +.corner-rail-left .corner-icon-button svg { + width: 1.15rem; + height: 1.15rem; +} + +/* Bottom-left: Multiplayer and how many people are racing right now. The + count sits beside the icon as readable text instead of a corner badge -- + as a badge it was a bare digit with nothing saying what it counted, and it + was competing for a corner with two other buttons. */ +.corner-rail-bottom-left { + position: fixed; + bottom: 1rem; left: 1rem; z-index: 100; display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; + flex-direction: row; + align-items: center; + gap: 0.6rem; +} + +.corner-rail-bottom-left .corner-icon-button { + position: static; +} + +.online-count { + font-size: 0.75rem; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--primary-color); + white-space: nowrap; } .corner-rail-right { |