srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/styles.css
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-02-28 01:17:00 +0200
committersrdusr <[email protected]>2026-02-28 01:17:00 +0200
commit5af72f55bb7fb620af740c9da394e093d7f1280a (patch)
tree81fb6ae1830886a25b0f81912a1a12636493ba51 /web/src/styles.css
parentd7c1c7b35e9d0995eed41d437404d5255529cf17 (diff)
downloadtyperpunk-5af72f55bb7fb620af740c9da394e093d7f1280a.tar.gz
typerpunk-5af72f55bb7fb620af740c9da394e093d7f1280a.zip
Sell merchandise, put the sprites on the track, and add the page margins
Merchandise. Shirts, mugs and deskmats, sold through the same hosted checkout. Physical goods differ from cosmetics in three ways the schema had to carry: they have a size to choose, they need an address, and payment unlocks nothing. What a paid order produces is an obligation to pack and post something. The address is collected by Stripe on its own page and arrives here on the webhook, so no postal detail is ever entered on this site. Postage is a separate line item rather than folded into the price. A size is checked against the sizes the item actually comes in, on the server as well as in the browser, so a request naming anything else is refused rather than quietly posted as a medium. /api/admin/orders lists what has been paid for and not yet sent, and the Contribute screen shows an administrator the same list with the address and a button to mark each one posted. Without that the list of what is owed lives only in the processor's dashboard. Shipping is limited to 21 countries, which is a list of places somebody is willing to post to, not a technical limit. The race view. The sprite sat still at the left of a 6px bar, which made the one thing a player owns and can see the least visible part of the race. It rides the track now, moving with progress, on a dashed line that reads as road ahead with the trail behind it. The field is 1040px wide rather than 680, the sprites are 30px rather than 18, and each row carries its own percentage as well as its speed. Advertising. Two 160x600 rails in the page margins, shown only above 1424px, which is the width at which they fit beside the widest content column without crowding it. Below that they do not exist. They follow the same rules as the banner: never while typing, never for a supporter. --border-color was used in five places and defined in none. An undefined custom property invalidates the whole declaration at computed-value time, so every one of those borders fell back to currentColor: bundle cards, merch cards, the size buttons and the settings checkbox all had borders that were either invisible or faint text-coloured lines. It is defined now, derived from each theme's neutral so it tracks the palette. .settings-hint caps itself at 340px, which is right under a heading and wrong for a line introducing a full-width grid. Verified against a real database: the catalogue endpoint, a shirt refused without a size, a shirt refused with a size it does not come in, a valid size and a no-variant mug both reaching checkout, 404 on an unknown item, 401 unauthenticated, and 401 on the admin orders list without the role. 24 Rust tests, 3 browser tests, and all 21 screen and viewport combinations clean.
Diffstat (limited to 'web/src/styles.css')
-rw-r--r--web/src/styles.css243
1 files changed, 225 insertions, 18 deletions
diff --git a/web/src/styles.css b/web/src/styles.css
index f51bb65..ee132d9 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -8,6 +8,9 @@
--neutral-color: #646669;
--caret-color: #00ff9d;
--sub-color: #8b8e92;
+ /* Panel and control outlines. Derived from the neutral so it tracks each
+ theme, rather than being a sixth colour to keep in step by hand. */
+ --border-color: color-mix(in srgb, var(--neutral-color) 55%, transparent);
/* Unified top offset below fixed header/toggle */
/* Reserved height for the top advertising banner, and the amount the
header moves down when one is shown. --ad-banner-h is set on the root
@@ -1945,21 +1948,23 @@ body {
left out the one bar you most need to see: your own. */
.mp-racers {
position: fixed;
- top: 58%;
+ top: 56%;
left: 50%;
transform: translateX(-50%);
z-index: 90;
display: flex;
flex-direction: column;
- gap: 0.5rem;
- width: min(680px, 80vw);
+ gap: 0.35rem;
+ /* Wider than it was. The field is the thing you look up at between
+ glances at the passage, so it gets the room. */
+ width: min(1040px, 94vw);
}
.mp-racer-row {
display: flex;
align-items: center;
- gap: 0.75rem;
- font-size: 0.8rem;
+ gap: 0.85rem;
+ font-size: 0.9rem;
color: var(--racer-color, var(--sub-color));
}
@@ -1970,32 +1975,61 @@ body {
}
.mp-racer-name {
- min-width: 7rem;
- max-width: 7rem;
+ min-width: 9rem;
+ max-width: 9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
-.mp-racer-bar {
+/* The track the sprite travels along. Tall enough to hold the sprite, with
+ the trail drawn behind it. */
+.mp-racer-track {
+ position: relative;
flex: 1 1 auto;
- height: 0.4rem;
- background: color-mix(in srgb, var(--neutral-color) 45%, transparent);
- border-radius: var(--radius-pill);
- overflow: hidden;
+ height: 2rem;
+ border-bottom: var(--border-width) dashed color-mix(in srgb, var(--neutral-color) 45%, transparent);
}
-.mp-racer-bar-fill {
- height: 100%;
+/* How far this racer has come. Sits at the foot of the track, on the dashed
+ line, so the line reads as road ahead and the trail as road behind. */
+.mp-racer-trail {
+ position: absolute;
+ left: 0;
+ bottom: -1px;
+ height: 2px;
background: var(--racer-color, var(--primary-color));
transition: width 0.15s ease;
}
+/* The sprite rides the track. The position is the fraction of the way along,
+ scaled by the room left after the sprite's own width, so at 100 percent the
+ sprite sits at the finish rather than overhanging it. */
+.mp-racer-sprite {
+ position: absolute;
+ bottom: 0.15rem;
+ left: calc(var(--p, 0) * (100% - 1.9rem));
+ width: 1.9rem;
+ height: 1.9rem;
+ transition: left 0.15s ease;
+}
+
+.mp-racer-row.me .mp-racer-sprite {
+ filter: drop-shadow(0 0 4px var(--racer-color, var(--primary-color)));
+}
+
+.mp-racer-percent,
.mp-racer-wpm {
- min-width: 2.5rem;
- text-align: left;
+ min-width: 3rem;
+ text-align: right;
font-family: 'JetBrains Mono', monospace;
+ font-variant-numeric: tabular-nums;
+}
+
+.mp-racer-percent {
+ color: var(--sub-color);
+ font-size: 0.8rem;
}
/* The one genuinely time-critical state in the app: seconds left to get your
@@ -3050,6 +3084,138 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
min-width: 5.5rem;
}
+/* A postal address, shown to an administrator so an order can be packed. */
+.order-address {
+ margin: 0.4rem 0;
+ padding: 0.5rem 0.75rem;
+ border-left: 2px solid var(--border-color);
+ color: var(--text-color);
+ font-size: 0.8rem;
+ line-height: 1.5;
+ white-space: normal;
+}
+
+/* Physical goods. A card, like a bundle, because each one has a description,
+ possibly a size to pick, and a postage line. */
+.merch-note {
+ text-align: left;
+ width: 100%;
+ max-width: none;
+ margin-bottom: 0.75rem;
+}
+
+.merch-kind {
+ width: 100%;
+ text-align: left;
+ color: var(--sub-color);
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin: 0.75rem 0 0.4rem 0;
+}
+
+.merch-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+ gap: 0.75rem;
+ width: 100%;
+ margin-bottom: 0.75rem;
+ box-sizing: border-box;
+}
+
+.merch-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0.9rem 1rem;
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--radius-panel);
+ text-align: left;
+}
+
+.merch-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.merch-name {
+ color: var(--text-color);
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.merch-price {
+ color: var(--text-color);
+ font-size: 1rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.merch-description {
+ color: var(--sub-color);
+ font-size: 0.8rem;
+ line-height: 1.45;
+}
+
+.merch-variants {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+.merch-variant {
+ padding: 0.3rem 0.6rem;
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--radius);
+ background: none;
+ color: var(--sub-color);
+ font-family: inherit;
+ font-size: 0.75rem;
+ cursor: pointer;
+}
+
+.merch-variant:hover {
+ border-color: var(--primary-color);
+ color: var(--text-color);
+}
+
+.merch-variant.active {
+ border-color: var(--primary-color);
+ background: var(--primary-color);
+ color: var(--background-color);
+}
+
+.merch-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-top: auto;
+ padding-top: 0.25rem;
+}
+
+.merch-foot .menu-button.small {
+ margin: 0;
+ padding: 0.35rem 0.9rem;
+ font-size: 0.8rem;
+}
+
+.merch-shipping {
+ color: var(--sub-color);
+ font-size: 0.72rem;
+}
+
+.merch-error:empty {
+ display: none;
+}
+
+.merch-error {
+ color: var(--error-color, var(--incorrect-color));
+ font-size: 0.75rem;
+}
+
/* 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 {
@@ -3673,12 +3839,18 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
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));
}
+/* Beside a name in a list, rather than on a track. */
+.leaderboard-name .mp-racer-sprite,
+.mp-standings-name .mp-racer-sprite {
+ position: static;
+ width: 1.15rem;
+ height: 1.15rem;
+}
+
.mp-racer-sprite svg {
width: 100%;
height: 100%;
@@ -3754,6 +3926,41 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
box-sizing: border-box;
}
+/* The page margins. Hidden by default and shown only on a window wide enough
+ to hold them beside the content without crowding it: the widest content
+ column is 1040px, and two 160px rails with gaps need 1424px before they fit
+ without pushing anything. Below that they simply do not exist. */
+.ad-rail {
+ display: none;
+}
+
+.ad-rail[hidden] {
+ display: none;
+}
+
+@media (min-width: 1424px) {
+ .ad-rail:not([hidden]) {
+ display: flex;
+ position: fixed;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 80;
+ align-items: center;
+ }
+
+ .ad-rail-left:not([hidden]) {
+ left: 1.5rem;
+ }
+
+ .ad-rail-right:not([hidden]) {
+ right: 1.5rem;
+ }
+
+ .ad-rail .ad-slot {
+ margin: 0;
+ }
+}
+
/* 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 {