diff options
| -rw-r--r-- | crates/server/migrations/0012_store_catalogue.sql | 40 | ||||
| -rw-r--r-- | crates/server/migrations/0013_supporter.sql | 5 | ||||
| -rw-r--r-- | crates/server/src/auth.rs | 17 | ||||
| -rw-r--r-- | crates/server/src/cosmetics.rs | 8 | ||||
| -rw-r--r-- | web/src/adSlot.js | 60 | ||||
| -rw-r--r-- | web/src/screens/accountScreen.js | 4 | ||||
| -rw-r--r-- | web/src/screens/endScreen.js | 6 | ||||
| -rw-r--r-- | web/src/screens/friendsScreen.js | 6 | ||||
| -rw-r--r-- | web/src/screens/icons.js | 35 | ||||
| -rw-r--r-- | web/src/screens/leaderboardScreen.js | 3 | ||||
| -rw-r--r-- | web/src/screens/mainMenu.js | 4 | ||||
| -rw-r--r-- | web/src/screens/multiplayerScreen.js | 29 | ||||
| -rw-r--r-- | web/src/screens/storeScreen.js | 16 | ||||
| -rw-r--r-- | web/src/styles.css | 256 |
14 files changed, 361 insertions, 128 deletions
diff --git a/crates/server/migrations/0012_store_catalogue.sql b/crates/server/migrations/0012_store_catalogue.sql new file mode 100644 index 0000000..f7e5d99 --- /dev/null +++ b/crates/server/migrations/0012_store_catalogue.sql @@ -0,0 +1,40 @@ +- A fuller catalogue. The store shipped with six items, which is not enough +- for the screen to be worth opening twice. +-- +- Racer sprites join carets and flair as a third slot: the sprite shown for +- you in a multiplayer race. Unequipped players fall back to the sprite their +- position in the room assigns, so nothing is required to race. +ALTER TABLE cosmetics DROP CONSTRAINT IF EXISTS cosmetics_category_check; +ALTER TABLE cosmetics ADD CONSTRAINT cosmetics_category_check + CHECK (category IN ('caret', 'flair', 'sprite')); + +ALTER TABLE users ADD COLUMN equipped_sprite TEXT; + +INSERT INTO cosmetics (id, name, category, price_cents, value) VALUES + - Carets. Priced the same as each other: they are the same thing in a + - different colour, and pricing them apart would only be arbitrary. + ('caret-crimson', 'Crimson Caret', 'caret', 199, '#ff3b5c'), + ('caret-violet', 'Violet Caret', 'caret', 199, '#a855f7'), + ('caret-lime', 'Lime Caret', 'caret', 199, '#a3e635'), + ('caret-ice', 'Ice Caret', 'caret', 199, '#7dd3fc'), + ('caret-ember', 'Ember Caret', 'caret', 199, '#fb923c'), + ('caret-bone', 'Bone Caret', 'caret', 199, '#e7e5e4'), + ('caret-void', 'Void Caret', 'caret', 299, '#6b21a8'), + ('caret-signal', 'Signal Caret', 'caret', 299, '#22d3ee'), + + - Flair, shown beside a username on the leaderboard and public profile. + ('flair-crown', 'Crown', 'flair', 149, 'crown'), + ('flair-circuit', 'Circuit', 'flair', 149, 'circuit'), + ('flair-eye', 'Eye', 'flair', 149, 'eye'), + ('flair-shard', 'Shard', 'flair', 149, 'shard'), + ('flair-moth', 'Moth', 'flair', 199, 'moth'), + ('flair-reactor', 'Reactor', 'flair', 199, 'reactor'), + + - Race sprites. + ('sprite-dart', 'Dart', 'sprite', 249, 'dart'), + ('sprite-rocket', 'Rocket', 'sprite', 249, 'rocket'), + ('sprite-helm', 'Helm', 'sprite', 249, 'helm'), + ('sprite-core', 'Core', 'sprite', 249, 'core'), + ('sprite-signal', 'Signal', 'sprite', 249, 'signal'), + ('sprite-blade', 'Blade', 'sprite', 249, 'blade') +ON CONFLICT (id) DO NOTHING; diff --git a/crates/server/migrations/0013_supporter.sql b/crates/server/migrations/0013_supporter.sql new file mode 100644 index 0000000..9e8531f --- /dev/null +++ b/crates/server/migrations/0013_supporter.sql @@ -0,0 +1,5 @@ +- Whether an account has paid to remove the ad slots. There is no payment +- processor yet, so nothing sets this to true, but the flag exists so the +- slots have a real rule to follow rather than being shown to everybody with +- a note saying they will be conditional later. +ALTER TABLE users ADD COLUMN is_supporter BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index 382dbfa..caf428b 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -306,8 +306,17 @@ async fn logout(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<im } async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { - match current_user(&state.db, &jar).await { - Some(user) => Ok(Json(user)), - None => Err(AppError::Unauthorized), - } + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + // Whether the ad slots are shown. Fetched here so the client has it with + // the identity rather than asking a second time. + let is_supporter: bool = sqlx::query_scalar("SELECT is_supporter FROM users WHERE id = $1") + .bind(&user.id) + .fetch_optional(&state.db) + .await? + .unwrap_or(false); + Ok(Json(serde_json::json!({ + "id": user.id, + "username": user.username, + "is_supporter": is_supporter, + }))) } diff --git a/crates/server/src/cosmetics.rs b/crates/server/src/cosmetics.rs index f128095..460fc66 100644 --- a/crates/server/src/cosmetics.rs +++ b/crates/server/src/cosmetics.rs @@ -51,6 +51,7 @@ struct MyCosmetics { owned: Vec<String>, equipped_caret: Option<String>, equipped_flair: Option<String>, + equipped_sprite: Option<String>, } async fn my_cosmetics(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { @@ -62,7 +63,7 @@ async fn my_cosmetics(State(state): State<Arc<AppState>>, jar: CookieJar) -> Res .await?; let owned: Vec<String> = owned_rows.into_iter().filter_map(|r| r.try_get("cosmetic_id").ok()).collect(); - let equip_row = sqlx::query("SELECT equipped_caret, equipped_flair FROM users WHERE id = $1") + let equip_row = sqlx::query("SELECT equipped_caret, equipped_flair, equipped_sprite FROM users WHERE id = $1") .bind(&user.id) .fetch_one(&state.db) .await?; @@ -76,6 +77,7 @@ async fn my_cosmetics(State(state): State<Arc<AppState>>, jar: CookieJar) -> Res // into Option<String> correctly does. Found via a real NULL column // round-tripping as "" instead of JSON null. equipped_caret: equip_row.try_get::<Option<String>, _>("equipped_caret").unwrap_or(None), + equipped_sprite: equip_row.try_get::<Option<String>, _>("equipped_sprite").unwrap_or(None), equipped_flair: equip_row.try_get::<Option<String>, _>("equipped_flair").unwrap_or(None), })) } @@ -126,6 +128,7 @@ async fn equip(State(state): State<Arc<AppState>>, jar: CookieJar, Path(cosmetic let column = match category.as_str() { "caret" => "equipped_caret", "flair" => "equipped_flair", + "sprite" => "equipped_sprite", _ => return Err(AppError::Internal(anyhow::anyhow!("unknown cosmetic category"))), }; @@ -147,7 +150,8 @@ async fn unequip(State(state): State<Arc<AppState>>, jar: CookieJar, Json(body): let column = match body.category.as_str() { "caret" => "equipped_caret", "flair" => "equipped_flair", - _ => return Err(AppError::InvalidInput("category must be 'caret' or 'flair'".into())), + "sprite" => "equipped_sprite", + _ => return Err(AppError::InvalidInput("category must be 'caret', 'flair' or 'sprite'".into())), }; let query = format!("UPDATE users SET {column} = NULL WHERE id = $1"); sqlx::query(&query).bind(&user.id).execute(&state.db).await?; diff --git a/web/src/adSlot.js b/web/src/adSlot.js new file mode 100644 index 0000000..4b8ce2e --- /dev/null +++ b/web/src/adSlot.js @@ -0,0 +1,60 @@ +import { getUser, onAuthChange } from './auth.js'; + +// Reserved space for advertising. +// +// This renders an empty, correctly sized box. It loads nothing, calls +// nothing, and tracks nobody. Its only job is to hold the space so that +// wiring a real network later does not move the rest of the page around +// after it loads, which is the usual way ads ruin a layout. +// +// Before any network goes in, three things have to change together: +// +// 1. The Content-Security-Policy in web/serve.mjs forbids third-party +// script, frame and image sources. An ad network needs all three, and +// relaxing them is the single largest change to this app's security +// posture. script-src in particular currently has no third-party source +// at all. +// 2. The privacy page in app.js says there is no advertising and no +// third-party script. That becomes untrue the moment a tag is added. +// 3. An ad network sets cookies and profiles visitors, which brings +// consent requirements with it. There is no consent mechanism here. +// +// Shown to signed-out visitors and to signed-in accounts without the +// supporter flag. Never shown on the typing screen: interrupting somebody +// mid-test is the one placement that would cost more than it earns. + +const SIZES = { + // Roughly a leaderboard unit, and a mobile banner below the breakpoint. + banner: { w: 728, h: 90, mobileW: 320, mobileH: 100 }, +}; + +export function shouldShowAds() { + const user = getUser(); + // A signed-out visitor is a free user for this purpose. + return !user || !user.is_supporter; +} + +/// Renders the slot into `root`. Returns a cleanup function. +export function renderAdSlot(root, { size = 'banner', label = 'Advertisement' } = {}) { + const spec = SIZES[size] || SIZES.banner; + const wrap = document.createElement('div'); + wrap.className = `ad-slot ad-slot-${size}`; + wrap.style.setProperty('--ad-w', `${spec.w}px`); + wrap.style.setProperty('--ad-h', `${spec.h}px`); + wrap.style.setProperty('--ad-mobile-w', `${spec.mobileW}px`); + wrap.style.setProperty('--ad-mobile-h', `${spec.mobileH}px`); + // Labelled for what it is. An unlabelled empty box reads as a bug. + wrap.innerHTML = `<span class="ad-slot-label">${label}</span>`; + + function paint() { + wrap.hidden = !shouldShowAds(); + } + paint(); + const unsubscribe = onAuthChange(paint); + + root.appendChild(wrap); + return () => { + unsubscribe(); + wrap.remove(); + }; +} diff --git a/web/src/screens/accountScreen.js b/web/src/screens/accountScreen.js index 5ae2bd0..5f3bdfe 100644 --- a/web/src/screens/accountScreen.js +++ b/web/src/screens/accountScreen.js @@ -44,8 +44,8 @@ export function renderAccountScreen(root, { onBack, onShowAccount, onShowStats, } return ` <div class="account-tabs"> - <button class="menu-button small${mode === 'login' ? '' : ' ghost'}" data-action="tab-login">Log In</button> - <button class="menu-button small${mode === 'register' ? '' : ' ghost'}" data-action="tab-register">Register</button> + <button class="menu-button small quiet${mode === 'login' ? ' active' : ''}" data-action="tab-login">Log In</button> + <button class="menu-button small quiet${mode === 'register' ? ' active' : ''}" data-action="tab-register">Register</button> </div> <form class="account-form"> <input class="account-input" type="text" name="username" placeholder="Username" autocomplete="username" required> diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js index 2787f76..aebd62b 100644 --- a/web/src/screens/endScreen.js +++ b/web/src/screens/endScreen.js @@ -3,6 +3,7 @@ import { escapeHtml } from '../util.js'; import { attachTooltips } from '../tooltip.js'; import { onThemeChange } from '../theme.js'; import { renderSiteFooter } from '../siteFooter.js'; +import { renderAdSlot } from '../adSlot.js'; import { recordResult } from '../pb.js'; import { recordTest } from '../profileStats.js'; import { renderCornerRail } from '../cornerRail.js'; @@ -82,6 +83,7 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s <div class="mp-standings-heading">Standings</div> <div class="mp-standings-rows"></div> </div>` : ''} + <div class="end-screen-ad"></div> <div class="end-screen-buttons"> <button class="end-screen-button primary" data-action="again">Play Again</button> </div> @@ -145,6 +147,9 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); // Share here carries the result just achieved, not just the site. const cleanupFooter = renderSiteFooter(root, { result: stats, onShowPrivacy }); + // Below the results and above Play Again: read after the run is over, + // never during it. + const cleanupAd = renderAdSlot(root.querySelector('.end-screen-ad')); root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onMainMenu)); root.querySelector('[data-action="again"]').addEventListener('click', onPlayAgain); @@ -234,6 +239,7 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s onLeaveRace?.(); cleanupTheme(); cleanupFooter(); + cleanupAd(); window.removeEventListener('resize', redraw); offTheme(); canvas.removeEventListener('mousemove', handleMove); diff --git a/web/src/screens/friendsScreen.js b/web/src/screens/friendsScreen.js index 14a0b2d..e121f4c 100644 --- a/web/src/screens/friendsScreen.js +++ b/web/src/screens/friendsScreen.js @@ -43,7 +43,7 @@ export function renderFriendsScreen(root, { onBack, onShowFriends, onShowStats, <div class="leaderboard-row"> <div class="leaderboard-name">${escapeHtml(r.username)}</div> <button class="menu-button small" data-action="accept" data-id="${r.friendship_id}">Accept</button> - <button class="menu-button small ghost" data-action="decline" data-id="${r.friendship_id}">Decline</button> + <button class="menu-button small quiet" data-action="decline" data-id="${r.friendship_id}">Decline</button> </div> `).join('')} </div>` : ''} @@ -55,7 +55,7 @@ export function renderFriendsScreen(root, { onBack, onShowFriends, onShowStats, <div class="leaderboard-row"> <div class="leaderboard-name">${escapeHtml(r.username)}</div> <div class="leaderboard-acc">Pending</div> - <button class="menu-button small ghost" data-action="cancel" data-id="${r.friendship_id}">Cancel</button> + <button class="menu-button small quiet" data-action="cancel" data-id="${r.friendship_id}">Cancel</button> </div> `).join('')} </div>` : ''} @@ -66,7 +66,7 @@ export function renderFriendsScreen(root, { onBack, onShowFriends, onShowStats, ${data.friends.map(f => ` <div class="leaderboard-row"> <div class="leaderboard-name">${escapeHtml(f.username)}</div> - <button class="menu-button small ghost" data-action="unfriend" data-id="${f.friendship_id}">Remove</button> + <button class="menu-button small quiet" data-action="unfriend" data-id="${f.friendship_id}">Remove</button> </div> `).join('')} </div>` : `<div class="stats-empty">No friends yet - add one by username above.</div>`} diff --git a/web/src/screens/icons.js b/web/src/screens/icons.js index 21c0548..462c6b0 100644 --- a/web/src/screens/icons.js +++ b/web/src/screens/icons.js @@ -114,3 +114,38 @@ export const CLOSE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 <path d="M5 5L19 19"/> <path d="M19 5L5 19"/> </svg>`; + +// Racer sprites. One per player in a multiplayer race, so a bar is a +// character rather than a coloured line with a name attached. Drawn in the +// same angular language as the rest of this set: straight edges, flat fill, +// no gradients, and they take their colour from the racer they belong to. +// +// Six of them, which is the room capacity, so no two racers in a full room +// share a sprite. +export const RACER_SPRITES = { + // A forward-leaning chevron, the fastest-reading shape in the set. + dart: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3 4L17 12L3 20L7 12Z"/><rect x="19" y="10" width="3" height="4"/></svg>`, + // A blocky rocket. + rocket: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L16 8V16H8V8Z"/><path d="M8 16L5 21L9 19Z"/><path d="M16 16L19 21L15 19Z"/><rect x="10" y="6" width="4" height="4" fill="var(--background-color)"/></svg>`, + // A visored helmet. + helm: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M4 11L12 3L20 11V18H4Z"/><rect x="7" y="11" width="10" height="4" fill="var(--background-color)"/><rect x="4" y="18" width="16" height="3"/></svg>`, + // A hexagonal core. + core: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L20 7V17L12 22L4 17V7Z"/><rect x="9" y="9" width="6" height="6" fill="var(--background-color)"/></svg>`, + // A signal mast. + signal: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><rect x="11" y="8" width="2" height="14"/><path d="M4 3L8 7L4 11Z"/><path d="M20 3L16 7L20 11Z"/><rect x="8" y="6" width="8" height="2"/></svg>`, + // A pair of stacked blades. + blade: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M2 6H14L18 10H6Z"/><path d="M6 14H18L22 18H10Z"/></svg>`, +}; + +export const RACER_SPRITE_IDS = Object.keys(RACER_SPRITES); + +// Flair added alongside the original three. Same construction: flat fill, +// straight edges, readable at 16px beside a username. +Object.assign(FLAIR_ICONS, { + crown: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3 7L7 12L12 5L17 12L21 7V19H3Z"/></svg>`, + circuit: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><rect x="9" y="9" width="6" height="6"/><rect x="11" y="2" width="2" height="6"/><rect x="11" y="16" width="2" height="6"/><rect x="2" y="11" width="6" height="2"/><rect x="16" y="11" width="6" height="2"/></svg>`, + eye: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M2 12L7 6H17L22 12L17 18H7Z"/><rect x="10" y="10" width="4" height="4" fill="var(--background-color)"/></svg>`, + shard: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L18 10L12 22L6 10Z"/></svg>`, + moth: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4L4 8L6 16L12 13Z"/><path d="M12 4L20 8L18 16L12 13Z"/><rect x="11" y="4" width="2" height="16"/></svg>`, + reactor: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L21 20H3Z"/><rect x="10" y="12" width="4" height="5" fill="var(--background-color)"/></svg>`, +}); diff --git a/web/src/screens/leaderboardScreen.js b/web/src/screens/leaderboardScreen.js index 5d56a97..8aeda98 100644 --- a/web/src/screens/leaderboardScreen.js +++ b/web/src/screens/leaderboardScreen.js @@ -89,7 +89,7 @@ export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onS root.querySelectorAll('.leaderboard-modes [data-mode]').forEach(btn => { btn.addEventListener('click', () => { selectedMode = btn.dataset.mode; - root.querySelectorAll('.leaderboard-modes [data-mode]').forEach(b => b.classList.toggle('ghost', b !== btn)); + root.querySelectorAll('.leaderboard-modes [data-mode]').forEach(b => b.classList.toggle('active', b === btn)); load(); }); }); @@ -100,7 +100,6 @@ export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onS deviceFilterBtn.textContent = `Devices: ${desktopOnly ? 'Desktop only' : 'All'}`; // A filter that is doing something should look like it is. deviceFilterBtn.classList.toggle('active', desktopOnly); - deviceFilterBtn.classList.toggle('ghost', !desktopOnly); load(); }); diff --git a/web/src/screens/mainMenu.js b/web/src/screens/mainMenu.js index 2d27468..f812509 100644 --- a/web/src/screens/mainMenu.js +++ b/web/src/screens/mainMenu.js @@ -168,7 +168,7 @@ export function renderMainMenu(root, props) { </div> <div class="settings-hint">Dev</div> <button class="menu-button small" data-action="simulate-test" data-tooltip="Jump straight to the end screen with fake results, no typing required.">Simulate Test → End Screen</button> - <button class="menu-button small ghost" data-action="close-settings">Close</button> + <button class="menu-button small quiet" data-action="close-settings">Close</button> </div> <div class="custom-panel" hidden> @@ -197,7 +197,7 @@ export function renderMainMenu(root, props) { </div>` : ''} <div class="custom-panel-row"> <button class="menu-button small" data-action="use-pasted">Use this text</button> - <button class="menu-button small ghost" data-action="close-custom">Cancel</button> + <button class="menu-button small quiet" data-action="close-custom">Cancel</button> </div> <div class="custom-error"></div> </div> diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js index 7292bcc..d3ae7d9 100644 --- a/web/src/screens/multiplayerScreen.js +++ b/web/src/screens/multiplayerScreen.js @@ -1,5 +1,5 @@ import { escapeHtml } from '../util.js'; -import { CLOSE_ICON } from './icons.js'; +import { CLOSE_ICON, RACER_SPRITES, RACER_SPRITE_IDS } from './icons.js'; import { renderCornerRail } from '../cornerRail.js'; import { attachTooltips } from '../tooltip.js'; import { renderTopRail } from '../topRail.js'; @@ -27,6 +27,18 @@ function racerColors(players, myId) { return colors; } +// A sprite per racer, assigned from the server's own player order so every +// client in the room draws the same person as the same character. Derived +// rather than stored: the order is identical everywhere, and a race is short +// enough that nobody needs to keep a sprite between rooms. +function racerSprites(players) { + const sprites = {}; + players.forEach((p, i) => { + sprites[p.id] = RACER_SPRITE_IDS[i % RACER_SPRITE_IDS.length]; + }); + return sprites; +} + // A style="" attribute in markup is refused by the Content-Security-Policy; // the same property set through element.style is not. Markup carries the // value in a data attribute and this applies it. @@ -76,7 +88,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o <h2>Multiplayer</h2> <div class="account-panel"> <input class="account-input" type="text" id="mp-name" placeholder="Your name" value="${escapeHtml(getUser()?.username || '')}"> - <button class="menu-button small ghost" data-action="toggle-device-filter" data-tooltip="Who you get matched with, and the setting any room you open uses. Joining by code always uses that room's setting.">Match: Everyone</button> + <button class="menu-button small quiet" data-action="toggle-device-filter" data-tooltip="Who you get matched with, and the setting any room you open uses. Joining by code always uses that room's setting.">Match: Everyone</button> <button class="menu-button primary" data-action="quick" data-tooltip="Drops you straight into a race with whoever else is looking. No code to share.">Find a Race</button> <div class="mp-divider"><span>or race friends</span></div> @@ -85,7 +97,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o <input class="account-input" type="text" id="mp-room-code" placeholder="CODE" maxlength="5" autocomplete="off" spellcheck="false"> <button class="menu-button" data-action="join">Join</button> </div> - <button class="menu-button ghost" data-action="create" data-tooltip="Opens an empty room and gives you a code to share.">Create a Room</button> + <button class="menu-button quiet" data-action="create" data-tooltip="Opens an empty room and gives you a code to share.">Create a Room</button> <div class="custom-error mp-error">${escapeHtml(landingError || '')}</div> </div> </div> @@ -168,7 +180,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o <div class="mp-countdown" hidden></div> <div class="leaderboard-list mp-player-list"></div> <button class="menu-button" data-action="ready">Ready</button> - <button class="menu-button small ghost" data-action="leave">Leave Room</button> + <button class="menu-button small quiet" data-action="leave">Leave Room</button> </div> `; root.querySelector('[data-action="menu"]').addEventListener('click', () => { leaveRoom(); onBack(); }); @@ -180,9 +192,10 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o const listEl = root.querySelector('.mp-player-list'); function paintPlayers() { const colors = racerColors(players, myId); + const sprites = racerSprites(players); listEl.innerHTML = players.map(p => ` <div class="leaderboard-row" data-racer-color="${colors[p.id]}"> - <div class="leaderboard-name"><span class="mp-racer-dot"></span>${escapeHtml(p.name)}${p.id === myId ? ' (you)' : ''}</div> + <div class="leaderboard-name"><span class="mp-racer-sprite">${RACER_SPRITES[sprites[p.id]]}</span>${escapeHtml(p.name)}${p.id === myId ? ' (you)' : ''}</div> <div class="leaderboard-acc">${p.ready ? 'Ready' : 'Not ready'}</div> </div> `).join(''); @@ -200,7 +213,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o connection.ready(); readyBtn.textContent = 'Waiting for players...'; readyBtn.disabled = true; - readyBtn.classList.add('ghost'); + readyBtn.classList.add('quiet'); } }); const offPlayerList = connection.on('playerList', list => { players = list; paintPlayers(); }); @@ -232,7 +245,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o connection.ready(); readyBtn.textContent = 'Waiting for players...'; readyBtn.disabled = true; - readyBtn.classList.add('ghost'); + readyBtn.classList.add('quiet'); }); root.querySelector('[data-action="leave"]').addEventListener('click', () => { leaveRoom(); renderLanding(); }); @@ -263,11 +276,13 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o // way theirs are. function paintOpponents() { const colors = racerColors(players, myId); + const sprites = racerSprites(players); opponents.innerHTML = players.map(p => { const me = p.id === myId; const prog = progressById[p.id] || {}; return ` <div class="mp-racer-row${me ? ' me' : ''}" data-racer-color="${colors[p.id]}"> + <span class="mp-racer-sprite">${RACER_SPRITES[sprites[p.id]]}</span> <span class="mp-racer-name">${escapeHtml(p.name)}${me ? ' (you)' : ''}</span> <div class="mp-racer-bar"><div class="mp-racer-bar-fill" data-percent="${prog.percent || 0}"></div></div> <span class="mp-racer-wpm">${Math.round(prog.wpm || 0)}</span> diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js index d1675a7..f66e3c8 100644 --- a/web/src/screens/storeScreen.js +++ b/web/src/screens/storeScreen.js @@ -4,14 +4,16 @@ import { attachTooltips } from '../tooltip.js'; import { renderTopRail } from '../topRail.js'; import { api, ApiError } from '../api.js'; import { getUser } from '../auth.js'; -import { FLAIR_ICONS, CLOSE_ICON } from './icons.js'; +import { FLAIR_ICONS, RACER_SPRITES, CLOSE_ICON } from './icons.js'; function formatPrice(cents) { return `$${(cents / 100).toFixed(2)}`; } function categoryLabel(category) { - return category === 'caret' ? 'Caret Colors' : 'Flair'; + if (category === 'caret') return 'Caret Colours'; + if (category === 'sprite') return 'Race Sprites'; + return 'Flair'; } // Real purchase/equip flow against the catalog, but purchase is a stub -- @@ -30,17 +32,19 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder const signedOut = status === 'signed-out'; const owned = !signedOut && mine.owned.includes(item.id); const equipped = !signedOut && (mine.equipped_caret === item.id || mine.equipped_flair === item.id); + // A caret is a colour, so the swatch is the colour itself; flair and + // sprites are drawings, so the swatch is the drawing. const swatch = item.category === 'caret' ? `<span class="store-swatch" data-swatch-colour="${escapeHtml(item.value)}"></span>` - : `<span class="store-swatch store-flair-swatch">${FLAIR_ICONS[item.value] || ''}</span>`; + : `<span class="store-swatch store-flair-swatch">${(item.category === 'sprite' ? RACER_SPRITES : FLAIR_ICONS)[item.value] || ''}</span>`; return ` <div class="leaderboard-row store-item-row"> ${swatch} <div class="leaderboard-name">${escapeHtml(item.name)}</div> ${!owned ? `<div class="leaderboard-acc">${formatPrice(item.price_cents)}</div>` : ''} ${owned - ? `<button class="menu-button small${equipped ? '' : ' ghost'}" data-action="${equipped ? 'unequip' : 'equip'}" data-id="${item.id}" data-category="${item.category}" data-tooltip="${equipped ? 'Unequip - back to the default look' : `Replaces whichever ${categoryLabel(item.category).toLowerCase()} you have equipped now`}">${equipped ? 'Equipped' : 'Equip'}</button>` - : `<button class="menu-button small${signedOut ? ' ghost' : ''}" data-action="${signedOut ? 'go-account' : 'buy'}" data-id="${item.id}"${signedOut ? ' data-tooltip="Sign in to buy this"' : ''}>Buy</button>`} + ? `<button class="menu-button small${equipped ? ' active' : ' quiet'}" data-action="${equipped ? 'unequip' : 'equip'}" data-id="${item.id}" data-category="${item.category}" data-tooltip="${equipped ? 'Unequip - back to the default look' : `Replaces whichever ${categoryLabel(item.category).toLowerCase()} you have equipped now`}">${equipped ? 'Equipped' : 'Equip'}</button>` + : `<button class="menu-button small${signedOut ? ' quiet' : ''}" data-action="${signedOut ? 'go-account' : 'buy'}" data-id="${item.id}"${signedOut ? ' data-tooltip="Sign in to buy this"' : ''}>Buy</button>`} </div> `; } @@ -54,7 +58,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder ? `<div class="store-signin-note">Sign in to buy and equip these. <button class="menu-button small" data-action="go-account">Sign In</button></div>` : ''; - const categories = ['caret', 'flair']; + const categories = ['caret', 'flair', 'sprite']; return banner + categories.map(cat => { const items = catalog.filter(i => i.category === cat); if (items.length === 0) return ''; diff --git a/web/src/styles.css b/web/src/styles.css index 9d47c68..3dae32b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -15,6 +15,17 @@ /* Room at the foot of every screen for the fixed footer and the corner rails, so normal-flow content never ends up underneath them. */ --bottom-chrome: 5.5rem; + + /* Controls and the surfaces they sit on. The stylesheet had five radii + in use (0, 2, 4, 6, 8 and 999px) with no rule for which applied where, + so a button and its own input looked like different components. */ + --radius: 4px; + --radius-panel: 8px; + --radius-pill: 999px; + /* One border weight. Buttons were 2px and inputs 1px, so a field and the + button next to it read as different weights of the same idea. The two + start buttons keep 2px, where it is doing real work. */ + --border-width: 1px; --record-color: #ffd166; --warning-color: #ffb454; --player-2: #4ea3ff; @@ -232,7 +243,7 @@ body { color: var(--primary-color); margin-bottom: 2rem; text-transform: uppercase; - letter-spacing: 4px; + letter-spacing: 2px; } .settings-row-group { @@ -287,7 +298,7 @@ body { font-size: 1.8rem; color: var(--primary-color); text-transform: uppercase; - letter-spacing: 3px; + letter-spacing: 2px; margin: 1.5rem 0 1.5rem; } @@ -306,8 +317,8 @@ body { flex-wrap: wrap; justify-content: center; gap: 1.5rem; - border: 1px solid var(--neutral-color); - border-radius: 8px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius-panel); padding: 1.25rem 1rem; text-align: center; } @@ -335,8 +346,8 @@ body { display: flex; align-items: center; gap: 1rem; - border: 1px solid var(--neutral-color); - border-radius: 6px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius); padding: 0.6rem 1rem; } @@ -376,8 +387,8 @@ body { display: flex; align-items: center; gap: 1rem; - border: 1px solid var(--neutral-color); - border-radius: 6px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius); padding: 0.6rem 1rem; } @@ -429,8 +440,8 @@ body { justify-content: center; width: 1.5rem; height: 1.5rem; - border-radius: 4px; - border: 1px solid var(--neutral-color); + border-radius: var(--radius); + border: var(--border-width) solid var(--neutral-color); } .store-flair-swatch { @@ -453,7 +464,7 @@ body { letter-spacing: 0.05em; color: var(--warning-color); border: 1px solid color-mix(in srgb, var(--warning-color) 45%, transparent); - border-radius: 4px; + border-radius: var(--radius); padding: 0.1rem 0.35rem; } @@ -474,7 +485,7 @@ body { .stats-placeholder { width: 100%; border: 1px dashed var(--neutral-color); - border-radius: 8px; + border-radius: var(--radius-panel); padding: 2rem 1rem; text-align: center; color: var(--sub-color); @@ -484,8 +495,8 @@ body { .stats-chart { width: 100%; height: 200px; - border: 1px solid var(--neutral-color); - border-radius: 8px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius-panel); padding: 0.5rem; box-sizing: border-box; } @@ -543,8 +554,8 @@ body { font-size: 1.5rem; padding-top: 1.3rem; padding-bottom: 1.3rem; - border-width: 3px; - letter-spacing: 3px; + border-width: 2px; + letter-spacing: 2px; } /* Mode + rules for the Single Player button. Hidden (not removed) while the @@ -601,8 +612,8 @@ body { align-items: flex-start; gap: 0.5rem; background: var(--background-color); - border: 1px solid var(--neutral-color); - border-radius: 8px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius-panel); padding: 0.5rem; z-index: 10; max-height: calc(100vh - 2rem); @@ -638,7 +649,7 @@ body { text-transform: uppercase; letter-spacing: 1px; padding: 0.45rem 0.7rem; - border-radius: 4px; + border-radius: var(--radius); cursor: pointer; text-align: left; flex-shrink: 0; @@ -655,7 +666,8 @@ body { .menu-button { background: none; - border: 2px solid var(--primary-color); + border: var(--border-width) solid var(--primary-color); + border-radius: var(--radius); color: var(--text-color); padding: 1rem 2rem; font-size: 1.2rem; @@ -686,19 +698,10 @@ body { min-width: 0; } -/* Secondary actions. A dashed outline conventionally means "placeholder" or - "drop target", so Join / Create a Room / the leaderboard filter chips all - read as unfinished. Solid at reduced strength says "secondary" instead, - and still inverts to the filled state on hover like every other button. */ -.menu-button.ghost { - border-color: color-mix(in srgb, var(--primary-color) 45%, transparent); - color: var(--sub-color); -} - -.menu-button.ghost:hover { - border-color: var(--primary-color); - color: var(--background-color); -} +/* Secondary actions. There used to be a .ghost modifier here as well, which + rendered identically to .quiet once the hierarchy was defined, so the two + were the same control under two names. .quiet is the one with a stated + role, and is defined with the rest of the hierarchy below. */ .custom-hint { font-size: 0.8rem; @@ -716,8 +719,8 @@ body { /* Tightened alongside the two-column split: .app is overflow:hidden, so anything past the viewport bottom is clipped rather than scrollable. */ gap: 0.55rem; - border: 1px solid var(--neutral-color); - border-radius: 8px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius-panel); padding: 0.85rem; margin: 1rem auto 0 auto; text-align: center; @@ -750,8 +753,8 @@ body { align-items: center; gap: 0.35rem; background: transparent; - border: 1px solid var(--neutral-color); - border-radius: 6px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius); padding: 0.5rem; cursor: pointer; font-family: inherit; @@ -765,7 +768,7 @@ body { display: block; width: 100%; height: 1.5rem; - border-radius: 4px; + border-radius: var(--radius); } .theme-swatch-label { @@ -787,8 +790,8 @@ body { display: flex; flex-direction: column; gap: 0.75rem; - border: 1px solid var(--neutral-color); - border-radius: 8px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius-panel); padding: 1rem; margin: 1.5rem auto 0 auto; } @@ -799,8 +802,8 @@ body { resize: vertical; background: transparent; color: var(--text-color); - border: 1px solid var(--neutral-color); - border-radius: 6px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius); padding: 0.75rem; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; @@ -868,8 +871,8 @@ body { .account-input { background: transparent; color: var(--text-color); - border: 1px solid var(--neutral-color); - border-radius: 6px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius); padding: 0.65rem 0.75rem; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; @@ -1007,7 +1010,7 @@ body { top: 1rem; right: 1rem; background: transparent; - border: 2px solid var(--primary-color); + border: var(--border-width) solid var(--primary-color); outline: none; box-shadow: none; color: var(--primary-color); @@ -1020,7 +1023,7 @@ body { align-items: center; justify-content: center; z-index: 100; - border-radius: 4px; + border-radius: var(--radius); backdrop-filter: none !important; -webkit-backdrop-filter: none !important; appearance: none; @@ -1062,7 +1065,7 @@ body { own, so it gets a visible button too. */ .restart-toggle { background: transparent; - border: 2px solid var(--primary-color); + border: var(--border-width) solid var(--primary-color); outline: none; box-shadow: none; color: var(--primary-color); @@ -1075,7 +1078,7 @@ body { align-items: center; justify-content: center; z-index: 100; - border-radius: 4px; + border-radius: var(--radius); font-size: 1.3rem; line-height: 1; appearance: none; @@ -1095,7 +1098,7 @@ body { controls. */ .zen-finish-button { background: transparent; - border: 2px solid var(--primary-color); + border: var(--border-width) solid var(--primary-color); outline: none; box-shadow: none; color: var(--primary-color); @@ -1107,7 +1110,7 @@ body { align-items: center; justify-content: center; z-index: 100; - border-radius: 4px; + border-radius: var(--radius); font-family: inherit; font-size: 0.9rem; text-transform: uppercase; @@ -1132,7 +1135,7 @@ body { .corner-icon-button { position: fixed; background: transparent; - border: 2px solid var(--primary-color); + border: var(--border-width) solid var(--primary-color); outline: none; box-shadow: none; color: var(--primary-color); @@ -1145,7 +1148,7 @@ body { align-items: center; justify-content: center; z-index: 100; - border-radius: 4px; + border-radius: var(--radius); appearance: none; } @@ -1232,8 +1235,8 @@ body { bottom: calc(100% + 0.6rem); left: 0; background: var(--background-color); - border: 1px solid var(--neutral-color); - border-radius: 8px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius-panel); padding: 0.75rem; width: 280px; } @@ -1243,8 +1246,8 @@ body { top: calc(100% + 0.6rem); right: 0; background: var(--background-color); - border: 1px solid var(--neutral-color); - border-radius: 8px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius-panel); padding: 0.75rem; width: 320px; } @@ -1401,7 +1404,7 @@ body { .text-display.code { text-align: left; background: rgba(127, 127, 127, 0.06); - border-radius: 8px; + border-radius: var(--radius-panel); } .text-display span.neutral.syn-keyword { color: var(--secondary-color); } @@ -1591,7 +1594,7 @@ body { .end-screen-button { background: none; - border: 2px solid var(--primary-color); + border: var(--border-width) solid var(--primary-color); color: var(--text-color); padding: 1rem 2rem; font-size: 1.2rem; @@ -1601,7 +1604,7 @@ body { text-transform: uppercase; letter-spacing: 2px; min-width: 250px; - border-radius: 8px; + border-radius: var(--radius-panel); } .end-screen-button:hover { @@ -1627,7 +1630,7 @@ body { tint on a light background, light tint on a dark one) into transparent instead makes this actually visible in every theme. */ background-color: color-mix(in srgb, var(--text-color) 18%, transparent); - border-radius: 2px; + border-radius: var(--radius); } .error-screen { @@ -1757,7 +1760,7 @@ body { flex: 1 1 auto; height: 0.4rem; background: color-mix(in srgb, var(--neutral-color) 45%, transparent); - border-radius: 999px; + border-radius: var(--radius-pill); overflow: hidden; } @@ -1773,18 +1776,6 @@ body { font-family: 'JetBrains Mono', monospace; } -/* Same colour key in the lobby list, so the mapping is learned before the - race starts rather than during it. */ -.leaderboard-row .mp-racer-dot { - display: inline-block; - width: 0.55rem; - height: 0.55rem; - border-radius: 2px; - margin-right: 0.5rem; - background: var(--racer-color, var(--primary-color)); - flex-shrink: 0; -} - /* The one genuinely time-critical state in the app: seconds left to get your hands on the keys. It read in the same accent as every idle figure on the screen, so it carried no urgency. --warning-color is used here and on the @@ -1827,7 +1818,7 @@ body { background: color-mix(in srgb, var(--text-color) 10%, var(--background-color)); color: var(--text-color); border: 1px solid var(--primary-color); - border-radius: 4px; + border-radius: var(--radius); padding: 0.4rem 0.6rem; font-size: 0.8rem; font-family: 'JetBrains Mono', monospace; @@ -1846,7 +1837,7 @@ body { background: rgba(20, 20, 20, 0.95); color: #fff; border: 1px solid var(--primary-color); - border-radius: 4px; + border-radius: var(--radius); padding: 0.3rem 0.6rem; font-size: 0.8rem; font-family: 'JetBrains Mono', monospace; @@ -1913,7 +1904,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { /* See .current's comment - a flat rgba(0,0,0,...) tint only darkens, so it did nothing at all against dark mode's pure-black background. */ background: color-mix(in srgb, var(--text-color) 6%, transparent); - border-radius: 6px; + border-radius: var(--radius); /* Side padding matches .text-container's 2rem so the centred passage lands on the same left edge as it had on the typing screen. */ padding: 1rem 2rem; @@ -1946,7 +1937,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { height: 240px; position: relative; background: color-mix(in srgb, var(--text-color) 3%, transparent); - border-radius: 8px; + border-radius: var(--radius-panel); } .end-screen-graph-row.end-screen-time-row { @@ -2275,8 +2266,8 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { top: calc(100% + 0.6rem); right: 0; background: var(--background-color); - border: 1px solid var(--neutral-color); - border-radius: 8px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius-panel); padding: 0.5rem; width: 210px; display: flex; @@ -2307,7 +2298,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { font-size: 0.9rem; text-align: left; padding: 0.4rem 0.6rem; - border-radius: 4px; + border-radius: var(--radius); cursor: pointer; } @@ -2380,9 +2371,9 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { min-width: 1.05rem; height: 1.05rem; padding: 0 0.2rem; - border-radius: 0.55rem; + border-radius: var(--radius-pill); background: var(--background-color); - border: 1px solid var(--neutral-color); + border: var(--border-width) solid var(--neutral-color); color: var(--sub-color); font-family: 'JetBrains Mono', monospace; font-size: 0.6rem; @@ -2578,7 +2569,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { gap: 0.75rem; padding: 0.4rem 0.75rem; border: 1px solid transparent; - border-radius: 6px; + border-radius: var(--radius); font-size: 0.85rem; color: var(--sub-color); } @@ -2661,7 +2652,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { .custom-textarea:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--background-color), 0 0 0 4px var(--primary-color); - border-radius: 6px; + border-radius: var(--radius); } /* Placeholders were inheriting the input's own text colour at whatever @@ -2728,7 +2719,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { .mp-race-gate-label { font-size: 0.8rem; text-transform: uppercase; - letter-spacing: 3px; + letter-spacing: 2px; color: var(--sub-color); } @@ -2744,7 +2735,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { margin-bottom: 1rem; padding: 0.75rem 1rem; border: 1px solid color-mix(in srgb, var(--primary-color) 35%, transparent); - border-radius: 8px; + border-radius: var(--radius-panel); font-size: 0.85rem; color: var(--sub-color); } @@ -2765,7 +2756,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { border: none; color: var(--sub-color); cursor: pointer; - border-radius: 6px; + border-radius: var(--radius); transition: color 0.15s ease, background-color 0.15s ease; } @@ -2802,8 +2793,8 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { gap: 0.15rem; min-width: 3.25rem; padding: 0.5rem 0.4rem; - border: 1px solid var(--neutral-color); - border-radius: 6px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius); } .weak-key-char { @@ -2840,8 +2831,8 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { text-transform: uppercase; letter-spacing: 0.05em; color: var(--sub-color); - border: 1px solid var(--neutral-color); - border-radius: 4px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius); padding: 0.1rem 0.35rem; } @@ -2883,7 +2874,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { color: var(--sub-color); text-decoration: none; cursor: pointer; - border-radius: 4px; + border-radius: var(--radius); transition: color 0.15s ease; } @@ -2959,8 +2950,8 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { } .submission-row { - border: 1px solid var(--neutral-color); - border-radius: 8px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius-panel); padding: 0.75rem 0.9rem; display: flex; flex-direction: column; @@ -3023,8 +3014,8 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { align-items: flex-start; gap: 0.15rem; background: none; - border: 1px solid var(--neutral-color); - border-radius: 6px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius); padding: 0.5rem 0.7rem; color: var(--text-color); font-family: inherit; @@ -3056,7 +3047,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { width: 2rem; background: none; border: 1px solid transparent; - border-radius: 6px; + border-radius: var(--radius); color: var(--sub-color); font-size: 1.1rem; cursor: pointer; @@ -3076,8 +3067,8 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { align-items: center; gap: 0.6rem; padding: 0.5rem 0.75rem; - border: 1px solid var(--neutral-color); - border-radius: 6px; + border: var(--border-width) solid var(--neutral-color); + border-radius: var(--radius); } .admin-user-name { @@ -3095,7 +3086,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { letter-spacing: 1px; color: var(--primary-color); border: 1px solid color-mix(in srgb, var(--primary-color) 45%, transparent); - border-radius: 4px; + border-radius: var(--radius); padding: 0.1rem 0.35rem; } @@ -3163,3 +3154,68 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { padding-bottom: var(--bottom-chrome); } } + +/* Racer sprites. A race is easier to read as characters moving than as + coloured lines with names attached, and the sprite is the same for a given + player on every client because it comes from the server's player order. */ +.mp-racer-sprite { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.15rem; + height: 1.15rem; + flex-shrink: 0; + color: var(--racer-color, var(--primary-color)); +} + +.mp-racer-sprite svg { + width: 100%; + height: 100%; +} + +.leaderboard-name .mp-racer-sprite { + margin-right: 0.5rem; + vertical-align: middle; +} + +/* Reserved advertising space. See web/src/adSlot.js: this holds the space so + a later ad tag cannot push the page around when it loads. Nothing is + loaded into it today. */ +.ad-slot { + width: var(--ad-w); + height: var(--ad-h); + max-width: 100%; + margin: 1.25rem auto 0 auto; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border: var(--border-width) dashed color-mix(in srgb, var(--neutral-color) 60%, transparent); + border-radius: var(--radius); + box-sizing: border-box; +} + +/* Dashed and unfilled, which is the one place that convention is right: it is + a placeholder, and it should not be mistaken for content. */ +.ad-slot-label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 1px; + color: color-mix(in srgb, var(--sub-color) 70%, transparent); +} + +@media (max-width: 768px) { + .ad-slot { + width: var(--ad-mobile-w); + height: var(--ad-mobile-h); + } +} + + +/* Holds the slot in the end screen's flex column without shrinking. */ +.end-screen-ad { + flex-shrink: 0; + width: 100%; + display: flex; + justify-content: center; +} |