srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/server/migrations/0012_store_catalogue.sql40
-rw-r--r--crates/server/migrations/0013_supporter.sql5
-rw-r--r--crates/server/src/auth.rs17
-rw-r--r--crates/server/src/cosmetics.rs8
-rw-r--r--web/src/adSlot.js60
-rw-r--r--web/src/screens/accountScreen.js4
-rw-r--r--web/src/screens/endScreen.js6
-rw-r--r--web/src/screens/friendsScreen.js6
-rw-r--r--web/src/screens/icons.js35
-rw-r--r--web/src/screens/leaderboardScreen.js3
-rw-r--r--web/src/screens/mainMenu.js4
-rw-r--r--web/src/screens/multiplayerScreen.js29
-rw-r--r--web/src/screens/storeScreen.js16
-rw-r--r--web/src/styles.css256
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 &rarr; 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;
+}