srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-12-05 22:56:00 +0200
committersrdusr <[email protected]>2025-12-05 22:56:00 +0200
commit076f29369e51dfb5798f70099a330ebeb1b66a4e (patch)
tree202d355c47f1b698f8c96d688b2e268c0d53281d /web/src/screens
parentf48fa830b10d4122d145e9822522d9b71e3f00fa (diff)
downloadtyperpunk-076f29369e51dfb5798f70099a330ebeb1b66a4e.tar.gz
typerpunk-076f29369e51dfb5798f70099a330ebeb1b66a4e.zip
Show the passage during the countdown, and fix navigation across screens
Countdown - Five seconds instead of three, and shown over the passage on the race screen rather than in an empty lobby. New ServerMessage::RaceText carries the text (and its attribution) just before the countdown starts, so every client can put it on screen and let players read the opening words while the numbers run - what TypeRacer and 10FastFingers both do. Typing is locked until Start lifts the gate. - Two subscriptions had to move with it. Building the race view tears the lobby down, and the lobby owned the only handlers for Countdown and Start, so the count stopped arriving exactly when it was needed and the gate would never have lifted. - The TUI and Steam clients match ServerMessage exhaustively, so both gained an arm for the new variant; whole workspace checks clean. Multiplayer attribution - Race passages now carry who wrote them, so a race shows its source the way single player always has. Previously the server sent bare strings and both the race and its end screen showed nothing. Navigation - Every screen's full-width "Back" button becomes a close control at the top of the content. The wordmark and Escape already went back, so it was a third way to do the same thing and the least reachable of them. - The multiplayer screen had a Back button on top of that; gone. Fixes - The end screen graph is drawn into a canvas, so it cannot inherit a theme change the way the rest of the page does. It now redraws on one - switching theme used to leave the chart in the old palette. - .stats-empty was align-self:flex-start inside a centred column, so "Sign in to add friends" sat on a different axis from its own Sign In button. - The store showed a sign-in prompt instead of its catalogue, so nobody could see what signing in would get them. The catalogue now renders signed out, with prices, behind a sign-in note. - The room code field is sized for the five characters a code actually is, and Join carries the same weight as the field it belongs to.
Diffstat (limited to 'web/src/screens')
-rw-r--r--web/src/screens/accountScreen.js3
-rw-r--r--web/src/screens/endScreen.js6
-rw-r--r--web/src/screens/friendsScreen.js3
-rw-r--r--web/src/screens/icons.js7
-rw-r--r--web/src/screens/leaderboardScreen.js6
-rw-r--r--web/src/screens/lyricsScreen.js3
-rw-r--r--web/src/screens/multiplayerScreen.js67
-rw-r--r--web/src/screens/placeholderScreen.js5
-rw-r--r--web/src/screens/publicProfileScreen.js6
-rw-r--r--web/src/screens/statsScreen.js5
-rw-r--r--web/src/screens/storeScreen.js26
11 files changed, 104 insertions, 33 deletions
diff --git a/web/src/screens/accountScreen.js b/web/src/screens/accountScreen.js
index 30161d9..5ae2bd0 100644
--- a/web/src/screens/accountScreen.js
+++ b/web/src/screens/accountScreen.js
@@ -1,3 +1,4 @@
+import { CLOSE_ICON } from './icons.js';
import { escapeHtml } from '../util.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
@@ -59,10 +60,10 @@ export function renderAccountScreen(root, { onBack, onShowAccount, onShowStats,
const user = getUser();
root.innerHTML = `
<div class="stats-screen">
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
<div class="logo" data-action="menu">TyperPunk</div>
<h2>Account</h2>
<div class="account-panel">${formMarkup(user)}</div>
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js
index aa4e29b..2b859bb 100644
--- a/web/src/screens/endScreen.js
+++ b/web/src/screens/endScreen.js
@@ -1,6 +1,7 @@
import { buildGraphPoints, buildErrorPoints, drawChart, hitTestError, hitTestLine, calculateConsistency } from '../chart.js';
import { escapeHtml } from '../util.js';
import { attachTooltips } from '../tooltip.js';
+import { onThemeChange } from '../theme.js';
import { recordResult } from '../pb.js';
import { recordTest } from '../profileStats.js';
import { renderCornerRail } from '../cornerRail.js';
@@ -137,6 +138,10 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s
const redraw = () => drawChart(canvas, { graphPoints, errorPoints, xMax });
redraw();
window.addEventListener('resize', redraw);
+ // The chart is drawn into a canvas, so it cannot inherit a theme change
+ // the way the rest of the page does - it has to be redrawn. Without this
+ // switching theme on the end screen left the graph in the old palette.
+ const offTheme = onThemeChange(redraw);
// A long passage wraps to more lines than a short one, growing
// .end-screen-text's height by an amount no fixed CSS margin/graph-height
@@ -243,6 +248,7 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s
onLeaveRace?.();
cleanupTheme();
window.removeEventListener('resize', redraw);
+ offTheme();
canvas.removeEventListener('mousemove', handleMove);
canvas.removeEventListener('mouseleave', handleLeave);
document.removeEventListener('keydown', handleKeydown);
diff --git a/web/src/screens/friendsScreen.js b/web/src/screens/friendsScreen.js
index 89317ad..14a0b2d 100644
--- a/web/src/screens/friendsScreen.js
+++ b/web/src/screens/friendsScreen.js
@@ -1,3 +1,4 @@
+import { CLOSE_ICON } from './icons.js';
import { escapeHtml } from '../util.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
@@ -78,10 +79,10 @@ export function renderFriendsScreen(root, { onBack, onShowFriends, onShowStats,
const user = getUser();
root.innerHTML = `
<div class="stats-screen">
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
<div class="logo" data-action="menu">TyperPunk</div>
<h2>Friends</h2>
${user ? signedInMarkup() : signedOutMarkup()}
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
diff --git a/web/src/screens/icons.js b/web/src/screens/icons.js
index c088c0d..21c0548 100644
--- a/web/src/screens/icons.js
+++ b/web/src/screens/icons.js
@@ -107,3 +107,10 @@ export const LANGUAGE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0
<path d="M13 21L17 11L21 21"/>
<path d="M14.4 18H19.6"/>
</svg>`;
+
+// Close. A plain X drawn as two strokes rather than a "×" glyph, so it
+// matches the weight and corner style of the rest of this set at any size.
+export const CLOSE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="square">
+ <path d="M5 5L19 19"/>
+ <path d="M19 5L5 19"/>
+</svg>`;
diff --git a/web/src/screens/leaderboardScreen.js b/web/src/screens/leaderboardScreen.js
index 2777b1c..2288400 100644
--- a/web/src/screens/leaderboardScreen.js
+++ b/web/src/screens/leaderboardScreen.js
@@ -3,7 +3,7 @@ import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
import { renderTopRail } from '../topRail.js';
import { api, ApiError } from '../api.js';
-import { FLAIR_ICONS } from './icons.js';
+import { FLAIR_ICONS, CLOSE_ICON } from './icons.js';
const WORD_COUNTS = [10, 25, 50, 100];
const TIME_DURATIONS = [15, 30, 60, 120];
@@ -47,14 +47,14 @@ export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onS
root.innerHTML = `
<div class="stats-screen">
- <div class="logo" data-action="menu">TyperPunk</div>
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
+ <div class="logo" data-action="menu">TyperPunk</div>
<h2>Leaderboard</h2>
<div class="leaderboard-modes">
${MODES.map(m => `<button class="menu-button small${m.key === selectedMode ? '' : ' ghost'}" data-mode="${m.key}">${escapeHtml(m.label)}</button>`).join('')}
</div>
<button class="menu-button small ghost" data-action="toggle-device-filter" data-tooltip="Mobile results still count for personal stats - this only filters what's shown here.">Devices: All</button>
<div class="leaderboard-results"></div>
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
diff --git a/web/src/screens/lyricsScreen.js b/web/src/screens/lyricsScreen.js
index ac8b4f4..95af6b2 100644
--- a/web/src/screens/lyricsScreen.js
+++ b/web/src/screens/lyricsScreen.js
@@ -1,3 +1,4 @@
+import { CLOSE_ICON } from './icons.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
import { renderTopRail } from '../topRail.js';
@@ -30,12 +31,12 @@ export function renderLyricsScreen(root, { onBack, onLyricsReady, onShowStats, o
function render() {
root.innerHTML = `
<div class="stats-screen">
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
<div class="logo" data-action="menu">TyperPunk</div>
<h2>Lyrics</h2>
<div class="stats-placeholder">${escapeHtml(bodyText())}</div>
${status === 'signed-out' ? `<button class="menu-button" data-action="go-account">Sign In</button>` : ''}
${status === 'not-connected' ? `<a class="menu-button" href="${apiUrl('/api/spotify/login')}">Connect Spotify</a>` : ''}
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', () => { stopped = true; onBack(); }));
diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js
index a89bfd4..59c4b3f 100644
--- a/web/src/screens/multiplayerScreen.js
+++ b/web/src/screens/multiplayerScreen.js
@@ -64,15 +64,16 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
<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" 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="settings-hint">or race specific people</div>
+
+ <div class="mp-divider"><span>or race friends</span></div>
+
<div class="mp-code-row">
- <input class="account-input" type="text" id="mp-room-code" placeholder="Room code">
- <button class="menu-button small ghost" data-action="join">Join</button>
+ <input class="account-input" type="text" id="mp-room-code" placeholder="Enter a room code" maxlength="5" autocomplete="off" spellcheck="false">
+ <button class="menu-button" data-action="join">Join</button>
</div>
- <button class="menu-button small 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 ghost" 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>
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
@@ -136,6 +137,11 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
renderLobby(roomCode, auto);
}
+ // Set by startRace so the lobby's countdown handler can hand off to the
+ // race view once the passage is on screen.
+ let showCountdown = null;
+ let releaseRace = null;
+
function renderLobby(roomCode, auto = false) {
root.innerHTML = `
<div class="stats-screen">
@@ -184,14 +190,19 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
const offPlayerList = connection.on('playerList', list => { players = list; paintPlayers(); });
const countdownEl = root.querySelector('.mp-countdown');
const offCountdown = connection.on('countdown', s => {
- // Takes over the lobby while it runs: it is the only thing that
- // matters in those three seconds, and it used to be a small number
- // tucked underneath a large disabled button.
+ // Once the race view exists the count belongs over the passage, so
+ // players read the opening words while it runs. Before that (a
+ // slow RaceText) it still shows in the lobby rather than nowhere.
+ if (showCountdown) { showCountdown(s); return; }
countdownEl.hidden = false;
countdownEl.textContent = s;
readyBtn.hidden = true;
});
- const offStart = connection.on('start', text => startRace(text));
+ // The passage arrives before the countdown so it can be read while the
+ // numbers run. The race view is built here, with typing locked; Start
+ // only unlocks it.
+ const offRaceText = connection.on('raceText', (text, attribution) => startRace(text, attribution));
+ const offStart = connection.on('start', () => releaseRace?.());
// The server closes the connection right after an Error (e.g. a
// desktop-only room rejecting a mobile joiner) - 'close' always
// follows, so it's what actually routes back to the landing screen;
@@ -210,12 +221,12 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
root.querySelector('[data-action="leave"]').addEventListener('click', () => { leaveRoom(); renderLanding(); });
cleanupInner = () => {
- offJoined(); offPlayerList(); offCountdown(); offStart(); offError(); offClose();
+ offJoined(); offPlayerList(); offCountdown(); offRaceText(); offStart(); offError(); offClose();
cleanupTheme(); cleanupRail();
};
}
- async function startRace(text) {
+ async function startRace(text, attribution) {
teardownInner();
try {
game = await createGame();
@@ -293,7 +304,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
});
const cleanupTyping = renderTypingGame(root, {
- game, text, modeKey: undefined,
+ game, text, attribution, modeKey: undefined,
multiplayer: { connection },
onFinish: result => {
freeGame(game);
@@ -323,8 +334,40 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
});
root.appendChild(opponents);
+ // The passage is on screen but locked until Start. The count sits over
+ // it, so the seconds are spent reading the opening words rather than
+ // staring at an empty lobby.
+ const input = root.querySelector('.typing-input');
+ if (input) input.disabled = true;
+ const gate = document.createElement('div');
+ gate.className = 'mp-race-gate';
+ gate.innerHTML = '<div class="mp-race-gate-count"></div><div class="mp-race-gate-label">Get ready</div>';
+ root.appendChild(gate);
+ const gateCount = gate.querySelector('.mp-race-gate-count');
+ // Its own subscription: building this view tears the lobby down, and
+ // the lobby owned the only countdown handler - so the count stopped
+ // arriving exactly when it was needed on screen.
+ const offGateCountdown = connection.on('countdown', seconds => { gateCount.textContent = seconds; });
+ // Start has the same problem the countdown did: the lobby's handler is
+ // gone by the time this view exists, so the gate would never lift.
+ const offGateStart = connection.on('start', () => releaseRace?.());
+ showCountdown = seconds => { gateCount.textContent = seconds; };
+ releaseRace = () => {
+ offGateCountdown();
+ offGateStart();
+ gate.remove();
+ if (input) { input.disabled = false; input.focus(); }
+ showCountdown = null;
+ releaseRace = null;
+ };
+
cleanupInner = () => {
if (!handedOff) { offProgress(); offFinished(); }
+ showCountdown = null;
+ releaseRace = null;
+ offGateCountdown();
+ offGateStart();
+ gate.remove();
cleanupTyping();
opponents.remove();
};
diff --git a/web/src/screens/placeholderScreen.js b/web/src/screens/placeholderScreen.js
index 5d652ce..4375b8d 100644
--- a/web/src/screens/placeholderScreen.js
+++ b/web/src/screens/placeholderScreen.js
@@ -1,3 +1,4 @@
+import { CLOSE_ICON } from './icons.js';
import { escapeHtml } from '../util.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
@@ -11,10 +12,10 @@ import { renderTopRail } from '../topRail.js';
export function renderPlaceholderScreen(root, { title, description, onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) {
root.innerHTML = `
<div class="stats-screen">
- <div class="logo" data-action="menu">TyperPunk</div>
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
+ <div class="logo" data-action="menu">TyperPunk</div>
<h2>${escapeHtml(title)}</h2>
<div class="stats-placeholder">${escapeHtml(description)}</div>
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
diff --git a/web/src/screens/publicProfileScreen.js b/web/src/screens/publicProfileScreen.js
index 5ce50ed..1479df6 100644
--- a/web/src/screens/publicProfileScreen.js
+++ b/web/src/screens/publicProfileScreen.js
@@ -3,7 +3,7 @@ import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
import { renderTopRail } from '../topRail.js';
import { api, ApiError } from '../api.js';
-import { FLAIR_ICONS } from './icons.js';
+import { FLAIR_ICONS, CLOSE_ICON } from './icons.js';
// modeKey format is set in app.js: "words-25", "time-30", "quote-<category>".
function modeLabel(modeKey) {
@@ -52,10 +52,10 @@ export function renderPublicProfileScreen(root, { username, onBack, onShowStats,
root.innerHTML = `
<div class="stats-screen">
- <div class="logo" data-action="menu">TyperPunk</div>
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
+ <div class="logo" data-action="menu">TyperPunk</div>
<h2 class="public-profile-heading">${escapeHtml(username)}</h2>
<div class="public-profile-body"></div>
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
diff --git a/web/src/screens/statsScreen.js b/web/src/screens/statsScreen.js
index a38e193..a76cbca 100644
--- a/web/src/screens/statsScreen.js
+++ b/web/src/screens/statsScreen.js
@@ -1,3 +1,4 @@
+import { CLOSE_ICON } from './icons.js';
import { getProfileStats } from '../profileStats.js';
import { getAllPersonalBests } from '../pb.js';
import { escapeHtml } from '../util.js';
@@ -26,7 +27,8 @@ export function renderStatsScreen(root, { onBack, onShowPlaceholder, onShowAccou
root.innerHTML = `
<div class="stats-screen">
- <div class="logo" data-action="menu">TyperPunk</div>
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
+ <div class="logo" data-action="menu">TyperPunk</div>
<h2>Stats</h2>
${profile.testsCompleted > 0 ? `
@@ -58,7 +60,6 @@ export function renderStatsScreen(root, { onBack, onShowPlaceholder, onShowAccou
? `<div class="stats-chart"><canvas></canvas></div>`
: `<div class="stats-placeholder">Complete a few more tests to see a progress chart.</div>`}
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js
index 1ab2f84..5a533d9 100644
--- a/web/src/screens/storeScreen.js
+++ b/web/src/screens/storeScreen.js
@@ -4,7 +4,7 @@ import { attachTooltips } from '../tooltip.js';
import { renderTopRail } from '../topRail.js';
import { api, ApiError } from '../api.js';
import { getUser } from '../auth.js';
-import { FLAIR_ICONS } from './icons.js';
+import { FLAIR_ICONS, CLOSE_ICON } from './icons.js';
function formatPrice(cents) {
return `$${(cents / 100).toFixed(2)}`;
@@ -27,8 +27,9 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
let stopped = false;
function itemMarkup(item) {
- const owned = mine.owned.includes(item.id);
- const equipped = mine.equipped_caret === item.id || mine.equipped_flair === item.id;
+ 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);
const swatch = item.category === 'caret'
? `<span class="store-swatch" style="background:${escapeHtml(item.value)}"></span>`
: `<span class="store-swatch store-flair-swatch">${FLAIR_ICONS[item.value] || ''}</span>`;
@@ -39,18 +40,22 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
${!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" data-action="buy" data-id="${item.id}">Buy</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>`}
</div>
`;
}
function bodyMarkup() {
- if (status === 'signed-out') return `<div class="stats-empty">Sign in to buy and equip cosmetics.</div><button class="menu-button" data-action="go-account">Sign In</button>`;
if (status === 'loading') return `<div class="stats-empty">Loading...</div>`;
if (status === 'error') return `<div class="stats-empty">${escapeHtml(message)}</div>`;
+ const signedOut = status === 'signed-out';
+ const banner = signedOut
+ ? `<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'];
- return categories.map(cat => {
+ return banner + categories.map(cat => {
const items = catalog.filter(i => i.category === cat);
if (items.length === 0) return '';
return `<h3>${categoryLabel(cat)}</h3><div class="leaderboard-list">${items.map(itemMarkup).join('')}</div>`;
@@ -60,10 +65,10 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
function render() {
root.innerHTML = `
<div class="stats-screen">
+ <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
<div class="logo" data-action="menu">TyperPunk</div>
<h2>Store</h2>
${bodyMarkup()}
- <button class="menu-button small ghost" data-action="menu">Back</button>
</div>
`;
root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
@@ -121,7 +126,12 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
}
async function loadMine() {
- if (!getUser()) { status = 'signed-out'; return; }
+ if (!getUser()) {
+ // The catalogue still renders - only ownership needs an account.
+ status = 'signed-out';
+ mine = { owned: [], equipped_caret: null, equipped_flair: null };
+ return;
+ }
try {
mine = await api.get('/api/cosmetics/me');
status = 'ok';