srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/src/cornerRail.js47
-rw-r--r--web/src/screens/mainMenu.js72
-rw-r--r--web/src/styles.css131
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 ? ` &middot; ${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 {