srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-12-25 00:24:00 +0200
committersrdusr <[email protected]>2025-12-25 00:24:00 +0200
commitb97beebfd527a12b182a12a556381768a728d9dc (patch)
tree78aa93d1ce8816ff772db8617c1f8e88a78ccb16 /web/src/screens
parent5e2d0bc673b7ac2cebc3bb9f3b9b30cbc19eba7f (diff)
downloadtyperpunk-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/src/screens')
-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
8 files changed, 81 insertions, 22 deletions
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 '';