diff options
| author | srdusr <[email protected]> | 2025-12-25 00:24:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-12-25 00:24:00 +0200 |
| commit | b97beebfd527a12b182a12a556381768a728d9dc (patch) | |
| tree | 78aa93d1ce8816ff772db8617c1f8e88a78ccb16 /web | |
| parent | 5e2d0bc673b7ac2cebc3bb9f3b9b30cbc19eba7f (diff) | |
| download | typerpunk-b97beebfd527a12b182a12a556381768a728d9dc.tar.gz typerpunk-b97beebfd527a12b182a12a556381768a728d9dc.zip | |
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.
Diffstat (limited to 'web')
| -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 |
10 files changed, 297 insertions, 122 deletions
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; +} |