From 695bf0df2ec48e67d716dad25711cdc2e96bf7f4 Mon Sep 17 00:00:00 2001
From: srdusr <99972264+srdusr@users.noreply.github.com>
Date: Tue, 13 Jan 2026 21:14:00 +0200
Subject: Fix the store showing every item as free, and six layout defects
The catalogue served every price as $0.00. The Cosmetic struct typed
price_cents as i64 against an INTEGER column, so sqlx refused the decode and
unwrap_or_default turned the failure into a zero. Nothing was logged. The
whole store read as free while the database held the real prices.
Decode errors are now returned rather than defaulted away, and create_session
refuses any amount at or below zero, so a price that fails to decode cannot
become a session that grants an item without charging.
Layout, all measured in a real browser at 1280, 820 and 390 pixels:
- The close button sat underneath the fixed top rail. The rail reaches into
the content column on any viewport under about 1350px, so the overlap was
there for nearly every visitor. The button starts below the rail now.
- The wordmark and the top rail overlapped by 75px at 390px. Both are fixed
to the top of the viewport and neither knew about the other. Both give
ground on narrow screens, and Sign Up drops out of the rail because Sign In
reaches the same screen.
- Store rows were 93px tall for one line of content, because .menu-button
carries a vertical margin meant for a stacked menu. Twenty six items came
to 2400px of scrolling. Rows are 47px and the page is 2467px rather than
3784px.
- .stats-screen centres its children, so any child without a declared width
shrink-wraps. That left the sign-in box at 415px, the bundle grid at 488px
(which collapsed it to a single column) and the leaderboard table at 498px,
all inside a 636px column. Every screen was swept for the same defect.
.account-panel is the one narrow child that is deliberate: it declares
max-width 360px because a sign-in form should not be 636px wide.
- Leaderboard rows had a hard 461px minimum from fixed column widths, so at
390px the row ran from x=-36 to x=426 and the date column was cut off the
side of the screen. The date is hidden on narrow viewports.
- The profile links in the leaderboard were 21px tall, under the 24x24
minimum target size, and they are the only route to a player's profile.
Sprites never showed as equipped: the store compared the equipped caret and
flair but not the sprite.
The store test bought items by clicking Buy, which used to grant them for
nothing. It now asserts that Buy does not grant, then grants the items the
way a signature-verified webhook would, and goes on to check that an equipped
caret colour reaches the typing screen.
All 21 screen and viewport combinations are clean for overlap, overflow,
clipped content and target size. 3/3 browser tests pass.
---
web/src/screens/storeScreen.js | 17 +++--
web/src/styles.css | 145 ++++++++++++++++++++++++++++++++++--
web/tests/helpers.py | 26 +++++++
web/tests/test_account_and_store.py | 49 ++++++++----
4 files changed, 212 insertions(+), 25 deletions(-)
(limited to 'web')
diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js
index 34b0b2e..7c9a047 100644
--- a/web/src/screens/storeScreen.js
+++ b/web/src/screens/storeScreen.js
@@ -10,6 +10,10 @@ function formatPrice(cents) {
return `$${(cents / 100).toFixed(2)}`;
}
+// Enough to show what kind of thing is in a bundle without making one card
+// far taller than the others beside it.
+const MAX_PREVIEWS = 10;
+
function categoryLabel(category) {
if (category === 'caret') return 'Caret Colours';
if (category === 'sprite') return 'Race Sprites';
@@ -70,12 +74,15 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
const saving = b.full_price_cents - b.price_cents;
// The contents are shown as their own swatches: a bundle you cannot
- // see the inside of is a bundle nobody buys.
- const previews = items
- .map(id => catalog.find(c => c.id === id))
- .filter(Boolean)
+ // see the inside of is a bundle nobody buys. Capped, because The Lot
+ // holds every item and its 26 swatches made the card three rows
+ // taller than the one beside it, which then sat in dead space.
+ const resolved = items.map(id => catalog.find(c => c.id === id)).filter(Boolean);
+ const shown = resolved.slice(0, MAX_PREVIEWS);
+ const hidden = resolved.length - shown.length;
+ const previews = shown
.map(item => `${swatchFor(item)}`)
- .join('');
+ .join('') + (hidden > 0 ? `+${hidden}` : '');
return `
diff --git a/web/src/styles.css b/web/src/styles.css
index b53e99c..376c074 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -376,6 +376,12 @@ body {
margin-bottom: 0.5rem;
}
+/* No width of its own, so it shrink-wrapped to 498px inside the 636px
+ content column and the table sat narrower than everything above it. */
+.leaderboard-results {
+ width: 100%;
+}
+
.leaderboard-list {
width: 100%;
display: flex;
@@ -406,7 +412,13 @@ body {
border: none;
color: inherit;
font: inherit;
- padding: 0;
+ /* Vertical padding rather than none: at 21px tall this was under the
+ 24x24 minimum target size, and it is the only way to reach a player's
+ profile. The negative inline margin keeps the text where it was so the
+ column still lines up with the rows above and below. */
+ padding: 0.25rem 0.35rem;
+ margin-left: -0.35rem;
+ border-radius: var(--radius);
cursor: pointer;
}
@@ -2729,10 +2741,16 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
.store-signin-note {
display: flex;
align-items: center;
- justify-content: center;
+ /* Was shrink-to-fit at 415px inside a 700px panel whose item list is
+ 636px, so the page had three different content widths stacked on top of
+ each other and this box read as misplaced. It matches the list now. */
+ width: 100%;
+ box-sizing: border-box;
+ justify-content: space-between;
+ text-align: left;
gap: 1rem;
flex-wrap: wrap;
- margin-bottom: 1rem;
+ margin-bottom: 1.5rem;
padding: 0.75rem 1rem;
border: 1px solid color-mix(in srgb, var(--primary-color) 35%, transparent);
border-radius: var(--radius-panel);
@@ -2740,9 +2758,47 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
color: var(--sub-color);
}
+/* Store rows. These reuse .leaderboard-row for its border and spacing, but a
+ store row is a swatch, a name, a price and a control, not four columns of
+ text, so the parts that differ are set here.
+
+ The row was 93px tall for one line of content: .menu-button carries a
+ vertical margin meant for a stacked menu, and it applied here too. Twenty
+ six items came to roughly 2400px of scrolling. */
+.store-item-row {
+ padding: 0.45rem 1rem;
+}
+
+.store-item-row .menu-button.small {
+ margin: 0;
+ padding: 0.35rem 0.9rem;
+ font-size: 0.8rem;
+}
+
+/* Fixed width so the prices form a column instead of drifting with the length
+ of each number. */
+.store-price {
+ min-width: 4.5rem;
+ text-align: right;
+ color: var(--sub-color);
+ font-size: 0.85rem;
+}
+
+.store-item-row .leaderboard-name {
+ font-size: 0.9rem;
+}
+
+/* Equip and Buy sit in the same place in every row, so the control column has
+ one width whichever state a row is in. */
+.store-item-row .menu-button {
+ min-width: 5.5rem;
+}
+
/* The supporter offer, above the catalogue. It is the one thing in the store
that is not cosmetic, so it does not sit in a category with the rest. */
.store-supporter {
+ width: 100%;
+ box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
@@ -2777,9 +2833,16 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
font-size: 0.8rem;
}
+.store-supporter .menu-button.small,
+.store-signin-note .menu-button.small {
+ margin: 0;
+}
+
/* Bundles. A card rather than a row: a bundle has to show what is inside it,
which a one-line row has no space for. */
.bundle-grid {
+ width: 100%;
+ box-sizing: border-box;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 0.75rem;
@@ -2834,13 +2897,30 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
opacity: 0.35;
}
+/* How many more the card did not have room to draw. */
+.bundle-preview-more {
+ display: flex;
+ align-items: center;
+ color: var(--sub-color);
+ font-size: 0.75rem;
+}
+
.bundle-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
+ /* Pushed to the bottom so the buy control lines up across a row of cards
+ whose contents are different heights. The Lot holds 26 swatches and its
+ neighbour holds six. */
margin-top: auto;
- padding-top: 0.25rem;
+ padding-top: 0.5rem;
+}
+
+.bundle-foot .menu-button.small {
+ margin: 0;
+ padding: 0.35rem 0.9rem;
+ font-size: 0.8rem;
}
.bundle-prices {
@@ -2872,7 +2952,11 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
wordmark and Escape already did, and the least reachable of the three. */
.screen-close {
position: absolute;
- top: 0;
+ /* The top rail is fixed to the viewport's top-right and is wide enough to
+ reach into the content column on any viewport narrower than about
+ 1350px, which is most of them. At top: 0 the close button sat directly
+ underneath the rail's buttons. It starts below the rail instead. */
+ top: 1.5rem;
right: 0;
width: 2.25rem;
height: 2.25rem;
@@ -3239,6 +3323,57 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
min-height: 180px;
}
+
+/* The wordmark and the top rail are both fixed to the top of the viewport and
+ neither knew about the other. Measured at 390px: the wordmark ran to x=142
+ and the rail started at x=67, so they overlapped by 75px. Both give ground
+ here, and the close button drops below the rail rather than under it. */
+@media (max-width: 700px) {
+ .logo {
+ font-size: 0.95rem;
+ letter-spacing: 1px;
+ }
+
+ .top-rail {
+ gap: 0.35rem;
+ }
+
+ .top-rail-link {
+ font-size: 0.7rem;
+ padding: 0.5rem 0.2rem;
+ }
+
+ /* Sign Up goes. Sign In reaches the same screen, which offers both, so
+ the second link only costs width. */
+ .top-rail-link.accent {
+ display: none;
+ }
+
+ .screen-close {
+ top: 2.75rem;
+ }
+}
+
+
+/* The leaderboard row's columns all have fixed widths, which added up to a
+ hard minimum of 461px. Measured at 390px the row ran from x=-36 to x=426
+ and the date column was cut off the edge of the screen entirely. The date
+ is the least useful of the six, so it goes first and the rest fit. */
+@media (max-width: 700px) {
+ .leaderboard-date {
+ display: none;
+ }
+
+ .leaderboard-row {
+ gap: 0.6rem;
+ padding: 0.6rem 0.75rem;
+ }
+
+ .leaderboard-rank {
+ min-width: 1.75rem;
+ }
+}
+
@media (max-height: 600px) {
.graph-container {
min-height: 150px;
diff --git a/web/tests/helpers.py b/web/tests/helpers.py
index 8aca3f6..285b769 100644
--- a/web/tests/helpers.py
+++ b/web/tests/helpers.py
@@ -1,11 +1,37 @@
"""Shared helpers for the Playwright test suite. See README.md for how to
run these tests and what needs to already be running.
"""
+import os
import random
import string
+import subprocess
FRONTEND_URL = "http://localhost:4173"
+# Buying is a real payment now, so a test cannot get an item by clicking Buy.
+# Ownership is granted straight in the database instead, which is what a paid
+# webhook would have done.
+DATABASE_URL = os.environ.get(
+ "TYPERPUNK_TEST_DATABASE_URL",
+ "postgresql://typerpunk:typerpunk@localhost/typerpunk",
+)
+
+
+def grant_cosmetics(username, cosmetic_ids):
+ """Gives an account the named cosmetics without going through checkout."""
+ ids = ", ".join(f"'{cid}'" for cid in cosmetic_ids)
+ sql = (
+ "INSERT INTO user_cosmetics (user_id, cosmetic_id, acquired_at) "
+ "SELECT u.id, c.id, '2026-01-01T00:00:00Z' "
+ "FROM users u CROSS JOIN cosmetics c "
+ f"WHERE u.username = '{username}' AND c.id IN ({ids}) "
+ "ON CONFLICT (user_id, cosmetic_id) DO NOTHING"
+ )
+ subprocess.run(
+ ["psql", DATABASE_URL, "-v", "ON_ERROR_STOP=1", "-c", sql],
+ check=True, capture_output=True,
+ )
+
def random_username(prefix="test"):
suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
diff --git a/web/tests/test_account_and_store.py b/web/tests/test_account_and_store.py
index f7c219c..9252cdb 100644
--- a/web/tests/test_account_and_store.py
+++ b/web/tests/test_account_and_store.py
@@ -1,9 +1,13 @@
-"""Account registration, and the cosmetics store's buy/equip flow --
-including that an equipped caret color and flair icon actually show up,
-not just that the API calls succeed. See README.md to run.
+"""Account registration, and the cosmetics store's buy and equip flow.
+
+Buying is a real payment now, so clicking Buy leaves for the processor rather
+than granting anything. The test asserts that it does not grant, then gives
+the account its items the way a paid webhook would, and checks that an
+equipped caret colour and flair icon actually show up rather than that the
+API calls returned 200. See README.md to run.
"""
from playwright.sync_api import sync_playwright
-from helpers import FRONTEND_URL, random_username, register_and_login
+from helpers import FRONTEND_URL, grant_cosmetics, random_username, register_and_login
def run():
@@ -20,18 +24,33 @@ def run():
rows = page.locator(".store-item-row")
assert rows.count() > 0, "store catalog did not render"
- def buy_and_equip(item_name):
- row = page.locator(".store-item-row", has=page.locator(".leaderboard-name", has_text=item_name))
- buy_btn = row.locator('[data-action="buy"]')
- if buy_btn.count() > 0:
- buy_btn.click()
- page.wait_for_timeout(500)
- row = page.locator(".store-item-row", has=page.locator(".leaderboard-name", has_text=item_name))
- row.locator('[data-action="equip"]').click(timeout=10000)
- page.wait_for_timeout(500)
+ def row_for(item_name):
+ return page.locator(
+ ".store-item-row",
+ has=page.locator(".leaderboard-name", has_text=item_name),
+ )
- buy_and_equip("Cyan Caret")
- buy_and_equip("Bolt")
+ # Buy must not grant. Before this was a real payment the endpoint
+ # handed the item over for nothing, so anyone signed in could take
+ # the catalogue. Clicking Buy leaves for the processor, or reports
+ # that payments are unconfigured; either way nothing is owned and
+ # no Equip control appears.
+ row_for("Cyan Caret").locator('[data-action="buy"]').click()
+ page.wait_for_timeout(800)
+ assert row_for("Cyan Caret").locator('[data-action="equip"]').count() == 0, \
+ "Buy granted the item without a payment"
+
+ # Granted the way a signature-verified webhook would.
+ grant_cosmetics(username, ["caret-cyan", "flair-bolt"])
+ # A reload drops back to the menu, so the store has to be reopened.
+ page.reload()
+ page.wait_for_timeout(600)
+ page.click('[data-action="store"]')
+ page.wait_for_selector(".store-item-row", timeout=10000)
+
+ for item_name in ("Cyan Caret", "Bolt"):
+ row_for(item_name).locator('[data-action="equip"]').click(timeout=10000)
+ page.wait_for_timeout(500)
equipped_rows = page.locator(".store-item-row", has=page.get_by_text("EQUIPPED"))
assert equipped_rows.count() >= 2, "expected caret and flair to both show as equipped"
--
cgit v1.2.3