From b97beebfd527a12b182a12a556381768a728d9dc Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Thu, 25 Dec 2025 00:24:00 +0200 Subject: Unify the control styling, add racer sprites, fill the store, reserve ad space Design tokens - The stylesheet had five corner radii in use with no rule for which applied where, so a button was square while the input beside it was rounded and the icon button next to that was something else again. Two tokens now: --radius for controls, --radius-panel for the surfaces they sit on. - One border weight. Buttons were 2px and inputs 1px, which made a field and its own button read as different weights of the same idea. The two start buttons keep 2px, where it is doing work. - Letter-spacing was 1px, 2px, 3px and 4px with no rule behind it. Now 1px for small caps labels and 2px for button text. - Removed the .ghost modifier. After the hierarchy pass it rendered identically to .quiet, so the two were one control under two names. Its uses were toggles, which .quiet plus .active already expresses. Racer sprites - Six sprites in the same angular language as the rest of the icon set, one per racer in the lobby and the race. A race reads as characters moving rather than as coloured lines with names attached. Assigned from the server's player order, so every client draws the same person as the same character without needing to agree on anything. Store - Six items to twenty-six, across three slots. Race sprites join carets and flair as a third slot, with the equipped sprite stored per user. Nothing is required to race: an unequipped player keeps the sprite their position in the room assigns. Advertising space - Reserved, empty, and loading nothing. The slot exists so that adding a network later cannot push the page around when the tag loads. Shown to signed-out visitors and to accounts without the supporter flag, on the end screen only: interrupting somebody mid-test is the one placement that would cost more than it earns. - No ad code is included, and none can be added without three deliberate changes recorded in adSlot.js: the CSP forbids third-party script, frame and image sources; the privacy page states there is no advertising and no third-party script; and an ad network brings consent requirements that this app has no mechanism for. Verified after this change that the only external request the page makes is still fonts.googleapis.com. --- crates/server/migrations/0012_store_catalogue.sql | 40 ++++ crates/server/migrations/0013_supporter.sql | 5 + crates/server/src/auth.rs | 17 +- crates/server/src/cosmetics.rs | 8 +- web/src/adSlot.js | 60 +++++ web/src/screens/accountScreen.js | 4 +- web/src/screens/endScreen.js | 6 + web/src/screens/friendsScreen.js | 6 +- web/src/screens/icons.js | 35 +++ web/src/screens/leaderboardScreen.js | 3 +- web/src/screens/mainMenu.js | 4 +- web/src/screens/multiplayerScreen.js | 29 ++- web/src/screens/storeScreen.js | 16 +- web/src/styles.css | 256 +++++++++++++--------- 14 files changed, 361 insertions(+), 128 deletions(-) create mode 100644 crates/server/migrations/0012_store_catalogue.sql create mode 100644 crates/server/migrations/0013_supporter.sql create mode 100644 web/src/adSlot.js 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>, jar: CookieJar) -> Result>, jar: CookieJar) -> Result { - 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, equipped_caret: Option, equipped_flair: Option, + equipped_sprite: Option, } async fn my_cosmetics(State(state): State>, jar: CookieJar) -> Result { @@ -62,7 +63,7 @@ async fn my_cosmetics(State(state): State>, jar: CookieJar) -> Res .await?; let owned: Vec = 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>, jar: CookieJar) -> Res // into Option correctly does. Found via a real NULL column // round-tripping as "" instead of JSON null. equipped_caret: equip_row.try_get::, _>("equipped_caret").unwrap_or(None), + equipped_sprite: equip_row.try_get::, _>("equipped_sprite").unwrap_or(None), equipped_flair: equip_row.try_get::, _>("equipped_flair").unwrap_or(None), })) } @@ -126,6 +128,7 @@ async fn equip(State(state): State>, 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>, 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 = `${label}`; + + 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 `