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. --- 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 ++++++++++------ 8 files changed, 81 insertions(+), 22 deletions(-) (limited to 'web/src/screens') 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 `
- - + +
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
Standings
` : ''} +
@@ -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,
${escapeHtml(r.username)}
- +
`).join('')} ` : ''} @@ -55,7 +55,7 @@ export function renderFriendsScreen(root, { onBack, onShowFriends, onShowStats,
${escapeHtml(r.username)}
Pending
- +
`).join('')} ` : ''} @@ -66,7 +66,7 @@ export function renderFriendsScreen(root, { onBack, onShowFriends, onShowStats, ${data.friends.map(f => `
${escapeHtml(f.username)}
- +
`).join('')} ` : `
No friends yet - add one by username above.
`} 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 = ` `; + +// 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: ``, + // A blocky rocket. + rocket: ``, + // A visored helmet. + helm: ``, + // A hexagonal core. + core: ``, + // A signal mast. + signal: ``, + // A pair of stacked blades. + blade: ``, +}; + +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: ``, + circuit: ``, + eye: ``, + shard: ``, + moth: ``, + reactor: ``, +}); 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) {
Dev
- + ` : ''}
- +
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

Multiplayer

- +
${escapeHtml(landingError || '')}
@@ -168,7 +180,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
- + `; 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 => `
-
${escapeHtml(p.name)}${p.id === myId ? ' (you)' : ''}
+
${RACER_SPRITES[sprites[p.id]]}${escapeHtml(p.name)}${p.id === myId ? ' (you)' : ''}
${p.ready ? 'Ready' : 'Not ready'}
`).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 `
+ ${RACER_SPRITES[sprites[p.id]]} ${escapeHtml(p.name)}${me ? ' (you)' : ''}
${Math.round(prog.wpm || 0)} 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' ? `` - : `${FLAIR_ICONS[item.value] || ''}`; + : `${(item.category === 'sprite' ? RACER_SPRITES : FLAIR_ICONS)[item.value] || ''}`; return `
${swatch}
${escapeHtml(item.name)}
${!owned ? `
${formatPrice(item.price_cents)}
` : ''} ${owned - ? `` - : ``} + ? `` + : ``}
`; } @@ -54,7 +58,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder ? `` : ''; - 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 ''; -- cgit v1.2.3