ReviewOS

also looking at this

stacks/lastsoulultra

LSU's own coaching surface, and what auditing the site turned up

#12
Merged glennmichael123 wants to merge claude/last-soul-ultra-web-6e0718 into main
86 files +3943 -1190
README.mdmodified+35-3
Changes to README.md
@@ -1,6 +1,6 @@
11# Last Soul Ultra on Stacks
22
3The existing LSU checkout is upgraded to Stacks 0.75.281 and recreates the public production site captured on 23 October 2026. Home, format, World Series, Soul Races, sponsorship, contact and FAQ, profiles, entry requests, results and legal pages use STX, shared navigation and local brand assets. `/coaching` embeds HQ's branded subscription and training portal.
3The existing LSU checkout is upgraded to Stacks 0.75.281 and recreates the public production site captured on 23 October 2026. Home, format, World Series, Soul Races, sponsorship, contact and FAQ, profiles, entry requests, results and legal pages use STX, shared navigation and local brand assets. `/coaching` carries this site's own coaching surface over HQ's branded subscription and training portal.
44
55## Develop and verify
66
@@ -14,7 +14,9 @@ bun scripts/build-site-assets.ts
1414./buddy test
1515```
1616
17`pantry.json` pins Bun 1.4.4; `./buddy --version` should match. `bun run build` builds local styles before the framework build. `bun scripts/seo.ts` regenerates the sitemap and robots file; `SITE_ORIGIN` overrides the production canonical origin for deployment.
17`bun run audit:html` reads `dist/` after a build and reports dead internal links, duplicate ids, skipped heading levels, images with no alt and links with no accessible name. The same rules run over the rendered views in `bun test`, so a build is only needed to check a release against what was actually produced.
18
19`pantry.json` pins Bun 1.4.4; `./buddy --version` should match. `bun run build` builds local styles, runs the framework build, then lifts each page's own module bundle out of its HTML into `_stx/modules.<hash>.js` so it can be cached (`bun run build:bundles`, idempotent). That step is what keeps `/world-series` at 34 KB of HTML rather than 879 KB: the globe library is used by one page, so the framework's own externaliser, which lifts what repeats across pages, leaves it inline. `bun scripts/seo.ts` regenerates the sitemap and robots file; `SITE_ORIGIN` overrides the production canonical origin for deployment.
1820
1921`resources/assets/styles/site/` contains the production brand styles adapted to stable semantic classes. `resources/data/production-pages.ts` supplies page metadata. Photos, logos and fonts are self-hosted under `public/assets/lsu/`, with source URLs and hashes in `asset-integrity.json`. The public site content and 2025/2026 result data are preserved; do not replace race facts with sample content.
2022
@@ -29,12 +31,42 @@ HQ_ORIGIN=https://hq.training
2931HQ_BRAND_SLUG=last-soul-ultra
3032```
3133
32`config/coaching.ts` validates the host through `CoachingPortal.stx`. The component loads `/assets/elements/hq-coaching.js` from HQ and embeds `/brands/<slug>`, with a full-page fallback. Before it is available, HQ's organization owner must publish the experience with real coach methods and active monthly products and permit the LSU origin. An unpublished HQ brand correctly returns 404; LSU does not manufacture offers or charge a placeholder price.
34`config/coaching.ts` names the tenant; `resources/functions/coaching-source.ts` validates the origin and slug, builds the portal URLs and reads the one public endpoint, `GET /api/brands/<slug>`, with a 2.5 second bound, a short cache and a null answer for every failure.
35
36The surface is this site's, not an embed of HQ's. `resources/data/coaching.ts` registers the eight training modules in LSU's own words, each with a scope: the storefront is public and indexed, the rest need an HQ account and are noindex, and `health` and `messages` are additionally account-private - they read the athlete's own HQ endpoints, which no brand ever sees, and the page says so. Each module has a route under `/coaching/<module>` with LSU's chrome around HQ's panel, and `/coaching/plans` plus the hub render the published tiers, coaches and prices directly from the public brand rather than only inside the frame. Nothing about a tier originates here. With no brand published, the modules say so and frame nothing.
37
38`CoachingPortal.stx` loads `/assets/elements/hq-coaching.js` from HQ and embeds `/brands/<slug>`, with a full-page fallback. Every module page also carries a visible link to the same section, because a cross-origin frame can decline to load for reasons this page cannot see and the element's own fallback sits below a tall empty iframe.
39
40`config/server.ts` delegates `Permissions-Policy: microphone` on `/coaching` and `/coaching/feedback`, naming the configured HQ origin. Without it the browser blocks recording inside HQ's frame and the feedback module fails silently. Before it is available, HQ's organization owner must publish the experience with real coach methods and active monthly products and permit the LSU origin. An unpublished HQ brand correctly returns 404; LSU does not manufacture offers or charge a placeholder price.
3341
3442HQ's **Brand experience** editor controls colors, fonts, corners, density, modules, navigation, offers, legal links and embedding origins. The LSU preset matches the site. Coaches author their methods in HQ's **Training methods** workspace, supply plan-backed monthly products in **Store**, and use their configured AI budget for feedback. Personal coaching remains a direct coach referral.
3543
3644HQ's repository documents provisioning, API endpoints, native app builds and launch decisions in `docs/developers/branded-coaching.md`. The new HQ database migrations and application deployment must be released before enabling the live LSU embed. This repository change does not deploy either site.
3745
46## Built, with nothing on the site reaching it
47
48Two working features have no entry point in the current pages. Both were
49added and then lost when the site was rebuilt from the production capture, so
50they are listed here rather than left to be found again by accident.
51
52**The live scoreboard.** `resources/data/live.ts` mirrors the race's own board
53- which publishes no JSON, so it lifts the state out of the page the worker
54serves - and `routes/api.ts` serves it at `GET /api/live`, cached for a minute
55and answering 503 with `ok: false` rather than a page of zeroes when upstream
56is down. Nothing fetches it. The only consumer left is the 404 page, which
57asks it whether the race is running to decide what to link to. Restoring the
58standings means rendering `livePayload` on `/results` and polling that
59endpoint; the shape is the same server-side and in the browser so the table
60cannot drift between them.
61
62**The newsletter.** `app/Actions/SubscriberEmailAction.ts`,
63`ConfirmSubscriptionAction.ts`, both `app/Mail/Subscription*` classes and the
64three templates in `resources/emails/` implement a real double opt-in, and
65`/api/email/confirm` is the middle step the framework does not provide. No
66page has a signup form, so nobody can start the flow. Adding one also needs
67privacy copy: the current `/privacy` documents analytics, server logs,
68accounts and entries, and says nothing about a mailing list.
69
3870## Existing race service
3971
4072`resources/functions/race-api.ts` talks to the existing `https://api.lastsoulultra.com/v1` service for public events, sign-in, profile updates and entry requests. Its established bearer session remains under `lsu.runner.session`. Coaching has its own HQ account session; credentials are never silently transferred or linked by unverified email. Form submissions reach the real race service, so use an approved test account/service for write testing.
config/server.tsmodified+28-0
Changes to config/server.ts
@@ -1,4 +1,11 @@
11import type { ServerConfig } from '@stacksjs/types'
2import coaching from './coaching'
3
4/**
5 * The HQ origin the coaching panel is framed from. Permissions-Policy needs
6 * the exact origin, quoted, and it must agree with what CoachingPortal frames.
7 */
8const hqOrigin = new URL(coaching.origin).origin
29
310/**
411 * **Server Configuration**
@@ -10,6 +17,27 @@ import type { ServerConfig } from '@stacksjs/types'
1017 * Discord or GitHub Discussions.
1118 */
1219export default {
20 /**
21 * **Response headers**
22 *
23 * `path` or `path/*` to the headers that path answers with.
24 *
25 * Recording a voice message is the whole point of the coaching feedback
26 * module, and a cross-origin frame can only reach the microphone if the
27 * page embedding it delegates that permission. Without this the browser
28 * blocks `getUserMedia` inside HQ's frame and the feature fails quietly,
29 * which is the worst way for it to fail: nothing errors, the button simply
30 * never does anything.
31 *
32 * Only the two routes that need it. The hub embeds every section, including
33 * feedback; the seven other module routes pin one section each and have no
34 * use for a microphone.
35 */
36 headers: {
37 '/coaching': { 'Permissions-Policy': `microphone=(self "${hqOrigin}")` },
38 '/coaching/feedback': { 'Permissions-Policy': `microphone=(self "${hqOrigin}")` },
39 },
40
1341 /**
1442 * **API Proxy**
1543 *
package.jsonmodified+4-2
Changes to package.json
@@ -27,7 +27,7 @@
2727 "upgrade": "./buddy upgrade",
2828 "dev": "./buddy dev",
2929 "development": "./buddy dev",
30 "build": "bun run build:assets && ./buddy build",
30 "build": "bun run build:assets && ./buddy build && bun run build:bundles",
3131 "prod": "./buddy build",
3232 "deploy": "./buddy deploy",
3333 "lint": "./buddy lint",
@@ -72,7 +72,9 @@
7272 "deps:lockfile:check": "bun .github/scripts/check-lockfile-version.ts",
7373 "bud": "./buddy",
7474 "stx": "./buddy",
75 "build:assets": "bun scripts/build-site-assets.ts"
75 "build:assets": "bun scripts/build-site-assets.ts",
76 "build:bundles": "bun scripts/externalize-page-bundles.ts",
77 "audit:html": "bun scripts/audit-html.ts"
7678 },
7779 "dependencies": {
7880 "@stacksjs/components": "0.2.687",
public/assets/images/race/board-1080.jpgdeleted+0-0

Binary file. Nothing to show line by line.

public/assets/images/race/board-540.jpgdeleted+0-0

Binary file. Nothing to show line by line.

public/assets/images/race/board-810.jpgdeleted+0-0

Binary file. Nothing to show line by line.

public/assets/images/race/loop-1080.jpgdeleted+0-0

Binary file. Nothing to show line by line.

public/assets/images/race/loop-540.jpgdeleted+0-0

Binary file. Nothing to show line by line.

public/assets/images/race/loop-810.jpgdeleted+0-0

Binary file. Nothing to show line by line.

public/assets/images/race/night-1080.jpgdeleted+0-0

Binary file. Nothing to show line by line.

public/assets/images/race/night-540.jpgdeleted+0-0

Binary file. Nothing to show line by line.

public/assets/images/race/night-810.jpgdeleted+0-0

Binary file. Nothing to show line by line.

public/assets/images/race/refuel-1080.jpgdeleted+0-0

Binary file. Nothing to show line by line.

public/assets/images/race/refuel-540.jpgdeleted+0-0

Binary file. Nothing to show line by line.

public/assets/images/race/refuel-810.jpgdeleted+0-0

Binary file. Nothing to show line by line.

public/assets/styles/production/base.cssmodified+6-0
Changes to public/assets/styles/production/base.css
@@ -1,1 +1,7 @@
11@font-face{font-family:DSEG7;src:url(/assets/lsu/fonts/DSEG7Classic-Bold.woff2) format("woff2");font-weight:700;font-display:swap}@font-face{font-family:Anton;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/anton-400-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Anton;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/anton-400-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:italic;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/barlow-400i-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Barlow;font-style:italic;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/barlow-400i-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:normal;font-weight:300;font-display:swap;src:url(/assets/lsu/fonts/barlow-300-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Barlow;font-style:normal;font-weight:300;font-display:swap;src:url(/assets/lsu/fonts/barlow-300-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/barlow-400-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Barlow;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/barlow-400-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/lsu/fonts/barlow-500-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Barlow;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/lsu/fonts/barlow-500-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/lsu/fonts/barlow-600-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Barlow;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/lsu/fonts/barlow-600-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:normal;font-weight:700;font-display:swap;src:url(/assets/lsu/fonts/barlow-700-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Barlow;font-style:normal;font-weight:700;font-display:swap;src:url(/assets/lsu/fonts/barlow-700-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Share Tech Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/share-tech-mono-400-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--lsu-black: #000000;--lsu-band: #050505;--lsu-card: #0d0d0f;--lsu-card-alt: #111111;--lsu-white: #ffffff;--lsu-dim-1: rgba(255, 255, 255, .65);--lsu-dim-2: rgba(255, 255, 255, .55);--lsu-dim-3: rgba(255, 255, 255, .4);--lsu-line: rgba(255, 255, 255, .14);--lsu-line-soft: rgba(255, 255, 255, .1);--lsu-line-faint: rgba(255, 255, 255, .08);--lsu-red: #dd0f15;--lsu-red-glow: rgba(221, 15, 21, .5);--lsu-seg-red: #ff2a1a;--lsu-display: "Anton", sans-serif;--lsu-body: "Barlow", system-ui, sans-serif;--lsu-mono: "Share Tech Mono", monospace;--lsu-seg: "DSEG7", "Share Tech Mono", monospace;--lsu-pad-y: clamp(72px, 9.3vw, 130px);--lsu-pad-x: clamp(18px, 5vw, 40px);--lsu-max: 1360px;--lsu-head-gap: clamp(36px, 4vw, 56px)}*{box-sizing:border-box}html{scroll-behavior:smooth;-webkit-text-size-adjust:100%;text-size-adjust:100%}img,svg,video{max-width:100%}img,video{height:auto}body{margin:0;background:var(--lsu-black);color:var(--lsu-white);font-family:var(--lsu-body);overflow-x:hidden}a{color:var(--lsu-white);text-decoration:none}a:hover{color:var(--lsu-red)}a[href^="mailto:"],a[href^="tel:"],a[href^=http]{overflow-wrap:anywhere}:where(.footer-col a,.drawer-social a,.login-links a,.runner-detail summary,.ed-link,.recap-link,.nav-profile,.portal button:not(.btn)){display:inline-flex;align-items:center;min-height:24px;min-width:24px}.kicker{font-size:19px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--lsu-red)}.kicker-sm{font-size:10px;letter-spacing:.26em;text-transform:uppercase;color:var(--lsu-red)}.label{font-size:17px;letter-spacing:.04em;text-transform:uppercase;color:var(--lsu-dim-2)}:where(.kicker-sm,.label,.seg-label,.stage-label,.ws-place-label,.ws-race-label,.marker-label,.duel-mark-label,.e404-route-label,.card-kicker,.form-kicker,.drawer-kicker,.hour-note,.faq-count,.hero-live,.event-meta dt,.runner-facts div,.runner-stats dt,.row-n span,.row-n small,.profile-portal .ticket-header,.profile-portal .timeline-cell span,.profile-portal .detail-row dt){font-size:17px!important;letter-spacing:.04em!important}.shell{max-width:var(--lsu-max);margin:0 auto}.section{padding:clamp(55px,7.1vw,100px) var(--lsu-pad-x);background:var(--lsu-black)}.btn{display:inline-block;max-width:100%;overflow-wrap:anywhere;padding:17px 34px;background:var(--lsu-red);color:var(--lsu-white);font-size:12px;font-weight:700;letter-spacing:.22em;text-transform:uppercase;border:none;font-family:var(--lsu-body);cursor:pointer}.btn:hover{background:var(--lsu-white);color:#000}.btn-lg{padding:20px 46px;font-size:13px}.btn-outline{display:inline-block;padding:17px 34px;border:1px solid rgba(255,255,255,.3);font-size:12px;font-weight:600;letter-spacing:.22em;text-transform:uppercase;color:var(--lsu-white)}.btn-outline:hover{border-color:var(--lsu-white);background:#ffffff12;color:var(--lsu-white)}.statgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(190px,100%),1fr));gap:1px;background:var(--lsu-line);border:1px solid var(--lsu-line)}.statgrid>*{background:var(--lsu-black);padding:28px 24px}.stat-v{font-family:var(--lsu-display);font-size:clamp(34px,4vw,52px);line-height:1}.stat-l{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--lsu-dim-2);margin-top:8px}.card{border:1px solid rgba(255,255,255,.16);padding:32px;display:flex;flex-direction:column;gap:14px}.card-win{border-color:var(--lsu-red)}.card-name{font-family:var(--lsu-display);font-size:clamp(26px,3vw,40px);line-height:1.05;text-transform:uppercase}.card-num{font-family:var(--lsu-display);font-size:60px;line-height:1.05}.card-num span{font-size:17px;color:var(--lsu-dim-2)}.card-body{font-size:14px;color:#ffffffb3;line-height:1.6}.h2{margin:0;font-family:var(--lsu-display);font-size:clamp(34px,5vw,76px);line-height:1.05;text-transform:uppercase}.h2-sm{margin:0;font-family:var(--lsu-display);font-size:clamp(26px,3.4vw,48px);line-height:1.05;text-transform:uppercase}.band{position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;border-top:1px solid var(--lsu-line-soft)}.band-bg{position:absolute;inset:-18% 0;background-size:cover;filter:grayscale(1) brightness(.45)}.band-scrim{position:absolute;inset:0;background:#0000008c}.band-copy{position:relative;z-index:2;text-align:center;padding:0 var(--lsu-pad-x)}.band-copy p{margin:0 auto;max-width:900px;font-family:var(--lsu-display);font-size:clamp(26px,3.6vw,54px);line-height:1.08;text-transform:uppercase}.phero{padding:150px var(--lsu-pad-x) 0;background:radial-gradient(ellipse 60% 55% at 50% 45%,#130306,#000)}.phero-ghost{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;font-family:var(--lsu-display);font-size:min(28vw,400px);color:#ffffff0d;text-transform:uppercase;white-space:nowrap}.phero-copy{max-width:var(--lsu-max);margin:0 auto;display:flex;flex-direction:column;gap:26px}.phero-copy h1{margin:0;font-family:var(--lsu-display);font-size:clamp(24px,8vw,190px);line-height:1.05;letter-spacing:.02em;text-transform:uppercase}.phero-copy h1 .kicker{display:block;margin-bottom:26px;font-family:var(--lsu-body);line-height:1.4}.phero-copy p{margin:0;max-width:480px;font-size:18px;line-height:1.6;color:var(--lsu-dim-1)}@media(max-width:700px){.phero{padding-top:125px}}.series-event{position:relative;isolation:isolate;display:flex;flex-direction:column;overflow:hidden;min-height:350px;padding:28px;border:1px solid rgba(221,15,21,.62);background:#100305}.series-event>*:not(.event-ember):not(.series-flag){position:relative;z-index:1}.event-ember{position:absolute;inset:0;z-index:-1;width:100%;height:100%;pointer-events:none;opacity:.98;mix-blend-mode:screen}.series-event-head{display:flex;flex-wrap:wrap;align-items:center;gap:10px}.series-event-edition{align-self:flex-start;padding:7px 12px 6px;border:1px solid rgba(221,15,21,.9);color:#ff6065;font:15px var(--lsu-mono);letter-spacing:.18em;text-transform:uppercase}.series-event h3{margin:22px 0 30px;font:clamp(32px,4vw,50px)/.9 var(--lsu-display);text-transform:uppercase}.series-event dl{display:grid;gap:16px;margin:0 0 32px}.series-event dl div{display:grid;grid-template-columns:120px 1fr;gap:16px}.series-event dt{font:12px var(--lsu-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--lsu-dim-2)}.series-event dd{margin:0;font-size:16px;line-height:1.45}.series-event-action{display:block;width:100%;margin-top:auto;padding:18px 20px;border:0;background:var(--lsu-red);color:var(--lsu-white);text-align:center;font:700 14px var(--lsu-body);letter-spacing:.15em;text-transform:uppercase;cursor:pointer;box-shadow:0 0 28px #dd0f1573;transition:background-color .2s}.series-event-action:hover{background:#fff;color:#000}.world .series-event-action{padding:16px 20px;font:500 19px/1 var(--lsu-body);letter-spacing:.06em}.series-event:has(.series-event-action){cursor:pointer}.series-event:has(.series-event-action):hover .series-event-action{background:#fff}.series-event.is-entered:after{content:"";position:absolute;inset:0;z-index:2;background:#0000009e;pointer-events:none}.series-event.is-entered>*:not(.event-ember){z-index:3}.series-event.is-entered .series-event-action{background:#fff;box-shadow:0 0 28px #ffffff47}.series-event.is-entered .series-event-action:hover,.series-event.is-entered:hover .series-event-action{background:#d6d6d6}.series-event.is-final{border:2px solid transparent;background-image:linear-gradient(#0e0603,#0e0603),linear-gradient(135deg,#7d5d18,#f6dc95,#c9a344,#fff3cf,#7d5d18);background-origin:border-box;background-clip:padding-box,border-box;box-shadow:0 0 46px #d4af3752;transition:box-shadow .3s ease,transform .3s ease}.series-event.is-final:hover{transform:translateY(-4px);box-shadow:0 0 80px #d4af378c}.series-event.is-final .series-event-edition{border-color:#f6dc95d9;color:#f6dc95}.series-event-final{padding:7px 12px 6px;background:linear-gradient(120deg,#c9a344,#fff3cf 45%,#c9a344);color:#1a1205;font:15px var(--lsu-mono);letter-spacing:.18em;text-transform:uppercase}.series-event.is-final .series-event-action{background:linear-gradient(120deg,#c9a344,#fff3cf 45%,#c9a344);color:#1a1205;box-shadow:0 0 28px #d4af3773}.series-event.is-final .series-event-action:hover,.series-event.is-final:hover .series-event-action{background:linear-gradient(120deg,#fff3cf,#fff 45%,#f6dc95);box-shadow:0 0 42px #f6dc9599}.series-event.is-final:before{content:"";position:absolute;z-index:0;top:0;bottom:0;left:0;width:60%;background:linear-gradient(105deg,transparent 42%,rgba(255,243,207,.16) 50%,transparent 58%);pointer-events:none;animation:finalSheen 9s ease-in-out infinite}.series-event.is-final:hover:before{animation-duration:3s}@keyframes finalSheen{0%{transform:translate(-120%)}55%,to{transform:translate(280%)}}@media(prefers-reduced-motion:reduce){.series-event.is-final,.series-event.is-final:hover{transition:none;transform:none}.series-event.is-final:before,.series-event.is-final:hover:before{animation:none;opacity:0}}@media(max-width:700px){.series-event dl div{grid-template-columns:100px 1fr}}.row{display:grid;grid-template-columns:70px 1fr 120px;gap:20px;align-items:baseline;padding:18px 0;border-bottom:1px solid var(--lsu-line-soft)}.row-n{font-family:var(--lsu-display);font-size:26px;color:var(--lsu-red)}.row-name{font-size:17px;font-weight:500}.row-note{font-size:14px;color:var(--lsu-dim-2);line-height:1.5;margin-top:3px}.row-km{font-size:13px;letter-spacing:.16em;text-transform:uppercase;color:#ffffff73;text-align:right}@media(max-width:900px){[data-cols]{grid-template-columns:1fr!important;gap:34px!important}.row{grid-template-columns:46px 1fr!important;gap:14px!important}.row-km{text-align:left!important;grid-column:2!important}}@media(max-width:760px){.phero-ghost{font-size:46vw!important}}@keyframes lsuPulse{0%,to{opacity:.25}50%{opacity:.9}}@keyframes lsuDrift{0%{transform:translateY(0)}to{transform:translateY(-14px)}}@keyframes lsuMist{0%{transform:translate3d(-6%,0,0) scale(1.1);opacity:.32}50%{opacity:.6}to{transform:translate3d(6%,-3%,0) scale(1.25);opacity:.32}}@keyframes lsuTorch{0%,to{opacity:.86}30%{opacity:1}55%{opacity:.9}80%{opacity:.97}}@keyframes lsuFlash{0%,92%,to{opacity:0}93%{opacity:.5}94.5%{opacity:.08}96%{opacity:.34}97.5%{opacity:0}}.legal{padding:var(--lsu-head-gap) var(--lsu-pad-x) var(--lsu-pad-y);background:var(--lsu-black)}.legal-shell{max-width:var(--lsu-max);margin:0 auto}.legal-shell>*{max-width:760px}.legal-shell>:first-child{margin-top:0}.legal-list{margin:0 0 52px;border-top:1px solid var(--lsu-line-soft)}.legal-row{display:grid;grid-template-columns:minmax(min(150px,100%),1fr) 2fr;gap:8px 28px;padding:15px 0;border-bottom:1px solid var(--lsu-line-faint)}.legal-row dt{font-family:var(--lsu-mono);font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2);line-height:1.5}.legal-row dd{margin:0;font-size:15.5px;color:var(--lsu-white);line-height:1.6}.legal-h2{font-family:var(--lsu-display);font-weight:400;text-transform:uppercase;font-size:clamp(20px,2.6vw,28px);line-height:1.05;letter-spacing:.01em;margin:44px 0 16px;color:var(--lsu-white)}.legal p{font-size:15.5px;line-height:1.72;color:var(--lsu-dim-1);margin:0 0 14px;max-width:68ch}.legal a{color:var(--lsu-red);text-underline-offset:2px}.country-picker{position:relative}.country-open{position:absolute;inset:0;width:100%;margin:0;padding:0;border:0;background:transparent;cursor:pointer}.country-panel{position:absolute;z-index:30;top:calc(100% + 4px);left:0;right:0;border:1px solid rgba(255,255,255,.38);background:#090909;box-shadow:0 18px 40px #0009;color:var(--lsu-white);font:16px/1.3 var(--lsu-body);letter-spacing:0;text-transform:none}.country-picker.country-picker .country-panel input[type=search]{-webkit-appearance:none;appearance:none;box-sizing:border-box;width:100%;min-height:48px;margin:0;padding:12px 15px;border:0;border-bottom:1px solid rgba(255,255,255,.14);border-radius:0;background:transparent;color:var(--lsu-white);font:16px var(--lsu-body);outline:none}.country-picker.country-picker .country-panel input[type=search]:focus-visible{border-bottom-color:var(--lsu-red);box-shadow:none}.country-panel input[type=search]::-webkit-search-cancel-button{-webkit-appearance:none}.country-list{max-height:280px;margin:0;padding:4px 0;list-style:none;overflow-y:auto;overscroll-behavior:contain}.country-list li{padding:10px 15px;color:#ffffffd1;cursor:pointer}.country-list li.active,.country-list li[data-value]:hover{background:#ffffff17;color:var(--lsu-white)}.country-list li[aria-selected=true]{box-shadow:inset 3px 0 0 var(--lsu-red)}.country-list li.empty{color:#ffffff80;cursor:default}.nav.lsu-scope-nav{position:fixed;top:0;left:0;right:0;z-index:100;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:12px max(var(--lsu-pad-x),calc(50% - var(--lsu-max) / 2));backdrop-filter:blur(14px);background:#000000eb;border-bottom:1px solid var(--lsu-line-faint);font-family:var(--lsu-body);color:var(--lsu-white)}.nav-logo.lsu-scope-nav{display:block;flex:0 0 auto}.nav-logo.lsu-scope-nav img.lsu-scope-nav{height:clamp(24px,3vw,34px);width:auto;display:block}.nav-links.lsu-scope-nav{display:flex;align-items:center;gap:26px;flex-wrap:wrap;font-size:12px;letter-spacing:.2em;text-transform:uppercase;font-weight:500}@media(max-width:1320px){.nav-links.lsu-scope-nav{gap:14px;font-size:11px;letter-spacing:.1em}}.nav-end.lsu-scope-nav{display:flex;align-items:center;gap:14px;flex:0 0 auto}.nav-cta.lsu-scope-nav{padding:11px 22px;background:var(--lsu-red);color:var(--lsu-white);font-weight:700;font-size:12px;letter-spacing:.2em;text-transform:uppercase}.nav-cta.lsu-scope-nav:hover{background:var(--lsu-white);color:#000}.nav-profile.lsu-scope-nav{font-size:11px;font-weight:600;letter-spacing:.16em;text-transform:uppercase}.nav-profile.lsu-scope-nav:hover{color:var(--lsu-red)}.burger.lsu-scope-nav{display:none;flex-direction:column;justify-content:center;gap:6px;width:48px;height:48px;padding:0 12px;background:none;border:0;cursor:pointer;flex:0 0 auto}.burger.lsu-scope-nav span.lsu-scope-nav{display:block;height:2px;width:100%;background:var(--lsu-white);transition:transform .3s ease,opacity .2s ease}.burger.lsu-scope-nav[aria-expanded=true] span.lsu-scope-nav:nth-child(1){transform:translateY(8px) rotate(45deg)}.burger.lsu-scope-nav[aria-expanded=true] span.lsu-scope-nav:nth-child(2){opacity:0}.burger.lsu-scope-nav[aria-expanded=true] span.lsu-scope-nav:nth-child(3){transform:translateY(-8px) rotate(-45deg)}.scrim.lsu-scope-nav{position:fixed;inset:0;z-index:98;background:#0009;backdrop-filter:blur(2px);transition:opacity .35s ease;opacity:0;pointer-events:none}.scrim.lsu-scope-nav[data-open]{opacity:1;pointer-events:auto}.drawer.lsu-scope-nav{position:fixed;top:0;right:0;bottom:0;z-index:99;width:min(86vw,380px);display:flex;flex-direction:column;background:#07070a;border-left:1px solid rgba(255,255,255,.1);padding:88px clamp(24px,7vw,40px) 32px;font-family:var(--lsu-body);color:var(--lsu-white);transition:transform .38s cubic-bezier(.22,.8,.28,1);transform:translate(102%);overflow-y:auto}.drawer.lsu-scope-nav[data-open]{transform:translate(0)}.drawer-kicker.lsu-scope-nav{font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:var(--lsu-red);margin-bottom:26px}.drawer-links.lsu-scope-nav{display:flex;flex-direction:column;gap:2px;font-family:var(--lsu-display);font-size:24px;letter-spacing:.01em;text-transform:uppercase}.drawer-links.lsu-scope-nav a.lsu-scope-nav{padding:14px 0;border-bottom:1px solid var(--lsu-line-faint);opacity:0;transform:translate(18px);transition:opacity .4s ease,transform .4s ease}.drawer.lsu-scope-nav[data-open] .drawer-links.lsu-scope-nav a.lsu-scope-nav{opacity:1;transform:translate(0)}.drawer-cta.lsu-scope-nav{margin-top:30px;padding:18px 24px;background:var(--lsu-red);color:var(--lsu-white);text-align:center;font-size:12px;font-weight:700;letter-spacing:.22em;text-transform:uppercase}.drawer-cta.lsu-scope-nav:hover{background:var(--lsu-white);color:#000}.drawer-social.lsu-scope-nav{margin-top:auto;padding-top:34px;display:flex;gap:18px;font-size:11px;letter-spacing:.2em;text-transform:uppercase}.drawer-social.lsu-scope-nav a.lsu-scope-nav{color:#ffffff73}.drawer-social.lsu-scope-nav a.lsu-scope-nav:hover{color:var(--lsu-white)}@media(max-width:1080px){.nav-links.lsu-scope-nav,.nav-cta.lsu-scope-nav,.nav-profile.lsu-scope-nav{display:none}.burger.lsu-scope-nav{display:flex}.nav-end.lsu-scope-nav{gap:4px}}.mobile-cta.lsu-scope-nav{display:none;position:fixed;left:0;right:0;bottom:0;z-index:40;padding:17px 16px calc(17px + env(safe-area-inset-bottom,0px));background:var(--lsu-red);color:var(--lsu-white);font-weight:700;font-size:12px;letter-spacing:.2em;text-transform:uppercase;text-align:center}@media(max-width:1080px){.mobile-cta.lsu-scope-nav{display:block}body:has(.mobile-cta){padding-bottom:calc(52px + env(safe-area-inset-bottom,0px))}}@media(prefers-reduced-motion:reduce){.drawer.lsu-scope-nav,.drawer-links.lsu-scope-nav a.lsu-scope-nav,.scrim.lsu-scope-nav,.burger.lsu-scope-nav span.lsu-scope-nav{transition:none}}.footer.lsu-sz7xmlte{padding:clamp(50px,6.4vw,90px) var(--lsu-pad-x) 36px;background:var(--lsu-black);border-top:1px solid rgba(255,255,255,.12);font-family:var(--lsu-body);color:var(--lsu-white)}.footer-grid.lsu-sz7xmlte{max-width:var(--lsu-max);margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(230px,100%),1fr));gap:48px;align-items:start}.footer-brand.lsu-sz7xmlte{display:flex;flex-direction:column;gap:16px}.footer-brand.lsu-sz7xmlte img.lsu-sz7xmlte{width:clamp(150px,17vw,220px);height:auto;display:block}.footer-org.lsu-sz7xmlte{font-family:var(--lsu-display);font-size:14px;letter-spacing:.2em;text-transform:uppercase;color:#ffffffbf}.footer-addr.lsu-sz7xmlte{font-size:13px;color:var(--lsu-dim-2);line-height:1.7}.footer-col.lsu-sz7xmlte{display:flex;flex-direction:column;gap:11px;font-size:13px}.footer-head.lsu-sz7xmlte{font-size:10px;letter-spacing:.26em;text-transform:uppercase;color:var(--lsu-red);margin-bottom:6px}.footer-col.lsu-sz7xmlte a.lsu-sz7xmlte{color:#ffffffbf}.footer-col.lsu-sz7xmlte a.lsu-sz7xmlte:hover{color:var(--lsu-white)}.footer-base.lsu-sz7xmlte{max-width:var(--lsu-max);margin:56px auto 0;padding-top:20px;border-top:1px solid var(--lsu-line-soft);display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between;font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--lsu-dim-3)}@media(max-width:700px){.footer-grid.lsu-sz7xmlte{justify-items:center;text-align:center;gap:40px}.footer-brand.lsu-sz7xmlte,.footer-col.lsu-sz7xmlte{align-items:center}.footer-brand.lsu-sz7xmlte img.lsu-sz7xmlte{margin:0 auto}.footer-base.lsu-sz7xmlte{justify-content:center;text-align:center;gap:10px;margin-top:44px}}.consent.lsu-ruz5wv6z{position:fixed;left:0;right:0;bottom:0;z-index:900;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px 28px;padding:18px var(--lsu-pad-x);background:var(--lsu-black);border-top:1px solid var(--lsu-red);font-family:var(--lsu-body);color:var(--lsu-white)}.consent.lsu-ruz5wv6z[hidden]{display:none}.consent-copy.lsu-ruz5wv6z{margin:0;max-width:68ch;font-size:13px;line-height:1.6;color:#fffc}.consent-copy.lsu-ruz5wv6z a.lsu-ruz5wv6z{color:var(--lsu-white);text-decoration:underline}.consent-acts.lsu-ruz5wv6z{display:flex;gap:10px;flex:none}.consent-btn.lsu-ruz5wv6z{padding:11px 20px;border:1px solid rgba(255,255,255,.35);background:transparent;color:var(--lsu-white);font-family:var(--lsu-body);font-weight:700;font-size:11px;letter-spacing:.2em;text-transform:uppercase;cursor:pointer}.consent-btn.lsu-ruz5wv6z:hover{border-color:var(--lsu-white)}.consent-btn-yes.lsu-ruz5wv6z{background:var(--lsu-red);border-color:var(--lsu-red)}@media(max-width:700px){.consent.lsu-ruz5wv6z{justify-content:center;text-align:center}}.page.lsu-scope-site{position:relative;width:100%;background:#000}
2
3/* The third step of the display scale. .h2 and .h2-sm were defined and .h3
4 never was, so the results tables' own headings - class="h3 table-title" -
5 rendered at 16px Barlow beside 51px Anton siblings and read as a stray
6 label rather than a heading. Continues the ratio the other two set. */
7.h3{margin:0;font-family:var(--lsu-display);font-size:clamp(20px,2.4vw,32px);line-height:1.1;letter-spacing:.02em;text-transform:uppercase;font-weight:400}
public/assets/styles/stx-site.css public/assets/styles/production/coaching-page.csscopied+27-21
Changes to public/assets/styles/production/coaching-page.css
@@ -1,25 +1,31 @@
1.lsu-skip{position:fixed;left:1rem;top:-5rem;z-index:200;background:#fff;color:#000;padding:1rem}.lsu-skip:focus{top:1rem}main:focus{outline:none}.nav-links{gap:clamp(12px,1.6vw,28px)}.coaching-hero{position:relative;padding:clamp(140px,18vw,240px) clamp(18px,5vw,40px) 100px;background:linear-gradient(90deg,#000e,#0003),url('/assets/lsu/images/hero-desktop.webp') 50% 35%/cover}.coaching-hero h1{max-width:13ch;font:400 clamp(48px,8vw,120px)/1.05 var(--lsu-display);text-transform:uppercase;letter-spacing:.02em;margin:24px 0}.coaching-hero p{max-width:54ch;color:#ffffffb3;font:400 19px/1.6 var(--lsu-body)}.coaching-body{max-width:1360px;margin:auto;padding:clamp(50px,7vw,100px) clamp(18px,5vw,40px)}.coaching-steps{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid #ffffff24;border-bottom:1px solid #ffffff24;margin:3rem 0}.coaching-steps article{padding:2rem;border-right:1px solid #ffffff24}.coaching-steps article:last-child{border:0}.coaching-steps h2{font:400 30px/1.2 var(--lsu-display);text-transform:uppercase}.coaching-steps p{color:#ffffffa6;line-height:1.7}.coaching-note{padding:2rem;border-left:3px solid var(--lsu-red);background:#0d0d0f}.coaching-note h2{font:400 36px var(--lsu-display);text-transform:uppercase}.coaching-note p{max-width:65ch;color:#ffffffa6;line-height:1.7}.race-portal{max-width:720px;margin:auto;padding:clamp(24px,5vw,64px);border:1px solid #ffffff24;background:#0d0d0f}.race-portal label{display:block;font:600 12px var(--lsu-body);text-transform:uppercase;letter-spacing:.12em;margin:1rem 0 .5rem}.race-portal input,.race-portal textarea,.race-portal select{display:block;width:100%;padding:1rem;background:#050505;border:1px solid #ffffff40;color:#fff;font:inherit}.race-portal .btn{margin-top:1.5rem}.race-portal [role=alert]{color:#ff928f}.race-portal h2{font:400 36px var(--lsu-display);text-transform:uppercase}.race-portal p{line-height:1.65}.race-tabs{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}.race-tabs button{background:transparent;color:#fff;border:0;border-bottom:2px solid #ffffff40;padding:.75rem;cursor:pointer}.race-tabs button[aria-selected=true]{border-color:#dd0f15}.race-portal .field-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.lsu-scroll-gallery{overflow:auto;scroll-snap-type:x mandatory}.lsu-scroll-gallery>*{scroll-snap-align:start}@media(max-width:700px){.coaching-hero{background-image:linear-gradient(90deg,#000d,#0006),url('/assets/lsu/images/hero-mobile.webp')}.coaching-steps{grid-template-columns:1fr}.coaching-steps article{border-right:0;border-bottom:1px solid #ffffff24}.race-portal .field-row{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
1/* /coaching, the page that explains what coaching here is.
2
3 Its own sheet rather than stx-site.css, which every route on the site
4 downloads: these rules were 7 KB of the 11 KB that file shipped to all
5 twenty-five pages, and they match on one. They were put there because the
6 page used to load base.css alone; it loads its own styles now.
7
8 Every rule stays wrapped in .coaching-page, because `.accent` only ever
9 exists per page in base.css, which is why the old hero's red words
10 rendered white. */
11
12/* Unscoped, and kept that way: the scoped rules below build on these - the
13 hero h1 takes its family, case and tracking from here and overrides only
14 the size. They came across from stx-site.css with the rest of the page. */
15.coaching-hero{position:relative;padding:clamp(140px,18vw,240px) clamp(18px,5vw,40px) 100px;background:linear-gradient(90deg,#000e,#0003),url('/assets/lsu/images/hero-desktop.webp') 50% 35%/cover}
16.coaching-hero h1{max-width:13ch;font:400 clamp(48px,8vw,120px)/1.05 var(--lsu-display);text-transform:uppercase;letter-spacing:.02em;margin:24px 0}
17.coaching-hero p{max-width:54ch;color:#ffffffb3;font:400 19px/1.6 var(--lsu-body)}
18.coaching-body{max-width:1360px;margin:auto;padding:clamp(50px,7vw,100px) clamp(18px,5vw,40px)}
19.coaching-steps{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid #ffffff24;border-bottom:1px solid #ffffff24;margin:3rem 0}
20.coaching-steps article{padding:2rem;border-right:1px solid #ffffff24}
21.coaching-steps article:last-child{border:0}
22.coaching-steps h2{font:400 30px/1.2 var(--lsu-display);text-transform:uppercase}
23.coaching-steps p{color:#ffffffa6;line-height:1.7}
24.coaching-note{padding:2rem;border-left:3px solid var(--lsu-red);background:#0d0d0f}
25.coaching-note h2{font:400 36px var(--lsu-display);text-transform:uppercase}
26.coaching-note p{max-width:65ch;color:#ffffffa6;line-height:1.7}
27@media(max-width:700px){.coaching-hero{background-image:linear-gradient(90deg,#000d,#0006),url('/assets/lsu/images/hero-mobile.webp')}.coaching-steps{grid-template-columns:1fr}.coaching-steps article{border-right:0;border-bottom:1px solid #ffffff24}}
228
3.lsu-globe-stage{position:relative;width:100%;height:100%;min-height:420px}.lsu-globe-fallback{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none}.lsu-event-dot{display:block;width:14px;height:14px;border-radius:50%;background:#dd0f15;border:2px solid white;box-shadow:0 0 0 6px #dd0f1540}.ts-map{background:transparent}.lsu-globe-stage .ts-map{position:absolute;inset:0}.lsu-globe-stage:has([data-ts-map])>.lsu-globe-fallback{visibility:hidden}
4
5.hour-dial .rest-ring{width:100%;height:auto;aspect-ratio:1;transform:rotate(-90deg)}
6
7.duel-fill-1.lsu-scope-home{width:71.28%}.duel-fill-2.lsu-scope-home{width:100%}
8@supports(animation-timeline:view()){.duelscroll{view-timeline-name:--duel;view-timeline-axis:block}.duel-fill{transform-origin:left;animation:lsu-grow linear both;animation-timeline:--duel;animation-range:entry 20% exit 65%}@keyframes lsu-grow{from{transform:scaleX(0)}to{transform:scaleX(1)}}}
9@media(prefers-reduced-motion:reduce){.duel-fill{animation:none}}
10
11.lsu-host-dialog[data-stx-dialog]{z-index:200}.lsu-host-dialog .sr-dialog{color:var(--lsu-white,#fff);background:#080808;padding:0;border:1px solid #333;border-radius:0}
12
13/* Let the browser defer distant story panels and their background photos.
14 Explicitly sized bands keep their authored height. Story panels remember
15 their measured height after their first visit; one viewport reserves space
16 before they approach the viewport. Unsupported browsers render normally. */
17.lapscroll.lsu-scope-home,.duelscroll.lsu-scope-home{content-visibility:auto;contain-intrinsic-block-size:auto 100svh}
18.attr-band.lsu-scope-home,.enter.lsu-scope-next-loop{content-visibility:auto}
19
20/* /coaching. Every rule is wrapped in .coaching-page because this file loads on
21 all sixteen routes, and because `.accent` only ever exists per page in
22 base.css, which is why the old hero's red words rendered white. */
2329.coaching-page .accent{color:var(--lsu-red)}
2430.coaching-page .coaching-body>*+*{margin-top:clamp(56px,7vw,104px)}
2531.coaching-page .coaching-head{display:flex;flex-direction:column;gap:18px;max-width:65ch}
public/assets/styles/production/coaching.cssadded+116-0
Changes to public/assets/styles/production/coaching.css
@@ -0,0 +1,116 @@
1/* The coaching surface: /coaching and the eight module routes under it.
2
3 Contrast: --lsu-dim-3 reaches 3.66:1 on black and the brand red 4.15:1, so
4 neither clears WCAG AA's 4.5 for body text. Both are used that way across
5 the rest of the site, which is the organiser's own design and not this
6 sheet's to change - but nothing here adds to it. Small text is --lsu-dim-2
7 (6.25) or brighter, and the red is kept for type at 22px and up, where the
8 3:1 bar for large text applies.
9 Loaded only by those routes, not from stx-site.css, which the whole site
10 pays for on every page.
11
12 The palette is the site's own, which is also what the HQ brand preset
13 publishes: black, #0d0d0f surfaces, #dd0f15, Anton over Barlow, square
14 corners. Nothing here reads a colour over the network - a page whose
15 palette arrives with a fetch paints unstyled while that fetch is in flight.
16
17 Prefixed .coach- throughout. The marketing page's .coaching-* rules live in
18 stx-site.css and the two must not reach into one another. */
19
20.coaching-surface {--coach-gutter: clamp(18px, 5vw, 40px); --coach-rule: 1px solid var(--lsu-line)}
21.coaching-surface .coach-shell {max-width:var(--lsu-max);margin:0 auto;padding:0 var(--coach-gutter)}
22/* The shell has no vertical padding, so the first child's top margin would
23 collapse straight through it and sit flush against the nav rule. The band
24 below the nav owns that space instead, and the panel carries none. */
25.coaching-surface .coach-nav+.coach-shell {padding-top:clamp(26px,3vw,40px)}
26
27/* Header. A compact band rather than another full-height hero: the runner who
28 reaches /coaching/calendar came to read this week's sessions, not to be
29 sold the idea again. The red hairline under it carries the brand instead. */
30.coaching-surface .coach-head {padding:clamp(104px,11vw,136px) 0 clamp(26px,3vw,38px)}
31.coaching-surface .coach-head .kicker {display:block;color:var(--lsu-red)}
32/* The kicker is the way back to the hub. The module nav hides itself when the
33 brand publishes nothing, and without this a module page was then a dead end
34 with no link to the page that explains what coaching here is. */
35.coaching-surface .coach-up {width:fit-content;text-decoration:none;border-bottom:1px solid transparent}
36.coaching-surface .coach-up:hover {border-bottom-color:var(--lsu-red)}
37.coaching-surface .coach-head h1 {max-width:16ch;margin:16px 0 0;font:400 clamp(38px,5.2vw,76px)/1.04 var(--lsu-display);text-transform:uppercase;letter-spacing:.02em}
38.coaching-surface .coach-head p {max-width:58ch;margin:20px 0 0;color:var(--lsu-dim-1);font:400 18px/1.65 var(--lsu-body)}
39
40/* Module nav. One row of words, the current one underlined in red. It scrolls
41 sideways on a phone rather than wrapping into three ragged lines, and the
42 scrollport keeps its own focus ring so a keyboard can reach the overflow. */
43.coaching-surface .coach-nav {border-block:var(--coach-rule);background:#000}
44.coaching-surface .coach-nav ul {display:flex;gap:clamp(18px,3vw,40px);margin:0;padding:0;list-style:none;overflow-x:auto;scrollbar-width:none;scroll-snap-type:x proximity;-webkit-overflow-scrolling:touch}
45.coaching-surface .coach-nav ul::-webkit-scrollbar {display:none}
46.coaching-surface .coach-nav li {scroll-snap-align:start}
47.coaching-surface .coach-nav a {display:block;padding:18px 0;border-bottom:2px solid transparent;color:var(--lsu-dim-2);font:400 13px/1 var(--lsu-body);letter-spacing:.14em;text-transform:uppercase;text-decoration:none;white-space:nowrap}
48.coaching-surface .coach-nav a:hover {color:var(--lsu-white)}
49.coaching-surface .coach-nav a[aria-current="page"] {border-bottom-color:var(--lsu-red);color:var(--lsu-white)}
50
51/* Above the panel, always. A cross-origin frame can decline to load for
52 reasons this page cannot detect, and the embed's own escape hatch sits
53 below 1100px of empty iframe, where nobody finds it. This one says where
54 the panel comes from, which the site's voice wants said anyway. */
55.coaching-surface .coach-panel-bar {display:flex;flex-wrap:wrap;gap:12px 24px;align-items:baseline;justify-content:space-between;padding-bottom:14px;border-bottom:var(--coach-rule)}
56.coaching-surface .coach-panel-bar p {margin:0;max-width:52ch;color:var(--lsu-dim-2);font:400 13px/1.5 var(--lsu-body)}
57.coaching-surface .coach-panel-bar a {color:var(--lsu-white);font:400 13px/1 var(--lsu-body);letter-spacing:.14em;text-transform:uppercase;text-decoration:none;border-bottom:1px solid var(--lsu-red);padding-bottom:3px}
58.coaching-surface .coach-panel-bar a:hover {color:var(--lsu-red)}
59
60/* Reachable by a screen reader, invisible to everyone else. */
61.coaching-surface .coach-sr {position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
62
63/* The panel. Space is reserved before the remote element upgrades, so HQ
64 loading cannot shove the footer down the page under the reader. */
65.coaching-surface .coach-panel {min-height:460px;margin:clamp(20px,2vw,28px) 0 clamp(60px,8vw,120px)}
66.coaching-surface .coach-panel hq-coaching {display:block}
67.coaching-surface .coach-panel hq-coaching:not(:defined) {display:grid;gap:22px;justify-items:start;padding:clamp(24px,5vw,56px);border:var(--coach-rule);background:var(--lsu-card)}
68.coaching-surface .coach-panel hq-coaching:not(:defined) p {margin:0;max-width:56ch;color:var(--lsu-dim-1);font:400 16px/1.7 var(--lsu-body)}
69
70/* Said before the click, not after it. A module that needs an account says so
71 here; a private one says what this site cannot see, which is the whole
72 point of health and messages reading the athlete's own account. */
73.coaching-surface .coach-scope {display:grid;gap:10px;margin:0 0 clamp(26px,3vw,40px);padding:18px 22px;border-left:3px solid var(--lsu-red);background:var(--lsu-card)}
74.coaching-surface .coach-scope strong {font:400 13px/1 var(--lsu-body);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-white)}
75.coaching-surface .coach-scope p {margin:0;max-width:62ch;color:var(--lsu-dim-1);font:400 15px/1.65 var(--lsu-body)}
76
77/* The storefront. One ruled row per tier, not a grid of pricing cards: there
78 is one offer today and a three-column grid holding one card looks broken. */
79.coaching-surface .coach-offers {display:grid;margin:0 0 clamp(40px,5vw,72px);padding:0;list-style:none;border-top:var(--coach-rule)}
80.coaching-surface .coach-offer {display:grid;grid-template-columns:minmax(0,1fr) auto;gap:clamp(18px,4vw,56px);align-items:start;padding:clamp(26px,3vw,40px) 0;border-bottom:var(--coach-rule)}
81.coaching-surface .coach-offer-name {margin:0;font:400 clamp(24px,2.8vw,36px)/1.1 var(--lsu-display);text-transform:uppercase}
82.coaching-surface .coach-offer-by {margin:10px 0 0;color:var(--lsu-dim-1);font:400 13px/1 var(--lsu-body);letter-spacing:.14em;text-transform:uppercase}
83.coaching-surface .coach-offer p {margin:16px 0 0;max-width:60ch;color:var(--lsu-dim-1);font:400 16px/1.7 var(--lsu-body)}
84.coaching-surface .coach-offer-price {text-align:right;white-space:nowrap}
85.coaching-surface .coach-offer-price b {display:block;color:var(--lsu-red);font:400 clamp(24px,2.4vw,30px)/1.1 var(--lsu-display);font-weight:400}
86.coaching-surface .coach-offer-trial {display:block;margin-top:8px;color:var(--lsu-dim-2);font:400 13px/1.4 var(--lsu-body)}
87.coaching-surface .coach-offer-method {color:var(--lsu-dim-2);font-size:15px}
88.coaching-surface .coach-offer-note {margin:14px 0 0;color:var(--lsu-dim-2);font:400 13px/1.5 var(--lsu-body)}
89
90/* The hub's index of modules. A ruled list rather than a card grid: eight
91 cards is a wall, and the sentence beside each name is the part that is
92 worth reading. The whole row is the target, not the word. */
93.coaching-surface .coach-index {display:grid;margin:clamp(34px,4vw,56px) 0 0;padding:0;list-style:none;border-top:var(--coach-rule)}
94.coaching-surface .coach-index li {border-bottom:var(--coach-rule)}
95.coaching-surface .coach-index a {display:grid;grid-template-columns:minmax(0,15rem) minmax(0,1fr);gap:clamp(12px,3vw,40px);align-items:baseline;padding:clamp(18px,2.2vw,26px) 0;text-decoration:none}
96.coaching-surface .coach-index a:hover .coach-index-label {color:var(--lsu-red)}
97.coaching-surface .coach-index-label {font:400 clamp(20px,2.2vw,28px)/1.1 var(--lsu-display);text-transform:uppercase;color:var(--lsu-white);transition:color .18s}
98.coaching-surface .coach-index-summary {max-width:60ch;color:var(--lsu-dim-1);font:400 15px/1.65 var(--lsu-body)}
99
100/* Nothing published yet. The honest state, and the one that ships today. */
101.coaching-surface .coach-empty {margin:0 0 clamp(40px,5vw,72px);padding:clamp(30px,5vw,56px);border:var(--coach-rule);background:var(--lsu-card)}
102.coaching-surface .coach-empty h2,.coaching-surface .coach-empty h3 {margin:0;font:400 clamp(24px,3vw,34px)/1.1 var(--lsu-display);text-transform:uppercase}
103.coaching-surface .coach-empty p {margin:16px 0 0;max-width:58ch;color:var(--lsu-dim-1);font:400 16px/1.7 var(--lsu-body)}
104.coaching-surface .coach-empty-back a {color:var(--lsu-white);text-decoration:none;border-bottom:1px solid var(--lsu-red);padding-bottom:2px}
105.coaching-surface .coach-empty-back a:hover {color:var(--lsu-red)}
106
107/* Focus has to clear the black. A red ring on #000 is not reliably visible at
108 small sizes, so the ring is white with a red cast behind it. */
109.coaching-surface a:focus-visible,.coaching-surface button:focus-visible,.coaching-surface [tabindex]:focus-visible {outline:2px solid var(--lsu-white);outline-offset:3px;box-shadow:0 0 0 5px var(--lsu-red-glow)}
110
111@media (max-width:760px) {
112 .coaching-surface .coach-offer {grid-template-columns:minmax(0,1fr);gap:18px}
113 .coaching-surface .coach-index a {grid-template-columns:minmax(0,1fr);gap:8px}
114 .coaching-surface .coach-offer-price {text-align:left}
115 .coaching-surface .coach-panel {min-height:520px}
116}
public/assets/styles/production/home.cssmodified+11-0
Changes to public/assets/styles/production/home.css
@@ -1,1 +1,12 @@
11.accent.lsu-scope-home{color:var(--lsu-red)}.hero.lsu-scope-home{position:relative;height:auto;min-height:100svh;overflow:hidden;display:flex;align-items:flex-end}.hero-photo.lsu-scope-home{position:absolute;inset:-8% 0 32%;background-image:url(/assets/lsu/images/hero-desktop.webp);background-size:cover;background-position:50% 35%;filter:brightness(.85)}@media(max-width:900px){.hero-photo.lsu-scope-home{background-image:url(/assets/lsu/images/hero-mobile.webp);background-position:50% 30%}}.rig.lsu-scope-home{position:absolute;inset:-20% 0;pointer-events:none;mix-blend-mode:screen}.rig-core.lsu-scope-home{opacity:.85;animation:lsuTorch 7.4s ease-in-out infinite}.rig-streak.lsu-scope-home{opacity:.8;animation:lsuTorch 5.6s ease-in-out infinite}.rig-mid.lsu-scope-home{opacity:.75;animation:lsuTorch 8.8s ease-in-out infinite}.rig-haze.lsu-scope-home{opacity:.7;animation:lsuTorch 10.6s ease-in-out infinite}.glow.lsu-scope-home,.ray.lsu-scope-home,.streak.lsu-scope-home,.mid.lsu-scope-home,.haze.lsu-scope-home{position:absolute;left:0;top:0;width:10px;height:10px;transform:translate(-50%,-50%)}.glow.lsu-scope-home{border-radius:50%;background:radial-gradient(circle,#fffdf6c7,#fff7e142 30%,#fff0cd0f 62%,#0000);filter:blur(4px)}.ray.lsu-scope-home{-webkit-mask-image:radial-gradient(circle,rgba(0,0,0,.9) 0%,rgba(0,0,0,.35) 34%,rgba(0,0,0,0) 66%);mask-image:radial-gradient(circle,#000000e6,#00000059 34%,#0000 66%);filter:blur(1.2px)}.streak.lsu-scope-home{height:3px;background:linear-gradient(90deg,#0000,#fff8e429 32%,#fffcf466,#fff8e429 68%,#0000);filter:blur(2px)}.mid.lsu-scope-home{border-radius:50%;background:radial-gradient(circle,#fff9e838,#fff4d812 40%,#0000);filter:blur(9px)}.haze.lsu-scope-home{border-radius:50%;background:radial-gradient(circle,#fff6dc24,#fff2d20a 48%,#0000);filter:blur(22px)}.rain.lsu-scope-home{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;mix-blend-mode:screen}.rain-back.lsu-scope-home{opacity:.9;-webkit-mask-image:linear-gradient(to bottom,rgba(0,0,0,.95) 0%,rgba(0,0,0,.8) 55%,rgba(0,0,0,.55) 100%);mask-image:linear-gradient(to bottom,#000000f2,#000c 55%,#0000008c)}.rain-front.lsu-scope-home{z-index:4;filter:blur(1.2px);opacity:.5}@media(max-width:900px){.rain-front.lsu-scope-home{filter:none}}.hero-vignette.lsu-scope-home{position:absolute;inset:0;background:radial-gradient(120% 90% at 50% 10%,#0000,#0000008c 55%,#000000f5)}.hero-foot.lsu-scope-home{position:absolute;left:0;right:0;bottom:0;height:40%;pointer-events:none;z-index:2;background:linear-gradient(to bottom,#0000,#0000004d 45%,#000000bf 78%,#000)}.hero-mist.lsu-scope-home{position:absolute;inset:0;pointer-events:none;background:radial-gradient(60% 45% at 22% 78%,#becddc29,#0000 70%),radial-gradient(50% 40% at 76% 66%,#becddc1f,#0000 70%);animation:lsuMist 17s ease-in-out infinite alternate;mix-blend-mode:screen}.hero-flash.lsu-scope-home{position:absolute;inset:0;pointer-events:none;background:linear-gradient(to bottom,#d2e1f01a,#0000 55%);animation:lsuFlash 14s linear infinite;mix-blend-mode:screen}.hero-copy.lsu-scope-home{position:relative;z-index:3;width:100%;max-width:calc(var(--lsu-max) + 2 * var(--lsu-pad-x));margin-inline:auto;padding:clamp(96px,12vh,140px) var(--lsu-pad-x) 56px;display:flex;flex-direction:column;gap:28px}.hero-live.lsu-scope-home{display:flex;align-items:center;gap:14px;font-size:11px;letter-spacing:.3em;text-transform:uppercase;color:var(--lsu-red);text-shadow:0 1px 14px rgba(0,0,0,.95),0 0 4px rgba(0,0,0,.8)}.hero-dot.lsu-scope-home{width:8px;height:8px;border-radius:50%;background:var(--lsu-red);animation:lsuPulse 2.4s ease-in-out infinite;flex:0 0 auto}.hero-copy.lsu-scope-home h1.lsu-scope-home{margin:0;font-family:var(--lsu-display);font-size:clamp(24px,8vw,190px);line-height:1.05;letter-spacing:.02em;text-transform:uppercase;max-width:1100px;white-space:nowrap}.hero-copy.lsu-scope-home h1.lsu-scope-home .hero-kicker.lsu-scope-home{display:block;margin-bottom:26px;font-family:var(--lsu-body);line-height:1.4;white-space:normal;color:#ffffffb8;text-shadow:0 1px 14px rgba(0,0,0,.95),0 0 4px rgba(0,0,0,.8)}.hero-base.lsu-scope-home{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:32px;border-top:1px solid var(--lsu-line);padding-top:26px}.hero-base.lsu-scope-home p.lsu-scope-home{margin:0;max-width:480px;font-size:18px;line-height:1.6;color:#ffffffb8}.hero-actions.lsu-scope-home{display:flex;gap:14px;flex-wrap:wrap}@media(max-width:1080px){.hero-actions.lsu-scope-home{display:none}}.marquee-band.lsu-scope-home{position:relative;z-index:4;background:var(--lsu-black);border-top:1px solid var(--lsu-line-soft);border-bottom:1px solid var(--lsu-line-soft);overflow:hidden}.marquee.lsu-scope-home{display:flex;gap:56px;padding:16px 0;white-space:nowrap;font-family:var(--lsu-display);font-size:15px;letter-spacing:.24em;text-transform:uppercase;color:#ffffff73;width:max-content;animation:lsuMarquee 60s linear infinite;will-change:transform}.marquee.lsu-scope-home>.lsu-scope-home{flex:none}@keyframes lsuMarquee{to{transform:translate(-50%)}}.marquee.lsu-scope-home .slash.lsu-scope-home{color:var(--lsu-red)}.lapscroll.lsu-scope-home{position:relative;background:var(--lsu-black)}.lap-inner.lsu-scope-home{position:relative;min-height:100vh;overflow:hidden;display:flex;align-items:center;padding-block:clamp(40px,5vw,72px)}.lap-photo.lsu-scope-home{position:absolute;inset:-10% 0;background-image:url(/assets/lsu/images/rappid-65.webp);background-size:cover;background-position:center;filter:grayscale(1) contrast(1.2) brightness(.3);opacity:.5}.lap-scrim.lsu-scope-home{position:absolute;inset:0;background:linear-gradient(to bottom,#000000f2,#00000080 50%,#000000f2)}.lap-grid.lsu-scope-home{position:relative;z-index:2;width:100%;padding:0 var(--lsu-pad-x);display:grid;grid-template-columns:1.15fr 1fr;gap:64px;align-items:center}.lap-kicker.lsu-scope-home{margin-bottom:22px}.lap-readouts.lsu-scope-home{display:flex;align-items:flex-start;gap:24px;flex-wrap:wrap}.seg.lsu-scope-home{position:relative;display:inline-block}.seg-ghost.lsu-scope-home{color:#ffffff14}.seg-live.lsu-scope-home{position:absolute;left:0;top:0;color:var(--lsu-white);text-shadow:0 0 16px rgba(255,255,255,.5),0 0 52px rgba(255,255,255,.2)}.seg-ghost.lsu-scope-home,.seg-live.lsu-scope-home{font-family:var(--lsu-seg);font-variant-numeric:tabular-nums}.seg-ghost-red.lsu-scope-home{color:#ff2a1a17}.seg-live-red.lsu-scope-home{color:var(--lsu-seg-red);text-shadow:0 0 18px rgba(255,42,26,.75),0 0 60px rgba(255,42,26,.35)}.seg-lap.lsu-scope-home{font-size:min(16vw,168px,18vh);line-height:.9}.seg-live.lsu-scope-home.seg-lap{inset:0}.seg-clock.lsu-scope-home{font-size:clamp(24px,3vw,46px);line-height:1;white-space:nowrap}.seg-live.lsu-scope-home.seg-clock{text-shadow:0 0 14px rgba(255,42,26,.7)}.seg-souls.lsu-scope-home{font-size:clamp(52px,7.5vw,110px);line-height:1}.seg-km.lsu-scope-home{font-size:clamp(30px,4vw,58px);line-height:1;white-space:nowrap}.seg-live.lsu-scope-home.seg-km{text-shadow:0 0 14px rgba(255,255,255,.45),0 0 44px rgba(255,255,255,.18)}.seg-unit.lsu-scope-home{font-family:var(--lsu-body);font-size:.35em;letter-spacing:.2em;color:var(--lsu-dim-2)}.seg-label.lsu-scope-home{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:#ffffff52;margin-bottom:8px}.seg-label-red.lsu-scope-home{color:#ff2a1aa6}.lap-elapsed.lsu-scope-home{margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,42,26,.25);display:flex;align-items:baseline;gap:12px}.lap-elapsed.lsu-scope-home .seg-label.lsu-scope-home{margin-bottom:0;color:#ff2a1a99}.lap-side.lsu-scope-home{padding-top:14px;display:flex;flex-direction:column;gap:18px}.lap-meta.lsu-scope-home{display:flex;flex-direction:column;gap:6px;font-size:11px;letter-spacing:.26em;text-transform:uppercase;color:#ffffff52;text-shadow:0 0 10px rgba(255,255,255,.15)}.lap-meta-live.lsu-scope-home{color:#ffffffd9;text-shadow:0 0 10px rgba(255,255,255,.25)}.attr.lsu-scope-home{margin-top:34px}.attr-head.lsu-scope-home{display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 16px;font-size:9px;letter-spacing:.28em;text-transform:uppercase;color:#ffffff4d;margin-bottom:8px}.attr.lsu-scope-home canvas.lsu-scope-home{cursor:crosshair;display:block;width:100%;height:44px;filter:drop-shadow(0 0 8px rgba(255,255,255,.28)) drop-shadow(0 0 5px rgba(255,42,26,.3))}.lap-copy.lsu-scope-home{display:flex;flex-direction:column;gap:26px}.lap-copy.lsu-scope-home p.lsu-scope-home{white-space:pre-line}.lap-copy.lsu-scope-home h2.lsu-scope-home{margin:0;font-family:var(--lsu-display);font-size:clamp(34px,4.2vw,62px);line-height:1.05;text-transform:uppercase;letter-spacing:.02em}.lap-copy.lsu-scope-home p.lsu-scope-home{margin:0;font-size:18px;line-height:1.6;color:var(--lsu-dim-2);max-width:430px;min-height:116px}.lap-facts.lsu-scope-home{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;background:var(--lsu-line);border:1px solid var(--lsu-line)}.lap-facts.lsu-scope-home>div.lsu-scope-home{background:var(--lsu-black);padding:20px 22px}.lap-fact-l.lsu-scope-home{font-size:10px;letter-spacing:.26em;text-transform:uppercase;color:#ffffff4d;margin-bottom:8px}.lap-fact-v.lsu-scope-home{font-family:var(--lsu-display);font-size:22px;text-transform:uppercase}.y87scroll.lsu-scope-home{position:relative;background:var(--lsu-black);border-top:1px solid var(--lsu-line-soft)}.y87-sticky.lsu-scope-home{position:relative;min-height:62vh;overflow:hidden;display:flex;align-items:flex-start;justify-content:flex-start;padding:clamp(56px,8vh,80px) 0 clamp(40px,14vh,140px)}.y87-bg.lsu-scope-home{position:absolute;inset:0}.y87-poster.lsu-scope-home{position:absolute;inset:0;background-image:url(/assets/lsu/images/yard87-poster.jpg);background-size:cover;background-position:38% 30%}.y87-vid.lsu-scope-home{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:38% 30%}.y87-top.lsu-scope-home{position:absolute;inset:0;background:linear-gradient(to bottom,#000000d9,#0000 30% 68%,#00000080)}.y87-shell.lsu-scope-home{position:relative;width:100%;padding:0 var(--lsu-pad-x);display:flex;justify-content:center}.y87-col.lsu-scope-home{width:100%;max-width:var(--lsu-max);display:flex;flex-direction:column;gap:clamp(18px,2.6vh,28px)}.y87-stage.lsu-scope-home{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}.y87-chapter.lsu-scope-home{display:flex;flex-direction:column;gap:10px;padding:20px;background:#0000008c;border:1px solid rgba(255,255,255,.16);backdrop-filter:blur(3px)}.y87-chapter.lsu-scope-home h2.lsu-scope-home{margin:0;font-family:var(--lsu-display);font-size:clamp(20px,2.2vw,30px);line-height:1;text-transform:uppercase;text-shadow:0 2px 30px rgba(0,0,0,.9)}.y87-chapter.lsu-scope-home p.lsu-scope-home{margin:0;font-size:14px;line-height:1.55;color:var(--lsu-dim-1);text-shadow:0 1px 12px rgba(0,0,0,.8)}@media(max-width:900px){.y87-stage.lsu-scope-home{grid-template-columns:1fr 1fr}.y87-vid.lsu-scope-home{object-position:42% 30%}.y87-poster.lsu-scope-home{background-position:42% 30%}}@media(max-width:560px){.y87-stage.lsu-scope-home{grid-template-columns:1fr}}.recap.lsu-scope-home{position:relative;padding:clamp(66px,8.6vw,120px) var(--lsu-pad-x);background:var(--lsu-black)}.recap-head.lsu-scope-home{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:22px 30px;margin-bottom:52px;border-bottom:1px solid var(--lsu-line);padding-bottom:22px}.recap-title.lsu-scope-home{margin:0;font-family:var(--lsu-display);font-size:clamp(38px,5.5vw,84px);line-height:1.05;text-transform:uppercase}.recap-meta.lsu-scope-home{display:flex;flex-direction:column;align-items:flex-end;gap:14px;padding-bottom:8px}.recap-sub.lsu-scope-home{font-size:12px;letter-spacing:.22em;text-transform:uppercase;color:var(--lsu-dim-2)}.recap-link.lsu-scope-home{display:inline-flex;align-items:center;gap:10px;font-size:12px;font-weight:600;letter-spacing:.22em;text-transform:uppercase;color:var(--lsu-red);border-bottom:1px solid var(--lsu-red);padding-bottom:4px}.recap-link.lsu-scope-home:hover{color:var(--lsu-white);border-color:var(--lsu-white)}.carousel-track.lsu-scope-home{display:flex;gap:20px;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;scrollbar-width:none;overscroll-behavior-x:contain}.carousel-track.lsu-scope-home::-webkit-scrollbar{display:none}.carousel-track.lsu-scope-home:focus-visible{outline:2px solid var(--lsu-red);outline-offset:4px}.carousel-slide.lsu-scope-home{flex:0 0 calc((100% - 40px) / 3);scroll-snap-align:start;aspect-ratio:3 / 4;overflow:hidden}.carousel-slide.lsu-scope-home img.lsu-scope-home{display:block;width:100%;height:100%;object-fit:cover;object-position:center}.carousel-nav.lsu-scope-home{display:flex;align-items:center;justify-content:center;gap:22px;margin-top:26px}.carousel-btn.lsu-scope-home{width:56px;height:56px;border:1px solid var(--lsu-line);background:transparent;color:var(--lsu-white);font-size:22px;cursor:pointer}.carousel-btn.lsu-scope-home:hover:not(:disabled){background:var(--lsu-red);border-color:var(--lsu-red);color:var(--lsu-white)}.carousel-btn.lsu-scope-home:disabled{opacity:.3;cursor:default}.carousel-count.lsu-scope-home{min-width:5ch;text-align:center;font-family:var(--lsu-mono);font-size:13px;letter-spacing:.16em;color:var(--lsu-dim-2)}@media(max-width:1000px){.carousel-slide.lsu-scope-home{flex-basis:calc((100% - 20px)/2)}}@media(max-width:640px){.carousel-slide.lsu-scope-home{flex-basis:100%}.carousel-track.lsu-scope-home{gap:12px}}@media(max-width:900px){.recap-head.lsu-scope-home>div.lsu-scope-home{align-items:flex-start}}.duelscroll.lsu-scope-home{position:relative;background:var(--lsu-black);border-top:1px solid var(--lsu-line-soft)}.duel-sticky.lsu-scope-home{position:relative;min-height:100vh;overflow:hidden;display:flex;align-items:center;justify-content:flex-start;padding-block:clamp(40px,5vw,72px)}.duel-bg.lsu-scope-home{position:absolute;inset:-26% 0;background-image:url(/assets/lsu/images/rappid-13-1080.webp);background-size:cover;background-position:center 30%;opacity:.22;filter:grayscale(1) contrast(1.15);will-change:transform}.duel-scrim.lsu-scope-home{position:absolute;inset:0;background:radial-gradient(90% 75% at 50% 46%,#00000052,#000000e6),linear-gradient(to bottom,#000000e6,#0003 26% 72%,#000000eb)}.duel-shell.lsu-scope-home{position:relative;width:100%;max-width:var(--lsu-max);margin:0 auto;padding:0 var(--lsu-pad-x)}.duel-kicker.lsu-scope-home{margin-bottom:18px}.duel-title.lsu-scope-home{margin-bottom:46px}.duel-bars.lsu-scope-home{display:flex;flex-direction:column;gap:38px}.duel-head.lsu-scope-home{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px;gap:16px;flex-wrap:wrap}.duel-id.lsu-scope-home{display:flex;align-items:baseline;gap:16px}.duel-ed.lsu-scope-home{font-family:var(--lsu-display);font-size:20px;text-transform:uppercase;color:#ffffffd9}.duel-ed-2.lsu-scope-home{color:var(--lsu-red)}.duel-km.lsu-scope-home{font-family:var(--lsu-seg);font-size:15px;color:var(--lsu-dim-1)}.duel-km-2.lsu-scope-home{color:var(--lsu-red)}.duel-unit.lsu-scope-home{font-family:var(--lsu-mono);font-size:12px;letter-spacing:.12em}.duel-note.lsu-scope-home{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--lsu-dim-3)}.duel-track.lsu-scope-home{position:relative;height:34px;background:#ffffff12}.duel-fill.lsu-scope-home{position:absolute;left:0;top:0;bottom:0;width:0%}.duel-fill-1.lsu-scope-home{background:#ffffffd9}.duel-fill-2.lsu-scope-home{background:var(--lsu-red)}.duel-yard.lsu-scope-home{position:absolute;left:8px;top:50%;transform:translateY(-50%);font-family:var(--lsu-seg);font-size:18px}.duel-yard-1.lsu-scope-home{color:#000;mix-blend-mode:difference;filter:invert(1)}.duel-yard-2.lsu-scope-home{color:var(--lsu-white);text-shadow:0 0 10px rgba(0,0,0,.6)}.duel-mark.lsu-scope-home{position:relative;height:0}.duel-mark-line.lsu-scope-home{position:absolute;left:95.74%;top:-152px;width:1px;height:170px;background:#ff2a1a80}.duel-mark-label.lsu-scope-home{position:absolute;left:95.74%;top:22px;transform:translate(-100%);font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:#ff2a1acc;white-space:nowrap;padding-right:8px}.duel-text.lsu-scope-home{margin:6px 0 0;max-width:560px;font-size:16px;line-height:1.6;color:var(--lsu-dim-2);min-height:52px}.attr-band.lsu-scope-home .band-bg.lsu-scope-home{inset:-8% 0;background-image:url(/assets/lsu/images/statement-1080.webp);background-size:cover;background-position:center 44%;filter:brightness(.6)}@media(max-width:760px){.attr-band.lsu-scope-home .band-bg.lsu-scope-home{background-image:url(/assets/lsu/images/statement-720.webp);background-position:center 30%}}.attr-band.lsu-scope-home .band-scrim.lsu-scope-home{background:linear-gradient(to top,#000000d9,#00000026 60%,#00000040)}.attr-band.lsu-scope-home .band-copy.lsu-scope-home{align-self:flex-end;padding-bottom:clamp(24px,5vw,64px)}.attr-band.lsu-scope-home{height:85vh;min-height:480px}.attr-copy.lsu-scope-home{font-size:clamp(28px,3.8vw,56px);letter-spacing:.02em}@media(max-width:760px){.lap-inner.lsu-scope-home{min-height:0}.lap-facts.lsu-scope-home{display:none}.lap-readouts.lsu-scope-home{gap:16px}.lap-side.lsu-scope-home{flex-direction:row;align-items:baseline;gap:26px;padding-top:6px;flex-wrap:wrap}}@media(prefers-reduced-motion:reduce){.rig.lsu-scope-home,.hero-mist.lsu-scope-home,.hero-flash.lsu-scope-home,.hero-dot.lsu-scope-home{animation:none}}
2
3/* Moved out of stx-site.css, which every route downloads and none of these
4 can match: the duel bars and the deferral hints belong to this page. */
5.duel-fill-1.lsu-scope-home{width:71.28%}
6.duel-fill-2.lsu-scope-home{width:100%}
7/* Let the browser defer distant story panels and their background photos.
8 Explicitly sized bands keep their authored height. Story panels remember
9 their measured height after their first visit; one viewport reserves space
10 before they approach the viewport. Unsupported browsers render normally. */
11.lapscroll.lsu-scope-home,.duelscroll.lsu-scope-home{content-visibility:auto;contain-intrinsic-block-size:auto 100svh}
12.attr-band.lsu-scope-home{content-visibility:auto}
public/assets/styles/production/next-loop.cssmodified+4-0
Changes to public/assets/styles/production/next-loop.css
@@ -1,1 +1,5 @@
11.enter.lsu-scope-next-loop{position:relative;min-height:640px;height:100vh;overflow:hidden;display:flex;align-items:center;justify-content:center}.enter-bg.lsu-scope-next-loop{position:absolute;inset:-20% 0;background-image:url(/assets/lsu/images/rappid-20.webp);background-size:cover;background-position:center 40%;filter:grayscale(1) brightness(.45)}.enter-scrim.lsu-scope-next-loop{position:absolute;inset:0;background:radial-gradient(80% 70% at 50% 50%,#00000059,#000000f2)}.enter-copy.lsu-scope-next-loop{position:relative;z-index:3;text-align:center;padding:0 var(--lsu-pad-x);display:flex;flex-direction:column;align-items:center;gap:30px}.seg.lsu-scope-next-loop{position:relative;display:inline-block}.seg-ghost.lsu-scope-next-loop,.seg-live.lsu-scope-next-loop{font-family:var(--lsu-seg);font-variant-numeric:tabular-nums;font-size:clamp(56px,12vw,160px);line-height:1;white-space:nowrap}.seg-ghost.lsu-scope-next-loop{color:#ffffff14}.seg-live.lsu-scope-next-loop{position:absolute;left:0;top:0;color:var(--lsu-white);text-shadow:0 0 16px rgba(255,255,255,.5),0 0 52px rgba(255,255,255,.2)}.enter-sub.lsu-scope-next-loop{margin-top:-18px;font:13px var(--lsu-mono);letter-spacing:.24em;text-transform:uppercase;color:var(--lsu-dim-1)}.enter-title.lsu-scope-next-loop{margin:0;font-family:var(--lsu-display);font-size:clamp(44px,8vw,132px);line-height:1;letter-spacing:.02em;text-transform:uppercase}.enter-copy.lsu-scope-next-loop p.lsu-scope-next-loop{margin:0;max-width:560px;font-size:17px;line-height:1.6;color:#ffffffb8}
2
3/* Moved out of stx-site.css: this sheet is loaded by the two pages that
4 have an .enter band, and no others. */
5.enter.lsu-scope-next-loop{content-visibility:auto}
public/assets/styles/production/not-found.cssadded+14-0
Changes to public/assets/styles/production/not-found.css
@@ -0,0 +1,14 @@
1/* /404.
2
3 The page used to be written in six class names that exist in no stylesheet
4 on this site - h-page, display, ink, faint, muted and btn-accent - left
5 over from an earlier design system, so its heading, eyebrow, body copy and
6 primary button all rendered unstyled. It is written in the site's own
7 vocabulary now: the same .phero block the legal pages use, and the .btn and
8 .btn-outline pair from base.css.
9
10 All this sheet adds is the row the two buttons sit in, and the bottom
11 padding .phero does not carry. */
12
13.not-found-actions{display:flex;flex-wrap:wrap;gap:14px;margin-top:10px}
14.phero:last-child{padding-bottom:clamp(90px,12vw,160px)}
public/assets/styles/production/race-entry.cssmodified+52-0
Changes to public/assets/styles/production/race-entry.css
@@ -1,1 +1,53 @@
11.form.lsu-scope-lottery{max-width:760px;border:1px solid rgba(255,255,255,.16);padding:44px;display:flex;flex-direction:column;gap:26px}.portal.lsu-scope-lottery .portal-head h2,.portal.lsu-scope-lottery .portal-top h2{margin:10px 0 0;font-family:var(--lsu-display);font-size:clamp(26px,3.4vw,48px);line-height:1.05;text-transform:uppercase}.portal.lsu-scope-lottery .portal-top{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.portal.lsu-scope-lottery button:not(.btn){appearance:none;border:0;padding:4px 0;background:transparent;color:#ffffffb8;font:inherit;text-decoration:underline;text-underline-offset:4px;cursor:pointer}.portal.lsu-scope-lottery .portal-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px 18px}.portal.lsu-scope-lottery .portal-form:not(.application){grid-template-columns:1fr;max-width:520px}.portal.lsu-scope-lottery .field{display:flex;flex-direction:column;gap:9px;color:#ffffff9e;font-family:var(--lsu-mono);font-size:17px;letter-spacing:.04em;line-height:1.4;text-transform:uppercase}.portal.lsu-scope-lottery .field input,.portal.lsu-scope-lottery .field select{box-sizing:border-box;width:100%;min-height:52px;border:1px solid rgba(255,255,255,.2);border-radius:0;background:#090909;color:var(--lsu-white);padding:13px 15px;font-family:var(--lsu-body);font-size:17px;letter-spacing:0;outline:none;text-transform:none}.portal.lsu-scope-lottery .field input:hover,.portal.lsu-scope-lottery .field select:hover{border-color:#ffffff61}.portal.lsu-scope-lottery .field select{appearance:none;padding-right:44px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='9' viewBox='0 0 14 9'%3E%3Cpath d='M1 1l6 6 6-6' fill='none' stroke='%23ffffff' stroke-opacity='.7' stroke-width='2'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 16px center;cursor:pointer}.portal.lsu-scope-lottery .field select option{background:#090909;color:var(--lsu-white)}.portal.lsu-scope-lottery .field input:focus-visible,.portal.lsu-scope-lottery .field select:focus-visible,.portal.lsu-scope-lottery .field textarea:focus-visible{border-color:var(--lsu-red);box-shadow:0 0 0 1px var(--lsu-red)}.portal.lsu-scope-lottery .portal-form>.form-submit,.portal.lsu-scope-lottery .portal-form>.form-status,.portal.lsu-scope-lottery .portal-form>.save-state,.portal.lsu-scope-lottery .field-wide,.portal.lsu-scope-lottery [data-pb],.portal.lsu-scope-lottery .check,.portal.lsu-scope-lottery .form-actions{grid-column:1 / -1}.portal.lsu-scope-lottery [data-pb]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px 18px}.portal.lsu-scope-lottery [data-pb][hidden]{display:none}.portal.lsu-scope-lottery .portal-links,.portal.lsu-scope-lottery .form-actions{display:flex;flex-wrap:wrap;align-items:center;gap:18px}.portal.lsu-scope-lottery .portal-links{max-width:520px;padding-top:2px}.portal.lsu-scope-lottery .form-actions .btn{flex:1}.portal.lsu-scope-lottery .form-back{min-height:49px;padding:0 18px;border:1px solid rgba(255,255,255,.28);color:var(--lsu-white);font:600 12px/1 var(--lsu-mono);letter-spacing:.12em;text-transform:uppercase}.portal.lsu-scope-lottery .form-back:hover,.portal.lsu-scope-lottery .form-back:focus-visible{border-color:var(--lsu-red);color:var(--lsu-red)}.portal.lsu-scope-lottery .field textarea{min-height:140px;resize:vertical;background:#0a0a0a;border:1px solid rgba(255,255,255,.2);color:var(--lsu-white);padding:14px;font:16px/1.5 var(--lsu-body)}.portal.lsu-scope-lottery .field small{min-height:1em;color:#ff725e;font:12px/1.35 var(--lsu-body);letter-spacing:0;text-transform:none}.portal.lsu-scope-lottery .check{display:flex;align-items:flex-start;gap:12px;color:#ffffffd1;font-size:15px;line-height:1.45}.portal.lsu-scope-lottery .check input{width:19px;height:19px;accent-color:var(--lsu-red)}.portal.lsu-scope-lottery .attest{padding:18px;border:1px solid rgba(255,255,255,.18)}.portal.lsu-scope-lottery .steps-mini{display:flex;gap:8px}.portal.lsu-scope-lottery .steps-mini span{width:100%;height:3px;background:#ffffff26;color:transparent}.portal.lsu-scope-lottery .steps-mini .active{background:var(--lsu-red);box-shadow:0 0 12px var(--lsu-red-glow)}.portal.lsu-scope-lottery .save-state{min-height:1em;margin:-10px 0 0;color:var(--lsu-dim-2);font-size:12px}.portal.lsu-scope-lottery .form-status.bad,.portal.lsu-scope-lottery .save-state.bad{color:#ff725e}.portal.lsu-scope-lottery .portal-message{text-align:center;padding:28px 0;color:#ffffffbf}.portal.lsu-scope-lottery .retry{align-self:center}.portal.lsu-scope-lottery .status-card{padding:28px;border:1px solid rgba(255,255,255,.18);font-size:22px}.portal.lsu-scope-lottery .status-card p{margin:10px 0 0;color:var(--lsu-dim-2);font-size:14px}.portal.lsu-scope-lottery .status-card .add-cal{display:inline-flex;align-items:center;min-height:44px;margin-top:10px;color:var(--lsu-red);font:600 11px var(--lsu-mono);letter-spacing:.16em;text-transform:uppercase;text-decoration:underline;text-underline-offset:4px}.portal.lsu-scope-lottery .close-note{margin:0 0 18px;color:var(--lsu-dim-2);font-size:13px}.portal.lsu-scope-lottery .status-dot{width:9px;height:9px;margin-bottom:14px;border-radius:50%;background:var(--lsu-red);box-shadow:0 0 14px var(--lsu-red)}.portal.lsu-scope-lottery .danger{color:#ff725e!important}.portal.lsu-scope-lottery .withdraw-row{grid-column:1 / -1;margin-top:38px;padding-top:22px;border-top:1px solid rgba(255,255,255,.12)}.portal.lsu-scope-lottery .withdraw-row .danger{font-size:14px;text-decoration:underline;text-underline-offset:4px}.portal.lsu-scope-lottery .withdraw-ask{display:grid;gap:6px;margin:0 0 18px;max-width:520px}.portal.lsu-scope-lottery .withdraw-ask strong{font-size:17px;color:var(--lsu-white)}.portal.lsu-scope-lottery .withdraw-ask span{font-size:14px;color:var(--lsu-dim-2)}.portal.lsu-scope-lottery .withdraw-why{max-width:520px;margin:0 0 18px;font-size:13px}.portal.lsu-scope-lottery .withdraw-why textarea{min-height:88px}.portal.lsu-scope-lottery .withdraw-answers{display:flex;flex-wrap:wrap;align-items:center;gap:18px}.portal.lsu-scope-lottery .withdraw-answers .btn{flex:0 0 auto}.lottery-dialog.lsu-scope-lottery{width:min(760px,calc(100% - 32px));max-width:none;max-height:calc(100dvh - 32px);padding:0;border:1px solid rgba(255,255,255,.16);background:#050505;color:var(--lsu-white);box-shadow:0 0 70px #dd0f1559;overflow:auto;overscroll-behavior:contain}.lottery-dialog.lsu-scope-lottery::backdrop{background:#000000d1}.lottery-dialog.lsu-scope-lottery .form.lsu-scope-lottery{border:0;padding-top:0}.lottery-dialog.lsu-scope-lottery[data-account-only] .lottery-stages.lsu-scope-lottery{display:none}.lottery-dialog.lsu-scope-lottery[data-account-only] .lottery-bar.lsu-scope-lottery{justify-content:flex-end}.lottery-bar.lsu-scope-lottery{position:sticky;top:0;z-index:1;display:flex;align-items:flex-start;gap:16px;padding:12px 8px 28px 44px;background:#050505}.lottery-stages.lsu-scope-lottery{flex:1;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:12px 0 0;padding:0;list-style:none}.lottery-stages.lsu-scope-lottery li.lsu-scope-lottery{display:flex;flex-direction:column;gap:8px;padding-top:12px;border-top:3px solid rgba(255,255,255,.15);color:#ffffff73;transition:border-color .3s,color .3s}.lottery-stages.lsu-scope-lottery .done.lsu-scope-lottery{border-color:var(--lsu-red);color:var(--lsu-red)}.lottery-stages.lsu-scope-lottery .lsu-scope-lottery[aria-current=step]{border-color:var(--lsu-red);color:var(--lsu-white)}.stage-n.lsu-scope-lottery{font:11px var(--lsu-mono);letter-spacing:.16em;text-transform:uppercase}.lsu-scope-lottery[aria-current=step] .stage-n.lsu-scope-lottery{color:var(--lsu-red)}.stage-label.lsu-scope-lottery{font:20px/1 var(--lsu-display);text-transform:uppercase}.lottery-close.lsu-scope-lottery{width:48px;height:48px;border:0;background:transparent;color:var(--lsu-white);font-size:32px;line-height:1;cursor:pointer}.lottery-close.lsu-scope-lottery:hover,.lottery-close.lsu-scope-lottery:focus-visible{color:var(--lsu-red)}html:has(.lottery-dialog[open]){overflow:hidden}@media(prefers-reduced-motion:no-preference){.lottery-dialog.lsu-scope-lottery[open]{animation:lottery-in .22s cubic-bezier(.2,.8,.2,1)}.lottery-dialog.lsu-scope-lottery[open]::backdrop{animation:lottery-fade .22s ease-out}}@keyframes lottery-in{0%{opacity:0;transform:translateY(12px)}}@keyframes lottery-fade{0%{opacity:0}}@media(prefers-reduced-motion:reduce){.lottery-stages.lsu-scope-lottery li.lsu-scope-lottery{transition:none}}@media(max-width:760px){.form.lsu-scope-lottery{padding:28px 20px}.lottery-bar.lsu-scope-lottery{gap:8px;padding:8px 4px 24px 20px}.stage-n.lsu-scope-lottery{font-size:10px;letter-spacing:.1em}.stage-label.lsu-scope-lottery{font-size:16px}.portal.lsu-scope-lottery .portal-form,.portal.lsu-scope-lottery [data-pb]{grid-template-columns:1fr}}
2
3/* The two sections below the form.
4
5 They shipped with no rules at all: .entry, .entry-shell, .open-now,
6 .open-list, .open-status, .steps-shell, .steps-title, .steps, .step,
7 .step-n, .step-copy, .step-title, .step-body and .deadline are named in
8 lottery.stx and defined in no stylesheet this site serves, so the four
9 open events rendered as a plain stack of country, date and status running
10 together, and the six entry steps as bare numerals over unstyled text.
11
12 Written in the vocabulary the rest of the site uses: hairline rules
13 rather than cards, Anton for anything that is a label or a number, Barlow
14 for anything that is a sentence, and red reserved for the one state that
15 is actionable. */
16
17.entry.lsu-scope-lottery{padding:clamp(40px,6vw,80px) var(--lsu-pad-x) 0}
18.entry-shell.lsu-scope-lottery{max-width:var(--lsu-max);margin:0 auto}
19
20.open-now.lsu-scope-lottery .h2-sm.lsu-scope-lottery{margin:0 0 clamp(26px,3vw,40px)}
21.open-list.lsu-scope-lottery{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border-top:1px solid var(--lsu-line)}
22.open-list.lsu-scope-lottery article.lsu-scope-lottery{display:flex;flex-direction:column;gap:12px;padding:clamp(22px,2.4vw,30px) clamp(18px,2vw,26px) clamp(26px,3vw,36px) 0;border-bottom:1px solid var(--lsu-line)}
23.open-list.lsu-scope-lottery h3.lsu-scope-lottery{display:flex;flex-direction:column;gap:8px;margin:0;font:400 clamp(20px,2.1vw,27px)/1.05 var(--lsu-display);letter-spacing:.02em;text-transform:uppercase}
24.open-list.lsu-scope-lottery h3.lsu-scope-lottery span.lsu-scope-lottery{color:var(--lsu-dim-2);font:400 12px/1 var(--lsu-body);letter-spacing:.16em}
25.open-status.lsu-scope-lottery{margin:auto 0 0;color:var(--lsu-dim-2);font:400 13px/1.5 var(--lsu-body)}
26.open-status.is-open.lsu-scope-lottery{display:flex;align-items:center;gap:9px;color:var(--lsu-white);font-weight:600;letter-spacing:.08em;text-transform:uppercase}
27/* The red is the marker, not the words: #dd0f15 on black is 4.15:1, which
28 clears the 3:1 a graphical indicator needs and not the 4.5 this 13px label
29 would. */
30.open-status.is-open.lsu-scope-lottery:before{content:"";flex:none;width:9px;height:9px;background:var(--lsu-red);box-shadow:0 0 10px var(--lsu-red-glow)}
31.open-status.lsu-scope-lottery a.lsu-scope-lottery{color:var(--lsu-white);text-decoration:underline;text-underline-offset:4px}
32.open-status.lsu-scope-lottery a.lsu-scope-lottery:hover{color:var(--lsu-red)}
33
34.steps-shell.lsu-scope-lottery{max-width:var(--lsu-max);margin:0 auto;padding:0 var(--lsu-pad-x)}
35.steps-title.lsu-scope-lottery{margin:0 0 clamp(30px,4vw,52px)}
36.steps.lsu-scope-lottery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0;border-top:1px solid var(--lsu-line)}
37.step.lsu-scope-lottery{display:grid;grid-template-columns:auto minmax(0,1fr);gap:clamp(14px,1.8vw,24px);align-items:start;padding:clamp(24px,2.6vw,34px) clamp(20px,2.2vw,30px) clamp(28px,3vw,38px) 0;border-bottom:1px solid var(--lsu-line-soft)}
38.step-n.lsu-scope-lottery{color:var(--lsu-red);font:400 clamp(26px,2.8vw,38px)/1 var(--lsu-display)}
39.step-copy.lsu-scope-lottery{display:flex;flex-direction:column;gap:10px}
40.step-title.lsu-scope-lottery{font:400 clamp(17px,1.7vw,21px)/1.2 var(--lsu-display);letter-spacing:.02em;text-transform:uppercase}
41.step-body.lsu-scope-lottery{max-width:40ch;color:var(--lsu-dim-1);font:400 15px/1.65 var(--lsu-body)}
42.step-body.lsu-scope-lottery a{color:var(--lsu-white);text-decoration:underline;text-underline-offset:4px}
43.step-body.lsu-scope-lottery a:hover{color:var(--lsu-red)}
44
45.deadline.lsu-scope-lottery{min-height:clamp(360px,54vh,520px)}
46
47@media(max-width:1000px){
48.open-list.lsu-scope-lottery{grid-template-columns:repeat(2,minmax(0,1fr))}
49.steps.lsu-scope-lottery{grid-template-columns:repeat(2,minmax(0,1fr))}
50}
51@media(max-width:620px){
52.open-list.lsu-scope-lottery,.steps.lsu-scope-lottery{grid-template-columns:minmax(0,1fr)}
53}
public/assets/styles/production/soul-races.cssmodified+1-1
Changes to public/assets/styles/production/soul-races.css
@@ -1,1 +1,1 @@
1.sr.lsu-scope-soul-races{min-height:100vh;padding-bottom:100px}.sr-body.lsu-scope-soul-races{padding:0 var(--lsu-pad-x)}.sr-head.lsu-scope-soul-races,.sr-filters.lsu-scope-soul-races,.sr-stage.lsu-scope-soul-races,.sr-host.lsu-scope-soul-races{max-width:var(--lsu-max);margin-left:auto;margin-right:auto}.sr-head.lsu-scope-soul-races .accent.lsu-scope-soul-races{color:var(--lsu-red)}.sr-note.lsu-scope-soul-races{max-width:640px;margin:0;font-size:15px;line-height:1.6;color:var(--lsu-dim-1)}.sr-counts.lsu-scope-soul-races{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px 24px;margin:14px 0 0;padding:0}.sr-counts.lsu-scope-soul-races div.lsu-scope-soul-races{display:flex;flex-direction:column-reverse;gap:6px;padding-top:14px;border-top:1px solid var(--lsu-line)}.sr-counts.lsu-scope-soul-races dd.lsu-scope-soul-races{margin:0;font:clamp(34px,4vw,52px)/1 var(--lsu-display)}.sr-counts.lsu-scope-soul-races dt.lsu-scope-soul-races{font:12px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2)}.sr-filters.lsu-scope-soul-races{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-top:var(--lsu-head-gap);padding:20px 0;border-top:1px solid var(--lsu-line);border-bottom:1px solid var(--lsu-line)}.sr-filters.lsu-scope-soul-races label.lsu-scope-soul-races{display:flex;flex-direction:column;gap:8px;font:12px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2)}.sr-filters.lsu-scope-soul-races select.lsu-scope-soul-races,.sr-filters.lsu-scope-soul-races input.lsu-scope-soul-races,.field.lsu-scope-soul-races input.lsu-scope-soul-races,.field.lsu-scope-soul-races select.lsu-scope-soul-races,.field.lsu-scope-soul-races textarea.lsu-scope-soul-races{box-sizing:border-box;width:100%;min-height:48px;padding:12px 14px;border:1px solid rgba(255,255,255,.2);border-radius:0;background:#090909;color:var(--lsu-white);font:16px var(--lsu-body);letter-spacing:0;text-transform:none;outline:none}.sr-filters.lsu-scope-soul-races select.lsu-scope-soul-races,.field.lsu-scope-soul-races select.lsu-scope-soul-races{appearance:none;padding-right:40px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='9' viewBox='0 0 14 9'%3E%3Cpath d='M1 1l6 6 6-6' fill='none' stroke='%23ffffff' stroke-opacity='.7' stroke-width='2'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;cursor:pointer}.sr-filters.lsu-scope-soul-races select.lsu-scope-soul-races option.lsu-scope-soul-races,.field.lsu-scope-soul-races select.lsu-scope-soul-races option.lsu-scope-soul-races{background:#090909;color:var(--lsu-white)}.sr-filters.lsu-scope-soul-races .lsu-scope-soul-races:is(select,input):hover,.field.lsu-scope-soul-races .lsu-scope-soul-races:is(input,select,textarea):hover{border-color:#ffffff61}.sr-filters.lsu-scope-soul-races .lsu-scope-soul-races:is(select,input):focus-visible,.field.lsu-scope-soul-races .lsu-scope-soul-races:is(input,select,textarea):focus-visible{border-color:var(--lsu-red);box-shadow:0 0 0 1px var(--lsu-red)}.sr-stage.lsu-scope-soul-races{display:grid;grid-template-columns:minmax(0,1fr) 420px;gap:28px;margin-top:28px;align-items:start}.sr-map.lsu-scope-soul-races{position:relative;width:100%;background:#050505;border:1px solid rgba(255,255,255,.1)}.sr-land.lsu-scope-soul-races{position:absolute;inset:0;width:100%;height:100%;display:block;filter:drop-shadow(0 0 10px rgba(221,15,21,.35))}.sr-overlay.lsu-scope-soul-races{position:absolute;inset:0;width:100%;height:100%;overflow:visible}.sr-hint.lsu-scope-soul-races{position:absolute;left:14px;bottom:10px;margin:0;font:11px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-3);pointer-events:none}.sr-overlay.lsu-scope-soul-races .sr-marker{cursor:pointer;outline:none}.sr-overlay.lsu-scope-soul-races .marker-hit{fill:transparent}.sr-overlay.lsu-scope-soul-races .marker-pulse{fill:none;stroke:#dd0f15;stroke-width:2;animation:srPulse 1.9s cubic-bezier(.22,.61,.36,1) infinite}.sr-overlay.lsu-scope-soul-races .marker-glow{fill:#dd0f15;opacity:.55;filter:drop-shadow(0 0 8px #dd0f15)}.sr-overlay.lsu-scope-soul-races .marker-ring{fill:#050505;stroke:#fff;stroke-width:2;filter:drop-shadow(0 0 6px #dd0f15)}.sr-overlay.lsu-scope-soul-races .marker-core{fill:#dd0f15}.sr-overlay.lsu-scope-soul-races .sr-marker:hover .marker-ring,.sr-overlay.lsu-scope-soul-races .sr-marker:focus-visible .marker-ring,.sr-overlay.lsu-scope-soul-races .sr-marker.is-active .marker-ring{stroke:var(--lsu-red);stroke-width:3.5}.sr-overlay.lsu-scope-soul-races .sr-marker:focus-visible .marker-hit{stroke:#fff;stroke-width:2}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-pulse{display:none}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-glow{fill:#777;opacity:.25;filter:none}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-ring{stroke:#ffffff8c;filter:none}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-core{fill:#8a8a8a}@keyframes srPulse{0%{r:5;opacity:.95;stroke-width:3}78%{r:22;opacity:0;stroke-width:.5}to{r:22;opacity:0}}@media(prefers-reduced-motion:reduce){.sr-overlay.lsu-scope-soul-races .marker-pulse{animation:none;opacity:0}}.sr-tip.lsu-scope-soul-races{position:absolute;z-index:2;width:min(340px,calc(100% - 16px));border:1px solid rgba(221,15,21,.8);background:#050505;box-shadow:0 0 34px #dd0f1573}.sr-tip.lsu-scope-soul-races .sr-card{border:0;background:none}.sr-side.lsu-scope-soul-races{display:flex;flex-direction:column;gap:12px;min-width:0}.sr-result-count.lsu-scope-soul-races{margin:0;font:12px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2)}.sr-list.lsu-scope-soul-races{display:grid;gap:10px;max-height:min(620px,70vh);overflow:auto;overscroll-behavior:contain;padding-right:4px}.sr-list.lsu-scope-soul-races .sr-empty{margin:0;padding:22px;border:1px solid var(--lsu-line);color:var(--lsu-dim-1);line-height:1.5}.sr-card{display:grid;gap:10px;padding:18px;border:1px solid rgba(255,255,255,.16);border-top:2px solid rgba(255,255,255,.3);background:#050505;cursor:pointer;transition:border-color .2s}.sr-card:hover{border-color:#ffffff57}.sr-card.is-active{border-color:var(--lsu-red);background:#140406;box-shadow:0 0 28px #dd0f154d}.sr-card.is-flash{animation:srFlash .9s ease-out}@keyframes srFlash{0%{background:#3a0a0d;box-shadow:0 0 44px #dd0f1599}}@media(prefers-reduced-motion:reduce){.sr-card.is-flash{animation:none}}.sr-card.is-past .sr-date{color:var(--lsu-dim-2)}.sr-card-top{display:flex;justify-content:space-between;align-items:baseline;gap:12px}.sr-card h3{margin:0;font:24px/1.1 var(--lsu-display);letter-spacing:.02em;text-transform:uppercase;font-weight:400}.sr-date{flex:none;font:12px var(--lsu-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--lsu-red)}.sr-meta{margin:0;font-size:13px;color:var(--lsu-dim-2)}.sr-summary{margin:0;font-size:15px;line-height:1.5;color:var(--lsu-dim-1)}.sr-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin-top:4px}.sr-status,.sr-qual,.sr-state{padding:5px 9px 4px;border:1px solid rgba(255,255,255,.3);font:11px var(--lsu-mono);letter-spacing:.12em;text-transform:uppercase}.sr-status.is-open{border-color:var(--lsu-red);color:#ff5a5f}.sr-status.is-past,.sr-state{color:var(--lsu-dim-2);border-color:#ffffff2e}.sr-qual{border-color:#f6dc9599;color:#f6dc95}.sr-site{font:11px var(--lsu-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--lsu-dim-1);text-decoration:underline;text-underline-offset:3px}.sr-enter{margin-left:auto;padding:11px 14px;background:var(--lsu-red);color:#fff;font:700 12px var(--lsu-body);letter-spacing:.15em;text-transform:uppercase}.sr-enter:hover{background:#fff;color:#000}.sr-card a:focus-visible{outline:3px solid var(--lsu-white);outline-offset:3px}.sr-host.lsu-scope-soul-races{display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-top:64px;padding-top:34px;border-top:2px solid var(--lsu-red)}.sr-host.lsu-scope-soul-races h2.lsu-scope-soul-races{margin:0;font:clamp(26px,3.4vw,48px)/1.05 var(--lsu-display);text-transform:uppercase}.sr-host.lsu-scope-soul-races p.lsu-scope-soul-races{max-width:560px;margin:14px 0 22px;font-size:16px;line-height:1.6;color:var(--lsu-dim-1)}.sr-dialog.lsu-scope-soul-races{width:min(820px,calc(100% - 32px));max-width:none;max-height:calc(100dvh - 32px);padding:0;border:1px solid rgba(255,255,255,.16);background:#050505;color:var(--lsu-white);box-shadow:0 0 70px #dd0f1559;overflow:auto;overscroll-behavior:contain}.sr-dialog.lsu-scope-soul-races::backdrop{background:#000000d1}html:has(.sr-dialog[open]){overflow:hidden}.sr-dialog-bar.lsu-scope-soul-races{position:sticky;top:0;z-index:1;display:flex;justify-content:space-between;align-items:flex-start;gap:16px;padding:22px 16px 16px 36px;background:#050505;border-bottom:1px solid var(--lsu-line)}.sr-dialog-bar.lsu-scope-soul-races h2.lsu-scope-soul-races{margin:8px 0 0;font:clamp(26px,3.4vw,48px)/1.05 var(--lsu-display);text-transform:uppercase}.sr-close.lsu-scope-soul-races{width:48px;height:48px;border:0;background:transparent;color:var(--lsu-white);font-size:32px;line-height:1;cursor:pointer}.sr-close.lsu-scope-soul-races:hover,.sr-close.lsu-scope-soul-races:focus-visible{color:var(--lsu-red)}.sr-dialog.lsu-scope-soul-races form.lsu-scope-soul-races,.sr-done.lsu-scope-soul-races{display:flex;flex-direction:column;gap:22px;padding:24px 36px 36px}.sr-intro.lsu-scope-soul-races{margin:0;color:var(--lsu-dim-1);line-height:1.5}fieldset.lsu-scope-soul-races{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 18px;margin:0;padding:0;border:0}legend.lsu-scope-soul-races{margin-bottom:16px;padding:0;font:12px var(--lsu-mono);letter-spacing:.16em;text-transform:uppercase;color:var(--lsu-red)}.field.lsu-scope-soul-races{display:flex;flex-direction:column;gap:8px;color:#ffffff9e;font:14px/1.4 var(--lsu-mono);letter-spacing:.06em;text-transform:uppercase}.field-wide.lsu-scope-soul-races{grid-column:1/-1}.field.lsu-scope-soul-races textarea.lsu-scope-soul-races{min-height:96px;resize:vertical;line-height:1.5}.field.lsu-scope-soul-races input.lsu-scope-soul-races[aria-invalid=true],.field.lsu-scope-soul-races select.lsu-scope-soul-races[aria-invalid=true],.field.lsu-scope-soul-races textarea.lsu-scope-soul-races[aria-invalid=true]{border-color:#ff725e}.field.lsu-scope-soul-races .hint.lsu-scope-soul-races{font:13px/1.4 var(--lsu-body);letter-spacing:0;text-transform:none;color:var(--lsu-dim-2)}.field.lsu-scope-soul-races small.lsu-scope-soul-races{min-height:0;color:#ff725e;font:13px/1.35 var(--lsu-body);letter-spacing:0;text-transform:none}.field.lsu-scope-soul-races small.lsu-scope-soul-races:empty{display:none}.field.lsu-scope-soul-races input.lsu-scope-soul-races[type=date]{color-scheme:dark}.sr-trap.lsu-scope-soul-races{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}.sr-privacy.lsu-scope-soul-races{margin:0;font-size:13px;line-height:1.5;color:var(--lsu-dim-2)}.sr-privacy.lsu-scope-soul-races a{text-decoration:underline;text-underline-offset:3px}.sr-form-status.lsu-scope-soul-races{margin:0;color:#ff725e;font-size:14px}.sr-form-status.lsu-scope-soul-races:empty{display:none}.sr-dialog.lsu-scope-soul-races .btn.lsu-scope-soul-races{align-self:flex-start}.sr-dialog.lsu-scope-soul-races .lsu-scope-soul-races[hidden]{display:none}.sr-done.lsu-scope-soul-races h3.lsu-scope-soul-races{margin:0;font:32px/1 var(--lsu-display);text-transform:uppercase;font-weight:400}.sr-done.lsu-scope-soul-races p.lsu-scope-soul-races{margin:0;color:var(--lsu-dim-1);line-height:1.5}.sr-done.lsu-scope-soul-races strong.lsu-scope-soul-races{color:var(--lsu-white);font-weight:600}@media(prefers-reduced-motion:no-preference){.sr-dialog.lsu-scope-soul-races[open]{animation:srIn .22s cubic-bezier(.2,.8,.2,1)}.sr-dialog.lsu-scope-soul-races[open]::backdrop{animation:srFade .22s ease-out}}@keyframes srIn{0%{opacity:0;transform:translateY(12px)}}@keyframes srFade{0%{opacity:0}}@media(max-width:1000px){.sr-stage.lsu-scope-soul-races{grid-template-columns:1fr}.sr-list.lsu-scope-soul-races{max-height:none;overflow:visible;padding-right:0}.sr-host.lsu-scope-soul-races{grid-template-columns:1fr}}@media(max-width:700px){.sr-counts.lsu-scope-soul-races,.sr-filters.lsu-scope-soul-races{grid-template-columns:1fr 1fr}.sr-filters.lsu-scope-soul-races{gap:10px;padding:14px 0}.sr-hint.lsu-scope-soul-races{display:none}.sr-tip.lsu-scope-soul-races{left:0!important;top:calc(100% + 8px)!important;width:100%}fieldset.lsu-scope-soul-races{grid-template-columns:1fr}.sr-dialog-bar.lsu-scope-soul-races{padding-left:18px}.sr-dialog.lsu-scope-soul-races form.lsu-scope-soul-races,.sr-done.lsu-scope-soul-races{padding:20px 18px 28px}.sr-enter{margin-left:0;width:100%;text-align:center}}
1.sr.lsu-scope-soul-races{min-height:100vh;padding-bottom:100px}.sr-body.lsu-scope-soul-races{padding:0 var(--lsu-pad-x)}.sr-head.lsu-scope-soul-races,.sr-filters.lsu-scope-soul-races,.sr-stage.lsu-scope-soul-races,.sr-host.lsu-scope-soul-races{max-width:var(--lsu-max);margin-left:auto;margin-right:auto}.sr-head.lsu-scope-soul-races .accent.lsu-scope-soul-races{color:var(--lsu-red)}.sr-note.lsu-scope-soul-races{max-width:640px;margin:0;font-size:15px;line-height:1.6;color:var(--lsu-dim-1)}.sr-counts.lsu-scope-soul-races{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px 24px;margin:14px 0 0;padding:0}.sr-counts.lsu-scope-soul-races div.lsu-scope-soul-races{display:flex;flex-direction:column-reverse;gap:6px;padding-top:14px;border-top:1px solid var(--lsu-line)}.sr-counts.lsu-scope-soul-races dd.lsu-scope-soul-races{margin:0;font:clamp(34px,4vw,52px)/1 var(--lsu-display)}.sr-counts.lsu-scope-soul-races dt.lsu-scope-soul-races{font:12px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2)}.sr-filters.lsu-scope-soul-races{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-top:var(--lsu-head-gap);padding:20px 0;border-top:1px solid var(--lsu-line);border-bottom:1px solid var(--lsu-line)}.sr-filters.lsu-scope-soul-races label.lsu-scope-soul-races{display:flex;flex-direction:column;gap:8px;font:12px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2)}.sr-filters.lsu-scope-soul-races select.lsu-scope-soul-races,.sr-filters.lsu-scope-soul-races input.lsu-scope-soul-races,.field.lsu-scope-soul-races input.lsu-scope-soul-races,.field.lsu-scope-soul-races select.lsu-scope-soul-races,.field.lsu-scope-soul-races textarea.lsu-scope-soul-races{box-sizing:border-box;width:100%;min-height:48px;padding:12px 14px;border:1px solid rgba(255,255,255,.2);border-radius:0;background:#090909;color:var(--lsu-white);font:16px var(--lsu-body);letter-spacing:0;text-transform:none;outline:none}.sr-filters.lsu-scope-soul-races select.lsu-scope-soul-races,.field.lsu-scope-soul-races select.lsu-scope-soul-races{appearance:none;padding-right:40px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='9' viewBox='0 0 14 9'%3E%3Cpath d='M1 1l6 6 6-6' fill='none' stroke='%23ffffff' stroke-opacity='.7' stroke-width='2'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;cursor:pointer}.sr-filters.lsu-scope-soul-races select.lsu-scope-soul-races option.lsu-scope-soul-races,.field.lsu-scope-soul-races select.lsu-scope-soul-races option.lsu-scope-soul-races{background:#090909;color:var(--lsu-white)}.sr-filters.lsu-scope-soul-races .lsu-scope-soul-races:is(select,input):hover,.field.lsu-scope-soul-races .lsu-scope-soul-races:is(input,select,textarea):hover{border-color:#ffffff61}.sr-filters.lsu-scope-soul-races .lsu-scope-soul-races:is(select,input):focus-visible,.field.lsu-scope-soul-races .lsu-scope-soul-races:is(input,select,textarea):focus-visible{border-color:var(--lsu-red);box-shadow:0 0 0 1px var(--lsu-red)}.sr-stage.lsu-scope-soul-races{display:grid;grid-template-columns:minmax(0,1fr) 420px;gap:28px;margin-top:28px;align-items:start}.sr-map.lsu-scope-soul-races{position:relative;width:100%;background:#050505;border:1px solid rgba(255,255,255,.1)}.sr-land.lsu-scope-soul-races{position:absolute;inset:0;width:100%;height:100%;display:block;filter:drop-shadow(0 0 10px rgba(221,15,21,.35))}.sr-overlay.lsu-scope-soul-races{position:absolute;inset:0;width:100%;height:100%;overflow:visible}.sr-hint.lsu-scope-soul-races{position:absolute;left:14px;bottom:10px;margin:0;font:11px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-3);pointer-events:none}.sr-overlay.lsu-scope-soul-races .sr-marker{cursor:pointer;outline:none}.sr-overlay.lsu-scope-soul-races .marker-hit{fill:transparent}.sr-overlay.lsu-scope-soul-races .marker-pulse{fill:none;stroke:#dd0f15;stroke-width:2;animation:srPulse 1.9s cubic-bezier(.22,.61,.36,1) infinite}.sr-overlay.lsu-scope-soul-races .marker-glow{fill:#dd0f15;opacity:.55;filter:drop-shadow(0 0 8px #dd0f15)}.sr-overlay.lsu-scope-soul-races .marker-ring{fill:#050505;stroke:#fff;stroke-width:2;filter:drop-shadow(0 0 6px #dd0f15)}.sr-overlay.lsu-scope-soul-races .marker-core{fill:#dd0f15}.sr-overlay.lsu-scope-soul-races .sr-marker:hover .marker-ring,.sr-overlay.lsu-scope-soul-races .sr-marker:focus-visible .marker-ring,.sr-overlay.lsu-scope-soul-races .sr-marker.is-active .marker-ring{stroke:var(--lsu-red);stroke-width:3.5}.sr-overlay.lsu-scope-soul-races .sr-marker:focus-visible .marker-hit{stroke:#fff;stroke-width:2}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-pulse{display:none}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-glow{fill:#777;opacity:.25;filter:none}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-ring{stroke:#ffffff8c;filter:none}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-core{fill:#8a8a8a}@keyframes srPulse{0%{r:5;opacity:.95;stroke-width:3}78%{r:22;opacity:0;stroke-width:.5}to{r:22;opacity:0}}@media(prefers-reduced-motion:reduce){.sr-overlay.lsu-scope-soul-races .marker-pulse{animation:none;opacity:0}}.sr-tip.lsu-scope-soul-races{position:absolute;z-index:2;width:min(340px,calc(100% - 16px));border:1px solid rgba(221,15,21,.8);background:#050505;box-shadow:0 0 34px #dd0f1573}.sr-tip.lsu-scope-soul-races .sr-card{border:0;background:none}.sr-side.lsu-scope-soul-races{display:flex;flex-direction:column;gap:12px;min-width:0}.sr-result-count.lsu-scope-soul-races{margin:0;font:12px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2)}.sr-list.lsu-scope-soul-races{display:grid;gap:10px;max-height:min(620px,70vh);overflow:auto;overscroll-behavior:contain;padding-right:4px}.sr-list.lsu-scope-soul-races .sr-empty{margin:0;padding:22px;border:1px solid var(--lsu-line);color:var(--lsu-dim-1);line-height:1.5}.sr-card{display:grid;gap:10px;padding:18px;border:1px solid rgba(255,255,255,.16);border-top:2px solid rgba(255,255,255,.3);background:#050505;cursor:pointer;transition:border-color .2s}.sr-card:hover{border-color:#ffffff57}.sr-card.is-active{border-color:var(--lsu-red);background:#140406;box-shadow:0 0 28px #dd0f154d}.sr-card.is-flash{animation:srFlash .9s ease-out}@keyframes srFlash{0%{background:#3a0a0d;box-shadow:0 0 44px #dd0f1599}}@media(prefers-reduced-motion:reduce){.sr-card.is-flash{animation:none}}.sr-card.is-past .sr-date{color:var(--lsu-dim-2)}.sr-card-top{display:flex;justify-content:space-between;align-items:baseline;gap:12px}.sr-card h2{margin:0;font:24px/1.1 var(--lsu-display);letter-spacing:.02em;text-transform:uppercase;font-weight:400}.sr-date{flex:none;font:12px var(--lsu-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--lsu-red)}.sr-meta{margin:0;font-size:13px;color:var(--lsu-dim-2)}.sr-summary{margin:0;font-size:15px;line-height:1.5;color:var(--lsu-dim-1)}.sr-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin-top:4px}.sr-status,.sr-qual,.sr-state{padding:5px 9px 4px;border:1px solid rgba(255,255,255,.3);font:11px var(--lsu-mono);letter-spacing:.12em;text-transform:uppercase}.sr-status.is-open{border-color:var(--lsu-red);color:#ff5a5f}.sr-status.is-past,.sr-state{color:var(--lsu-dim-2);border-color:#ffffff2e}.sr-qual{border-color:#f6dc9599;color:#f6dc95}.sr-site{font:11px var(--lsu-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--lsu-dim-1);text-decoration:underline;text-underline-offset:3px}.sr-enter{margin-left:auto;padding:11px 14px;background:var(--lsu-red);color:#fff;font:700 12px var(--lsu-body);letter-spacing:.15em;text-transform:uppercase}.sr-enter:hover{background:#fff;color:#000}.sr-card a:focus-visible{outline:3px solid var(--lsu-white);outline-offset:3px}.sr-host.lsu-scope-soul-races{display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-top:64px;padding-top:34px;border-top:2px solid var(--lsu-red)}.sr-host.lsu-scope-soul-races h2.lsu-scope-soul-races{margin:0;font:clamp(26px,3.4vw,48px)/1.05 var(--lsu-display);text-transform:uppercase}.sr-host.lsu-scope-soul-races p.lsu-scope-soul-races{max-width:560px;margin:14px 0 22px;font-size:16px;line-height:1.6;color:var(--lsu-dim-1)}.sr-dialog.lsu-scope-soul-races{width:min(820px,calc(100% - 32px));max-width:none;max-height:calc(100dvh - 32px);padding:0;border:1px solid rgba(255,255,255,.16);background:#050505;color:var(--lsu-white);box-shadow:0 0 70px #dd0f1559;overflow:auto;overscroll-behavior:contain}.sr-dialog.lsu-scope-soul-races::backdrop{background:#000000d1}html:has(.sr-dialog[open]){overflow:hidden}.sr-dialog-bar.lsu-scope-soul-races{position:sticky;top:0;z-index:1;display:flex;justify-content:space-between;align-items:flex-start;gap:16px;padding:22px 16px 16px 36px;background:#050505;border-bottom:1px solid var(--lsu-line)}.sr-dialog-bar.lsu-scope-soul-races h2.lsu-scope-soul-races{margin:8px 0 0;font:clamp(26px,3.4vw,48px)/1.05 var(--lsu-display);text-transform:uppercase}.sr-close.lsu-scope-soul-races{width:48px;height:48px;border:0;background:transparent;color:var(--lsu-white);font-size:32px;line-height:1;cursor:pointer}.sr-close.lsu-scope-soul-races:hover,.sr-close.lsu-scope-soul-races:focus-visible{color:var(--lsu-red)}.sr-dialog.lsu-scope-soul-races form.lsu-scope-soul-races,.sr-done.lsu-scope-soul-races{display:flex;flex-direction:column;gap:22px;padding:24px 36px 36px}.sr-intro.lsu-scope-soul-races{margin:0;color:var(--lsu-dim-1);line-height:1.5}fieldset.lsu-scope-soul-races{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 18px;margin:0;padding:0;border:0}legend.lsu-scope-soul-races{margin-bottom:16px;padding:0;font:12px var(--lsu-mono);letter-spacing:.16em;text-transform:uppercase;color:var(--lsu-red)}.field.lsu-scope-soul-races{display:flex;flex-direction:column;gap:8px;color:#ffffff9e;font:14px/1.4 var(--lsu-mono);letter-spacing:.06em;text-transform:uppercase}.field-wide.lsu-scope-soul-races{grid-column:1/-1}.field.lsu-scope-soul-races textarea.lsu-scope-soul-races{min-height:96px;resize:vertical;line-height:1.5}.field.lsu-scope-soul-races input.lsu-scope-soul-races[aria-invalid=true],.field.lsu-scope-soul-races select.lsu-scope-soul-races[aria-invalid=true],.field.lsu-scope-soul-races textarea.lsu-scope-soul-races[aria-invalid=true]{border-color:#ff725e}.field.lsu-scope-soul-races .hint.lsu-scope-soul-races{font:13px/1.4 var(--lsu-body);letter-spacing:0;text-transform:none;color:var(--lsu-dim-2)}.field.lsu-scope-soul-races small.lsu-scope-soul-races{min-height:0;color:#ff725e;font:13px/1.35 var(--lsu-body);letter-spacing:0;text-transform:none}.field.lsu-scope-soul-races small.lsu-scope-soul-races:empty{display:none}.field.lsu-scope-soul-races input.lsu-scope-soul-races[type=date]{color-scheme:dark}.sr-trap.lsu-scope-soul-races{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}.sr-privacy.lsu-scope-soul-races{margin:0;font-size:13px;line-height:1.5;color:var(--lsu-dim-2)}.sr-privacy.lsu-scope-soul-races a{text-decoration:underline;text-underline-offset:3px}.sr-form-status.lsu-scope-soul-races{margin:0;color:#ff725e;font-size:14px}.sr-form-status.lsu-scope-soul-races:empty{display:none}.sr-dialog.lsu-scope-soul-races .btn.lsu-scope-soul-races{align-self:flex-start}.sr-dialog.lsu-scope-soul-races .lsu-scope-soul-races[hidden]{display:none}.sr-done.lsu-scope-soul-races h3.lsu-scope-soul-races{margin:0;font:32px/1 var(--lsu-display);text-transform:uppercase;font-weight:400}.sr-done.lsu-scope-soul-races p.lsu-scope-soul-races{margin:0;color:var(--lsu-dim-1);line-height:1.5}.sr-done.lsu-scope-soul-races strong.lsu-scope-soul-races{color:var(--lsu-white);font-weight:600}@media(prefers-reduced-motion:no-preference){.sr-dialog.lsu-scope-soul-races[open]{animation:srIn .22s cubic-bezier(.2,.8,.2,1)}.sr-dialog.lsu-scope-soul-races[open]::backdrop{animation:srFade .22s ease-out}}@keyframes srIn{0%{opacity:0;transform:translateY(12px)}}@keyframes srFade{0%{opacity:0}}@media(max-width:1000px){.sr-stage.lsu-scope-soul-races{grid-template-columns:1fr}.sr-list.lsu-scope-soul-races{max-height:none;overflow:visible;padding-right:0}.sr-host.lsu-scope-soul-races{grid-template-columns:1fr}}@media(max-width:700px){.sr-counts.lsu-scope-soul-races,.sr-filters.lsu-scope-soul-races{grid-template-columns:1fr 1fr}.sr-filters.lsu-scope-soul-races{gap:10px;padding:14px 0}.sr-hint.lsu-scope-soul-races{display:none}.sr-tip.lsu-scope-soul-races{left:0!important;top:calc(100% + 8px)!important;width:100%}fieldset.lsu-scope-soul-races{grid-template-columns:1fr}.sr-dialog-bar.lsu-scope-soul-races{padding-left:18px}.sr-dialog.lsu-scope-soul-races form.lsu-scope-soul-races,.sr-done.lsu-scope-soul-races{padding:20px 18px 28px}.sr-enter{margin-left:0;width:100%;text-align:center}}
public/assets/styles/stx-site.cssmodified+9-109
Changes to public/assets/styles/stx-site.css
@@ -1,109 +1,9 @@
1.lsu-skip{position:fixed;left:1rem;top:-5rem;z-index:200;background:#fff;color:#000;padding:1rem}.lsu-skip:focus{top:1rem}main:focus{outline:none}.nav-links{gap:clamp(12px,1.6vw,28px)}.coaching-hero{position:relative;padding:clamp(140px,18vw,240px) clamp(18px,5vw,40px) 100px;background:linear-gradient(90deg,#000e,#0003),url('/assets/lsu/images/hero-desktop.webp') 50% 35%/cover}.coaching-hero h1{max-width:13ch;font:400 clamp(48px,8vw,120px)/1.05 var(--lsu-display);text-transform:uppercase;letter-spacing:.02em;margin:24px 0}.coaching-hero p{max-width:54ch;color:#ffffffb3;font:400 19px/1.6 var(--lsu-body)}.coaching-body{max-width:1360px;margin:auto;padding:clamp(50px,7vw,100px) clamp(18px,5vw,40px)}.coaching-steps{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid #ffffff24;border-bottom:1px solid #ffffff24;margin:3rem 0}.coaching-steps article{padding:2rem;border-right:1px solid #ffffff24}.coaching-steps article:last-child{border:0}.coaching-steps h2{font:400 30px/1.2 var(--lsu-display);text-transform:uppercase}.coaching-steps p{color:#ffffffa6;line-height:1.7}.coaching-note{padding:2rem;border-left:3px solid var(--lsu-red);background:#0d0d0f}.coaching-note h2{font:400 36px var(--lsu-display);text-transform:uppercase}.coaching-note p{max-width:65ch;color:#ffffffa6;line-height:1.7}.race-portal{max-width:720px;margin:auto;padding:clamp(24px,5vw,64px);border:1px solid #ffffff24;background:#0d0d0f}.race-portal label{display:block;font:600 12px var(--lsu-body);text-transform:uppercase;letter-spacing:.12em;margin:1rem 0 .5rem}.race-portal input,.race-portal textarea,.race-portal select{display:block;width:100%;padding:1rem;background:#050505;border:1px solid #ffffff40;color:#fff;font:inherit}.race-portal .btn{margin-top:1.5rem}.race-portal [role=alert]{color:#ff928f}.race-portal h2{font:400 36px var(--lsu-display);text-transform:uppercase}.race-portal p{line-height:1.65}.race-tabs{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}.race-tabs button{background:transparent;color:#fff;border:0;border-bottom:2px solid #ffffff40;padding:.75rem;cursor:pointer}.race-tabs button[aria-selected=true]{border-color:#dd0f15}.race-portal .field-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.lsu-scroll-gallery{overflow:auto;scroll-snap-type:x mandatory}.lsu-scroll-gallery>*{scroll-snap-align:start}@media(max-width:700px){.coaching-hero{background-image:linear-gradient(90deg,#000d,#0006),url('/assets/lsu/images/hero-mobile.webp')}.coaching-steps{grid-template-columns:1fr}.coaching-steps article{border-right:0;border-bottom:1px solid #ffffff24}.race-portal .field-row{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
2
3.lsu-globe-stage{position:relative;width:100%;height:100%;min-height:420px}.lsu-globe-fallback{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none}.lsu-event-dot{display:block;width:14px;height:14px;border-radius:50%;background:#dd0f15;border:2px solid white;box-shadow:0 0 0 6px #dd0f1540}.ts-map{background:transparent}.lsu-globe-stage .ts-map{position:absolute;inset:0}.lsu-globe-stage:has([data-ts-map])>.lsu-globe-fallback{visibility:hidden}
4
5.hour-dial .rest-ring{width:100%;height:auto;aspect-ratio:1;transform:rotate(-90deg)}
6
7.duel-fill-1.lsu-scope-home{width:71.28%}.duel-fill-2.lsu-scope-home{width:100%}
8@supports(animation-timeline:view()){.duelscroll{view-timeline-name:--duel;view-timeline-axis:block}.duel-fill{transform-origin:left;animation:lsu-grow linear both;animation-timeline:--duel;animation-range:entry 20% exit 65%}@keyframes lsu-grow{from{transform:scaleX(0)}to{transform:scaleX(1)}}}
9@media(prefers-reduced-motion:reduce){.duel-fill{animation:none}}
10
11.lsu-host-dialog[data-stx-dialog]{z-index:200}.lsu-host-dialog .sr-dialog{color:var(--lsu-white,#fff);background:#080808;padding:0;border:1px solid #333;border-radius:0}
12
13/* Let the browser defer distant story panels and their background photos.
14 Explicitly sized bands keep their authored height. Story panels remember
15 their measured height after their first visit; one viewport reserves space
16 before they approach the viewport. Unsupported browsers render normally. */
17.lapscroll.lsu-scope-home,.duelscroll.lsu-scope-home{content-visibility:auto;contain-intrinsic-block-size:auto 100svh}
18.attr-band.lsu-scope-home,.enter.lsu-scope-next-loop{content-visibility:auto}
19
20/* /coaching. Every rule is wrapped in .coaching-page because this file loads on
21 all sixteen routes, and because `.accent` only ever exists per page in
22 base.css, which is why the old hero's red words rendered white. */
23.coaching-page .accent{color:var(--lsu-red)}
24.coaching-page .coaching-body>*+*{margin-top:clamp(56px,7vw,104px)}
25.coaching-page .coaching-head{display:flex;flex-direction:column;gap:18px;max-width:65ch}
26.coaching-page .coaching-head p{margin:0;color:var(--lsu-dim-1);font-size:18px;line-height:1.6}
27
28/* Hero: copy beside the photograph rather than over a full-bleed background, so
29 the frame is a real <img> that can be sized, eager-loaded and described. */
30.coaching-page .coaching-hero{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr);align-items:center;gap:clamp(24px,4vw,64px);min-height:100svh;max-width:var(--lsu-max);margin:0 auto;padding:clamp(104px,11vw,128px) var(--lsu-pad-x) clamp(44px,6vw,80px);background:radial-gradient(ellipse 75% 65% at 18% 45%,#150307,#000)}
31.coaching-page .coaching-hero-copy{display:flex;flex-direction:column;align-items:flex-start;gap:22px}
32.coaching-page .coaching-hero h1{max-width:18ch;margin:0;font-size:clamp(40px,5.6vw,88px)}
33.coaching-page .coaching-hero p{margin:0}
34.coaching-page .coaching-hero-shot{position:relative;aspect-ratio:3/4}
35.coaching-page .coaching-hero-shot img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:50% 26%;filter:grayscale(1) contrast(1.08) brightness(.66)}
36
37/* Two motions, transform and opacity only: the hero stack arrives in reading
38 order, the loop rows in the order the week runs. */
39@keyframes lsu-coaching-rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
40.coaching-page .coaching-hero-copy>*{animation:lsu-coaching-rise .62s cubic-bezier(.16,1,.3,1) both}
41.coaching-page .coaching-hero-copy>:nth-child(2){animation-delay:.08s}
42.coaching-page .coaching-hero-copy>:nth-child(3){animation-delay:.16s}
43.coaching-page .coaching-hero-copy>:nth-child(4){animation-delay:.24s}
44.coaching-page .coaching-hero-shot{animation:lsu-coaching-rise .8s cubic-bezier(.16,1,.3,1) .1s both}
45
46/* The loop. Not three equal cards: three ruled rows, each stepped further in,
47 so the sequence reads as one movement down the page. */
48.coaching-page .coaching-loop{display:grid;margin-top:clamp(30px,4vw,52px);border-top:1px solid var(--lsu-line)}
49.coaching-page .coaching-loop article{display:grid;grid-template-columns:clamp(76px,9vw,150px) minmax(0,1fr);gap:clamp(18px,3vw,44px);align-items:start;padding:clamp(24px,3vw,38px) 0;border-bottom:1px solid var(--lsu-line-soft)}
50.coaching-page .coaching-loop article:nth-child(2){margin-inline-start:clamp(0px,6vw,96px)}
51.coaching-page .coaching-loop article:nth-child(3){margin-inline-start:clamp(0px,12vw,192px)}
52.coaching-page .coaching-loop h3{margin:0;font:400 clamp(22px,2.4vw,34px)/1 var(--lsu-display);text-transform:uppercase;color:var(--lsu-red)}
53.coaching-page .coaching-loop p{margin:0;max-width:56ch;color:#ffffffa6;font-size:16px;line-height:1.7}
54
55/* The one photographic statement. .band, .band-bg, .band-scrim and .band-copy
56 come from base.css unscoped; the height and the darker scrim are page
57 business, the scrim because body copy sits on the frame as well. */
58.coaching-page .coaching-band{min-height:clamp(440px,72vh,720px)}
59.coaching-page .coaching-band .band-scrim{background:#000000b3}
60.coaching-page .coaching-band .band-copy{max-width:1040px}
61.coaching-page .coaching-band .band-copy p{font-size:clamp(26px,3.4vw,50px)}
62.coaching-page .coaching-band-note{margin:clamp(20px,3vw,32px) auto 0;max-width:62ch;color:#ffffffb3;font-family:var(--lsu-body);font-size:16px;line-height:1.7;text-transform:none}
63
64/* .coaching-steps keeps its three hairline-ruled columns; the headings move to
65 h3 under the section's own h2, so they need their own type rule. */
66.coaching-page .coaching-steps h3{margin:0 0 14px;font:400 clamp(24px,2.6vw,30px)/1.2 var(--lsu-display);text-transform:uppercase}
67.coaching-page .coaching-steps p{margin:0}
68.coaching-page .coaching-tiers{margin:0;max-width:62ch;color:var(--lsu-dim-1);font-size:16px;line-height:1.7}
69
70.coaching-page .coaching-note h2{margin:0 0 18px;font-size:clamp(28px,3.2vw,40px);line-height:1.08}
71.coaching-page .coaching-note p{margin:0}
72.coaching-page .coaching-note p+p{margin-top:16px}
73
74/* The embed. Space is reserved so the remote element cannot shift the page in,
75 and :not(:defined) dresses the slot content, which is the loading state, the
76 empty state and the blocked-origin state in one rule. */
77.coaching-page .coaching-portal{min-height:420px;margin-top:clamp(30px,4vw,52px)}
78.coaching-page .coaching-portal hq-coaching{display:block}
79.coaching-page .coaching-portal hq-coaching:not(:defined){display:grid;gap:24px;justify-items:start;max-width:720px;margin:0 auto;padding:clamp(24px,5vw,56px);border:1px solid var(--lsu-line);background:var(--lsu-card)}
80.coaching-page .coaching-portal hq-coaching:not(:defined) p{margin:0;max-width:56ch;color:#ffffffb3;font-size:16px;line-height:1.7}
81
82/* The coach-facing close: a different audience on the same page, so a contained
83 surface marks it off rather than another hairline. */
84.coaching-page .coaching-coaches{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1fr);gap:clamp(28px,5vw,80px);align-items:start;padding-top:clamp(34px,4vw,56px);border-top:1px solid var(--lsu-line)}
85.coaching-page .coaching-coaches .kicker{display:block}
86.coaching-page .coaching-coaches h2{margin:18px 0 0}
87.coaching-page .coaching-coaches p{margin:22px 0 0;max-width:56ch;color:#ffffffa6;font-size:16px;line-height:1.7}
88.coaching-page .coaching-coaches-panel{padding:clamp(22px,3vw,32px);border:1px solid var(--lsu-line);background:var(--lsu-card-alt)}
89.coaching-page .coaching-coaches-panel p{margin:0 0 24px;max-width:38ch}
90
91/* Tactile feedback on this page's buttons; the global .btn stays instant. */
92.coaching-page .btn,.coaching-page .btn-outline{transition:background-color .2s,border-color .2s,color .2s,transform .12s}
93.coaching-page .btn:active,.coaching-page .btn-outline:active{transform:scale(.98)}
94
95@media(max-width:900px){
96.coaching-page .coaching-hero{grid-template-columns:minmax(0,1fr);min-height:0;gap:36px;padding-top:clamp(96px,22vw,118px)}
97.coaching-page .coaching-hero-shot{aspect-ratio:16/10}
98.coaching-page .coaching-coaches{grid-template-columns:minmax(0,1fr);gap:34px}
99}
100@media(max-width:760px){
101.coaching-page .coaching-loop article{grid-template-columns:minmax(0,1fr);gap:12px;padding:26px 0}
102.coaching-page .coaching-loop article:nth-child(2),.coaching-page .coaching-loop article:nth-child(3){margin-inline-start:0}
103.coaching-page .coaching-band{min-height:clamp(400px,78vh,560px)}
104}
105@media(prefers-reduced-motion:reduce){
106.coaching-page .coaching-hero-copy>*,.coaching-page .coaching-hero-shot{animation:none}
107.coaching-page .btn,.coaching-page .btn-outline{transition:none}
108.coaching-page .btn:active,.coaching-page .btn-outline:active{transform:none}
109}
1.lsu-skip{position:fixed;left:1rem;top:-5rem;z-index:200;background:#fff;color:#000;padding:1rem}.lsu-skip:focus{top:1rem}main:focus{outline:none}.nav-links{gap:clamp(12px,1.6vw,28px)}.race-portal{max-width:720px;margin:auto;padding:clamp(24px,5vw,64px);border:1px solid #ffffff24;background:#0d0d0f}.race-portal label{display:block;font:600 12px var(--lsu-body);text-transform:uppercase;letter-spacing:.12em;margin:1rem 0 .5rem}.race-portal input,.race-portal textarea,.race-portal select{display:block;width:100%;padding:1rem;background:#050505;border:1px solid #ffffff40;color:#fff;font:inherit}.race-portal .btn{margin-top:1.5rem}.race-portal [role=alert]{color:#ff928f}.race-portal h2{font:400 36px var(--lsu-display);text-transform:uppercase}.race-portal p{line-height:1.65}.race-tabs{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}.race-tabs button{background:transparent;color:#fff;border:0;border-bottom:2px solid #ffffff40;padding:.75rem;cursor:pointer}.race-tabs button[aria-selected=true]{border-color:#dd0f15}.race-portal .field-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.lsu-scroll-gallery{overflow:auto;scroll-snap-type:x mandatory}.lsu-scroll-gallery>*{scroll-snap-align:start}@media(max-width:700px){.race-portal .field-row{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}.lsu-globe-stage{position:relative;width:100%;height:100%;min-height:420px}.lsu-globe-fallback{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none}.lsu-event-dot{display:block;width:14px;height:14px;border-radius:50%;background:#dd0f15;border:2px solid white;box-shadow:0 0 0 6px #dd0f1540}.ts-map{background:transparent}.lsu-globe-stage .ts-map{position:absolute;inset:0}.lsu-globe-stage:has([data-ts-map])>.lsu-globe-fallback{visibility:hidden}.hour-dial .rest-ring{width:100%;height:auto;aspect-ratio:1;transform:rotate(-90deg)}@supports(animation-timeline:view()){.duelscroll{view-timeline-name:--duel;view-timeline-axis:block}.duel-fill{transform-origin:left;animation:lsu-grow linear both;animation-timeline:--duel;animation-range:entry 20% exit 65%}@keyframes lsu-grow{from{transform:scaleX(0)}to{transform:scaleX(1)}}}@media(prefers-reduced-motion:reduce){.duel-fill{animation:none}}.lsu-host-dialog[data-stx-dialog]{z-index:200}.lsu-host-dialog .sr-dialog{color:var(--lsu-white,#fff);background:#080808;padding:0;border:1px solid #333;border-radius:0}
2
3/* The page you are on, in the chrome every page shares. Nothing marked it
4 before, so the nav read the same on all twenty-five routes. A section
5 marks its own subtree, so a coaching module lights Coaching. Both a colour
6 and a rule, because colour alone is not a distinction everyone can see. */
7.nav-links.lsu-scope-nav a.lsu-scope-nav[aria-current=page]{color:var(--lsu-white);box-shadow:inset 0 -2px 0 0 var(--lsu-red)}
8.drawer-links.lsu-scope-nav a.lsu-scope-nav[aria-current=page]{color:var(--lsu-red)}
9.footer-col.lsu-sz7xmlte a.lsu-sz7xmlte[aria-current=page]{color:var(--lsu-white);text-decoration:underline;text-decoration-color:var(--lsu-red);text-underline-offset:4px}
public/sitemap.xmlmodified+1-1
Changes to public/sitemap.xml
@@ -43,6 +43,6 @@
4343 <loc>https://www.lastsoulultra.com/coaching</loc>
4444 </url>
4545 <url>
46 <loc>https://www.lastsoulultra.com/faq</loc>
46 <loc>https://www.lastsoulultra.com/coaching/plans</loc>
4747 </url>
4848</urlset>
resources/assets/styles/site/base.cssmodified+6-0
Changes to resources/assets/styles/site/base.css
@@ -1,1 +1,7 @@
11@font-face{font-family:DSEG7;src:url(/assets/lsu/fonts/DSEG7Classic-Bold.woff2) format("woff2");font-weight:700;font-display:swap}@font-face{font-family:Anton;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/anton-400-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Anton;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/anton-400-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:italic;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/barlow-400i-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Barlow;font-style:italic;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/barlow-400i-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:normal;font-weight:300;font-display:swap;src:url(/assets/lsu/fonts/barlow-300-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Barlow;font-style:normal;font-weight:300;font-display:swap;src:url(/assets/lsu/fonts/barlow-300-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/barlow-400-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Barlow;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/barlow-400-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/lsu/fonts/barlow-500-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Barlow;font-style:normal;font-weight:500;font-display:swap;src:url(/assets/lsu/fonts/barlow-500-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/lsu/fonts/barlow-600-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Barlow;font-style:normal;font-weight:600;font-display:swap;src:url(/assets/lsu/fonts/barlow-600-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Barlow;font-style:normal;font-weight:700;font-display:swap;src:url(/assets/lsu/fonts/barlow-700-latin-ext.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Barlow;font-style:normal;font-weight:700;font-display:swap;src:url(/assets/lsu/fonts/barlow-700-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Share Tech Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/assets/lsu/fonts/share-tech-mono-400-latin.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--lsu-black: #000000;--lsu-band: #050505;--lsu-card: #0d0d0f;--lsu-card-alt: #111111;--lsu-white: #ffffff;--lsu-dim-1: rgba(255, 255, 255, .65);--lsu-dim-2: rgba(255, 255, 255, .55);--lsu-dim-3: rgba(255, 255, 255, .4);--lsu-line: rgba(255, 255, 255, .14);--lsu-line-soft: rgba(255, 255, 255, .1);--lsu-line-faint: rgba(255, 255, 255, .08);--lsu-red: #dd0f15;--lsu-red-glow: rgba(221, 15, 21, .5);--lsu-seg-red: #ff2a1a;--lsu-display: "Anton", sans-serif;--lsu-body: "Barlow", system-ui, sans-serif;--lsu-mono: "Share Tech Mono", monospace;--lsu-seg: "DSEG7", "Share Tech Mono", monospace;--lsu-pad-y: clamp(72px, 9.3vw, 130px);--lsu-pad-x: clamp(18px, 5vw, 40px);--lsu-max: 1360px;--lsu-head-gap: clamp(36px, 4vw, 56px)}*{box-sizing:border-box}html{scroll-behavior:smooth;-webkit-text-size-adjust:100%;text-size-adjust:100%}img,svg,video{max-width:100%}img,video{height:auto}body{margin:0;background:var(--lsu-black);color:var(--lsu-white);font-family:var(--lsu-body);overflow-x:hidden}a{color:var(--lsu-white);text-decoration:none}a:hover{color:var(--lsu-red)}a[href^="mailto:"],a[href^="tel:"],a[href^=http]{overflow-wrap:anywhere}:where(.footer-col a,.drawer-social a,.login-links a,.runner-detail summary,.ed-link,.recap-link,.nav-profile,.portal button:not(.btn)){display:inline-flex;align-items:center;min-height:24px;min-width:24px}.kicker{font-size:19px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--lsu-red)}.kicker-sm{font-size:10px;letter-spacing:.26em;text-transform:uppercase;color:var(--lsu-red)}.label{font-size:17px;letter-spacing:.04em;text-transform:uppercase;color:var(--lsu-dim-2)}:where(.kicker-sm,.label,.seg-label,.stage-label,.ws-place-label,.ws-race-label,.marker-label,.duel-mark-label,.e404-route-label,.card-kicker,.form-kicker,.drawer-kicker,.hour-note,.faq-count,.hero-live,.event-meta dt,.runner-facts div,.runner-stats dt,.row-n span,.row-n small,.profile-portal .ticket-header,.profile-portal .timeline-cell span,.profile-portal .detail-row dt){font-size:17px!important;letter-spacing:.04em!important}.shell{max-width:var(--lsu-max);margin:0 auto}.section{padding:clamp(55px,7.1vw,100px) var(--lsu-pad-x);background:var(--lsu-black)}.btn{display:inline-block;max-width:100%;overflow-wrap:anywhere;padding:17px 34px;background:var(--lsu-red);color:var(--lsu-white);font-size:12px;font-weight:700;letter-spacing:.22em;text-transform:uppercase;border:none;font-family:var(--lsu-body);cursor:pointer}.btn:hover{background:var(--lsu-white);color:#000}.btn-lg{padding:20px 46px;font-size:13px}.btn-outline{display:inline-block;padding:17px 34px;border:1px solid rgba(255,255,255,.3);font-size:12px;font-weight:600;letter-spacing:.22em;text-transform:uppercase;color:var(--lsu-white)}.btn-outline:hover{border-color:var(--lsu-white);background:#ffffff12;color:var(--lsu-white)}.statgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(190px,100%),1fr));gap:1px;background:var(--lsu-line);border:1px solid var(--lsu-line)}.statgrid>*{background:var(--lsu-black);padding:28px 24px}.stat-v{font-family:var(--lsu-display);font-size:clamp(34px,4vw,52px);line-height:1}.stat-l{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--lsu-dim-2);margin-top:8px}.card{border:1px solid rgba(255,255,255,.16);padding:32px;display:flex;flex-direction:column;gap:14px}.card-win{border-color:var(--lsu-red)}.card-name{font-family:var(--lsu-display);font-size:clamp(26px,3vw,40px);line-height:1.05;text-transform:uppercase}.card-num{font-family:var(--lsu-display);font-size:60px;line-height:1.05}.card-num span{font-size:17px;color:var(--lsu-dim-2)}.card-body{font-size:14px;color:#ffffffb3;line-height:1.6}.h2{margin:0;font-family:var(--lsu-display);font-size:clamp(34px,5vw,76px);line-height:1.05;text-transform:uppercase}.h2-sm{margin:0;font-family:var(--lsu-display);font-size:clamp(26px,3.4vw,48px);line-height:1.05;text-transform:uppercase}.band{position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;border-top:1px solid var(--lsu-line-soft)}.band-bg{position:absolute;inset:-18% 0;background-size:cover;filter:grayscale(1) brightness(.45)}.band-scrim{position:absolute;inset:0;background:#0000008c}.band-copy{position:relative;z-index:2;text-align:center;padding:0 var(--lsu-pad-x)}.band-copy p{margin:0 auto;max-width:900px;font-family:var(--lsu-display);font-size:clamp(26px,3.6vw,54px);line-height:1.08;text-transform:uppercase}.phero{padding:150px var(--lsu-pad-x) 0;background:radial-gradient(ellipse 60% 55% at 50% 45%,#130306,#000)}.phero-ghost{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;font-family:var(--lsu-display);font-size:min(28vw,400px);color:#ffffff0d;text-transform:uppercase;white-space:nowrap}.phero-copy{max-width:var(--lsu-max);margin:0 auto;display:flex;flex-direction:column;gap:26px}.phero-copy h1{margin:0;font-family:var(--lsu-display);font-size:clamp(24px,8vw,190px);line-height:1.05;letter-spacing:.02em;text-transform:uppercase}.phero-copy h1 .kicker{display:block;margin-bottom:26px;font-family:var(--lsu-body);line-height:1.4}.phero-copy p{margin:0;max-width:480px;font-size:18px;line-height:1.6;color:var(--lsu-dim-1)}@media(max-width:700px){.phero{padding-top:125px}}.series-event{position:relative;isolation:isolate;display:flex;flex-direction:column;overflow:hidden;min-height:350px;padding:28px;border:1px solid rgba(221,15,21,.62);background:#100305}.series-event>*:not(.event-ember):not(.series-flag){position:relative;z-index:1}.event-ember{position:absolute;inset:0;z-index:-1;width:100%;height:100%;pointer-events:none;opacity:.98;mix-blend-mode:screen}.series-event-head{display:flex;flex-wrap:wrap;align-items:center;gap:10px}.series-event-edition{align-self:flex-start;padding:7px 12px 6px;border:1px solid rgba(221,15,21,.9);color:#ff6065;font:15px var(--lsu-mono);letter-spacing:.18em;text-transform:uppercase}.series-event h3{margin:22px 0 30px;font:clamp(32px,4vw,50px)/.9 var(--lsu-display);text-transform:uppercase}.series-event dl{display:grid;gap:16px;margin:0 0 32px}.series-event dl div{display:grid;grid-template-columns:120px 1fr;gap:16px}.series-event dt{font:12px var(--lsu-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--lsu-dim-2)}.series-event dd{margin:0;font-size:16px;line-height:1.45}.series-event-action{display:block;width:100%;margin-top:auto;padding:18px 20px;border:0;background:var(--lsu-red);color:var(--lsu-white);text-align:center;font:700 14px var(--lsu-body);letter-spacing:.15em;text-transform:uppercase;cursor:pointer;box-shadow:0 0 28px #dd0f1573;transition:background-color .2s}.series-event-action:hover{background:#fff;color:#000}.world .series-event-action{padding:16px 20px;font:500 19px/1 var(--lsu-body);letter-spacing:.06em}.series-event:has(.series-event-action){cursor:pointer}.series-event:has(.series-event-action):hover .series-event-action{background:#fff}.series-event.is-entered:after{content:"";position:absolute;inset:0;z-index:2;background:#0000009e;pointer-events:none}.series-event.is-entered>*:not(.event-ember){z-index:3}.series-event.is-entered .series-event-action{background:#fff;box-shadow:0 0 28px #ffffff47}.series-event.is-entered .series-event-action:hover,.series-event.is-entered:hover .series-event-action{background:#d6d6d6}.series-event.is-final{border:2px solid transparent;background-image:linear-gradient(#0e0603,#0e0603),linear-gradient(135deg,#7d5d18,#f6dc95,#c9a344,#fff3cf,#7d5d18);background-origin:border-box;background-clip:padding-box,border-box;box-shadow:0 0 46px #d4af3752;transition:box-shadow .3s ease,transform .3s ease}.series-event.is-final:hover{transform:translateY(-4px);box-shadow:0 0 80px #d4af378c}.series-event.is-final .series-event-edition{border-color:#f6dc95d9;color:#f6dc95}.series-event-final{padding:7px 12px 6px;background:linear-gradient(120deg,#c9a344,#fff3cf 45%,#c9a344);color:#1a1205;font:15px var(--lsu-mono);letter-spacing:.18em;text-transform:uppercase}.series-event.is-final .series-event-action{background:linear-gradient(120deg,#c9a344,#fff3cf 45%,#c9a344);color:#1a1205;box-shadow:0 0 28px #d4af3773}.series-event.is-final .series-event-action:hover,.series-event.is-final:hover .series-event-action{background:linear-gradient(120deg,#fff3cf,#fff 45%,#f6dc95);box-shadow:0 0 42px #f6dc9599}.series-event.is-final:before{content:"";position:absolute;z-index:0;top:0;bottom:0;left:0;width:60%;background:linear-gradient(105deg,transparent 42%,rgba(255,243,207,.16) 50%,transparent 58%);pointer-events:none;animation:finalSheen 9s ease-in-out infinite}.series-event.is-final:hover:before{animation-duration:3s}@keyframes finalSheen{0%{transform:translate(-120%)}55%,to{transform:translate(280%)}}@media(prefers-reduced-motion:reduce){.series-event.is-final,.series-event.is-final:hover{transition:none;transform:none}.series-event.is-final:before,.series-event.is-final:hover:before{animation:none;opacity:0}}@media(max-width:700px){.series-event dl div{grid-template-columns:100px 1fr}}.row{display:grid;grid-template-columns:70px 1fr 120px;gap:20px;align-items:baseline;padding:18px 0;border-bottom:1px solid var(--lsu-line-soft)}.row-n{font-family:var(--lsu-display);font-size:26px;color:var(--lsu-red)}.row-name{font-size:17px;font-weight:500}.row-note{font-size:14px;color:var(--lsu-dim-2);line-height:1.5;margin-top:3px}.row-km{font-size:13px;letter-spacing:.16em;text-transform:uppercase;color:#ffffff73;text-align:right}@media(max-width:900px){[data-cols]{grid-template-columns:1fr!important;gap:34px!important}.row{grid-template-columns:46px 1fr!important;gap:14px!important}.row-km{text-align:left!important;grid-column:2!important}}@media(max-width:760px){.phero-ghost{font-size:46vw!important}}@keyframes lsuPulse{0%,to{opacity:.25}50%{opacity:.9}}@keyframes lsuDrift{0%{transform:translateY(0)}to{transform:translateY(-14px)}}@keyframes lsuMist{0%{transform:translate3d(-6%,0,0) scale(1.1);opacity:.32}50%{opacity:.6}to{transform:translate3d(6%,-3%,0) scale(1.25);opacity:.32}}@keyframes lsuTorch{0%,to{opacity:.86}30%{opacity:1}55%{opacity:.9}80%{opacity:.97}}@keyframes lsuFlash{0%,92%,to{opacity:0}93%{opacity:.5}94.5%{opacity:.08}96%{opacity:.34}97.5%{opacity:0}}.legal{padding:var(--lsu-head-gap) var(--lsu-pad-x) var(--lsu-pad-y);background:var(--lsu-black)}.legal-shell{max-width:var(--lsu-max);margin:0 auto}.legal-shell>*{max-width:760px}.legal-shell>:first-child{margin-top:0}.legal-list{margin:0 0 52px;border-top:1px solid var(--lsu-line-soft)}.legal-row{display:grid;grid-template-columns:minmax(min(150px,100%),1fr) 2fr;gap:8px 28px;padding:15px 0;border-bottom:1px solid var(--lsu-line-faint)}.legal-row dt{font-family:var(--lsu-mono);font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2);line-height:1.5}.legal-row dd{margin:0;font-size:15.5px;color:var(--lsu-white);line-height:1.6}.legal-h2{font-family:var(--lsu-display);font-weight:400;text-transform:uppercase;font-size:clamp(20px,2.6vw,28px);line-height:1.05;letter-spacing:.01em;margin:44px 0 16px;color:var(--lsu-white)}.legal p{font-size:15.5px;line-height:1.72;color:var(--lsu-dim-1);margin:0 0 14px;max-width:68ch}.legal a{color:var(--lsu-red);text-underline-offset:2px}.country-picker{position:relative}.country-open{position:absolute;inset:0;width:100%;margin:0;padding:0;border:0;background:transparent;cursor:pointer}.country-panel{position:absolute;z-index:30;top:calc(100% + 4px);left:0;right:0;border:1px solid rgba(255,255,255,.38);background:#090909;box-shadow:0 18px 40px #0009;color:var(--lsu-white);font:16px/1.3 var(--lsu-body);letter-spacing:0;text-transform:none}.country-picker.country-picker .country-panel input[type=search]{-webkit-appearance:none;appearance:none;box-sizing:border-box;width:100%;min-height:48px;margin:0;padding:12px 15px;border:0;border-bottom:1px solid rgba(255,255,255,.14);border-radius:0;background:transparent;color:var(--lsu-white);font:16px var(--lsu-body);outline:none}.country-picker.country-picker .country-panel input[type=search]:focus-visible{border-bottom-color:var(--lsu-red);box-shadow:none}.country-panel input[type=search]::-webkit-search-cancel-button{-webkit-appearance:none}.country-list{max-height:280px;margin:0;padding:4px 0;list-style:none;overflow-y:auto;overscroll-behavior:contain}.country-list li{padding:10px 15px;color:#ffffffd1;cursor:pointer}.country-list li.active,.country-list li[data-value]:hover{background:#ffffff17;color:var(--lsu-white)}.country-list li[aria-selected=true]{box-shadow:inset 3px 0 0 var(--lsu-red)}.country-list li.empty{color:#ffffff80;cursor:default}.nav.lsu-scope-nav{position:fixed;top:0;left:0;right:0;z-index:100;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:12px max(var(--lsu-pad-x),calc(50% - var(--lsu-max) / 2));backdrop-filter:blur(14px);background:#000000eb;border-bottom:1px solid var(--lsu-line-faint);font-family:var(--lsu-body);color:var(--lsu-white)}.nav-logo.lsu-scope-nav{display:block;flex:0 0 auto}.nav-logo.lsu-scope-nav img.lsu-scope-nav{height:clamp(24px,3vw,34px);width:auto;display:block}.nav-links.lsu-scope-nav{display:flex;align-items:center;gap:26px;flex-wrap:wrap;font-size:12px;letter-spacing:.2em;text-transform:uppercase;font-weight:500}@media(max-width:1320px){.nav-links.lsu-scope-nav{gap:14px;font-size:11px;letter-spacing:.1em}}.nav-end.lsu-scope-nav{display:flex;align-items:center;gap:14px;flex:0 0 auto}.nav-cta.lsu-scope-nav{padding:11px 22px;background:var(--lsu-red);color:var(--lsu-white);font-weight:700;font-size:12px;letter-spacing:.2em;text-transform:uppercase}.nav-cta.lsu-scope-nav:hover{background:var(--lsu-white);color:#000}.nav-profile.lsu-scope-nav{font-size:11px;font-weight:600;letter-spacing:.16em;text-transform:uppercase}.nav-profile.lsu-scope-nav:hover{color:var(--lsu-red)}.burger.lsu-scope-nav{display:none;flex-direction:column;justify-content:center;gap:6px;width:48px;height:48px;padding:0 12px;background:none;border:0;cursor:pointer;flex:0 0 auto}.burger.lsu-scope-nav span.lsu-scope-nav{display:block;height:2px;width:100%;background:var(--lsu-white);transition:transform .3s ease,opacity .2s ease}.burger.lsu-scope-nav[aria-expanded=true] span.lsu-scope-nav:nth-child(1){transform:translateY(8px) rotate(45deg)}.burger.lsu-scope-nav[aria-expanded=true] span.lsu-scope-nav:nth-child(2){opacity:0}.burger.lsu-scope-nav[aria-expanded=true] span.lsu-scope-nav:nth-child(3){transform:translateY(-8px) rotate(-45deg)}.scrim.lsu-scope-nav{position:fixed;inset:0;z-index:98;background:#0009;backdrop-filter:blur(2px);transition:opacity .35s ease;opacity:0;pointer-events:none}.scrim.lsu-scope-nav[data-open]{opacity:1;pointer-events:auto}.drawer.lsu-scope-nav{position:fixed;top:0;right:0;bottom:0;z-index:99;width:min(86vw,380px);display:flex;flex-direction:column;background:#07070a;border-left:1px solid rgba(255,255,255,.1);padding:88px clamp(24px,7vw,40px) 32px;font-family:var(--lsu-body);color:var(--lsu-white);transition:transform .38s cubic-bezier(.22,.8,.28,1);transform:translate(102%);overflow-y:auto}.drawer.lsu-scope-nav[data-open]{transform:translate(0)}.drawer-kicker.lsu-scope-nav{font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:var(--lsu-red);margin-bottom:26px}.drawer-links.lsu-scope-nav{display:flex;flex-direction:column;gap:2px;font-family:var(--lsu-display);font-size:24px;letter-spacing:.01em;text-transform:uppercase}.drawer-links.lsu-scope-nav a.lsu-scope-nav{padding:14px 0;border-bottom:1px solid var(--lsu-line-faint);opacity:0;transform:translate(18px);transition:opacity .4s ease,transform .4s ease}.drawer.lsu-scope-nav[data-open] .drawer-links.lsu-scope-nav a.lsu-scope-nav{opacity:1;transform:translate(0)}.drawer-cta.lsu-scope-nav{margin-top:30px;padding:18px 24px;background:var(--lsu-red);color:var(--lsu-white);text-align:center;font-size:12px;font-weight:700;letter-spacing:.22em;text-transform:uppercase}.drawer-cta.lsu-scope-nav:hover{background:var(--lsu-white);color:#000}.drawer-social.lsu-scope-nav{margin-top:auto;padding-top:34px;display:flex;gap:18px;font-size:11px;letter-spacing:.2em;text-transform:uppercase}.drawer-social.lsu-scope-nav a.lsu-scope-nav{color:#ffffff73}.drawer-social.lsu-scope-nav a.lsu-scope-nav:hover{color:var(--lsu-white)}@media(max-width:1080px){.nav-links.lsu-scope-nav,.nav-cta.lsu-scope-nav,.nav-profile.lsu-scope-nav{display:none}.burger.lsu-scope-nav{display:flex}.nav-end.lsu-scope-nav{gap:4px}}.mobile-cta.lsu-scope-nav{display:none;position:fixed;left:0;right:0;bottom:0;z-index:40;padding:17px 16px calc(17px + env(safe-area-inset-bottom,0px));background:var(--lsu-red);color:var(--lsu-white);font-weight:700;font-size:12px;letter-spacing:.2em;text-transform:uppercase;text-align:center}@media(max-width:1080px){.mobile-cta.lsu-scope-nav{display:block}body:has(.mobile-cta){padding-bottom:calc(52px + env(safe-area-inset-bottom,0px))}}@media(prefers-reduced-motion:reduce){.drawer.lsu-scope-nav,.drawer-links.lsu-scope-nav a.lsu-scope-nav,.scrim.lsu-scope-nav,.burger.lsu-scope-nav span.lsu-scope-nav{transition:none}}.footer.lsu-sz7xmlte{padding:clamp(50px,6.4vw,90px) var(--lsu-pad-x) 36px;background:var(--lsu-black);border-top:1px solid rgba(255,255,255,.12);font-family:var(--lsu-body);color:var(--lsu-white)}.footer-grid.lsu-sz7xmlte{max-width:var(--lsu-max);margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(230px,100%),1fr));gap:48px;align-items:start}.footer-brand.lsu-sz7xmlte{display:flex;flex-direction:column;gap:16px}.footer-brand.lsu-sz7xmlte img.lsu-sz7xmlte{width:clamp(150px,17vw,220px);height:auto;display:block}.footer-org.lsu-sz7xmlte{font-family:var(--lsu-display);font-size:14px;letter-spacing:.2em;text-transform:uppercase;color:#ffffffbf}.footer-addr.lsu-sz7xmlte{font-size:13px;color:var(--lsu-dim-2);line-height:1.7}.footer-col.lsu-sz7xmlte{display:flex;flex-direction:column;gap:11px;font-size:13px}.footer-head.lsu-sz7xmlte{font-size:10px;letter-spacing:.26em;text-transform:uppercase;color:var(--lsu-red);margin-bottom:6px}.footer-col.lsu-sz7xmlte a.lsu-sz7xmlte{color:#ffffffbf}.footer-col.lsu-sz7xmlte a.lsu-sz7xmlte:hover{color:var(--lsu-white)}.footer-base.lsu-sz7xmlte{max-width:var(--lsu-max);margin:56px auto 0;padding-top:20px;border-top:1px solid var(--lsu-line-soft);display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between;font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--lsu-dim-3)}@media(max-width:700px){.footer-grid.lsu-sz7xmlte{justify-items:center;text-align:center;gap:40px}.footer-brand.lsu-sz7xmlte,.footer-col.lsu-sz7xmlte{align-items:center}.footer-brand.lsu-sz7xmlte img.lsu-sz7xmlte{margin:0 auto}.footer-base.lsu-sz7xmlte{justify-content:center;text-align:center;gap:10px;margin-top:44px}}.consent.lsu-ruz5wv6z{position:fixed;left:0;right:0;bottom:0;z-index:900;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px 28px;padding:18px var(--lsu-pad-x);background:var(--lsu-black);border-top:1px solid var(--lsu-red);font-family:var(--lsu-body);color:var(--lsu-white)}.consent.lsu-ruz5wv6z[hidden]{display:none}.consent-copy.lsu-ruz5wv6z{margin:0;max-width:68ch;font-size:13px;line-height:1.6;color:#fffc}.consent-copy.lsu-ruz5wv6z a.lsu-ruz5wv6z{color:var(--lsu-white);text-decoration:underline}.consent-acts.lsu-ruz5wv6z{display:flex;gap:10px;flex:none}.consent-btn.lsu-ruz5wv6z{padding:11px 20px;border:1px solid rgba(255,255,255,.35);background:transparent;color:var(--lsu-white);font-family:var(--lsu-body);font-weight:700;font-size:11px;letter-spacing:.2em;text-transform:uppercase;cursor:pointer}.consent-btn.lsu-ruz5wv6z:hover{border-color:var(--lsu-white)}.consent-btn-yes.lsu-ruz5wv6z{background:var(--lsu-red);border-color:var(--lsu-red)}@media(max-width:700px){.consent.lsu-ruz5wv6z{justify-content:center;text-align:center}}.page.lsu-scope-site{position:relative;width:100%;background:#000}
2
3/* The third step of the display scale. .h2 and .h2-sm were defined and .h3
4 never was, so the results tables' own headings - class="h3 table-title" -
5 rendered at 16px Barlow beside 51px Anton siblings and read as a stray
6 label rather than a heading. Continues the ratio the other two set. */
7.h3{margin:0;font-family:var(--lsu-display);font-size:clamp(20px,2.4vw,32px);line-height:1.1;letter-spacing:.02em;text-transform:uppercase;font-weight:400}
public/assets/styles/stx-site.css resources/assets/styles/site/coaching-page.csscopied+27-21
Changes to resources/assets/styles/site/coaching-page.css
@@ -1,25 +1,31 @@
1.lsu-skip{position:fixed;left:1rem;top:-5rem;z-index:200;background:#fff;color:#000;padding:1rem}.lsu-skip:focus{top:1rem}main:focus{outline:none}.nav-links{gap:clamp(12px,1.6vw,28px)}.coaching-hero{position:relative;padding:clamp(140px,18vw,240px) clamp(18px,5vw,40px) 100px;background:linear-gradient(90deg,#000e,#0003),url('/assets/lsu/images/hero-desktop.webp') 50% 35%/cover}.coaching-hero h1{max-width:13ch;font:400 clamp(48px,8vw,120px)/1.05 var(--lsu-display);text-transform:uppercase;letter-spacing:.02em;margin:24px 0}.coaching-hero p{max-width:54ch;color:#ffffffb3;font:400 19px/1.6 var(--lsu-body)}.coaching-body{max-width:1360px;margin:auto;padding:clamp(50px,7vw,100px) clamp(18px,5vw,40px)}.coaching-steps{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid #ffffff24;border-bottom:1px solid #ffffff24;margin:3rem 0}.coaching-steps article{padding:2rem;border-right:1px solid #ffffff24}.coaching-steps article:last-child{border:0}.coaching-steps h2{font:400 30px/1.2 var(--lsu-display);text-transform:uppercase}.coaching-steps p{color:#ffffffa6;line-height:1.7}.coaching-note{padding:2rem;border-left:3px solid var(--lsu-red);background:#0d0d0f}.coaching-note h2{font:400 36px var(--lsu-display);text-transform:uppercase}.coaching-note p{max-width:65ch;color:#ffffffa6;line-height:1.7}.race-portal{max-width:720px;margin:auto;padding:clamp(24px,5vw,64px);border:1px solid #ffffff24;background:#0d0d0f}.race-portal label{display:block;font:600 12px var(--lsu-body);text-transform:uppercase;letter-spacing:.12em;margin:1rem 0 .5rem}.race-portal input,.race-portal textarea,.race-portal select{display:block;width:100%;padding:1rem;background:#050505;border:1px solid #ffffff40;color:#fff;font:inherit}.race-portal .btn{margin-top:1.5rem}.race-portal [role=alert]{color:#ff928f}.race-portal h2{font:400 36px var(--lsu-display);text-transform:uppercase}.race-portal p{line-height:1.65}.race-tabs{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}.race-tabs button{background:transparent;color:#fff;border:0;border-bottom:2px solid #ffffff40;padding:.75rem;cursor:pointer}.race-tabs button[aria-selected=true]{border-color:#dd0f15}.race-portal .field-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.lsu-scroll-gallery{overflow:auto;scroll-snap-type:x mandatory}.lsu-scroll-gallery>*{scroll-snap-align:start}@media(max-width:700px){.coaching-hero{background-image:linear-gradient(90deg,#000d,#0006),url('/assets/lsu/images/hero-mobile.webp')}.coaching-steps{grid-template-columns:1fr}.coaching-steps article{border-right:0;border-bottom:1px solid #ffffff24}.race-portal .field-row{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
1/* /coaching, the page that explains what coaching here is.
2
3 Its own sheet rather than stx-site.css, which every route on the site
4 downloads: these rules were 7 KB of the 11 KB that file shipped to all
5 twenty-five pages, and they match on one. They were put there because the
6 page used to load base.css alone; it loads its own styles now.
7
8 Every rule stays wrapped in .coaching-page, because `.accent` only ever
9 exists per page in base.css, which is why the old hero's red words
10 rendered white. */
11
12/* Unscoped, and kept that way: the scoped rules below build on these - the
13 hero h1 takes its family, case and tracking from here and overrides only
14 the size. They came across from stx-site.css with the rest of the page. */
15.coaching-hero{position:relative;padding:clamp(140px,18vw,240px) clamp(18px,5vw,40px) 100px;background:linear-gradient(90deg,#000e,#0003),url('/assets/lsu/images/hero-desktop.webp') 50% 35%/cover}
16.coaching-hero h1{max-width:13ch;font:400 clamp(48px,8vw,120px)/1.05 var(--lsu-display);text-transform:uppercase;letter-spacing:.02em;margin:24px 0}
17.coaching-hero p{max-width:54ch;color:#ffffffb3;font:400 19px/1.6 var(--lsu-body)}
18.coaching-body{max-width:1360px;margin:auto;padding:clamp(50px,7vw,100px) clamp(18px,5vw,40px)}
19.coaching-steps{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid #ffffff24;border-bottom:1px solid #ffffff24;margin:3rem 0}
20.coaching-steps article{padding:2rem;border-right:1px solid #ffffff24}
21.coaching-steps article:last-child{border:0}
22.coaching-steps h2{font:400 30px/1.2 var(--lsu-display);text-transform:uppercase}
23.coaching-steps p{color:#ffffffa6;line-height:1.7}
24.coaching-note{padding:2rem;border-left:3px solid var(--lsu-red);background:#0d0d0f}
25.coaching-note h2{font:400 36px var(--lsu-display);text-transform:uppercase}
26.coaching-note p{max-width:65ch;color:#ffffffa6;line-height:1.7}
27@media(max-width:700px){.coaching-hero{background-image:linear-gradient(90deg,#000d,#0006),url('/assets/lsu/images/hero-mobile.webp')}.coaching-steps{grid-template-columns:1fr}.coaching-steps article{border-right:0;border-bottom:1px solid #ffffff24}}
228
3.lsu-globe-stage{position:relative;width:100%;height:100%;min-height:420px}.lsu-globe-fallback{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none}.lsu-event-dot{display:block;width:14px;height:14px;border-radius:50%;background:#dd0f15;border:2px solid white;box-shadow:0 0 0 6px #dd0f1540}.ts-map{background:transparent}.lsu-globe-stage .ts-map{position:absolute;inset:0}.lsu-globe-stage:has([data-ts-map])>.lsu-globe-fallback{visibility:hidden}
4
5.hour-dial .rest-ring{width:100%;height:auto;aspect-ratio:1;transform:rotate(-90deg)}
6
7.duel-fill-1.lsu-scope-home{width:71.28%}.duel-fill-2.lsu-scope-home{width:100%}
8@supports(animation-timeline:view()){.duelscroll{view-timeline-name:--duel;view-timeline-axis:block}.duel-fill{transform-origin:left;animation:lsu-grow linear both;animation-timeline:--duel;animation-range:entry 20% exit 65%}@keyframes lsu-grow{from{transform:scaleX(0)}to{transform:scaleX(1)}}}
9@media(prefers-reduced-motion:reduce){.duel-fill{animation:none}}
10
11.lsu-host-dialog[data-stx-dialog]{z-index:200}.lsu-host-dialog .sr-dialog{color:var(--lsu-white,#fff);background:#080808;padding:0;border:1px solid #333;border-radius:0}
12
13/* Let the browser defer distant story panels and their background photos.
14 Explicitly sized bands keep their authored height. Story panels remember
15 their measured height after their first visit; one viewport reserves space
16 before they approach the viewport. Unsupported browsers render normally. */
17.lapscroll.lsu-scope-home,.duelscroll.lsu-scope-home{content-visibility:auto;contain-intrinsic-block-size:auto 100svh}
18.attr-band.lsu-scope-home,.enter.lsu-scope-next-loop{content-visibility:auto}
19
20/* /coaching. Every rule is wrapped in .coaching-page because this file loads on
21 all sixteen routes, and because `.accent` only ever exists per page in
22 base.css, which is why the old hero's red words rendered white. */
2329.coaching-page .accent{color:var(--lsu-red)}
2430.coaching-page .coaching-body>*+*{margin-top:clamp(56px,7vw,104px)}
2531.coaching-page .coaching-head{display:flex;flex-direction:column;gap:18px;max-width:65ch}
resources/assets/styles/site/coaching.cssadded+116-0
Changes to resources/assets/styles/site/coaching.css
@@ -0,0 +1,116 @@
1/* The coaching surface: /coaching and the eight module routes under it.
2
3 Contrast: --lsu-dim-3 reaches 3.66:1 on black and the brand red 4.15:1, so
4 neither clears WCAG AA's 4.5 for body text. Both are used that way across
5 the rest of the site, which is the organiser's own design and not this
6 sheet's to change - but nothing here adds to it. Small text is --lsu-dim-2
7 (6.25) or brighter, and the red is kept for type at 22px and up, where the
8 3:1 bar for large text applies.
9 Loaded only by those routes, not from stx-site.css, which the whole site
10 pays for on every page.
11
12 The palette is the site's own, which is also what the HQ brand preset
13 publishes: black, #0d0d0f surfaces, #dd0f15, Anton over Barlow, square
14 corners. Nothing here reads a colour over the network - a page whose
15 palette arrives with a fetch paints unstyled while that fetch is in flight.
16
17 Prefixed .coach- throughout. The marketing page's .coaching-* rules live in
18 stx-site.css and the two must not reach into one another. */
19
20.coaching-surface {--coach-gutter: clamp(18px, 5vw, 40px); --coach-rule: 1px solid var(--lsu-line)}
21.coaching-surface .coach-shell {max-width:var(--lsu-max);margin:0 auto;padding:0 var(--coach-gutter)}
22/* The shell has no vertical padding, so the first child's top margin would
23 collapse straight through it and sit flush against the nav rule. The band
24 below the nav owns that space instead, and the panel carries none. */
25.coaching-surface .coach-nav+.coach-shell {padding-top:clamp(26px,3vw,40px)}
26
27/* Header. A compact band rather than another full-height hero: the runner who
28 reaches /coaching/calendar came to read this week's sessions, not to be
29 sold the idea again. The red hairline under it carries the brand instead. */
30.coaching-surface .coach-head {padding:clamp(104px,11vw,136px) 0 clamp(26px,3vw,38px)}
31.coaching-surface .coach-head .kicker {display:block;color:var(--lsu-red)}
32/* The kicker is the way back to the hub. The module nav hides itself when the
33 brand publishes nothing, and without this a module page was then a dead end
34 with no link to the page that explains what coaching here is. */
35.coaching-surface .coach-up {width:fit-content;text-decoration:none;border-bottom:1px solid transparent}
36.coaching-surface .coach-up:hover {border-bottom-color:var(--lsu-red)}
37.coaching-surface .coach-head h1 {max-width:16ch;margin:16px 0 0;font:400 clamp(38px,5.2vw,76px)/1.04 var(--lsu-display);text-transform:uppercase;letter-spacing:.02em}
38.coaching-surface .coach-head p {max-width:58ch;margin:20px 0 0;color:var(--lsu-dim-1);font:400 18px/1.65 var(--lsu-body)}
39
40/* Module nav. One row of words, the current one underlined in red. It scrolls
41 sideways on a phone rather than wrapping into three ragged lines, and the
42 scrollport keeps its own focus ring so a keyboard can reach the overflow. */
43.coaching-surface .coach-nav {border-block:var(--coach-rule);background:#000}
44.coaching-surface .coach-nav ul {display:flex;gap:clamp(18px,3vw,40px);margin:0;padding:0;list-style:none;overflow-x:auto;scrollbar-width:none;scroll-snap-type:x proximity;-webkit-overflow-scrolling:touch}
45.coaching-surface .coach-nav ul::-webkit-scrollbar {display:none}
46.coaching-surface .coach-nav li {scroll-snap-align:start}
47.coaching-surface .coach-nav a {display:block;padding:18px 0;border-bottom:2px solid transparent;color:var(--lsu-dim-2);font:400 13px/1 var(--lsu-body);letter-spacing:.14em;text-transform:uppercase;text-decoration:none;white-space:nowrap}
48.coaching-surface .coach-nav a:hover {color:var(--lsu-white)}
49.coaching-surface .coach-nav a[aria-current="page"] {border-bottom-color:var(--lsu-red);color:var(--lsu-white)}
50
51/* Above the panel, always. A cross-origin frame can decline to load for
52 reasons this page cannot detect, and the embed's own escape hatch sits
53 below 1100px of empty iframe, where nobody finds it. This one says where
54 the panel comes from, which the site's voice wants said anyway. */
55.coaching-surface .coach-panel-bar {display:flex;flex-wrap:wrap;gap:12px 24px;align-items:baseline;justify-content:space-between;padding-bottom:14px;border-bottom:var(--coach-rule)}
56.coaching-surface .coach-panel-bar p {margin:0;max-width:52ch;color:var(--lsu-dim-2);font:400 13px/1.5 var(--lsu-body)}
57.coaching-surface .coach-panel-bar a {color:var(--lsu-white);font:400 13px/1 var(--lsu-body);letter-spacing:.14em;text-transform:uppercase;text-decoration:none;border-bottom:1px solid var(--lsu-red);padding-bottom:3px}
58.coaching-surface .coach-panel-bar a:hover {color:var(--lsu-red)}
59
60/* Reachable by a screen reader, invisible to everyone else. */
61.coaching-surface .coach-sr {position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
62
63/* The panel. Space is reserved before the remote element upgrades, so HQ
64 loading cannot shove the footer down the page under the reader. */
65.coaching-surface .coach-panel {min-height:460px;margin:clamp(20px,2vw,28px) 0 clamp(60px,8vw,120px)}
66.coaching-surface .coach-panel hq-coaching {display:block}
67.coaching-surface .coach-panel hq-coaching:not(:defined) {display:grid;gap:22px;justify-items:start;padding:clamp(24px,5vw,56px);border:var(--coach-rule);background:var(--lsu-card)}
68.coaching-surface .coach-panel hq-coaching:not(:defined) p {margin:0;max-width:56ch;color:var(--lsu-dim-1);font:400 16px/1.7 var(--lsu-body)}
69
70/* Said before the click, not after it. A module that needs an account says so
71 here; a private one says what this site cannot see, which is the whole
72 point of health and messages reading the athlete's own account. */
73.coaching-surface .coach-scope {display:grid;gap:10px;margin:0 0 clamp(26px,3vw,40px);padding:18px 22px;border-left:3px solid var(--lsu-red);background:var(--lsu-card)}
74.coaching-surface .coach-scope strong {font:400 13px/1 var(--lsu-body);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-white)}
75.coaching-surface .coach-scope p {margin:0;max-width:62ch;color:var(--lsu-dim-1);font:400 15px/1.65 var(--lsu-body)}
76
77/* The storefront. One ruled row per tier, not a grid of pricing cards: there
78 is one offer today and a three-column grid holding one card looks broken. */
79.coaching-surface .coach-offers {display:grid;margin:0 0 clamp(40px,5vw,72px);padding:0;list-style:none;border-top:var(--coach-rule)}
80.coaching-surface .coach-offer {display:grid;grid-template-columns:minmax(0,1fr) auto;gap:clamp(18px,4vw,56px);align-items:start;padding:clamp(26px,3vw,40px) 0;border-bottom:var(--coach-rule)}
81.coaching-surface .coach-offer-name {margin:0;font:400 clamp(24px,2.8vw,36px)/1.1 var(--lsu-display);text-transform:uppercase}
82.coaching-surface .coach-offer-by {margin:10px 0 0;color:var(--lsu-dim-1);font:400 13px/1 var(--lsu-body);letter-spacing:.14em;text-transform:uppercase}
83.coaching-surface .coach-offer p {margin:16px 0 0;max-width:60ch;color:var(--lsu-dim-1);font:400 16px/1.7 var(--lsu-body)}
84.coaching-surface .coach-offer-price {text-align:right;white-space:nowrap}
85.coaching-surface .coach-offer-price b {display:block;color:var(--lsu-red);font:400 clamp(24px,2.4vw,30px)/1.1 var(--lsu-display);font-weight:400}
86.coaching-surface .coach-offer-trial {display:block;margin-top:8px;color:var(--lsu-dim-2);font:400 13px/1.4 var(--lsu-body)}
87.coaching-surface .coach-offer-method {color:var(--lsu-dim-2);font-size:15px}
88.coaching-surface .coach-offer-note {margin:14px 0 0;color:var(--lsu-dim-2);font:400 13px/1.5 var(--lsu-body)}
89
90/* The hub's index of modules. A ruled list rather than a card grid: eight
91 cards is a wall, and the sentence beside each name is the part that is
92 worth reading. The whole row is the target, not the word. */
93.coaching-surface .coach-index {display:grid;margin:clamp(34px,4vw,56px) 0 0;padding:0;list-style:none;border-top:var(--coach-rule)}
94.coaching-surface .coach-index li {border-bottom:var(--coach-rule)}
95.coaching-surface .coach-index a {display:grid;grid-template-columns:minmax(0,15rem) minmax(0,1fr);gap:clamp(12px,3vw,40px);align-items:baseline;padding:clamp(18px,2.2vw,26px) 0;text-decoration:none}
96.coaching-surface .coach-index a:hover .coach-index-label {color:var(--lsu-red)}
97.coaching-surface .coach-index-label {font:400 clamp(20px,2.2vw,28px)/1.1 var(--lsu-display);text-transform:uppercase;color:var(--lsu-white);transition:color .18s}
98.coaching-surface .coach-index-summary {max-width:60ch;color:var(--lsu-dim-1);font:400 15px/1.65 var(--lsu-body)}
99
100/* Nothing published yet. The honest state, and the one that ships today. */
101.coaching-surface .coach-empty {margin:0 0 clamp(40px,5vw,72px);padding:clamp(30px,5vw,56px);border:var(--coach-rule);background:var(--lsu-card)}
102.coaching-surface .coach-empty h2,.coaching-surface .coach-empty h3 {margin:0;font:400 clamp(24px,3vw,34px)/1.1 var(--lsu-display);text-transform:uppercase}
103.coaching-surface .coach-empty p {margin:16px 0 0;max-width:58ch;color:var(--lsu-dim-1);font:400 16px/1.7 var(--lsu-body)}
104.coaching-surface .coach-empty-back a {color:var(--lsu-white);text-decoration:none;border-bottom:1px solid var(--lsu-red);padding-bottom:2px}
105.coaching-surface .coach-empty-back a:hover {color:var(--lsu-red)}
106
107/* Focus has to clear the black. A red ring on #000 is not reliably visible at
108 small sizes, so the ring is white with a red cast behind it. */
109.coaching-surface a:focus-visible,.coaching-surface button:focus-visible,.coaching-surface [tabindex]:focus-visible {outline:2px solid var(--lsu-white);outline-offset:3px;box-shadow:0 0 0 5px var(--lsu-red-glow)}
110
111@media (max-width:760px) {
112 .coaching-surface .coach-offer {grid-template-columns:minmax(0,1fr);gap:18px}
113 .coaching-surface .coach-index a {grid-template-columns:minmax(0,1fr);gap:8px}
114 .coaching-surface .coach-offer-price {text-align:left}
115 .coaching-surface .coach-panel {min-height:520px}
116}
resources/assets/styles/site/home.cssmodified+11-0
Changes to resources/assets/styles/site/home.css
@@ -1,1 +1,12 @@
11.accent.lsu-scope-home{color:var(--lsu-red)}.hero.lsu-scope-home{position:relative;height:auto;min-height:100svh;overflow:hidden;display:flex;align-items:flex-end}.hero-photo.lsu-scope-home{position:absolute;inset:-8% 0 32%;background-image:url(/assets/lsu/images/hero-desktop.webp);background-size:cover;background-position:50% 35%;filter:brightness(.85)}@media(max-width:900px){.hero-photo.lsu-scope-home{background-image:url(/assets/lsu/images/hero-mobile.webp);background-position:50% 30%}}.rig.lsu-scope-home{position:absolute;inset:-20% 0;pointer-events:none;mix-blend-mode:screen}.rig-core.lsu-scope-home{opacity:.85;animation:lsuTorch 7.4s ease-in-out infinite}.rig-streak.lsu-scope-home{opacity:.8;animation:lsuTorch 5.6s ease-in-out infinite}.rig-mid.lsu-scope-home{opacity:.75;animation:lsuTorch 8.8s ease-in-out infinite}.rig-haze.lsu-scope-home{opacity:.7;animation:lsuTorch 10.6s ease-in-out infinite}.glow.lsu-scope-home,.ray.lsu-scope-home,.streak.lsu-scope-home,.mid.lsu-scope-home,.haze.lsu-scope-home{position:absolute;left:0;top:0;width:10px;height:10px;transform:translate(-50%,-50%)}.glow.lsu-scope-home{border-radius:50%;background:radial-gradient(circle,#fffdf6c7,#fff7e142 30%,#fff0cd0f 62%,#0000);filter:blur(4px)}.ray.lsu-scope-home{-webkit-mask-image:radial-gradient(circle,rgba(0,0,0,.9) 0%,rgba(0,0,0,.35) 34%,rgba(0,0,0,0) 66%);mask-image:radial-gradient(circle,#000000e6,#00000059 34%,#0000 66%);filter:blur(1.2px)}.streak.lsu-scope-home{height:3px;background:linear-gradient(90deg,#0000,#fff8e429 32%,#fffcf466,#fff8e429 68%,#0000);filter:blur(2px)}.mid.lsu-scope-home{border-radius:50%;background:radial-gradient(circle,#fff9e838,#fff4d812 40%,#0000);filter:blur(9px)}.haze.lsu-scope-home{border-radius:50%;background:radial-gradient(circle,#fff6dc24,#fff2d20a 48%,#0000);filter:blur(22px)}.rain.lsu-scope-home{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;mix-blend-mode:screen}.rain-back.lsu-scope-home{opacity:.9;-webkit-mask-image:linear-gradient(to bottom,rgba(0,0,0,.95) 0%,rgba(0,0,0,.8) 55%,rgba(0,0,0,.55) 100%);mask-image:linear-gradient(to bottom,#000000f2,#000c 55%,#0000008c)}.rain-front.lsu-scope-home{z-index:4;filter:blur(1.2px);opacity:.5}@media(max-width:900px){.rain-front.lsu-scope-home{filter:none}}.hero-vignette.lsu-scope-home{position:absolute;inset:0;background:radial-gradient(120% 90% at 50% 10%,#0000,#0000008c 55%,#000000f5)}.hero-foot.lsu-scope-home{position:absolute;left:0;right:0;bottom:0;height:40%;pointer-events:none;z-index:2;background:linear-gradient(to bottom,#0000,#0000004d 45%,#000000bf 78%,#000)}.hero-mist.lsu-scope-home{position:absolute;inset:0;pointer-events:none;background:radial-gradient(60% 45% at 22% 78%,#becddc29,#0000 70%),radial-gradient(50% 40% at 76% 66%,#becddc1f,#0000 70%);animation:lsuMist 17s ease-in-out infinite alternate;mix-blend-mode:screen}.hero-flash.lsu-scope-home{position:absolute;inset:0;pointer-events:none;background:linear-gradient(to bottom,#d2e1f01a,#0000 55%);animation:lsuFlash 14s linear infinite;mix-blend-mode:screen}.hero-copy.lsu-scope-home{position:relative;z-index:3;width:100%;max-width:calc(var(--lsu-max) + 2 * var(--lsu-pad-x));margin-inline:auto;padding:clamp(96px,12vh,140px) var(--lsu-pad-x) 56px;display:flex;flex-direction:column;gap:28px}.hero-live.lsu-scope-home{display:flex;align-items:center;gap:14px;font-size:11px;letter-spacing:.3em;text-transform:uppercase;color:var(--lsu-red);text-shadow:0 1px 14px rgba(0,0,0,.95),0 0 4px rgba(0,0,0,.8)}.hero-dot.lsu-scope-home{width:8px;height:8px;border-radius:50%;background:var(--lsu-red);animation:lsuPulse 2.4s ease-in-out infinite;flex:0 0 auto}.hero-copy.lsu-scope-home h1.lsu-scope-home{margin:0;font-family:var(--lsu-display);font-size:clamp(24px,8vw,190px);line-height:1.05;letter-spacing:.02em;text-transform:uppercase;max-width:1100px;white-space:nowrap}.hero-copy.lsu-scope-home h1.lsu-scope-home .hero-kicker.lsu-scope-home{display:block;margin-bottom:26px;font-family:var(--lsu-body);line-height:1.4;white-space:normal;color:#ffffffb8;text-shadow:0 1px 14px rgba(0,0,0,.95),0 0 4px rgba(0,0,0,.8)}.hero-base.lsu-scope-home{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:32px;border-top:1px solid var(--lsu-line);padding-top:26px}.hero-base.lsu-scope-home p.lsu-scope-home{margin:0;max-width:480px;font-size:18px;line-height:1.6;color:#ffffffb8}.hero-actions.lsu-scope-home{display:flex;gap:14px;flex-wrap:wrap}@media(max-width:1080px){.hero-actions.lsu-scope-home{display:none}}.marquee-band.lsu-scope-home{position:relative;z-index:4;background:var(--lsu-black);border-top:1px solid var(--lsu-line-soft);border-bottom:1px solid var(--lsu-line-soft);overflow:hidden}.marquee.lsu-scope-home{display:flex;gap:56px;padding:16px 0;white-space:nowrap;font-family:var(--lsu-display);font-size:15px;letter-spacing:.24em;text-transform:uppercase;color:#ffffff73;width:max-content;animation:lsuMarquee 60s linear infinite;will-change:transform}.marquee.lsu-scope-home>.lsu-scope-home{flex:none}@keyframes lsuMarquee{to{transform:translate(-50%)}}.marquee.lsu-scope-home .slash.lsu-scope-home{color:var(--lsu-red)}.lapscroll.lsu-scope-home{position:relative;background:var(--lsu-black)}.lap-inner.lsu-scope-home{position:relative;min-height:100vh;overflow:hidden;display:flex;align-items:center;padding-block:clamp(40px,5vw,72px)}.lap-photo.lsu-scope-home{position:absolute;inset:-10% 0;background-image:url(/assets/lsu/images/rappid-65.webp);background-size:cover;background-position:center;filter:grayscale(1) contrast(1.2) brightness(.3);opacity:.5}.lap-scrim.lsu-scope-home{position:absolute;inset:0;background:linear-gradient(to bottom,#000000f2,#00000080 50%,#000000f2)}.lap-grid.lsu-scope-home{position:relative;z-index:2;width:100%;padding:0 var(--lsu-pad-x);display:grid;grid-template-columns:1.15fr 1fr;gap:64px;align-items:center}.lap-kicker.lsu-scope-home{margin-bottom:22px}.lap-readouts.lsu-scope-home{display:flex;align-items:flex-start;gap:24px;flex-wrap:wrap}.seg.lsu-scope-home{position:relative;display:inline-block}.seg-ghost.lsu-scope-home{color:#ffffff14}.seg-live.lsu-scope-home{position:absolute;left:0;top:0;color:var(--lsu-white);text-shadow:0 0 16px rgba(255,255,255,.5),0 0 52px rgba(255,255,255,.2)}.seg-ghost.lsu-scope-home,.seg-live.lsu-scope-home{font-family:var(--lsu-seg);font-variant-numeric:tabular-nums}.seg-ghost-red.lsu-scope-home{color:#ff2a1a17}.seg-live-red.lsu-scope-home{color:var(--lsu-seg-red);text-shadow:0 0 18px rgba(255,42,26,.75),0 0 60px rgba(255,42,26,.35)}.seg-lap.lsu-scope-home{font-size:min(16vw,168px,18vh);line-height:.9}.seg-live.lsu-scope-home.seg-lap{inset:0}.seg-clock.lsu-scope-home{font-size:clamp(24px,3vw,46px);line-height:1;white-space:nowrap}.seg-live.lsu-scope-home.seg-clock{text-shadow:0 0 14px rgba(255,42,26,.7)}.seg-souls.lsu-scope-home{font-size:clamp(52px,7.5vw,110px);line-height:1}.seg-km.lsu-scope-home{font-size:clamp(30px,4vw,58px);line-height:1;white-space:nowrap}.seg-live.lsu-scope-home.seg-km{text-shadow:0 0 14px rgba(255,255,255,.45),0 0 44px rgba(255,255,255,.18)}.seg-unit.lsu-scope-home{font-family:var(--lsu-body);font-size:.35em;letter-spacing:.2em;color:var(--lsu-dim-2)}.seg-label.lsu-scope-home{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:#ffffff52;margin-bottom:8px}.seg-label-red.lsu-scope-home{color:#ff2a1aa6}.lap-elapsed.lsu-scope-home{margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,42,26,.25);display:flex;align-items:baseline;gap:12px}.lap-elapsed.lsu-scope-home .seg-label.lsu-scope-home{margin-bottom:0;color:#ff2a1a99}.lap-side.lsu-scope-home{padding-top:14px;display:flex;flex-direction:column;gap:18px}.lap-meta.lsu-scope-home{display:flex;flex-direction:column;gap:6px;font-size:11px;letter-spacing:.26em;text-transform:uppercase;color:#ffffff52;text-shadow:0 0 10px rgba(255,255,255,.15)}.lap-meta-live.lsu-scope-home{color:#ffffffd9;text-shadow:0 0 10px rgba(255,255,255,.25)}.attr.lsu-scope-home{margin-top:34px}.attr-head.lsu-scope-home{display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 16px;font-size:9px;letter-spacing:.28em;text-transform:uppercase;color:#ffffff4d;margin-bottom:8px}.attr.lsu-scope-home canvas.lsu-scope-home{cursor:crosshair;display:block;width:100%;height:44px;filter:drop-shadow(0 0 8px rgba(255,255,255,.28)) drop-shadow(0 0 5px rgba(255,42,26,.3))}.lap-copy.lsu-scope-home{display:flex;flex-direction:column;gap:26px}.lap-copy.lsu-scope-home p.lsu-scope-home{white-space:pre-line}.lap-copy.lsu-scope-home h2.lsu-scope-home{margin:0;font-family:var(--lsu-display);font-size:clamp(34px,4.2vw,62px);line-height:1.05;text-transform:uppercase;letter-spacing:.02em}.lap-copy.lsu-scope-home p.lsu-scope-home{margin:0;font-size:18px;line-height:1.6;color:var(--lsu-dim-2);max-width:430px;min-height:116px}.lap-facts.lsu-scope-home{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;background:var(--lsu-line);border:1px solid var(--lsu-line)}.lap-facts.lsu-scope-home>div.lsu-scope-home{background:var(--lsu-black);padding:20px 22px}.lap-fact-l.lsu-scope-home{font-size:10px;letter-spacing:.26em;text-transform:uppercase;color:#ffffff4d;margin-bottom:8px}.lap-fact-v.lsu-scope-home{font-family:var(--lsu-display);font-size:22px;text-transform:uppercase}.y87scroll.lsu-scope-home{position:relative;background:var(--lsu-black);border-top:1px solid var(--lsu-line-soft)}.y87-sticky.lsu-scope-home{position:relative;min-height:62vh;overflow:hidden;display:flex;align-items:flex-start;justify-content:flex-start;padding:clamp(56px,8vh,80px) 0 clamp(40px,14vh,140px)}.y87-bg.lsu-scope-home{position:absolute;inset:0}.y87-poster.lsu-scope-home{position:absolute;inset:0;background-image:url(/assets/lsu/images/yard87-poster.jpg);background-size:cover;background-position:38% 30%}.y87-vid.lsu-scope-home{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:38% 30%}.y87-top.lsu-scope-home{position:absolute;inset:0;background:linear-gradient(to bottom,#000000d9,#0000 30% 68%,#00000080)}.y87-shell.lsu-scope-home{position:relative;width:100%;padding:0 var(--lsu-pad-x);display:flex;justify-content:center}.y87-col.lsu-scope-home{width:100%;max-width:var(--lsu-max);display:flex;flex-direction:column;gap:clamp(18px,2.6vh,28px)}.y87-stage.lsu-scope-home{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}.y87-chapter.lsu-scope-home{display:flex;flex-direction:column;gap:10px;padding:20px;background:#0000008c;border:1px solid rgba(255,255,255,.16);backdrop-filter:blur(3px)}.y87-chapter.lsu-scope-home h2.lsu-scope-home{margin:0;font-family:var(--lsu-display);font-size:clamp(20px,2.2vw,30px);line-height:1;text-transform:uppercase;text-shadow:0 2px 30px rgba(0,0,0,.9)}.y87-chapter.lsu-scope-home p.lsu-scope-home{margin:0;font-size:14px;line-height:1.55;color:var(--lsu-dim-1);text-shadow:0 1px 12px rgba(0,0,0,.8)}@media(max-width:900px){.y87-stage.lsu-scope-home{grid-template-columns:1fr 1fr}.y87-vid.lsu-scope-home{object-position:42% 30%}.y87-poster.lsu-scope-home{background-position:42% 30%}}@media(max-width:560px){.y87-stage.lsu-scope-home{grid-template-columns:1fr}}.recap.lsu-scope-home{position:relative;padding:clamp(66px,8.6vw,120px) var(--lsu-pad-x);background:var(--lsu-black)}.recap-head.lsu-scope-home{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:22px 30px;margin-bottom:52px;border-bottom:1px solid var(--lsu-line);padding-bottom:22px}.recap-title.lsu-scope-home{margin:0;font-family:var(--lsu-display);font-size:clamp(38px,5.5vw,84px);line-height:1.05;text-transform:uppercase}.recap-meta.lsu-scope-home{display:flex;flex-direction:column;align-items:flex-end;gap:14px;padding-bottom:8px}.recap-sub.lsu-scope-home{font-size:12px;letter-spacing:.22em;text-transform:uppercase;color:var(--lsu-dim-2)}.recap-link.lsu-scope-home{display:inline-flex;align-items:center;gap:10px;font-size:12px;font-weight:600;letter-spacing:.22em;text-transform:uppercase;color:var(--lsu-red);border-bottom:1px solid var(--lsu-red);padding-bottom:4px}.recap-link.lsu-scope-home:hover{color:var(--lsu-white);border-color:var(--lsu-white)}.carousel-track.lsu-scope-home{display:flex;gap:20px;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;scrollbar-width:none;overscroll-behavior-x:contain}.carousel-track.lsu-scope-home::-webkit-scrollbar{display:none}.carousel-track.lsu-scope-home:focus-visible{outline:2px solid var(--lsu-red);outline-offset:4px}.carousel-slide.lsu-scope-home{flex:0 0 calc((100% - 40px) / 3);scroll-snap-align:start;aspect-ratio:3 / 4;overflow:hidden}.carousel-slide.lsu-scope-home img.lsu-scope-home{display:block;width:100%;height:100%;object-fit:cover;object-position:center}.carousel-nav.lsu-scope-home{display:flex;align-items:center;justify-content:center;gap:22px;margin-top:26px}.carousel-btn.lsu-scope-home{width:56px;height:56px;border:1px solid var(--lsu-line);background:transparent;color:var(--lsu-white);font-size:22px;cursor:pointer}.carousel-btn.lsu-scope-home:hover:not(:disabled){background:var(--lsu-red);border-color:var(--lsu-red);color:var(--lsu-white)}.carousel-btn.lsu-scope-home:disabled{opacity:.3;cursor:default}.carousel-count.lsu-scope-home{min-width:5ch;text-align:center;font-family:var(--lsu-mono);font-size:13px;letter-spacing:.16em;color:var(--lsu-dim-2)}@media(max-width:1000px){.carousel-slide.lsu-scope-home{flex-basis:calc((100% - 20px)/2)}}@media(max-width:640px){.carousel-slide.lsu-scope-home{flex-basis:100%}.carousel-track.lsu-scope-home{gap:12px}}@media(max-width:900px){.recap-head.lsu-scope-home>div.lsu-scope-home{align-items:flex-start}}.duelscroll.lsu-scope-home{position:relative;background:var(--lsu-black);border-top:1px solid var(--lsu-line-soft)}.duel-sticky.lsu-scope-home{position:relative;min-height:100vh;overflow:hidden;display:flex;align-items:center;justify-content:flex-start;padding-block:clamp(40px,5vw,72px)}.duel-bg.lsu-scope-home{position:absolute;inset:-26% 0;background-image:url(/assets/lsu/images/rappid-13-1080.webp);background-size:cover;background-position:center 30%;opacity:.22;filter:grayscale(1) contrast(1.15);will-change:transform}.duel-scrim.lsu-scope-home{position:absolute;inset:0;background:radial-gradient(90% 75% at 50% 46%,#00000052,#000000e6),linear-gradient(to bottom,#000000e6,#0003 26% 72%,#000000eb)}.duel-shell.lsu-scope-home{position:relative;width:100%;max-width:var(--lsu-max);margin:0 auto;padding:0 var(--lsu-pad-x)}.duel-kicker.lsu-scope-home{margin-bottom:18px}.duel-title.lsu-scope-home{margin-bottom:46px}.duel-bars.lsu-scope-home{display:flex;flex-direction:column;gap:38px}.duel-head.lsu-scope-home{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px;gap:16px;flex-wrap:wrap}.duel-id.lsu-scope-home{display:flex;align-items:baseline;gap:16px}.duel-ed.lsu-scope-home{font-family:var(--lsu-display);font-size:20px;text-transform:uppercase;color:#ffffffd9}.duel-ed-2.lsu-scope-home{color:var(--lsu-red)}.duel-km.lsu-scope-home{font-family:var(--lsu-seg);font-size:15px;color:var(--lsu-dim-1)}.duel-km-2.lsu-scope-home{color:var(--lsu-red)}.duel-unit.lsu-scope-home{font-family:var(--lsu-mono);font-size:12px;letter-spacing:.12em}.duel-note.lsu-scope-home{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--lsu-dim-3)}.duel-track.lsu-scope-home{position:relative;height:34px;background:#ffffff12}.duel-fill.lsu-scope-home{position:absolute;left:0;top:0;bottom:0;width:0%}.duel-fill-1.lsu-scope-home{background:#ffffffd9}.duel-fill-2.lsu-scope-home{background:var(--lsu-red)}.duel-yard.lsu-scope-home{position:absolute;left:8px;top:50%;transform:translateY(-50%);font-family:var(--lsu-seg);font-size:18px}.duel-yard-1.lsu-scope-home{color:#000;mix-blend-mode:difference;filter:invert(1)}.duel-yard-2.lsu-scope-home{color:var(--lsu-white);text-shadow:0 0 10px rgba(0,0,0,.6)}.duel-mark.lsu-scope-home{position:relative;height:0}.duel-mark-line.lsu-scope-home{position:absolute;left:95.74%;top:-152px;width:1px;height:170px;background:#ff2a1a80}.duel-mark-label.lsu-scope-home{position:absolute;left:95.74%;top:22px;transform:translate(-100%);font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:#ff2a1acc;white-space:nowrap;padding-right:8px}.duel-text.lsu-scope-home{margin:6px 0 0;max-width:560px;font-size:16px;line-height:1.6;color:var(--lsu-dim-2);min-height:52px}.attr-band.lsu-scope-home .band-bg.lsu-scope-home{inset:-8% 0;background-image:url(/assets/lsu/images/statement-1080.webp);background-size:cover;background-position:center 44%;filter:brightness(.6)}@media(max-width:760px){.attr-band.lsu-scope-home .band-bg.lsu-scope-home{background-image:url(/assets/lsu/images/statement-720.webp);background-position:center 30%}}.attr-band.lsu-scope-home .band-scrim.lsu-scope-home{background:linear-gradient(to top,#000000d9,#00000026 60%,#00000040)}.attr-band.lsu-scope-home .band-copy.lsu-scope-home{align-self:flex-end;padding-bottom:clamp(24px,5vw,64px)}.attr-band.lsu-scope-home{height:85vh;min-height:480px}.attr-copy.lsu-scope-home{font-size:clamp(28px,3.8vw,56px);letter-spacing:.02em}@media(max-width:760px){.lap-inner.lsu-scope-home{min-height:0}.lap-facts.lsu-scope-home{display:none}.lap-readouts.lsu-scope-home{gap:16px}.lap-side.lsu-scope-home{flex-direction:row;align-items:baseline;gap:26px;padding-top:6px;flex-wrap:wrap}}@media(prefers-reduced-motion:reduce){.rig.lsu-scope-home,.hero-mist.lsu-scope-home,.hero-flash.lsu-scope-home,.hero-dot.lsu-scope-home{animation:none}}
2
3/* Moved out of stx-site.css, which every route downloads and none of these
4 can match: the duel bars and the deferral hints belong to this page. */
5.duel-fill-1.lsu-scope-home{width:71.28%}
6.duel-fill-2.lsu-scope-home{width:100%}
7/* Let the browser defer distant story panels and their background photos.
8 Explicitly sized bands keep their authored height. Story panels remember
9 their measured height after their first visit; one viewport reserves space
10 before they approach the viewport. Unsupported browsers render normally. */
11.lapscroll.lsu-scope-home,.duelscroll.lsu-scope-home{content-visibility:auto;contain-intrinsic-block-size:auto 100svh}
12.attr-band.lsu-scope-home{content-visibility:auto}
resources/assets/styles/site/next-loop.cssmodified+4-0
Changes to resources/assets/styles/site/next-loop.css
@@ -1,1 +1,5 @@
11.enter.lsu-scope-next-loop{position:relative;min-height:640px;height:100vh;overflow:hidden;display:flex;align-items:center;justify-content:center}.enter-bg.lsu-scope-next-loop{position:absolute;inset:-20% 0;background-image:url(/assets/lsu/images/rappid-20.webp);background-size:cover;background-position:center 40%;filter:grayscale(1) brightness(.45)}.enter-scrim.lsu-scope-next-loop{position:absolute;inset:0;background:radial-gradient(80% 70% at 50% 50%,#00000059,#000000f2)}.enter-copy.lsu-scope-next-loop{position:relative;z-index:3;text-align:center;padding:0 var(--lsu-pad-x);display:flex;flex-direction:column;align-items:center;gap:30px}.seg.lsu-scope-next-loop{position:relative;display:inline-block}.seg-ghost.lsu-scope-next-loop,.seg-live.lsu-scope-next-loop{font-family:var(--lsu-seg);font-variant-numeric:tabular-nums;font-size:clamp(56px,12vw,160px);line-height:1;white-space:nowrap}.seg-ghost.lsu-scope-next-loop{color:#ffffff14}.seg-live.lsu-scope-next-loop{position:absolute;left:0;top:0;color:var(--lsu-white);text-shadow:0 0 16px rgba(255,255,255,.5),0 0 52px rgba(255,255,255,.2)}.enter-sub.lsu-scope-next-loop{margin-top:-18px;font:13px var(--lsu-mono);letter-spacing:.24em;text-transform:uppercase;color:var(--lsu-dim-1)}.enter-title.lsu-scope-next-loop{margin:0;font-family:var(--lsu-display);font-size:clamp(44px,8vw,132px);line-height:1;letter-spacing:.02em;text-transform:uppercase}.enter-copy.lsu-scope-next-loop p.lsu-scope-next-loop{margin:0;max-width:560px;font-size:17px;line-height:1.6;color:#ffffffb8}
2
3/* Moved out of stx-site.css: this sheet is loaded by the two pages that
4 have an .enter band, and no others. */
5.enter.lsu-scope-next-loop{content-visibility:auto}
resources/assets/styles/site/not-found.cssadded+14-0
Changes to resources/assets/styles/site/not-found.css
@@ -0,0 +1,14 @@
1/* /404.
2
3 The page used to be written in six class names that exist in no stylesheet
4 on this site - h-page, display, ink, faint, muted and btn-accent - left
5 over from an earlier design system, so its heading, eyebrow, body copy and
6 primary button all rendered unstyled. It is written in the site's own
7 vocabulary now: the same .phero block the legal pages use, and the .btn and
8 .btn-outline pair from base.css.
9
10 All this sheet adds is the row the two buttons sit in, and the bottom
11 padding .phero does not carry. */
12
13.not-found-actions{display:flex;flex-wrap:wrap;gap:14px;margin-top:10px}
14.phero:last-child{padding-bottom:clamp(90px,12vw,160px)}
resources/assets/styles/site/race-entry.cssmodified+52-0
Changes to resources/assets/styles/site/race-entry.css
@@ -1,1 +1,53 @@
11.form.lsu-scope-lottery{max-width:760px;border:1px solid rgba(255,255,255,.16);padding:44px;display:flex;flex-direction:column;gap:26px}.portal.lsu-scope-lottery .portal-head h2,.portal.lsu-scope-lottery .portal-top h2{margin:10px 0 0;font-family:var(--lsu-display);font-size:clamp(26px,3.4vw,48px);line-height:1.05;text-transform:uppercase}.portal.lsu-scope-lottery .portal-top{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.portal.lsu-scope-lottery button:not(.btn){appearance:none;border:0;padding:4px 0;background:transparent;color:#ffffffb8;font:inherit;text-decoration:underline;text-underline-offset:4px;cursor:pointer}.portal.lsu-scope-lottery .portal-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px 18px}.portal.lsu-scope-lottery .portal-form:not(.application){grid-template-columns:1fr;max-width:520px}.portal.lsu-scope-lottery .field{display:flex;flex-direction:column;gap:9px;color:#ffffff9e;font-family:var(--lsu-mono);font-size:17px;letter-spacing:.04em;line-height:1.4;text-transform:uppercase}.portal.lsu-scope-lottery .field input,.portal.lsu-scope-lottery .field select{box-sizing:border-box;width:100%;min-height:52px;border:1px solid rgba(255,255,255,.2);border-radius:0;background:#090909;color:var(--lsu-white);padding:13px 15px;font-family:var(--lsu-body);font-size:17px;letter-spacing:0;outline:none;text-transform:none}.portal.lsu-scope-lottery .field input:hover,.portal.lsu-scope-lottery .field select:hover{border-color:#ffffff61}.portal.lsu-scope-lottery .field select{appearance:none;padding-right:44px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='9' viewBox='0 0 14 9'%3E%3Cpath d='M1 1l6 6 6-6' fill='none' stroke='%23ffffff' stroke-opacity='.7' stroke-width='2'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 16px center;cursor:pointer}.portal.lsu-scope-lottery .field select option{background:#090909;color:var(--lsu-white)}.portal.lsu-scope-lottery .field input:focus-visible,.portal.lsu-scope-lottery .field select:focus-visible,.portal.lsu-scope-lottery .field textarea:focus-visible{border-color:var(--lsu-red);box-shadow:0 0 0 1px var(--lsu-red)}.portal.lsu-scope-lottery .portal-form>.form-submit,.portal.lsu-scope-lottery .portal-form>.form-status,.portal.lsu-scope-lottery .portal-form>.save-state,.portal.lsu-scope-lottery .field-wide,.portal.lsu-scope-lottery [data-pb],.portal.lsu-scope-lottery .check,.portal.lsu-scope-lottery .form-actions{grid-column:1 / -1}.portal.lsu-scope-lottery [data-pb]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px 18px}.portal.lsu-scope-lottery [data-pb][hidden]{display:none}.portal.lsu-scope-lottery .portal-links,.portal.lsu-scope-lottery .form-actions{display:flex;flex-wrap:wrap;align-items:center;gap:18px}.portal.lsu-scope-lottery .portal-links{max-width:520px;padding-top:2px}.portal.lsu-scope-lottery .form-actions .btn{flex:1}.portal.lsu-scope-lottery .form-back{min-height:49px;padding:0 18px;border:1px solid rgba(255,255,255,.28);color:var(--lsu-white);font:600 12px/1 var(--lsu-mono);letter-spacing:.12em;text-transform:uppercase}.portal.lsu-scope-lottery .form-back:hover,.portal.lsu-scope-lottery .form-back:focus-visible{border-color:var(--lsu-red);color:var(--lsu-red)}.portal.lsu-scope-lottery .field textarea{min-height:140px;resize:vertical;background:#0a0a0a;border:1px solid rgba(255,255,255,.2);color:var(--lsu-white);padding:14px;font:16px/1.5 var(--lsu-body)}.portal.lsu-scope-lottery .field small{min-height:1em;color:#ff725e;font:12px/1.35 var(--lsu-body);letter-spacing:0;text-transform:none}.portal.lsu-scope-lottery .check{display:flex;align-items:flex-start;gap:12px;color:#ffffffd1;font-size:15px;line-height:1.45}.portal.lsu-scope-lottery .check input{width:19px;height:19px;accent-color:var(--lsu-red)}.portal.lsu-scope-lottery .attest{padding:18px;border:1px solid rgba(255,255,255,.18)}.portal.lsu-scope-lottery .steps-mini{display:flex;gap:8px}.portal.lsu-scope-lottery .steps-mini span{width:100%;height:3px;background:#ffffff26;color:transparent}.portal.lsu-scope-lottery .steps-mini .active{background:var(--lsu-red);box-shadow:0 0 12px var(--lsu-red-glow)}.portal.lsu-scope-lottery .save-state{min-height:1em;margin:-10px 0 0;color:var(--lsu-dim-2);font-size:12px}.portal.lsu-scope-lottery .form-status.bad,.portal.lsu-scope-lottery .save-state.bad{color:#ff725e}.portal.lsu-scope-lottery .portal-message{text-align:center;padding:28px 0;color:#ffffffbf}.portal.lsu-scope-lottery .retry{align-self:center}.portal.lsu-scope-lottery .status-card{padding:28px;border:1px solid rgba(255,255,255,.18);font-size:22px}.portal.lsu-scope-lottery .status-card p{margin:10px 0 0;color:var(--lsu-dim-2);font-size:14px}.portal.lsu-scope-lottery .status-card .add-cal{display:inline-flex;align-items:center;min-height:44px;margin-top:10px;color:var(--lsu-red);font:600 11px var(--lsu-mono);letter-spacing:.16em;text-transform:uppercase;text-decoration:underline;text-underline-offset:4px}.portal.lsu-scope-lottery .close-note{margin:0 0 18px;color:var(--lsu-dim-2);font-size:13px}.portal.lsu-scope-lottery .status-dot{width:9px;height:9px;margin-bottom:14px;border-radius:50%;background:var(--lsu-red);box-shadow:0 0 14px var(--lsu-red)}.portal.lsu-scope-lottery .danger{color:#ff725e!important}.portal.lsu-scope-lottery .withdraw-row{grid-column:1 / -1;margin-top:38px;padding-top:22px;border-top:1px solid rgba(255,255,255,.12)}.portal.lsu-scope-lottery .withdraw-row .danger{font-size:14px;text-decoration:underline;text-underline-offset:4px}.portal.lsu-scope-lottery .withdraw-ask{display:grid;gap:6px;margin:0 0 18px;max-width:520px}.portal.lsu-scope-lottery .withdraw-ask strong{font-size:17px;color:var(--lsu-white)}.portal.lsu-scope-lottery .withdraw-ask span{font-size:14px;color:var(--lsu-dim-2)}.portal.lsu-scope-lottery .withdraw-why{max-width:520px;margin:0 0 18px;font-size:13px}.portal.lsu-scope-lottery .withdraw-why textarea{min-height:88px}.portal.lsu-scope-lottery .withdraw-answers{display:flex;flex-wrap:wrap;align-items:center;gap:18px}.portal.lsu-scope-lottery .withdraw-answers .btn{flex:0 0 auto}.lottery-dialog.lsu-scope-lottery{width:min(760px,calc(100% - 32px));max-width:none;max-height:calc(100dvh - 32px);padding:0;border:1px solid rgba(255,255,255,.16);background:#050505;color:var(--lsu-white);box-shadow:0 0 70px #dd0f1559;overflow:auto;overscroll-behavior:contain}.lottery-dialog.lsu-scope-lottery::backdrop{background:#000000d1}.lottery-dialog.lsu-scope-lottery .form.lsu-scope-lottery{border:0;padding-top:0}.lottery-dialog.lsu-scope-lottery[data-account-only] .lottery-stages.lsu-scope-lottery{display:none}.lottery-dialog.lsu-scope-lottery[data-account-only] .lottery-bar.lsu-scope-lottery{justify-content:flex-end}.lottery-bar.lsu-scope-lottery{position:sticky;top:0;z-index:1;display:flex;align-items:flex-start;gap:16px;padding:12px 8px 28px 44px;background:#050505}.lottery-stages.lsu-scope-lottery{flex:1;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:12px 0 0;padding:0;list-style:none}.lottery-stages.lsu-scope-lottery li.lsu-scope-lottery{display:flex;flex-direction:column;gap:8px;padding-top:12px;border-top:3px solid rgba(255,255,255,.15);color:#ffffff73;transition:border-color .3s,color .3s}.lottery-stages.lsu-scope-lottery .done.lsu-scope-lottery{border-color:var(--lsu-red);color:var(--lsu-red)}.lottery-stages.lsu-scope-lottery .lsu-scope-lottery[aria-current=step]{border-color:var(--lsu-red);color:var(--lsu-white)}.stage-n.lsu-scope-lottery{font:11px var(--lsu-mono);letter-spacing:.16em;text-transform:uppercase}.lsu-scope-lottery[aria-current=step] .stage-n.lsu-scope-lottery{color:var(--lsu-red)}.stage-label.lsu-scope-lottery{font:20px/1 var(--lsu-display);text-transform:uppercase}.lottery-close.lsu-scope-lottery{width:48px;height:48px;border:0;background:transparent;color:var(--lsu-white);font-size:32px;line-height:1;cursor:pointer}.lottery-close.lsu-scope-lottery:hover,.lottery-close.lsu-scope-lottery:focus-visible{color:var(--lsu-red)}html:has(.lottery-dialog[open]){overflow:hidden}@media(prefers-reduced-motion:no-preference){.lottery-dialog.lsu-scope-lottery[open]{animation:lottery-in .22s cubic-bezier(.2,.8,.2,1)}.lottery-dialog.lsu-scope-lottery[open]::backdrop{animation:lottery-fade .22s ease-out}}@keyframes lottery-in{0%{opacity:0;transform:translateY(12px)}}@keyframes lottery-fade{0%{opacity:0}}@media(prefers-reduced-motion:reduce){.lottery-stages.lsu-scope-lottery li.lsu-scope-lottery{transition:none}}@media(max-width:760px){.form.lsu-scope-lottery{padding:28px 20px}.lottery-bar.lsu-scope-lottery{gap:8px;padding:8px 4px 24px 20px}.stage-n.lsu-scope-lottery{font-size:10px;letter-spacing:.1em}.stage-label.lsu-scope-lottery{font-size:16px}.portal.lsu-scope-lottery .portal-form,.portal.lsu-scope-lottery [data-pb]{grid-template-columns:1fr}}
2
3/* The two sections below the form.
4
5 They shipped with no rules at all: .entry, .entry-shell, .open-now,
6 .open-list, .open-status, .steps-shell, .steps-title, .steps, .step,
7 .step-n, .step-copy, .step-title, .step-body and .deadline are named in
8 lottery.stx and defined in no stylesheet this site serves, so the four
9 open events rendered as a plain stack of country, date and status running
10 together, and the six entry steps as bare numerals over unstyled text.
11
12 Written in the vocabulary the rest of the site uses: hairline rules
13 rather than cards, Anton for anything that is a label or a number, Barlow
14 for anything that is a sentence, and red reserved for the one state that
15 is actionable. */
16
17.entry.lsu-scope-lottery{padding:clamp(40px,6vw,80px) var(--lsu-pad-x) 0}
18.entry-shell.lsu-scope-lottery{max-width:var(--lsu-max);margin:0 auto}
19
20.open-now.lsu-scope-lottery .h2-sm.lsu-scope-lottery{margin:0 0 clamp(26px,3vw,40px)}
21.open-list.lsu-scope-lottery{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border-top:1px solid var(--lsu-line)}
22.open-list.lsu-scope-lottery article.lsu-scope-lottery{display:flex;flex-direction:column;gap:12px;padding:clamp(22px,2.4vw,30px) clamp(18px,2vw,26px) clamp(26px,3vw,36px) 0;border-bottom:1px solid var(--lsu-line)}
23.open-list.lsu-scope-lottery h3.lsu-scope-lottery{display:flex;flex-direction:column;gap:8px;margin:0;font:400 clamp(20px,2.1vw,27px)/1.05 var(--lsu-display);letter-spacing:.02em;text-transform:uppercase}
24.open-list.lsu-scope-lottery h3.lsu-scope-lottery span.lsu-scope-lottery{color:var(--lsu-dim-2);font:400 12px/1 var(--lsu-body);letter-spacing:.16em}
25.open-status.lsu-scope-lottery{margin:auto 0 0;color:var(--lsu-dim-2);font:400 13px/1.5 var(--lsu-body)}
26.open-status.is-open.lsu-scope-lottery{display:flex;align-items:center;gap:9px;color:var(--lsu-white);font-weight:600;letter-spacing:.08em;text-transform:uppercase}
27/* The red is the marker, not the words: #dd0f15 on black is 4.15:1, which
28 clears the 3:1 a graphical indicator needs and not the 4.5 this 13px label
29 would. */
30.open-status.is-open.lsu-scope-lottery:before{content:"";flex:none;width:9px;height:9px;background:var(--lsu-red);box-shadow:0 0 10px var(--lsu-red-glow)}
31.open-status.lsu-scope-lottery a.lsu-scope-lottery{color:var(--lsu-white);text-decoration:underline;text-underline-offset:4px}
32.open-status.lsu-scope-lottery a.lsu-scope-lottery:hover{color:var(--lsu-red)}
33
34.steps-shell.lsu-scope-lottery{max-width:var(--lsu-max);margin:0 auto;padding:0 var(--lsu-pad-x)}
35.steps-title.lsu-scope-lottery{margin:0 0 clamp(30px,4vw,52px)}
36.steps.lsu-scope-lottery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0;border-top:1px solid var(--lsu-line)}
37.step.lsu-scope-lottery{display:grid;grid-template-columns:auto minmax(0,1fr);gap:clamp(14px,1.8vw,24px);align-items:start;padding:clamp(24px,2.6vw,34px) clamp(20px,2.2vw,30px) clamp(28px,3vw,38px) 0;border-bottom:1px solid var(--lsu-line-soft)}
38.step-n.lsu-scope-lottery{color:var(--lsu-red);font:400 clamp(26px,2.8vw,38px)/1 var(--lsu-display)}
39.step-copy.lsu-scope-lottery{display:flex;flex-direction:column;gap:10px}
40.step-title.lsu-scope-lottery{font:400 clamp(17px,1.7vw,21px)/1.2 var(--lsu-display);letter-spacing:.02em;text-transform:uppercase}
41.step-body.lsu-scope-lottery{max-width:40ch;color:var(--lsu-dim-1);font:400 15px/1.65 var(--lsu-body)}
42.step-body.lsu-scope-lottery a{color:var(--lsu-white);text-decoration:underline;text-underline-offset:4px}
43.step-body.lsu-scope-lottery a:hover{color:var(--lsu-red)}
44
45.deadline.lsu-scope-lottery{min-height:clamp(360px,54vh,520px)}
46
47@media(max-width:1000px){
48.open-list.lsu-scope-lottery{grid-template-columns:repeat(2,minmax(0,1fr))}
49.steps.lsu-scope-lottery{grid-template-columns:repeat(2,minmax(0,1fr))}
50}
51@media(max-width:620px){
52.open-list.lsu-scope-lottery,.steps.lsu-scope-lottery{grid-template-columns:minmax(0,1fr)}
53}
resources/assets/styles/site/soul-races.cssmodified+1-1
Changes to resources/assets/styles/site/soul-races.css
@@ -1,1 +1,1 @@
1.sr.lsu-scope-soul-races{min-height:100vh;padding-bottom:100px}.sr-body.lsu-scope-soul-races{padding:0 var(--lsu-pad-x)}.sr-head.lsu-scope-soul-races,.sr-filters.lsu-scope-soul-races,.sr-stage.lsu-scope-soul-races,.sr-host.lsu-scope-soul-races{max-width:var(--lsu-max);margin-left:auto;margin-right:auto}.sr-head.lsu-scope-soul-races .accent.lsu-scope-soul-races{color:var(--lsu-red)}.sr-note.lsu-scope-soul-races{max-width:640px;margin:0;font-size:15px;line-height:1.6;color:var(--lsu-dim-1)}.sr-counts.lsu-scope-soul-races{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px 24px;margin:14px 0 0;padding:0}.sr-counts.lsu-scope-soul-races div.lsu-scope-soul-races{display:flex;flex-direction:column-reverse;gap:6px;padding-top:14px;border-top:1px solid var(--lsu-line)}.sr-counts.lsu-scope-soul-races dd.lsu-scope-soul-races{margin:0;font:clamp(34px,4vw,52px)/1 var(--lsu-display)}.sr-counts.lsu-scope-soul-races dt.lsu-scope-soul-races{font:12px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2)}.sr-filters.lsu-scope-soul-races{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-top:var(--lsu-head-gap);padding:20px 0;border-top:1px solid var(--lsu-line);border-bottom:1px solid var(--lsu-line)}.sr-filters.lsu-scope-soul-races label.lsu-scope-soul-races{display:flex;flex-direction:column;gap:8px;font:12px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2)}.sr-filters.lsu-scope-soul-races select.lsu-scope-soul-races,.sr-filters.lsu-scope-soul-races input.lsu-scope-soul-races,.field.lsu-scope-soul-races input.lsu-scope-soul-races,.field.lsu-scope-soul-races select.lsu-scope-soul-races,.field.lsu-scope-soul-races textarea.lsu-scope-soul-races{box-sizing:border-box;width:100%;min-height:48px;padding:12px 14px;border:1px solid rgba(255,255,255,.2);border-radius:0;background:#090909;color:var(--lsu-white);font:16px var(--lsu-body);letter-spacing:0;text-transform:none;outline:none}.sr-filters.lsu-scope-soul-races select.lsu-scope-soul-races,.field.lsu-scope-soul-races select.lsu-scope-soul-races{appearance:none;padding-right:40px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='9' viewBox='0 0 14 9'%3E%3Cpath d='M1 1l6 6 6-6' fill='none' stroke='%23ffffff' stroke-opacity='.7' stroke-width='2'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;cursor:pointer}.sr-filters.lsu-scope-soul-races select.lsu-scope-soul-races option.lsu-scope-soul-races,.field.lsu-scope-soul-races select.lsu-scope-soul-races option.lsu-scope-soul-races{background:#090909;color:var(--lsu-white)}.sr-filters.lsu-scope-soul-races .lsu-scope-soul-races:is(select,input):hover,.field.lsu-scope-soul-races .lsu-scope-soul-races:is(input,select,textarea):hover{border-color:#ffffff61}.sr-filters.lsu-scope-soul-races .lsu-scope-soul-races:is(select,input):focus-visible,.field.lsu-scope-soul-races .lsu-scope-soul-races:is(input,select,textarea):focus-visible{border-color:var(--lsu-red);box-shadow:0 0 0 1px var(--lsu-red)}.sr-stage.lsu-scope-soul-races{display:grid;grid-template-columns:minmax(0,1fr) 420px;gap:28px;margin-top:28px;align-items:start}.sr-map.lsu-scope-soul-races{position:relative;width:100%;background:#050505;border:1px solid rgba(255,255,255,.1)}.sr-land.lsu-scope-soul-races{position:absolute;inset:0;width:100%;height:100%;display:block;filter:drop-shadow(0 0 10px rgba(221,15,21,.35))}.sr-overlay.lsu-scope-soul-races{position:absolute;inset:0;width:100%;height:100%;overflow:visible}.sr-hint.lsu-scope-soul-races{position:absolute;left:14px;bottom:10px;margin:0;font:11px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-3);pointer-events:none}.sr-overlay.lsu-scope-soul-races .sr-marker{cursor:pointer;outline:none}.sr-overlay.lsu-scope-soul-races .marker-hit{fill:transparent}.sr-overlay.lsu-scope-soul-races .marker-pulse{fill:none;stroke:#dd0f15;stroke-width:2;animation:srPulse 1.9s cubic-bezier(.22,.61,.36,1) infinite}.sr-overlay.lsu-scope-soul-races .marker-glow{fill:#dd0f15;opacity:.55;filter:drop-shadow(0 0 8px #dd0f15)}.sr-overlay.lsu-scope-soul-races .marker-ring{fill:#050505;stroke:#fff;stroke-width:2;filter:drop-shadow(0 0 6px #dd0f15)}.sr-overlay.lsu-scope-soul-races .marker-core{fill:#dd0f15}.sr-overlay.lsu-scope-soul-races .sr-marker:hover .marker-ring,.sr-overlay.lsu-scope-soul-races .sr-marker:focus-visible .marker-ring,.sr-overlay.lsu-scope-soul-races .sr-marker.is-active .marker-ring{stroke:var(--lsu-red);stroke-width:3.5}.sr-overlay.lsu-scope-soul-races .sr-marker:focus-visible .marker-hit{stroke:#fff;stroke-width:2}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-pulse{display:none}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-glow{fill:#777;opacity:.25;filter:none}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-ring{stroke:#ffffff8c;filter:none}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-core{fill:#8a8a8a}@keyframes srPulse{0%{r:5;opacity:.95;stroke-width:3}78%{r:22;opacity:0;stroke-width:.5}to{r:22;opacity:0}}@media(prefers-reduced-motion:reduce){.sr-overlay.lsu-scope-soul-races .marker-pulse{animation:none;opacity:0}}.sr-tip.lsu-scope-soul-races{position:absolute;z-index:2;width:min(340px,calc(100% - 16px));border:1px solid rgba(221,15,21,.8);background:#050505;box-shadow:0 0 34px #dd0f1573}.sr-tip.lsu-scope-soul-races .sr-card{border:0;background:none}.sr-side.lsu-scope-soul-races{display:flex;flex-direction:column;gap:12px;min-width:0}.sr-result-count.lsu-scope-soul-races{margin:0;font:12px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2)}.sr-list.lsu-scope-soul-races{display:grid;gap:10px;max-height:min(620px,70vh);overflow:auto;overscroll-behavior:contain;padding-right:4px}.sr-list.lsu-scope-soul-races .sr-empty{margin:0;padding:22px;border:1px solid var(--lsu-line);color:var(--lsu-dim-1);line-height:1.5}.sr-card{display:grid;gap:10px;padding:18px;border:1px solid rgba(255,255,255,.16);border-top:2px solid rgba(255,255,255,.3);background:#050505;cursor:pointer;transition:border-color .2s}.sr-card:hover{border-color:#ffffff57}.sr-card.is-active{border-color:var(--lsu-red);background:#140406;box-shadow:0 0 28px #dd0f154d}.sr-card.is-flash{animation:srFlash .9s ease-out}@keyframes srFlash{0%{background:#3a0a0d;box-shadow:0 0 44px #dd0f1599}}@media(prefers-reduced-motion:reduce){.sr-card.is-flash{animation:none}}.sr-card.is-past .sr-date{color:var(--lsu-dim-2)}.sr-card-top{display:flex;justify-content:space-between;align-items:baseline;gap:12px}.sr-card h3{margin:0;font:24px/1.1 var(--lsu-display);letter-spacing:.02em;text-transform:uppercase;font-weight:400}.sr-date{flex:none;font:12px var(--lsu-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--lsu-red)}.sr-meta{margin:0;font-size:13px;color:var(--lsu-dim-2)}.sr-summary{margin:0;font-size:15px;line-height:1.5;color:var(--lsu-dim-1)}.sr-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin-top:4px}.sr-status,.sr-qual,.sr-state{padding:5px 9px 4px;border:1px solid rgba(255,255,255,.3);font:11px var(--lsu-mono);letter-spacing:.12em;text-transform:uppercase}.sr-status.is-open{border-color:var(--lsu-red);color:#ff5a5f}.sr-status.is-past,.sr-state{color:var(--lsu-dim-2);border-color:#ffffff2e}.sr-qual{border-color:#f6dc9599;color:#f6dc95}.sr-site{font:11px var(--lsu-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--lsu-dim-1);text-decoration:underline;text-underline-offset:3px}.sr-enter{margin-left:auto;padding:11px 14px;background:var(--lsu-red);color:#fff;font:700 12px var(--lsu-body);letter-spacing:.15em;text-transform:uppercase}.sr-enter:hover{background:#fff;color:#000}.sr-card a:focus-visible{outline:3px solid var(--lsu-white);outline-offset:3px}.sr-host.lsu-scope-soul-races{display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-top:64px;padding-top:34px;border-top:2px solid var(--lsu-red)}.sr-host.lsu-scope-soul-races h2.lsu-scope-soul-races{margin:0;font:clamp(26px,3.4vw,48px)/1.05 var(--lsu-display);text-transform:uppercase}.sr-host.lsu-scope-soul-races p.lsu-scope-soul-races{max-width:560px;margin:14px 0 22px;font-size:16px;line-height:1.6;color:var(--lsu-dim-1)}.sr-dialog.lsu-scope-soul-races{width:min(820px,calc(100% - 32px));max-width:none;max-height:calc(100dvh - 32px);padding:0;border:1px solid rgba(255,255,255,.16);background:#050505;color:var(--lsu-white);box-shadow:0 0 70px #dd0f1559;overflow:auto;overscroll-behavior:contain}.sr-dialog.lsu-scope-soul-races::backdrop{background:#000000d1}html:has(.sr-dialog[open]){overflow:hidden}.sr-dialog-bar.lsu-scope-soul-races{position:sticky;top:0;z-index:1;display:flex;justify-content:space-between;align-items:flex-start;gap:16px;padding:22px 16px 16px 36px;background:#050505;border-bottom:1px solid var(--lsu-line)}.sr-dialog-bar.lsu-scope-soul-races h2.lsu-scope-soul-races{margin:8px 0 0;font:clamp(26px,3.4vw,48px)/1.05 var(--lsu-display);text-transform:uppercase}.sr-close.lsu-scope-soul-races{width:48px;height:48px;border:0;background:transparent;color:var(--lsu-white);font-size:32px;line-height:1;cursor:pointer}.sr-close.lsu-scope-soul-races:hover,.sr-close.lsu-scope-soul-races:focus-visible{color:var(--lsu-red)}.sr-dialog.lsu-scope-soul-races form.lsu-scope-soul-races,.sr-done.lsu-scope-soul-races{display:flex;flex-direction:column;gap:22px;padding:24px 36px 36px}.sr-intro.lsu-scope-soul-races{margin:0;color:var(--lsu-dim-1);line-height:1.5}fieldset.lsu-scope-soul-races{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 18px;margin:0;padding:0;border:0}legend.lsu-scope-soul-races{margin-bottom:16px;padding:0;font:12px var(--lsu-mono);letter-spacing:.16em;text-transform:uppercase;color:var(--lsu-red)}.field.lsu-scope-soul-races{display:flex;flex-direction:column;gap:8px;color:#ffffff9e;font:14px/1.4 var(--lsu-mono);letter-spacing:.06em;text-transform:uppercase}.field-wide.lsu-scope-soul-races{grid-column:1/-1}.field.lsu-scope-soul-races textarea.lsu-scope-soul-races{min-height:96px;resize:vertical;line-height:1.5}.field.lsu-scope-soul-races input.lsu-scope-soul-races[aria-invalid=true],.field.lsu-scope-soul-races select.lsu-scope-soul-races[aria-invalid=true],.field.lsu-scope-soul-races textarea.lsu-scope-soul-races[aria-invalid=true]{border-color:#ff725e}.field.lsu-scope-soul-races .hint.lsu-scope-soul-races{font:13px/1.4 var(--lsu-body);letter-spacing:0;text-transform:none;color:var(--lsu-dim-2)}.field.lsu-scope-soul-races small.lsu-scope-soul-races{min-height:0;color:#ff725e;font:13px/1.35 var(--lsu-body);letter-spacing:0;text-transform:none}.field.lsu-scope-soul-races small.lsu-scope-soul-races:empty{display:none}.field.lsu-scope-soul-races input.lsu-scope-soul-races[type=date]{color-scheme:dark}.sr-trap.lsu-scope-soul-races{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}.sr-privacy.lsu-scope-soul-races{margin:0;font-size:13px;line-height:1.5;color:var(--lsu-dim-2)}.sr-privacy.lsu-scope-soul-races a{text-decoration:underline;text-underline-offset:3px}.sr-form-status.lsu-scope-soul-races{margin:0;color:#ff725e;font-size:14px}.sr-form-status.lsu-scope-soul-races:empty{display:none}.sr-dialog.lsu-scope-soul-races .btn.lsu-scope-soul-races{align-self:flex-start}.sr-dialog.lsu-scope-soul-races .lsu-scope-soul-races[hidden]{display:none}.sr-done.lsu-scope-soul-races h3.lsu-scope-soul-races{margin:0;font:32px/1 var(--lsu-display);text-transform:uppercase;font-weight:400}.sr-done.lsu-scope-soul-races p.lsu-scope-soul-races{margin:0;color:var(--lsu-dim-1);line-height:1.5}.sr-done.lsu-scope-soul-races strong.lsu-scope-soul-races{color:var(--lsu-white);font-weight:600}@media(prefers-reduced-motion:no-preference){.sr-dialog.lsu-scope-soul-races[open]{animation:srIn .22s cubic-bezier(.2,.8,.2,1)}.sr-dialog.lsu-scope-soul-races[open]::backdrop{animation:srFade .22s ease-out}}@keyframes srIn{0%{opacity:0;transform:translateY(12px)}}@keyframes srFade{0%{opacity:0}}@media(max-width:1000px){.sr-stage.lsu-scope-soul-races{grid-template-columns:1fr}.sr-list.lsu-scope-soul-races{max-height:none;overflow:visible;padding-right:0}.sr-host.lsu-scope-soul-races{grid-template-columns:1fr}}@media(max-width:700px){.sr-counts.lsu-scope-soul-races,.sr-filters.lsu-scope-soul-races{grid-template-columns:1fr 1fr}.sr-filters.lsu-scope-soul-races{gap:10px;padding:14px 0}.sr-hint.lsu-scope-soul-races{display:none}.sr-tip.lsu-scope-soul-races{left:0!important;top:calc(100% + 8px)!important;width:100%}fieldset.lsu-scope-soul-races{grid-template-columns:1fr}.sr-dialog-bar.lsu-scope-soul-races{padding-left:18px}.sr-dialog.lsu-scope-soul-races form.lsu-scope-soul-races,.sr-done.lsu-scope-soul-races{padding:20px 18px 28px}.sr-enter{margin-left:0;width:100%;text-align:center}}
1.sr.lsu-scope-soul-races{min-height:100vh;padding-bottom:100px}.sr-body.lsu-scope-soul-races{padding:0 var(--lsu-pad-x)}.sr-head.lsu-scope-soul-races,.sr-filters.lsu-scope-soul-races,.sr-stage.lsu-scope-soul-races,.sr-host.lsu-scope-soul-races{max-width:var(--lsu-max);margin-left:auto;margin-right:auto}.sr-head.lsu-scope-soul-races .accent.lsu-scope-soul-races{color:var(--lsu-red)}.sr-note.lsu-scope-soul-races{max-width:640px;margin:0;font-size:15px;line-height:1.6;color:var(--lsu-dim-1)}.sr-counts.lsu-scope-soul-races{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px 24px;margin:14px 0 0;padding:0}.sr-counts.lsu-scope-soul-races div.lsu-scope-soul-races{display:flex;flex-direction:column-reverse;gap:6px;padding-top:14px;border-top:1px solid var(--lsu-line)}.sr-counts.lsu-scope-soul-races dd.lsu-scope-soul-races{margin:0;font:clamp(34px,4vw,52px)/1 var(--lsu-display)}.sr-counts.lsu-scope-soul-races dt.lsu-scope-soul-races{font:12px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2)}.sr-filters.lsu-scope-soul-races{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-top:var(--lsu-head-gap);padding:20px 0;border-top:1px solid var(--lsu-line);border-bottom:1px solid var(--lsu-line)}.sr-filters.lsu-scope-soul-races label.lsu-scope-soul-races{display:flex;flex-direction:column;gap:8px;font:12px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2)}.sr-filters.lsu-scope-soul-races select.lsu-scope-soul-races,.sr-filters.lsu-scope-soul-races input.lsu-scope-soul-races,.field.lsu-scope-soul-races input.lsu-scope-soul-races,.field.lsu-scope-soul-races select.lsu-scope-soul-races,.field.lsu-scope-soul-races textarea.lsu-scope-soul-races{box-sizing:border-box;width:100%;min-height:48px;padding:12px 14px;border:1px solid rgba(255,255,255,.2);border-radius:0;background:#090909;color:var(--lsu-white);font:16px var(--lsu-body);letter-spacing:0;text-transform:none;outline:none}.sr-filters.lsu-scope-soul-races select.lsu-scope-soul-races,.field.lsu-scope-soul-races select.lsu-scope-soul-races{appearance:none;padding-right:40px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='9' viewBox='0 0 14 9'%3E%3Cpath d='M1 1l6 6 6-6' fill='none' stroke='%23ffffff' stroke-opacity='.7' stroke-width='2'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;cursor:pointer}.sr-filters.lsu-scope-soul-races select.lsu-scope-soul-races option.lsu-scope-soul-races,.field.lsu-scope-soul-races select.lsu-scope-soul-races option.lsu-scope-soul-races{background:#090909;color:var(--lsu-white)}.sr-filters.lsu-scope-soul-races .lsu-scope-soul-races:is(select,input):hover,.field.lsu-scope-soul-races .lsu-scope-soul-races:is(input,select,textarea):hover{border-color:#ffffff61}.sr-filters.lsu-scope-soul-races .lsu-scope-soul-races:is(select,input):focus-visible,.field.lsu-scope-soul-races .lsu-scope-soul-races:is(input,select,textarea):focus-visible{border-color:var(--lsu-red);box-shadow:0 0 0 1px var(--lsu-red)}.sr-stage.lsu-scope-soul-races{display:grid;grid-template-columns:minmax(0,1fr) 420px;gap:28px;margin-top:28px;align-items:start}.sr-map.lsu-scope-soul-races{position:relative;width:100%;background:#050505;border:1px solid rgba(255,255,255,.1)}.sr-land.lsu-scope-soul-races{position:absolute;inset:0;width:100%;height:100%;display:block;filter:drop-shadow(0 0 10px rgba(221,15,21,.35))}.sr-overlay.lsu-scope-soul-races{position:absolute;inset:0;width:100%;height:100%;overflow:visible}.sr-hint.lsu-scope-soul-races{position:absolute;left:14px;bottom:10px;margin:0;font:11px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-3);pointer-events:none}.sr-overlay.lsu-scope-soul-races .sr-marker{cursor:pointer;outline:none}.sr-overlay.lsu-scope-soul-races .marker-hit{fill:transparent}.sr-overlay.lsu-scope-soul-races .marker-pulse{fill:none;stroke:#dd0f15;stroke-width:2;animation:srPulse 1.9s cubic-bezier(.22,.61,.36,1) infinite}.sr-overlay.lsu-scope-soul-races .marker-glow{fill:#dd0f15;opacity:.55;filter:drop-shadow(0 0 8px #dd0f15)}.sr-overlay.lsu-scope-soul-races .marker-ring{fill:#050505;stroke:#fff;stroke-width:2;filter:drop-shadow(0 0 6px #dd0f15)}.sr-overlay.lsu-scope-soul-races .marker-core{fill:#dd0f15}.sr-overlay.lsu-scope-soul-races .sr-marker:hover .marker-ring,.sr-overlay.lsu-scope-soul-races .sr-marker:focus-visible .marker-ring,.sr-overlay.lsu-scope-soul-races .sr-marker.is-active .marker-ring{stroke:var(--lsu-red);stroke-width:3.5}.sr-overlay.lsu-scope-soul-races .sr-marker:focus-visible .marker-hit{stroke:#fff;stroke-width:2}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-pulse{display:none}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-glow{fill:#777;opacity:.25;filter:none}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-ring{stroke:#ffffff8c;filter:none}.sr-overlay.lsu-scope-soul-races .sr-marker.is-past .marker-core{fill:#8a8a8a}@keyframes srPulse{0%{r:5;opacity:.95;stroke-width:3}78%{r:22;opacity:0;stroke-width:.5}to{r:22;opacity:0}}@media(prefers-reduced-motion:reduce){.sr-overlay.lsu-scope-soul-races .marker-pulse{animation:none;opacity:0}}.sr-tip.lsu-scope-soul-races{position:absolute;z-index:2;width:min(340px,calc(100% - 16px));border:1px solid rgba(221,15,21,.8);background:#050505;box-shadow:0 0 34px #dd0f1573}.sr-tip.lsu-scope-soul-races .sr-card{border:0;background:none}.sr-side.lsu-scope-soul-races{display:flex;flex-direction:column;gap:12px;min-width:0}.sr-result-count.lsu-scope-soul-races{margin:0;font:12px var(--lsu-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-dim-2)}.sr-list.lsu-scope-soul-races{display:grid;gap:10px;max-height:min(620px,70vh);overflow:auto;overscroll-behavior:contain;padding-right:4px}.sr-list.lsu-scope-soul-races .sr-empty{margin:0;padding:22px;border:1px solid var(--lsu-line);color:var(--lsu-dim-1);line-height:1.5}.sr-card{display:grid;gap:10px;padding:18px;border:1px solid rgba(255,255,255,.16);border-top:2px solid rgba(255,255,255,.3);background:#050505;cursor:pointer;transition:border-color .2s}.sr-card:hover{border-color:#ffffff57}.sr-card.is-active{border-color:var(--lsu-red);background:#140406;box-shadow:0 0 28px #dd0f154d}.sr-card.is-flash{animation:srFlash .9s ease-out}@keyframes srFlash{0%{background:#3a0a0d;box-shadow:0 0 44px #dd0f1599}}@media(prefers-reduced-motion:reduce){.sr-card.is-flash{animation:none}}.sr-card.is-past .sr-date{color:var(--lsu-dim-2)}.sr-card-top{display:flex;justify-content:space-between;align-items:baseline;gap:12px}.sr-card h2{margin:0;font:24px/1.1 var(--lsu-display);letter-spacing:.02em;text-transform:uppercase;font-weight:400}.sr-date{flex:none;font:12px var(--lsu-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--lsu-red)}.sr-meta{margin:0;font-size:13px;color:var(--lsu-dim-2)}.sr-summary{margin:0;font-size:15px;line-height:1.5;color:var(--lsu-dim-1)}.sr-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin-top:4px}.sr-status,.sr-qual,.sr-state{padding:5px 9px 4px;border:1px solid rgba(255,255,255,.3);font:11px var(--lsu-mono);letter-spacing:.12em;text-transform:uppercase}.sr-status.is-open{border-color:var(--lsu-red);color:#ff5a5f}.sr-status.is-past,.sr-state{color:var(--lsu-dim-2);border-color:#ffffff2e}.sr-qual{border-color:#f6dc9599;color:#f6dc95}.sr-site{font:11px var(--lsu-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--lsu-dim-1);text-decoration:underline;text-underline-offset:3px}.sr-enter{margin-left:auto;padding:11px 14px;background:var(--lsu-red);color:#fff;font:700 12px var(--lsu-body);letter-spacing:.15em;text-transform:uppercase}.sr-enter:hover{background:#fff;color:#000}.sr-card a:focus-visible{outline:3px solid var(--lsu-white);outline-offset:3px}.sr-host.lsu-scope-soul-races{display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-top:64px;padding-top:34px;border-top:2px solid var(--lsu-red)}.sr-host.lsu-scope-soul-races h2.lsu-scope-soul-races{margin:0;font:clamp(26px,3.4vw,48px)/1.05 var(--lsu-display);text-transform:uppercase}.sr-host.lsu-scope-soul-races p.lsu-scope-soul-races{max-width:560px;margin:14px 0 22px;font-size:16px;line-height:1.6;color:var(--lsu-dim-1)}.sr-dialog.lsu-scope-soul-races{width:min(820px,calc(100% - 32px));max-width:none;max-height:calc(100dvh - 32px);padding:0;border:1px solid rgba(255,255,255,.16);background:#050505;color:var(--lsu-white);box-shadow:0 0 70px #dd0f1559;overflow:auto;overscroll-behavior:contain}.sr-dialog.lsu-scope-soul-races::backdrop{background:#000000d1}html:has(.sr-dialog[open]){overflow:hidden}.sr-dialog-bar.lsu-scope-soul-races{position:sticky;top:0;z-index:1;display:flex;justify-content:space-between;align-items:flex-start;gap:16px;padding:22px 16px 16px 36px;background:#050505;border-bottom:1px solid var(--lsu-line)}.sr-dialog-bar.lsu-scope-soul-races h2.lsu-scope-soul-races{margin:8px 0 0;font:clamp(26px,3.4vw,48px)/1.05 var(--lsu-display);text-transform:uppercase}.sr-close.lsu-scope-soul-races{width:48px;height:48px;border:0;background:transparent;color:var(--lsu-white);font-size:32px;line-height:1;cursor:pointer}.sr-close.lsu-scope-soul-races:hover,.sr-close.lsu-scope-soul-races:focus-visible{color:var(--lsu-red)}.sr-dialog.lsu-scope-soul-races form.lsu-scope-soul-races,.sr-done.lsu-scope-soul-races{display:flex;flex-direction:column;gap:22px;padding:24px 36px 36px}.sr-intro.lsu-scope-soul-races{margin:0;color:var(--lsu-dim-1);line-height:1.5}fieldset.lsu-scope-soul-races{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 18px;margin:0;padding:0;border:0}legend.lsu-scope-soul-races{margin-bottom:16px;padding:0;font:12px var(--lsu-mono);letter-spacing:.16em;text-transform:uppercase;color:var(--lsu-red)}.field.lsu-scope-soul-races{display:flex;flex-direction:column;gap:8px;color:#ffffff9e;font:14px/1.4 var(--lsu-mono);letter-spacing:.06em;text-transform:uppercase}.field-wide.lsu-scope-soul-races{grid-column:1/-1}.field.lsu-scope-soul-races textarea.lsu-scope-soul-races{min-height:96px;resize:vertical;line-height:1.5}.field.lsu-scope-soul-races input.lsu-scope-soul-races[aria-invalid=true],.field.lsu-scope-soul-races select.lsu-scope-soul-races[aria-invalid=true],.field.lsu-scope-soul-races textarea.lsu-scope-soul-races[aria-invalid=true]{border-color:#ff725e}.field.lsu-scope-soul-races .hint.lsu-scope-soul-races{font:13px/1.4 var(--lsu-body);letter-spacing:0;text-transform:none;color:var(--lsu-dim-2)}.field.lsu-scope-soul-races small.lsu-scope-soul-races{min-height:0;color:#ff725e;font:13px/1.35 var(--lsu-body);letter-spacing:0;text-transform:none}.field.lsu-scope-soul-races small.lsu-scope-soul-races:empty{display:none}.field.lsu-scope-soul-races input.lsu-scope-soul-races[type=date]{color-scheme:dark}.sr-trap.lsu-scope-soul-races{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}.sr-privacy.lsu-scope-soul-races{margin:0;font-size:13px;line-height:1.5;color:var(--lsu-dim-2)}.sr-privacy.lsu-scope-soul-races a{text-decoration:underline;text-underline-offset:3px}.sr-form-status.lsu-scope-soul-races{margin:0;color:#ff725e;font-size:14px}.sr-form-status.lsu-scope-soul-races:empty{display:none}.sr-dialog.lsu-scope-soul-races .btn.lsu-scope-soul-races{align-self:flex-start}.sr-dialog.lsu-scope-soul-races .lsu-scope-soul-races[hidden]{display:none}.sr-done.lsu-scope-soul-races h3.lsu-scope-soul-races{margin:0;font:32px/1 var(--lsu-display);text-transform:uppercase;font-weight:400}.sr-done.lsu-scope-soul-races p.lsu-scope-soul-races{margin:0;color:var(--lsu-dim-1);line-height:1.5}.sr-done.lsu-scope-soul-races strong.lsu-scope-soul-races{color:var(--lsu-white);font-weight:600}@media(prefers-reduced-motion:no-preference){.sr-dialog.lsu-scope-soul-races[open]{animation:srIn .22s cubic-bezier(.2,.8,.2,1)}.sr-dialog.lsu-scope-soul-races[open]::backdrop{animation:srFade .22s ease-out}}@keyframes srIn{0%{opacity:0;transform:translateY(12px)}}@keyframes srFade{0%{opacity:0}}@media(max-width:1000px){.sr-stage.lsu-scope-soul-races{grid-template-columns:1fr}.sr-list.lsu-scope-soul-races{max-height:none;overflow:visible;padding-right:0}.sr-host.lsu-scope-soul-races{grid-template-columns:1fr}}@media(max-width:700px){.sr-counts.lsu-scope-soul-races,.sr-filters.lsu-scope-soul-races{grid-template-columns:1fr 1fr}.sr-filters.lsu-scope-soul-races{gap:10px;padding:14px 0}.sr-hint.lsu-scope-soul-races{display:none}.sr-tip.lsu-scope-soul-races{left:0!important;top:calc(100% + 8px)!important;width:100%}fieldset.lsu-scope-soul-races{grid-template-columns:1fr}.sr-dialog-bar.lsu-scope-soul-races{padding-left:18px}.sr-dialog.lsu-scope-soul-races form.lsu-scope-soul-races,.sr-done.lsu-scope-soul-races{padding:20px 18px 28px}.sr-enter{margin-left:0;width:100%;text-align:center}}
resources/assets/styles/site/stx-site.cssmodified+9-109
Changes to resources/assets/styles/site/stx-site.css
@@ -1,109 +1,9 @@
1.lsu-skip{position:fixed;left:1rem;top:-5rem;z-index:200;background:#fff;color:#000;padding:1rem}.lsu-skip:focus{top:1rem}main:focus{outline:none}.nav-links{gap:clamp(12px,1.6vw,28px)}.coaching-hero{position:relative;padding:clamp(140px,18vw,240px) clamp(18px,5vw,40px) 100px;background:linear-gradient(90deg,#000e,#0003),url('/assets/lsu/images/hero-desktop.webp') 50% 35%/cover}.coaching-hero h1{max-width:13ch;font:400 clamp(48px,8vw,120px)/1.05 var(--lsu-display);text-transform:uppercase;letter-spacing:.02em;margin:24px 0}.coaching-hero p{max-width:54ch;color:#ffffffb3;font:400 19px/1.6 var(--lsu-body)}.coaching-body{max-width:1360px;margin:auto;padding:clamp(50px,7vw,100px) clamp(18px,5vw,40px)}.coaching-steps{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid #ffffff24;border-bottom:1px solid #ffffff24;margin:3rem 0}.coaching-steps article{padding:2rem;border-right:1px solid #ffffff24}.coaching-steps article:last-child{border:0}.coaching-steps h2{font:400 30px/1.2 var(--lsu-display);text-transform:uppercase}.coaching-steps p{color:#ffffffa6;line-height:1.7}.coaching-note{padding:2rem;border-left:3px solid var(--lsu-red);background:#0d0d0f}.coaching-note h2{font:400 36px var(--lsu-display);text-transform:uppercase}.coaching-note p{max-width:65ch;color:#ffffffa6;line-height:1.7}.race-portal{max-width:720px;margin:auto;padding:clamp(24px,5vw,64px);border:1px solid #ffffff24;background:#0d0d0f}.race-portal label{display:block;font:600 12px var(--lsu-body);text-transform:uppercase;letter-spacing:.12em;margin:1rem 0 .5rem}.race-portal input,.race-portal textarea,.race-portal select{display:block;width:100%;padding:1rem;background:#050505;border:1px solid #ffffff40;color:#fff;font:inherit}.race-portal .btn{margin-top:1.5rem}.race-portal [role=alert]{color:#ff928f}.race-portal h2{font:400 36px var(--lsu-display);text-transform:uppercase}.race-portal p{line-height:1.65}.race-tabs{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}.race-tabs button{background:transparent;color:#fff;border:0;border-bottom:2px solid #ffffff40;padding:.75rem;cursor:pointer}.race-tabs button[aria-selected=true]{border-color:#dd0f15}.race-portal .field-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.lsu-scroll-gallery{overflow:auto;scroll-snap-type:x mandatory}.lsu-scroll-gallery>*{scroll-snap-align:start}@media(max-width:700px){.coaching-hero{background-image:linear-gradient(90deg,#000d,#0006),url('/assets/lsu/images/hero-mobile.webp')}.coaching-steps{grid-template-columns:1fr}.coaching-steps article{border-right:0;border-bottom:1px solid #ffffff24}.race-portal .field-row{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
2
3.lsu-globe-stage{position:relative;width:100%;height:100%;min-height:420px}.lsu-globe-fallback{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none}.lsu-event-dot{display:block;width:14px;height:14px;border-radius:50%;background:#dd0f15;border:2px solid white;box-shadow:0 0 0 6px #dd0f1540}.ts-map{background:transparent}.lsu-globe-stage .ts-map{position:absolute;inset:0}.lsu-globe-stage:has([data-ts-map])>.lsu-globe-fallback{visibility:hidden}
4
5.hour-dial .rest-ring{width:100%;height:auto;aspect-ratio:1;transform:rotate(-90deg)}
6
7.duel-fill-1.lsu-scope-home{width:71.28%}.duel-fill-2.lsu-scope-home{width:100%}
8@supports(animation-timeline:view()){.duelscroll{view-timeline-name:--duel;view-timeline-axis:block}.duel-fill{transform-origin:left;animation:lsu-grow linear both;animation-timeline:--duel;animation-range:entry 20% exit 65%}@keyframes lsu-grow{from{transform:scaleX(0)}to{transform:scaleX(1)}}}
9@media(prefers-reduced-motion:reduce){.duel-fill{animation:none}}
10
11.lsu-host-dialog[data-stx-dialog]{z-index:200}.lsu-host-dialog .sr-dialog{color:var(--lsu-white,#fff);background:#080808;padding:0;border:1px solid #333;border-radius:0}
12
13/* Let the browser defer distant story panels and their background photos.
14 Explicitly sized bands keep their authored height. Story panels remember
15 their measured height after their first visit; one viewport reserves space
16 before they approach the viewport. Unsupported browsers render normally. */
17.lapscroll.lsu-scope-home,.duelscroll.lsu-scope-home{content-visibility:auto;contain-intrinsic-block-size:auto 100svh}
18.attr-band.lsu-scope-home,.enter.lsu-scope-next-loop{content-visibility:auto}
19
20/* /coaching. Every rule is wrapped in .coaching-page because this file loads on
21 all sixteen routes, and because `.accent` only ever exists per page in
22 base.css, which is why the old hero's red words rendered white. */
23.coaching-page .accent{color:var(--lsu-red)}
24.coaching-page .coaching-body>*+*{margin-top:clamp(56px,7vw,104px)}
25.coaching-page .coaching-head{display:flex;flex-direction:column;gap:18px;max-width:65ch}
26.coaching-page .coaching-head p{margin:0;color:var(--lsu-dim-1);font-size:18px;line-height:1.6}
27
28/* Hero: copy beside the photograph rather than over a full-bleed background, so
29 the frame is a real <img> that can be sized, eager-loaded and described. */
30.coaching-page .coaching-hero{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr);align-items:center;gap:clamp(24px,4vw,64px);min-height:100svh;max-width:var(--lsu-max);margin:0 auto;padding:clamp(104px,11vw,128px) var(--lsu-pad-x) clamp(44px,6vw,80px);background:radial-gradient(ellipse 75% 65% at 18% 45%,#150307,#000)}
31.coaching-page .coaching-hero-copy{display:flex;flex-direction:column;align-items:flex-start;gap:22px}
32.coaching-page .coaching-hero h1{max-width:18ch;margin:0;font-size:clamp(40px,5.6vw,88px)}
33.coaching-page .coaching-hero p{margin:0}
34.coaching-page .coaching-hero-shot{position:relative;aspect-ratio:3/4}
35.coaching-page .coaching-hero-shot img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:50% 26%;filter:grayscale(1) contrast(1.08) brightness(.66)}
36
37/* Two motions, transform and opacity only: the hero stack arrives in reading
38 order, the loop rows in the order the week runs. */
39@keyframes lsu-coaching-rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
40.coaching-page .coaching-hero-copy>*{animation:lsu-coaching-rise .62s cubic-bezier(.16,1,.3,1) both}
41.coaching-page .coaching-hero-copy>:nth-child(2){animation-delay:.08s}
42.coaching-page .coaching-hero-copy>:nth-child(3){animation-delay:.16s}
43.coaching-page .coaching-hero-copy>:nth-child(4){animation-delay:.24s}
44.coaching-page .coaching-hero-shot{animation:lsu-coaching-rise .8s cubic-bezier(.16,1,.3,1) .1s both}
45
46/* The loop. Not three equal cards: three ruled rows, each stepped further in,
47 so the sequence reads as one movement down the page. */
48.coaching-page .coaching-loop{display:grid;margin-top:clamp(30px,4vw,52px);border-top:1px solid var(--lsu-line)}
49.coaching-page .coaching-loop article{display:grid;grid-template-columns:clamp(76px,9vw,150px) minmax(0,1fr);gap:clamp(18px,3vw,44px);align-items:start;padding:clamp(24px,3vw,38px) 0;border-bottom:1px solid var(--lsu-line-soft)}
50.coaching-page .coaching-loop article:nth-child(2){margin-inline-start:clamp(0px,6vw,96px)}
51.coaching-page .coaching-loop article:nth-child(3){margin-inline-start:clamp(0px,12vw,192px)}
52.coaching-page .coaching-loop h3{margin:0;font:400 clamp(22px,2.4vw,34px)/1 var(--lsu-display);text-transform:uppercase;color:var(--lsu-red)}
53.coaching-page .coaching-loop p{margin:0;max-width:56ch;color:#ffffffa6;font-size:16px;line-height:1.7}
54
55/* The one photographic statement. .band, .band-bg, .band-scrim and .band-copy
56 come from base.css unscoped; the height and the darker scrim are page
57 business, the scrim because body copy sits on the frame as well. */
58.coaching-page .coaching-band{min-height:clamp(440px,72vh,720px)}
59.coaching-page .coaching-band .band-scrim{background:#000000b3}
60.coaching-page .coaching-band .band-copy{max-width:1040px}
61.coaching-page .coaching-band .band-copy p{font-size:clamp(26px,3.4vw,50px)}
62.coaching-page .coaching-band-note{margin:clamp(20px,3vw,32px) auto 0;max-width:62ch;color:#ffffffb3;font-family:var(--lsu-body);font-size:16px;line-height:1.7;text-transform:none}
63
64/* .coaching-steps keeps its three hairline-ruled columns; the headings move to
65 h3 under the section's own h2, so they need their own type rule. */
66.coaching-page .coaching-steps h3{margin:0 0 14px;font:400 clamp(24px,2.6vw,30px)/1.2 var(--lsu-display);text-transform:uppercase}
67.coaching-page .coaching-steps p{margin:0}
68.coaching-page .coaching-tiers{margin:0;max-width:62ch;color:var(--lsu-dim-1);font-size:16px;line-height:1.7}
69
70.coaching-page .coaching-note h2{margin:0 0 18px;font-size:clamp(28px,3.2vw,40px);line-height:1.08}
71.coaching-page .coaching-note p{margin:0}
72.coaching-page .coaching-note p+p{margin-top:16px}
73
74/* The embed. Space is reserved so the remote element cannot shift the page in,
75 and :not(:defined) dresses the slot content, which is the loading state, the
76 empty state and the blocked-origin state in one rule. */
77.coaching-page .coaching-portal{min-height:420px;margin-top:clamp(30px,4vw,52px)}
78.coaching-page .coaching-portal hq-coaching{display:block}
79.coaching-page .coaching-portal hq-coaching:not(:defined){display:grid;gap:24px;justify-items:start;max-width:720px;margin:0 auto;padding:clamp(24px,5vw,56px);border:1px solid var(--lsu-line);background:var(--lsu-card)}
80.coaching-page .coaching-portal hq-coaching:not(:defined) p{margin:0;max-width:56ch;color:#ffffffb3;font-size:16px;line-height:1.7}
81
82/* The coach-facing close: a different audience on the same page, so a contained
83 surface marks it off rather than another hairline. */
84.coaching-page .coaching-coaches{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1fr);gap:clamp(28px,5vw,80px);align-items:start;padding-top:clamp(34px,4vw,56px);border-top:1px solid var(--lsu-line)}
85.coaching-page .coaching-coaches .kicker{display:block}
86.coaching-page .coaching-coaches h2{margin:18px 0 0}
87.coaching-page .coaching-coaches p{margin:22px 0 0;max-width:56ch;color:#ffffffa6;font-size:16px;line-height:1.7}
88.coaching-page .coaching-coaches-panel{padding:clamp(22px,3vw,32px);border:1px solid var(--lsu-line);background:var(--lsu-card-alt)}
89.coaching-page .coaching-coaches-panel p{margin:0 0 24px;max-width:38ch}
90
91/* Tactile feedback on this page's buttons; the global .btn stays instant. */
92.coaching-page .btn,.coaching-page .btn-outline{transition:background-color .2s,border-color .2s,color .2s,transform .12s}
93.coaching-page .btn:active,.coaching-page .btn-outline:active{transform:scale(.98)}
94
95@media(max-width:900px){
96.coaching-page .coaching-hero{grid-template-columns:minmax(0,1fr);min-height:0;gap:36px;padding-top:clamp(96px,22vw,118px)}
97.coaching-page .coaching-hero-shot{aspect-ratio:16/10}
98.coaching-page .coaching-coaches{grid-template-columns:minmax(0,1fr);gap:34px}
99}
100@media(max-width:760px){
101.coaching-page .coaching-loop article{grid-template-columns:minmax(0,1fr);gap:12px;padding:26px 0}
102.coaching-page .coaching-loop article:nth-child(2),.coaching-page .coaching-loop article:nth-child(3){margin-inline-start:0}
103.coaching-page .coaching-band{min-height:clamp(400px,78vh,560px)}
104}
105@media(prefers-reduced-motion:reduce){
106.coaching-page .coaching-hero-copy>*,.coaching-page .coaching-hero-shot{animation:none}
107.coaching-page .btn,.coaching-page .btn-outline{transition:none}
108.coaching-page .btn:active,.coaching-page .btn-outline:active{transform:none}
109}
1.lsu-skip{position:fixed;left:1rem;top:-5rem;z-index:200;background:#fff;color:#000;padding:1rem}.lsu-skip:focus{top:1rem}main:focus{outline:none}.nav-links{gap:clamp(12px,1.6vw,28px)}.race-portal{max-width:720px;margin:auto;padding:clamp(24px,5vw,64px);border:1px solid #ffffff24;background:#0d0d0f}.race-portal label{display:block;font:600 12px var(--lsu-body);text-transform:uppercase;letter-spacing:.12em;margin:1rem 0 .5rem}.race-portal input,.race-portal textarea,.race-portal select{display:block;width:100%;padding:1rem;background:#050505;border:1px solid #ffffff40;color:#fff;font:inherit}.race-portal .btn{margin-top:1.5rem}.race-portal [role=alert]{color:#ff928f}.race-portal h2{font:400 36px var(--lsu-display);text-transform:uppercase}.race-portal p{line-height:1.65}.race-tabs{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}.race-tabs button{background:transparent;color:#fff;border:0;border-bottom:2px solid #ffffff40;padding:.75rem;cursor:pointer}.race-tabs button[aria-selected=true]{border-color:#dd0f15}.race-portal .field-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.lsu-scroll-gallery{overflow:auto;scroll-snap-type:x mandatory}.lsu-scroll-gallery>*{scroll-snap-align:start}@media(max-width:700px){.race-portal .field-row{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}.lsu-globe-stage{position:relative;width:100%;height:100%;min-height:420px}.lsu-globe-fallback{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none}.lsu-event-dot{display:block;width:14px;height:14px;border-radius:50%;background:#dd0f15;border:2px solid white;box-shadow:0 0 0 6px #dd0f1540}.ts-map{background:transparent}.lsu-globe-stage .ts-map{position:absolute;inset:0}.lsu-globe-stage:has([data-ts-map])>.lsu-globe-fallback{visibility:hidden}.hour-dial .rest-ring{width:100%;height:auto;aspect-ratio:1;transform:rotate(-90deg)}@supports(animation-timeline:view()){.duelscroll{view-timeline-name:--duel;view-timeline-axis:block}.duel-fill{transform-origin:left;animation:lsu-grow linear both;animation-timeline:--duel;animation-range:entry 20% exit 65%}@keyframes lsu-grow{from{transform:scaleX(0)}to{transform:scaleX(1)}}}@media(prefers-reduced-motion:reduce){.duel-fill{animation:none}}.lsu-host-dialog[data-stx-dialog]{z-index:200}.lsu-host-dialog .sr-dialog{color:var(--lsu-white,#fff);background:#080808;padding:0;border:1px solid #333;border-radius:0}
2
3/* The page you are on, in the chrome every page shares. Nothing marked it
4 before, so the nav read the same on all twenty-five routes. A section
5 marks its own subtree, so a coaching module lights Coaching. Both a colour
6 and a rule, because colour alone is not a distinction everyone can see. */
7.nav-links.lsu-scope-nav a.lsu-scope-nav[aria-current=page]{color:var(--lsu-white);box-shadow:inset 0 -2px 0 0 var(--lsu-red)}
8.drawer-links.lsu-scope-nav a.lsu-scope-nav[aria-current=page]{color:var(--lsu-red)}
9.footer-col.lsu-sz7xmlte a.lsu-sz7xmlte[aria-current=page]{color:var(--lsu-white);text-decoration:underline;text-decoration-color:var(--lsu-red);text-underline-offset:4px}
resources/components/CoachingIndex.stxadded+28-0
Changes to resources/components/CoachingIndex.stx
@@ -0,0 +1,28 @@
1<script server>
2import { enabledModules, modulePath } from '../data/coaching'
3import { loadBrand } from '../functions/coaching-source'
4
5/* The hub's version of the nav: the same modules, but with the sentence that
6 says what each one is for. A row of eight words is navigation; this is the
7 page that has to explain itself.
8
9 Rendered whenever there is anything to list, including the one-row draft
10 state. CoachingNav hides itself below two modules because a tab strip of
11 one is not navigation, but this list is the only inbound link /coaching
12 /plans has: hiding it put an indexed page in the sitemap that nothing on
13 the site linked to. */
14const indexBrand = await loadBrand()
15const indexModules = enabledModules(indexBrand?.modules)
16</script>
17@if(indexModules.length > 0)
18<ul class="coach-index">
19 @foreach(indexModules as item)
20 <li>
21 <a href="">
22 <span class="coach-index-label">{{ item.label }}</span>
23 <span class="coach-index-summary">{{ item.summary }}</span>
24 </a>
25 </li>
26 @endforeach
27</ul>
28@endif
resources/components/CoachingModule.stxadded+70-0
Changes to resources/components/CoachingModule.stx
@@ -0,0 +1,70 @@
1<script>
2interface Props { slug: string }
3const { slug } = defineProps<Props>()
4</script>
5<script server>
6import { coachingModule } from '../data/coaching'
7import { loadBrand, portalHost, portalUrl } from '../functions/coaching-source'
8
9/* The registry is the only place a module's words live, so a page cannot
10 disagree with the nav about what it is called. */
11const panel = coachingModule(slug)
12if (!panel) throw new Error(`unknown coaching module: ${slug}`)
13
14/* Whether the brand has published this module, read from the brand itself
15 rather than from what the nav chooses to show. Without it the page framed
16 a panel that answers 404, under a notice promising an account would open
17 it. The storefront keeps its own empty state, so it is excluded here
18 rather than carrying two. */
19const panelBrand = await loadBrand()
20const panelLive = Boolean(panelBrand?.modules.includes(panel.slug))
21const panelStorefront = panel.slug === 'plans'
22
23/* The panel is a cross-origin frame, and a frame can decline to load for
24 reasons this page cannot see: an origin the brand has not allowed, a
25 browser blocking third-party frames, an extension. When that happens the
26 embed leaves a tall empty box, so the way out never lives inside it. */
27const panelHref = portalUrl(panel.slug)
28const panelHost = portalHost()
29</script>
30<div class="coaching-surface">
31 <header class="coach-head">
32 <div class="coach-shell">
33 <a class="coach-up kicker" href="/coaching">Coaching</a>
34 <h1>{{ panel.title }}</h1>
35 <p>{{ panel.summary }}</p>
36 </div>
37 </header>
38 <CoachingNav current="" />
39 <div class="coach-shell">
40 @if(panelLive && panel.scope === 'private')
41 <div class="coach-scope">
42 <strong>Only you see this</strong>
43 <p>This panel reads your own training account, not anything this site holds. Last Soul Ultra cannot see it, and neither can the coach whose method you are following.</p>
44 </div>
45 @elseif(panelLive && panel.scope === 'athlete')
46 <div class="coach-scope">
47 <strong>Needs a training account</strong>
48 <p>Sign in happens inside the panel, with the account that holds your subscription. That is a separate account from the one you use to request a race entry.</p>
49 </div>
50 @endif
51 @if(panelStorefront)
52 <CoachingOffers level="2" />
53 @elseif(!panelLive)
54 <div class="coach-empty">
55 <h2>Not open yet</h2>
56 <p>No coach has published a method here, so there is nothing for this panel to show. It fills in once the first tier opens, under the coach's own name and the coach's own price.</p>
57 <p class="coach-empty-back"><a href="/coaching">Read what coaching here will be</a></p>
58 </div>
59 @endif
60 @if(panelLive)
61 <div class="coach-panel-bar">
62 <p>Loaded from {{ panelHost }}. Your training account lives there, not here.</p>
63 <a href="" target="_blank" rel="noopener">Open it there<span class="coach-sr"> (opens in a new tab)</span></a>
64 </div>
65 <div class="coach-panel">
66 <CoachingPortal section="" />
67 </div>
68 @endif
69 </div>
70</div>
resources/components/CoachingNav.stxadded+26-0
Changes to resources/components/CoachingNav.stx
@@ -0,0 +1,26 @@
1<script>
2interface Props { current?: string }
3const { current = '' } = defineProps<Props>()
4</script>
5<script server>
6import { enabledModules, modulePath } from '../data/coaching'
7import { loadBrand } from '../functions/coaching-source'
8
9/* The brand decides which modules exist; this site decides their order and
10 their names. An unreachable or draft brand leaves the storefront alone,
11 so the nav never offers a panel that cannot load. */
12const navBrand = await loadBrand()
13const navModules = enabledModules(navBrand?.modules)
14const navCurrent = current
15</script>
16@if(navModules.length > 1)
17<nav class="coach-nav" aria-label="Coaching">
18 <div class="coach-shell">
19 <ul>
20 @foreach(navModules as item)
21 <li><a href="" @if(item.slug === navCurrent) aria-current="page" @endif>{{ item.label }}</a></li>
22 @endforeach
23 </ul>
24 </div>
25</nav>
26@endif
resources/components/CoachingOffers.stxadded+75-0
Changes to resources/components/CoachingOffers.stx
@@ -0,0 +1,75 @@
1<script>
2interface Props {
3 /** Heading level for a tier name: 2 under a page heading, 3 under a section. */
4 level?: string
5}
6const { level = '3' } = defineProps<Props>()
7</script>
8<script server>
9import { formatPrice, formatTrial } from '../functions/coaching-brand'
10import { loadBrand, portalHost } from '../functions/coaching-source'
11
12/* The one piece of coaching this site renders itself. The published brand is
13 public, so the tiers, the coach behind each one and the coach's own price
14 can be read here rather than only inside the frame - a search result, a
15 shared link and a visitor with frames blocked all get the same answer.
16 Nothing is composed: no price, no trial and no coach name originates here. */
17const offerBrand = await loadBrand()
18const offers = offerBrand?.offers ?? []
19const offerLocale = offerBrand?.locale ?? 'en'
20const offerHost = portalHost()
21/* A tier name sits directly under the page heading on /coaching/plans and
22 under a section heading on the hub, so the level is the caller's to say.
23 Getting it wrong is an h1 followed by an h3, which is how a screen-reader
24 user loses the outline of the page. */
25const offerTop = String(level) === '2'
26
27/* Two different things, and a storefront needs both: what this tier gives you
28 (the coach's product description) and how the coach trains people (their
29 published method summary). HQ sometimes carries the same sentence in both,
30 so the second is dropped when it repeats the first rather than printing a
31 paragraph twice. */
32const offerRows = offers.map((offer) => {
33 const includes = offer.product.description.trim()
34 const approach = offer.method.summary.trim()
35 return { ...offer, includes, approach: approach && approach !== includes ? approach : '' }
36})
37</script>
38@if(offers.length > 0)
39<ul class="coach-offers">
40 @foreach(offerRows as offer)
41 <li class="coach-offer">
42 <div>
43 @if(offerTop)
44 <h2 class="coach-offer-name">{{ offer.label }}</h2>
45 @else
46 <h3 class="coach-offer-name">{{ offer.label }}</h3>
47 @endif
48 <p class="coach-offer-by">{{ offer.coach }}</p>
49 @if(offer.includes)
50 <p>{{ offer.includes }}</p>
51 @endif
52 @if(offer.approach)
53 <p class="coach-offer-method">{{ offer.approach }}</p>
54 @endif
55 <p class="coach-offer-note">{{ offer.method.name }}, version {{ offer.method.version }}. Bought and billed at {{ offerHost }}.</p>
56 </div>
57 <div class="coach-offer-price">
58 <b>{{ formatPrice(offer.product, offerLocale) }}</b>
59 @if(formatTrial(offer.product))
60 <span class="coach-offer-trial">{{ formatTrial(offer.product) }}</span>
61 @endif
62 </div>
63 </li>
64 @endforeach
65</ul>
66@else
67<div class="coach-empty">
68 @if(offerTop)
69 <h2>Nothing is open yet</h2>
70 @else
71 <h3>Nothing is open yet</h3>
72 @endif
73 <p>No coach has published a method here and no price has been set. When the first tier opens it appears here, under the coach's own name and at the coach's own price. This site does not set either.</p>
74</div>
75@endif
resources/components/CoachingPortal.stxmodified+13-9
Changes to resources/components/CoachingPortal.stx
@@ -4,20 +4,24 @@ const { section = '' } = defineProps<Props>()
44</script>
55<script server>
66import coaching from '../../config/coaching'
7const coachingOrigin = new URL(coaching.origin).origin
8if (new URL(coachingOrigin).protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(new URL(coachingOrigin).hostname)) throw new Error('HQ_ORIGIN must use HTTPS')
9if (!/^[a-z0-9][a-z0-9-]{0,139}$/.test(coaching.brand)) throw new Error('HQ_BRAND_SLUG must be an organization slug')
10const coachingSection = ['plans', 'calendar', 'feedback', 'progress', 'library', 'health', 'messages', 'account'].includes(section) ? section : ''
11const portalUrl = `${coachingOrigin}/brands/${coaching.brand}`
12const elementUrl = `${coachingOrigin}/assets/elements/hq-coaching.js`
7import { coachingBrand, coachingOrigin, coachingSection, portalElementUrl, portalUrl } from '../functions/coaching-source'
8
9/* Origin, slug and section are all checked in coaching-source, which is also
10 what the module pages build their links from, so the embed and the link
11 beside it cannot disagree about where the portal is. */
12const portalBrand = coachingBrand()
13const portalApi = coachingOrigin()
14const portalNamedSection = coachingSection(section)
15const portalHref = portalUrl(portalNamedSection)
16const portalElement = portalElementUrl()
1317</script>
1418<script client>
1519import { publicSubscriptionReceipt } from '../functions/coaching-return'
1620const coachingSearch = useSearchParams()
1721const coachingSubscription = memo(() => publicSubscriptionReceipt(coachingSearch.get('hq_subscription')))
1822</script>
19<script type="module" src=""></script>
20<hq-coaching brand="" api="" label="Last Soul Ultra coaching" section="" :subscription="coachingSubscription">
23<script type="module" src=""></script>
24<hq-coaching brand="" api="" label="Last Soul Ultra coaching" section="" :subscription="coachingSubscription">
2125 <p>Training plans, monthly subscriptions and feedback, powered by HQ.</p>
22 <a class="btn" href="">Open your training</a>
26 <a class="btn" href="">Open your training</a>
2327</hq-coaching>
resources/components/SiteFooter.stxmodified+25-12
Changes to resources/components/SiteFooter.stx
@@ -1,3 +1,15 @@
1<script>
2interface Props { current?: string }
3const { current = '' } = defineProps<Props>()
4</script>
5<script server>
6/** Same rule as the nav: a section marks its own subtree. */
7function here(href) {
8 if (!current) return false
9 if (href === '/') return current === '/'
10 return current === href || current.startsWith(`${href}/`)
11}
12</script>
113<footer class="footer lsu-sz7xmlte">
214<div class="footer-grid lsu-sz7xmlte">
315<div class="footer-brand lsu-sz7xmlte">
@@ -8,13 +20,13 @@
820</div>
921<div class="footer-col lsu-sz7xmlte">
1022<div class="footer-head lsu-sz7xmlte">Race</div>
11<a href="/" class="lsu-sz7xmlte">Home</a>
12<a href="/format" class="lsu-sz7xmlte">The format</a>
13<a href="/world-series" class="lsu-sz7xmlte">World Series</a>
14<a href="/soul-races" class="lsu-sz7xmlte">Soul Races</a>
15<a href="/results" class="lsu-sz7xmlte">Results overview</a>
16<a href="/results/last-soul-ultra-2026" class="lsu-sz7xmlte">Season 2 results</a>
17<a href="/results/last-soul-ultra-2025" class="lsu-sz7xmlte">Season 1 results</a>
23<a href="/" class="lsu-sz7xmlte" @if(here('/')) aria-current="page" @endif>Home</a>
24<a href="/format" class="lsu-sz7xmlte" @if(here('/format')) aria-current="page" @endif>The format</a>
25<a href="/world-series" class="lsu-sz7xmlte" @if(here('/world-series')) aria-current="page" @endif>World Series</a>
26<a href="/soul-races" class="lsu-sz7xmlte" @if(here('/soul-races')) aria-current="page" @endif>Soul Races</a>
27<a href="/results" class="lsu-sz7xmlte" @if(here('/results')) aria-current="page" @endif>Results overview</a>
28<a href="/results/last-soul-ultra-2026" class="lsu-sz7xmlte" @if(here('/results/last-soul-ultra-2026')) aria-current="page" @endif>Season 2 results</a>
29<a href="/results/last-soul-ultra-2025" class="lsu-sz7xmlte" @if(here('/results/last-soul-ultra-2025')) aria-current="page" @endif>Season 1 results</a>
1830<a href="/world-series" data-apply data-view-href="/profile" class="lsu-sz7xmlte">Request a race entry</a>
1931</div>
2032<div class="footer-col lsu-sz7xmlte">
@@ -26,11 +38,12 @@
2638</div>
2739<div class="footer-col lsu-sz7xmlte">
2840<div class="footer-head lsu-sz7xmlte">Info</div>
29<a href="/sponsorship" class="lsu-sz7xmlte">Sponsorship</a>
30<a href="/contact" class="lsu-sz7xmlte">Contact &amp; FAQ</a>
31<a href="/imprint" class="lsu-sz7xmlte">Imprint</a>
32<a href="/privacy" class="lsu-sz7xmlte">Privacy</a>
33<a href="/terms" class="lsu-sz7xmlte">Terms</a>
41<a href="/sponsorship" class="lsu-sz7xmlte" @if(here('/sponsorship')) aria-current="page" @endif>Sponsorship</a>
42<a href="/coaching" class="lsu-sz7xmlte" @if(here('/coaching')) aria-current="page" @endif>Coaching</a>
43<a href="/contact" class="lsu-sz7xmlte" @if(here('/contact')) aria-current="page" @endif>Contact &amp; FAQ</a>
44<a href="/imprint" class="lsu-sz7xmlte" @if(here('/imprint')) aria-current="page" @endif>Imprint</a>
45<a href="/privacy" class="lsu-sz7xmlte" @if(here('/privacy')) aria-current="page" @endif>Privacy</a>
46<a href="/terms" class="lsu-sz7xmlte" @if(here('/terms')) aria-current="page" @endif>Terms</a>
3447</div>
3548</div>
3649<div class="footer-base lsu-sz7xmlte">
resources/components/SiteNav.stxmodified+36-16
Changes to resources/components/SiteNav.stx
@@ -1,3 +1,23 @@
1<script>
2interface Props {
3 /** The canonical path of the page this nav is rendering on. */
4 current?: string
5}
6const { current = '' } = defineProps<Props>()
7</script>
8<script server>
9/**
10 * Which link is the page you are on.
11 *
12 * A section marks its own subtree, so /coaching/calendar lights Coaching,
13 * and /faq canonicalises to /contact so Contact & FAQ is already right.
14 */
15function here(href) {
16 if (!current) return false
17 if (href === '/') return current === '/'
18 return current === href || current.startsWith(`${href}/`)
19}
20</script>
121<script client>
222const lsuMenu = state(false)
323function lsuToggleMenu() { lsuMenu.update(value => !value) }
@@ -8,16 +28,16 @@ function lsuCloseMenu() { lsuMenu.set(false) }
828<img src="/assets/lsu/images/lsu-logo.svg" alt="Last Soul Ultra" width="877" height="177" class="lsu-scope-nav">
929</a>
1030<div class="lsu-scope-nav nav-links">
11<a href="/" class="lsu-scope-nav">Home</a>
12<a href="/format" class="lsu-scope-nav">Format</a>
13<a href="/world-series" class="lsu-scope-nav">World Series</a>
14<a href="/soul-races" class="lsu-scope-nav">Soul Races</a>
15<a href="/sponsorship" class="lsu-scope-nav">Sponsorship</a>
16<a href="/coaching" class="lsu-scope-nav">Coaching</a>
17<a href="/contact" class="lsu-scope-nav">Contact &amp; FAQ</a>
31<a href="/" class="lsu-scope-nav" @if(here('/')) aria-current="page" @endif>Home</a>
32<a href="/format" class="lsu-scope-nav" @if(here('/format')) aria-current="page" @endif>Format</a>
33<a href="/world-series" class="lsu-scope-nav" @if(here('/world-series')) aria-current="page" @endif>World Series</a>
34<a href="/soul-races" class="lsu-scope-nav" @if(here('/soul-races')) aria-current="page" @endif>Soul Races</a>
35<a href="/sponsorship" class="lsu-scope-nav" @if(here('/sponsorship')) aria-current="page" @endif>Sponsorship</a>
36<a href="/coaching" class="lsu-scope-nav" @if(here('/coaching')) aria-current="page" @endif>Coaching</a>
37<a href="/contact" class="lsu-scope-nav" @if(here('/contact')) aria-current="page" @endif>Contact &amp; FAQ</a>
1838</div>
1939<div class="lsu-scope-nav nav-end">
20<a href="/profile" class="lsu-scope-nav nav-profile">Profile</a>
40<a href="/profile" class="lsu-scope-nav nav-profile" @if(here('/profile')) aria-current="page" @endif>Profile</a>
2141<a href="/world-series" class="lsu-scope-nav nav-cta" data-apply data-view-href="/profile" data-view-label="View entry">Request a race entry</a>
2242<button class="burger lsu-scope-nav" data-burger aria-label="Menu" :aria-expanded="lsuMenu" @click="lsuToggleMenu()" aria-controls="nav-drawer">
2343<span class="lsu-scope-nav">
@@ -35,14 +55,14 @@ function lsuCloseMenu() { lsuMenu.set(false) }
3555<aside :data-open="lsuMenu ? '' : null" :inert="!lsuMenu" @keydown.escape="lsuCloseMenu()" class="drawer lsu-scope-nav" id="nav-drawer" data-drawer :aria-hidden="!lsuMenu">
3656<div class="drawer-kicker lsu-scope-nav">Last Soul Ultra events</div>
3757<div class="drawer-links lsu-scope-nav">
38<a href="/" class="lsu-scope-nav">Home</a>
39<a href="/format" class="lsu-scope-nav">Format</a>
40<a href="/world-series" class="lsu-scope-nav">World Series</a>
41<a href="/soul-races" class="lsu-scope-nav">Soul Races</a>
42<a href="/sponsorship" class="lsu-scope-nav">Sponsorship</a>
43<a href="/coaching" class="lsu-scope-nav">Coaching</a>
44<a href="/contact" class="lsu-scope-nav">Contact &amp; FAQ</a>
45<a href="/profile" class="lsu-scope-nav">Profile</a>
58<a href="/" class="lsu-scope-nav" @if(here('/')) aria-current="page" @endif>Home</a>
59<a href="/format" class="lsu-scope-nav" @if(here('/format')) aria-current="page" @endif>Format</a>
60<a href="/world-series" class="lsu-scope-nav" @if(here('/world-series')) aria-current="page" @endif>World Series</a>
61<a href="/soul-races" class="lsu-scope-nav" @if(here('/soul-races')) aria-current="page" @endif>Soul Races</a>
62<a href="/sponsorship" class="lsu-scope-nav" @if(here('/sponsorship')) aria-current="page" @endif>Sponsorship</a>
63<a href="/coaching" class="lsu-scope-nav" @if(here('/coaching')) aria-current="page" @endif>Coaching</a>
64<a href="/contact" class="lsu-scope-nav" @if(here('/contact')) aria-current="page" @endif>Contact &amp; FAQ</a>
65<a href="/profile" class="lsu-scope-nav" @if(here('/profile')) aria-current="page" @endif>Profile</a>
4666</div>
4767<a href="/world-series" class="drawer-cta lsu-scope-nav" data-apply data-view-href="/profile" data-view-label="View entry">Request a race entry</a>
4868<div class="drawer-social lsu-scope-nav">
resources/data/coaching.tsadded+160-0
Changes to resources/data/coaching.ts
@@ -0,0 +1,160 @@
1/**
2 * The coaching modules, in LSU's own words.
3 *
4 * HQ publishes eight composable training modules and a navigation list for the
5 * brand. This site does not simply print HQ's labels: a runner arriving from
6 * /format should read the same voice here as everywhere else, so the label,
7 * the page heading and the one-line summary are LSU's. HQ remains the only
8 * source of the data inside each panel, and of whether a module is enabled at
9 * all - `enabledModules()` intersects this list with the published brand.
10 *
11 * `scope` is the part worth keeping straight:
12 * 'public' - readable without an HQ account. The storefront.
13 * 'athlete' - needs a signed-in HQ subscriber. Noindex, and said plainly on
14 * the page rather than discovered after a click.
15 * 'private' - as athlete, and additionally account-scoped inside HQ: the
16 * athlete's own health metrics and messages, which a brand never
17 * sees. LSU must never imply it can read them.
18 */
19
20export type ModuleScope = 'public' | 'athlete' | 'private'
21
22export interface CoachingModule {
23 /** HQ's module key. Also this site's URL segment, under /coaching/. */
24 slug: string
25 /** Nav word. LSU's, not HQ's. */
26 label: string
27 /** Page heading. */
28 title: string
29 /** One line, in the site's voice, saying what the panel actually does. */
30 summary: string
31 scope: ModuleScope
32}
33
34export const coachingModules: CoachingModule[] = [
35 {
36 slug: 'plans',
37 label: 'Tiers',
38 title: 'What is open',
39 summary: 'The tiers a coach has published, what each one covers and what it costs. Nothing here is set by this site.',
40 scope: 'public',
41 },
42 {
43 slug: 'calendar',
44 label: 'The week',
45 title: 'Your week',
46 summary: 'The sessions your method has given you, sized to the days you have. Log the sets, the time and how hard it was.',
47 scope: 'athlete',
48 },
49 {
50 slug: 'feedback',
51 label: 'Say how it felt',
52 title: 'Say how it felt',
53 summary: 'Record a voice message after a session. You read the transcript back and send it yourself; next week moves inside the method.',
54 scope: 'athlete',
55 },
56 {
57 slug: 'progress',
58 label: 'Progress',
59 title: 'What you have done',
60 summary: 'Sessions given against sessions finished, across every tier you are paying for.',
61 scope: 'athlete',
62 },
63 {
64 slug: 'library',
65 label: 'The method',
66 title: 'The method you are in',
67 summary: 'The coach behind your tier and the published summary of how they train people. The private methodology stays with them.',
68 scope: 'athlete',
69 },
70 {
71 slug: 'health',
72 label: 'Health',
73 title: 'Your health data',
74 summary: 'Sleep, heart rate and the rest, read from your own account. This site never sees it and neither does the race.',
75 scope: 'private',
76 },
77 {
78 slug: 'messages',
79 label: 'Messages',
80 title: 'Your messages',
81 summary: 'Your own inbox, read from your own account. Not a channel to the race, and not something the race can read.',
82 scope: 'private',
83 },
84 {
85 slug: 'account',
86 label: 'Account',
87 title: 'Billing and renewal',
88 summary: 'Cancel, resume a renewal, or open billing. Payment is handled where your account lives, never on this site.',
89 scope: 'athlete',
90 },
91]
92
93const bySlug = new Map(coachingModules.map(module => [module.slug, module]))
94
95export function coachingModule(slug: string): CoachingModule | undefined {
96 return bySlug.get(slug)
97}
98
99/** The URL this site serves a module at. */
100export function modulePath(slug: string): string {
101 return `/coaching/${slug}`
102}
103
104/**
105 * The modules to show, in this site's order, limited to what the published
106 * brand enables. An unreachable brand means the storefront only: linking a
107 * runner to a panel that cannot load is worse than not offering it.
108 */
109export function enabledModules(published: readonly string[] | undefined): CoachingModule[] {
110 if (!published || published.length === 0)
111 return coachingModules.filter(module => module.scope === 'public')
112 return coachingModules.filter(module => published.includes(module.slug))
113}
114
115/** Only `public` modules may be indexed; the rest need an account to show anything. */
116export function indexableModulePaths(): string[] {
117 return coachingModules.filter(module => module.scope === 'public').map(module => modulePath(module.slug))
118}
119
120/** Base plus the surface sheet: the chrome every coaching route wears. */
121export const coachingStyles = [
122 '/assets/styles/production/base.css',
123 '/assets/styles/production/coaching.css',
124]
125
126/**
127 * The hub also carries the marketing page's own rules. They are a sheet of
128 * their own rather than part of stx-site.css, which every route downloads.
129 */
130export const coachingHubStyles = [
131 ...coachingStyles,
132 '/assets/styles/production/coaching-page.css',
133]
134
135export interface CoachingPageMeta {
136 title: string
137 description: string
138 styles: string[]
139 noindex?: boolean
140 /** Origins this page will open a connection to. Read by the head partial. */
141 preconnect?: string[]
142}
143
144/**
145 * Head metadata for a module route. A module that needs an account is noindex:
146 * a crawler sees the chrome and a sign-in prompt, which is not a page worth
147 * putting in a search result.
148 */
149export function coachingPage(slug: string): CoachingPageMeta {
150 const module = coachingModule(slug)
151 if (!module)
152 throw new Error(`unknown coaching module: ${slug}`)
153
154 return {
155 title: `${module.title} - Last Soul Ultra coaching`,
156 description: module.summary,
157 styles: coachingStyles,
158 noindex: module.scope !== 'public',
159 }
160}
resources/data/faq.tsdeleted+0-136
Changes to resources/data/faq.ts
@@ -1,136 +0,0 @@
1/**
2 * The FAQ, in the organiser's own words.
3 *
4 * Grouped the way lastsoulultra.com groups it - the race, the athlete's crew,
5 * spectators - because the answers genuinely address different people. A
6 * prospective entrant and somebody hoping to come and watch want different
7 * halves of this page, and a single ungrouped list makes both of them read all
8 * of it.
9 */
10
11export interface Question {
12 q: string
13 /** Paragraphs, so a long answer keeps its breaks. */
14 a: string[]
15}
16
17export interface FaqGroup {
18 slug: string
19 title: string
20 blurb: string
21 questions: Question[]
22}
23
24export const faq: FaqGroup[] = [
25 {
26 slug: 'the-race',
27 title: 'the race',
28 blurb: 'Format, timings and what is provided on site.',
29 questions: [
30 {
31 q: 'How does the Last Soul Ultra work?',
32 a: [
33 'The Last Soul Ultra follows the backyard ultra format. Every hour, all runners start together on a 6.7 km loop. Anyone who does not finish the loop in time and return to the starting corral before the next hour is eliminated.',
34 'The race continues until only one runner remains. That athlete must then complete one final solo loop to be officially declared the winner.',
35 ],
36 },
37 {
38 q: 'When does the race start?',
39 a: ['It kicks off Friday, 14 August 2026.'],
40 },
41 {
42 q: 'Is there a time limit or a maximum race duration?',
43 a: [
44 'No. There is no set end time. The race goes on until only one person is able to complete a loop.',
45 'This unpredictability is exactly what makes the format so unique and challenging.',
46 ],
47 },
48 {
49 q: 'Is there electricity, water or food?',
50 a: [
51 'There is no electrical hookup. Water is provided in containers for each athlete.',
52 'Full catering is not provided: every participant must manage their own nutrition.',
53 'There is an aid station at the base camp stocked throughout the event, but it is intended only as a supplement and does not replace self-sufficiency.',
54 ],
55 },
56 {
57 q: 'How is the base camp organised?',
58 a: [
59 'Each participant is allocated a 3x3 metre space at the base camp, marked in advance and assigned on a first come, first served basis.',
60 'As runners drop out during the race, remaining camps may be moved closer to the starting line.',
61 ],
62 },
63 {
64 q: 'What mandatory gear is required?',
65 a: ['Each participant must wear a working headlamp when running in twilight or darkness. Participation without this mandatory gear is not allowed.'],
66 },
67 {
68 q: 'What are the participation requirements?',
69 a: ['Before the start, every athlete must sign a waiver. This document, including all terms and conditions, will be made available ahead of time.'],
70 },
71 {
72 q: 'Who is participating?',
73 a: ['The starting field consists of international ultrarunners, athletes, well-known sports personalities, content creators and selected lottery participants.'],
74 },
75 {
76 q: 'Why is the location kept secret?',
77 a: ['The exact location is not disclosed in order to guarantee the safety and smooth running of the event.'],
78 },
79 ],
80 },
81 {
82 slug: 'crew',
83 title: 'athlete crew',
84 blurb: 'What a crew may do, and the two things that end an athlete’s race.',
85 questions: [
86 {
87 q: 'How much support can an athlete receive?',
88 a: [
89 'In the base camp, crew members can fully assist their athlete: with setup, nutrition, and preparation for the next loop.',
90 'Once the athlete enters the course, any kind of support is strictly prohibited. Even handing over food or gear during a loop will result in the runner’s immediate disqualification.',
91 ],
92 },
93 {
94 q: 'Can crew members enter the track?',
95 a: ['No. Crew members are not allowed on the track. If this rule is broken, the corresponding athlete will be automatically disqualified.'],
96 },
97 {
98 q: 'How many crew members are allowed?',
99 a: ['Each runner may have up to three crew members. Having a crew is optional, not mandatory.'],
100 },
101 ],
102 },
103 {
104 slug: 'watching',
105 title: 'following the race',
106 blurb: 'The race is closed to the public and open on every screen.',
107 questions: [
108 {
109 q: 'Can people watch the event in person?',
110 a: ['No, on-site spectators are not allowed. This ensures safety and smooth operations during the race.'],
111 },
112 {
113 q: 'Will the race be streamed live?',
114 a: [
115 'Yes. There will be a 24/7 livestream broadcasting the race from start to finish.',
116 'Updates, stories, and behind-the-scenes content will be shared regularly on Instagram.',
117 ],
118 },
119 {
120 q: 'Where can I follow the race?',
121 a: [
122 'All highlights, updates and important information are shared on the official Instagram channel @lastsoulultra, as well as on the channels of Kim Gottwald and rappid.',
123 'The race is streamed live on YouTube, with selected highlights and additional content available after the event.',
124 ],
125 },
126 ],
127 },
128]
129
130export function groupBySlug(slug: string): FaqGroup | undefined {
131 return faq.find(group => group.slug === slug)
132}
133
134export function questionCount(): number {
135 return faq.reduce((total, group) => total + group.questions.length, 0)
136}
resources/data/legal.tsdeleted+0-100
Changes to resources/data/legal.ts
@@ -1,100 +0,0 @@
1/**
2 * The legal pages, verbatim.
3 *
4 * German, and left in German: these are the organiser's binding wording and a
5 * redesign restyles a policy, it does not translate or rewrite one. A
6 * translation would read as authoritative while carrying none of the legal
7 * weight, which is worse than asking a reader to translate it themselves.
8 *
9 * Each page is a list of blocks so the template can set headings and
10 * paragraphs properly instead of dumping vendor HTML into the page.
11 */
12
13export interface LegalBlock {
14 kind: 'heading' | 'text'
15 value: string
16}
17
18export interface LegalPage {
19 slug: string
20 title: string
21 /** English one-liner, so the nav and the page header stay readable. */
22 blurb: string
23 /** The language the body is actually in, for the `lang` attribute. */
24 lang: 'de' | 'en'
25 blocks: LegalBlock[]
26}
27
28export const legalPages: LegalPage[] = [
29 {
30 slug: 'imprint',
31 title: 'imprint',
32 blurb: 'Who runs the race, and where they are registered.',
33 lang: 'en',
34 blocks: [
35 { kind: 'heading', value: 'Legal Notice' },
36 { kind: 'text', value: 'Last Soul GmbH\nEhrenfeldstr. 14\n44789 Bochum\nGermany' },
37 { kind: 'heading', value: 'Represented by the Managing Director' },
38 { kind: 'text', value: 'Kim Gottwald' },
39 { kind: 'heading', value: 'Contact' },
40 { kind: 'text', value: 'Email: mail@lastsoulultra.com' },
41 { kind: 'heading', value: 'Commercial Register' },
42 { kind: 'text', value: 'Registered with the Commercial Register of the Local Court (Amtsgericht) Bochum\nCommercial Register Number: HRB 23048' },
43 { kind: 'heading', value: 'Tax Number' },
44 { kind: 'text', value: '1130 413 50751' },
45 ],
46 },
47 {
48 slug: 'privacy',
49 title: 'privacy policy',
50 blurb: 'Datenschutzerklärung. Stand: Januar 2026.',
51 lang: 'de',
52 blocks: [
53 { kind: 'heading', value: '1. Verantwortlicher' },
54 { kind: 'text', value: 'Last Soul GmbH\nDeutschland\nE-Mail: mail@lastsoulultra.com\nGeschäftsführer: Kim Gottwald' },
55 { kind: 'heading', value: '2. Allgemeine Hinweise' },
56 { kind: 'text', value: 'Der Schutz Ihrer personenbezogenen Daten ist uns wichtig. Wir verarbeiten Ihre Daten ausschließlich im Rahmen der geltenden Datenschutzgesetze, insbesondere der DSGVO und des BDSG.' },
57 { kind: 'heading', value: '3. Hosting' },
58 { kind: 'text', value: 'Unsere Website wird bei der Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen, Deutschland, auf Servern in Deutschland gehostet. Hetzner verarbeitet personenbezogene Daten ausschließlich in unserem Auftrag im Rahmen einer Auftragsverarbeitung gemäß Art. 28 DSGVO.' },
59 { kind: 'heading', value: '4. Server-Logfiles' },
60 { kind: 'text', value: 'Beim Besuch unserer Website werden automatisch Daten wie IP-Adresse, Browsertyp, Betriebssystem, Referrer-URL sowie Datum und Uhrzeit des Zugriffs verarbeitet. Die Verarbeitung erfolgt zur Sicherstellung des technischen Betriebs und der Sicherheit auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO.' },
61 { kind: 'heading', value: '5. Cookies und Analyse' },
62 { kind: 'text', value: 'Diese Website nutzt keine Analyse-, Tracking- oder Werbedienste. Gesetzt wird ausschließlich ein technisch notwendiges Cookie (X-CSRF-Token) zum Schutz von Formularen vor Cross-Site-Request-Forgery; es läuft nach zwei Stunden ab und dient keiner Analyse. Da keine einwilligungspflichtigen Cookies zum Einsatz kommen, ist kein Cookie-Banner erforderlich. Schriften und Bilder werden von unserem eigenen Server ausgeliefert, sodass beim Aufruf der Seiten keine Inhalte Dritter nachgeladen werden und Ihre IP-Adresse nicht an Dritte übertragen wird.' },
63 { kind: 'heading', value: '6. Newsletter' },
64 { kind: 'text', value: 'Wenn Sie unseren Newsletter abonnieren, speichern wir Ihre E-Mail-Adresse sowie den Zeitpunkt und die Herkunft der Anmeldung. Die Anmeldung erfolgt im Double-Opt-in-Verfahren: Sie erhalten zunächst eine E-Mail mit einem Bestätigungslink, und erst mit dessen Aufruf wird die Anmeldung wirksam. Bis dahin wird die Adresse nicht für den Versand verwendet. Rechtsgrundlage ist Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Sie können diese jederzeit widerrufen, über den Abmeldelink in jeder E-Mail oder per Nachricht an uns.' },
65 { kind: 'heading', value: '7. Event-Anmeldung' },
66 { kind: 'text', value: 'Bei der Anmeldung zu unseren Events verarbeiten wir die von Ihnen angegebenen Daten zur Organisation und Durchführung der Veranstaltung.' },
67 { kind: 'heading', value: '8. Ihre Rechte' },
68 { kind: 'text', value: 'Sie haben jederzeit das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit sowie Widerspruch gegen die Verarbeitung Ihrer personenbezogenen Daten.' },
69 { kind: 'heading', value: '9. Beschwerderecht' },
70 { kind: 'text', value: 'Sie haben das Recht, sich bei einer Datenschutzaufsichtsbehörde zu beschweren.' },
71 { kind: 'heading', value: '10. Stand der Datenschutzerklärung' },
72 { kind: 'text', value: 'Stand: Januar 2026' },
73 ],
74 },
75 {
76 slug: 'terms',
77 title: 'terms & conditions',
78 blurb: 'Haftungsausschluss. Must be signed before the start.',
79 lang: 'de',
80 blocks: [
81 { kind: 'heading', value: 'Disclaimer' },
82 { kind: 'text', value: 'RAD RACE GmbH, Fischmarkt 9, 22767 Hamburg im Auftrag des Veranstalters Last Soul GmbH, Alte Jakobsstraße 85-86, 10179 Berlin' },
83 { kind: 'text', value: 'Mit dem Betreten des Veranstaltungsgeländes und der Teilnahme an der Veranstaltung LAST SOUL ULTRA erkennen alle Teilnehmer und Besucher den folgenden Haftungsausschluss:' },
84 { kind: 'text', value: 'Die Veranstaltung Last Soul Ultra findet auf öffentlichen Flächen statt, darunter Straßen und Wege, die für den übrigen Verkehr zugänglich sind. Alle Teilnehmer sind verpflichtet, sich strikt an die geltenden Straßenverkehrsregeln (StVO oder gleichwertige lokale Vorschriften) zu halten. Dazu gehören unter anderem die Beachtung von Verkehrszeichen, Ampeln und Vorfahrtsregeln sowie das Treffen geeigneter Vorsichtsmaßnahmen zur Gewährleistung der eigenen Sicherheit und der Sicherheit anderer. Die Teilnehmer werden hiermit darauf hingewiesen, dass die Nichtbeachtung dieser Vorschriften das Risiko schwerer Verletzungen oder tödlicher Unfälle erheblich erhöht.' },
85 { kind: 'text', value: 'Die Streckenabschnitte wurden größtenteils von den Veranstaltern auf sicherheitsrelevante Aspekte geprüft jedoch nicht zu 100% von daher bleibt die Verantwortung für die eigene Sicherheit beim Teilnehmer. Dieser Verpflichtet sich, die eigene Geschwindigkeit an die Beschaffenheiten der Strecke anzupassen so dass auch auf Hindernisse reagiert werden kann. In Anbetracht des Vorstehenden nimmt jeder Teilnehmer auf eigenes Risiko an der Veranstaltung teil.' },
86 { kind: 'text', value: 'Der Veranstalter (Last Soul GmbH) haftet nicht für einfache Fahrlässigkeit. Dies gilt sowohl für eigene Handlungen als auch für Handlungen seiner Vertreter oder Erfüllungsgehilfen. Das Vorstehende gilt nicht bei Verletzung von Leben, Körper oder Gesundheit - in diesen Fällen gelten die allgemeinen gesetzlichen Bestimmungen.' },
87 { kind: 'text', value: 'Mit meiner Anmeldung erklärt jeder Teilnehmer, dass er für die Teilnahme an dieser Veranstaltung ausreichend trainiert hat, körperlich fit ist und sein Gesundheitszustand ärztlich bestätigt wurde. Jeder Teilnehmer erklärt außerdem, dass seine Fähigkeiten es ihm ermöglichen, an einer anspruchsvollen und sehr extremen Ultra-Laufveranstaltung wie dem Last Soul Ultra teilzunehmen. Sollte er während der Veranstaltung feststellen, dass sich sein körperlicher Zustand verschlechtert, versichert er, dass der Veranstalter darüber informiert wird, damit die Last Soul GmbH geeignete Maßnahmen ergreifen kann.' },
88 { kind: 'text', value: 'Jeder Teilnehmer verpflichtet sich, die vom Veranstalter festgelegten Regeln und Vorschriften einzuhalten (die Regeln und Vorschriften werden vorab veröffentlicht und am Veranstaltungsort erneut im Athlete Briefing mitgeteilt). Jeder ist sich bewusst, dass man disqualifiziert wird, wenn die offizielle Teilnehmernummer in irgendeiner Weise verändert wird oder sich den Regeln widersetzt wird, insbesondere wenn man den Werbeaufdruck unsichtbar oder unkenntlich mache. Darüber hinaus ist es verboten, Tiere zum Veranstaltungsort mitzubringen.' },
89 { kind: 'text', value: 'Die RAD RACE GmbH und Last Soul GmbH engagiert sich in allen Bereichen ihrer Geschäftstätigkeit aktiv für den Umweltschutz. Der Veranstalter erwartet auch von den Teilnehmern und allen Beteiligten, dass sie die Umwelt respektieren. Die strikte Einhaltung aller umweltrechtlichen Vorschriften ist eine Grundvoraussetzung. Dies gilt insbesondere für die Vorschriften zur Abfallentsorgung, zum Boden- und Gewässerschutz sowie zur Vermeidung von Umweltverschmutzung. Ich bin mir bewusst, dass sich der Veranstalter das Recht vorbehält, Teilnehmer wegen Verstoßes gegen eine der oben genannten Verpflichtungen von der Veranstaltung auszuschließen. Die allgemeine Anmeldung sowie die Abbuchung von Kreditkarten werden vom Veranstalter (Last Soul GmbH) durchgeführt.' },
90 { kind: 'text', value: 'Mit der Anmeldung erklärt sich jeder Teilnehmer damit einverstanden, dass die von ihm im Zusammenhang mit seiner Teilnahme am Last Soul Ultra angefertigten Fotos, Filmaufnahmen und Interviews ohne Anspruch auf Vergütung meinerseits in Radio, Fernsehen, Internet, Werbung, Büchern, fotomechanischen Kopien, Filmen, Videokassetten, DVDs usw. verwendet, verbreitet und veröffentlicht werden dürfen und sein Name in der Ergebnisliste auf der Veranstalterwebsite und Partnerwebsite (im Rahmen der Medienberichterstattung über die Veranstaltung) veröffentlicht werden darf.' },
91 { kind: 'text', value: 'Vor Ort dürfen keine kommerziellen Aufnahmen gemacht und verbreitet werden. Zudem dürfen keine dritten Events mit dem Content des Last Soul Ultra beworben werden. Eine redaktionelle Berichterstattung in den sozialen Medien ist ausdrücklich erwünscht. Bei Fragen meldet euch gerne an mail@lastsoulultra.com' },
92 { kind: 'text', value: 'Die Teilnehmer versichern, dass ihr Geburtsdatum sowie alle anderen von ihm angegebenen Daten korrekt sind. Die Teilnahme an der Veranstaltung ist nur möglich, wenn die Teilnehmer zum Zeitpunkt der Anmeldung volljährig (18 Jahre) sind.' },
93 { kind: 'text', value: 'Der vorliegende Haftungsausschluss muss vor Beginn der Veranstaltung unterschrieben eingereicht werden. Wenn ein Teilnehmer für die Angabe eines falschen Geburtsdatums verantwortlich ist, behält sich der Veranstalter das Recht vor, eine Disqualifikation zu verhängen.' },
94 ],
95 },
96]
97
98export function legalBySlug(slug: string): LegalPage | undefined {
99 return legalPages.find(page => page.slug === slug)
100}
resources/data/media.tsdeleted+0-82
Changes to resources/data/media.ts
@@ -1,82 +0,0 @@
1/**
2 * Race photography, published by the organiser on lastsoulultra.com and shot
3 * by rappid at the race.
4 *
5 * Only frames that have actually been looked at appear here, and each alt
6 * describes what is in that specific frame. Alt text written from a filename
7 * is worse than none: it tells a screen-reader user something confidently
8 * wrong, and nobody sighted ever notices.
9 *
10 * Served from this origin. They used to be hotlinked from the organiser's
11 * Squarespace CDN, which sent every visitor's IP to a third party the privacy
12 * policy did not name, and made every photograph on the site depend on a host
13 * we do not control and cannot cache.
14 *
15 * The four originals are 1080x1350, so the ladder stops at 1080 - Squarespace
16 * happily answered a request for 1600w with the same 1080px file, which is how
17 * the site came to believe it had widths it never had. Regenerate with
18 * `bun run scripts/photos.ts` (macOS only, uses sips).
19 */
20
21export interface Shot {
22 /** Slug on disk, not a URL: `<DIR>/<src>-<width>.jpg`. */
23 src: string
24 alt: string
25 /** Roughly how the frame is composed, so a slot can pick one that fits. */
26 orientation: 'portrait' | 'landscape'
27}
28
29/** Widths on disk. Nothing above 1080 exists, because nothing above 1080 was
30 * ever shot. */
31export const WIDTHS = [540, 810, 1080] as const
32
33const DIR = '/assets/images/race'
34
35export const shots = {
36 /**
37 * The lap board, and the best single image the race has: it states the
38 * format in the brand's own red without a word of explanation.
39 */
40 board: {
41 src: 'board',
42 alt: 'The race board lit red at dusk, reading 90 souls remaining, 7 laps, 46.9 total km',
43 orientation: 'portrait',
44 },
45 /** A runner caught by the flash against fog, out on the loop after dark. */
46 night: {
47 src: 'night',
48 alt: 'A runner in a pale jacket and rappid. cap lit by a flash against fog and darkness, carrying a soft flask',
49 orientation: 'portrait',
50 },
51 /** The gap between hours, which is the whole tactical problem of the format. */
52 refuel: {
53 src: 'refuel',
54 alt: 'A runner in a rappid. cap eating instant noodles from a cup, resting between laps',
55 orientation: 'portrait',
56 },
57 /** Daylight on the loop, wet road, somewhere in the middle of the race. */
58 loop: {
59 src: 'loop',
60 alt: 'A runner in a black jacket and rappid. cap running back down a wet country road at dawn',
61 orientation: 'portrait',
62 },
63} satisfies Record<string, Shot>
64
65/**
66 * The frame at the width a slot actually paints, snapped UP to a width that
67 * exists on disk. Asking for something we do not have used to yield a working
68 * URL from the CDN; now it would 404, so the snap is what keeps a template
69 * free to name the width it wants.
70 */
71export function at(shot: Shot, width: number): string {
72 const w = WIDTHS.find(candidate => candidate >= width) ?? WIDTHS[WIDTHS.length - 1]
73 return `${DIR}/${shot.src}-${w}.jpg`
74}
75
76/** 1x and 2x, so a retina screen gets the sharp file and a phone does not. */
77export function srcset(shot: Shot, width: number): string {
78 return `${at(shot, width)} 1x, ${at(shot, width * 2)} 2x`
79}
80
81/** The strip on the home page, ordered the way a race night runs. */
82export const recap: Shot[] = [shots.board, shots.loop, shots.refuel, shots.night]
resources/data/share.tsadded+92-0
Changes to resources/data/share.ts
@@ -0,0 +1,92 @@
1/**
2 * The social share cards, one per page.
3 *
4 * Nine 1200x630 cards ship under `public/assets/lsu/images/`, produced by the
5 * organiser in the brand's own black-and-red: the LSU wordmark top left, a red
6 * kicker, a white headline, and `lastsoulultra.com` bottom right over a
7 * darkened race photograph. They were captured with the rest of the production
8 * assets and then never referenced, so every page in the site shared one
9 * portrait WebP of the hero instead - the wrong shape for a card slot that is
10 * 1.91:1, and a format several scrapers still decline to render.
11 *
12 * Mapped here rather than in `production-pages.ts`, which records the captured
13 * page metadata as it was found.
14 */
15
16export interface ShareCard {
17 /** Site-relative path. Absolute URLs are built at render time. */
18 src: string
19 /**
20 * What the card shows AND what it reads. A share card is mostly type, so an
21 * alt that describes only the photograph behind it drops the actual message.
22 */
23 alt: string
24}
25
26/** Every card on disk is this size, which is also what the card slot wants. */
27export const CARD_WIDTH = 1200
28export const CARD_HEIGHT = 630
29
30const DIR = '/assets/lsu/images'
31
32export const cards = {
33 default: {
34 src: `${DIR}/og-default.jpg`,
35 alt: 'Silhouetted runners against floodlights, captioned "World Series · Season 3 · 2027" and "Three qualifiers. One final."',
36 },
37 format: {
38 src: `${DIR}/og-format.jpg`,
39 alt: 'A rain-beaded pair of rappid. sunglasses in close-up, captioned "What a backyard ultra is" and "One loop. Every hour."',
40 },
41 soulRaces: {
42 src: `${DIR}/og-soul-races.jpg`,
43 alt: 'Runners and crew waiting in a corral at the start, captioned "Independent backyard ultras" and "Soul Races around the world".',
44 },
45 sponsorship: {
46 src: `${DIR}/og-sponsorship.jpg`,
47 alt: 'A runner in a cap and race vest lit from the side in the dark, captioned "Partner with the World Series" and "A race built to be watched".',
48 },
49 contact: {
50 src: `${DIR}/og-contact.jpg`,
51 alt: 'A lone runner silhouetted in fog, captioned "We reply within 24 hours" and "Contact & FAQ".',
52 },
53 lottery: {
54 src: `${DIR}/og-lottery.jpg`,
55 alt: 'The lap board reading "souls remaining", captioned "Four events · One World Series" and "Enter the lottery".',
56 },
57 results: {
58 src: `${DIR}/og-results.jpg`,
59 alt: 'Runners with hands raised under floodlights, captioned "Every edition · Every yard" and "Results".',
60 },
61 season2025: {
62 src: `${DIR}/og-2025.jpg`,
63 alt: 'The Last Soul lettering on a dark wall, captioned "Season 1 · 3 October 2025" and "Last Soul 2025".',
64 },
65 season2026: {
66 src: `${DIR}/og-2026.jpg`,
67 alt: 'A runner in a headtorch looking straight at the camera at night, captioned "Season 2 · 14 August 2026" and "Last Soul 2026".',
68 },
69} satisfies Record<string, ShareCard>
70
71/**
72 * Path to card. A page with no card of its own gets the default one, which
73 * carries the World Series message rather than a page-specific headline.
74 */
75const byPath: Record<string, ShareCard> = {
76 '/': cards.default,
77 '/world-series': cards.default,
78 '/format': cards.format,
79 '/soul-races': cards.soulRaces,
80 '/sponsorship': cards.sponsorship,
81 '/contact': cards.contact,
82 '/faq': cards.contact,
83 '/lottery': cards.lottery,
84 '/results': cards.results,
85 '/results/last-soul-ultra-2025': cards.season2025,
86 '/results/last-soul-ultra-2026': cards.season2026,
87}
88
89/** The card for a page, falling back to the default for anything unlisted. */
90export function shareCard(path: string): ShareCard {
91 return byPath[path] ?? cards.default
92}
resources/data/site.tsmodified+16-42
Changes to resources/data/site.ts
@@ -1,11 +1,15 @@
11import { env } from '@stacksjs/env'
2import { at, shots } from './media'
2import { indexableModulePaths } from './coaching'
33import { race } from './race'
44
55/**
6 * Site-level constants: origin, navigation, footer.
6 * Site-level constants: the origin, and the list of what the site publishes.
77 *
8 * Anything a page prints twice lives here so the two copies cannot drift.
8 * Anything a page prints twice lives here so the two copies cannot drift. The
9 * navigation used to live here as well, in a second copy that no template read
10 * and that still pointed at `/legal/*` routes this site does not serve;
11 * SiteNav and SiteFooter are the only copy now. Share cards moved to
12 * `share.ts`, which maps each page to one of the cards on disk.
913 */
1014
1115/**
@@ -28,24 +32,6 @@ export function absolute(path: string): string {
2832 return `${siteOrigin}${path.startsWith('/') ? path : `/${path}`}`
2933}
3034
31/**
32 * The share image. One frame for the whole site rather than a generated card
33 * per route: this is an eight-page event site, and the photograph of a corral
34 * under floodlights says more about it than any composed card would.
35 */
36/* Absolute, because og:image and twitter:image are read by machines that have
37 * no page to resolve a path against. `at()` returns an origin-relative path
38 * now that the photographs are served from here rather than a CDN. */
39export const socialImage = absolute(at(shots.board, 1080))
40export const socialImageAlt = shots.board.alt
41
42export interface NavItem {
43 label: string
44 href: string
45 /** Sends the visitor off-site. */
46 external?: boolean
47}
48
4935/**
5036 * Every indexable URL on the site, in one place.
5137 *
@@ -59,27 +45,15 @@ export interface NavItem {
5945export const indexablePaths: string[] = [
6046 '/', '/format', '/world-series', '/soul-races', '/sponsorship', '/contact',
6147 '/lottery', '/results', '/results/last-soul-ultra-2026', '/results/last-soul-ultra-2025',
62 '/imprint', '/privacy', '/terms', '/coaching', '/faq',
63]
64
65export const nav: NavItem[] = [
66 { label: 'the format', href: '/format' },
67 { label: 'enter', href: '/lottery' },
68 { label: 'results', href: '/results' },
69 { label: 'faq', href: '/faq' },
70 { label: 'sponsorship', href: '/sponsorship' },
71]
72
73export const footerLinks: NavItem[] = [
74 { label: 'contact', href: '/contact' },
75 { label: 'imprint', href: '/legal/imprint' },
76 { label: 'privacy', href: '/legal/privacy' },
77 { label: 'terms', href: '/legal/terms' },
78]
79
80export const elsewhere: NavItem[] = [
81 { label: 'instagram', href: race.instagram, external: true },
82 { label: 'livestreams', href: race.livestreams, external: true },
48 '/imprint', '/privacy', '/terms', '/coaching',
49 // /faq is served, and stays served: it is a URL the previous site published
50 // and other people's links still point at. It is not listed, because it
51 // renders the contact page and canonicalises to /contact - advertising a
52 // URL that tells a crawler it is not the canonical one asks for the page to
53 // be ignored and spends the site's crawl budget doing it.
54 // The storefront only. The other seven training modules show a sign-in
55 // prompt to anyone without an account, which is not a search result.
56 ...indexableModulePaths(),
8357]
8458
8559/** "14 august 2026", lowercase like the rest of the site's voice. */
resources/data/structured.tsadded+124-0
Changes to resources/data/structured.ts
@@ -0,0 +1,124 @@
1import { coachingModule } from './coaching'
2import { race } from './race'
3import { absolute, siteOrigin } from './site'
4
5/**
6 * JSON-LD, for the two things a search engine cannot work out from the page.
7 *
8 * The site had none at all, so nothing tied its pages to one publisher and
9 * nothing described where a nested page sits.
10 *
11 * Deliberately narrow. Every value here is one the site already prints in
12 * visible text - the organisation's details come from the imprint, the
13 * profiles from the footer - because markup that claims more than the page
14 * shows is what gets a site's rich results turned off. In particular:
15 *
16 * No Event. race.ts describes the August 2026 edition, which has been run;
17 * describing a finished race as an event invites a wrong date in a result.
18 * No FAQPage. The questions on /contact are written into that view, and
19 * resources/data/faq.ts is a second copy nothing renders, so marking up
20 * from it would publish answers that need not match the page.
21 * No Product or Offer for coaching. Those prices are the coach's, served
22 * from HQ, and are not approved LSU pricing.
23 */
24
25const ORGANIZATION_ID = `${siteOrigin}/#organization`
26const WEBSITE_ID = `${siteOrigin}/#website`
27
28/**
29 * The publisher, as the imprint states it.
30 *
31 * `sameAs` carries the two profiles that belong to the organisation. The
32 * footer also links Kim Gottwald's YouTube and Twitch; those are a person's
33 * channels, and listing them as the company's own accounts would be a claim
34 * the imprint does not make.
35 */
36export function organization(): Record<string, unknown> {
37 return {
38 '@type': 'Organization',
39 '@id': ORGANIZATION_ID,
40 'name': race.organiser,
41 'alternateName': race.name,
42 'url': absolute('/'),
43 'logo': absolute('/assets/lsu/images/lsu-logo.svg'),
44 'email': race.email,
45 'telephone': '+49 176 3122 9027',
46 'address': {
47 '@type': 'PostalAddress',
48 'streetAddress': 'Ehrenfeldstr. 14',
49 'postalCode': '44789',
50 'addressLocality': 'Bochum',
51 'addressCountry': 'DE',
52 },
53 'sameAs': [race.instagram, race.livestreams],
54 }
55}
56
57export function website(): Record<string, unknown> {
58 return {
59 '@type': 'WebSite',
60 '@id': WEBSITE_ID,
61 'url': absolute('/'),
62 'name': race.name,
63 'inLanguage': 'en-GB',
64 'publisher': { '@id': ORGANIZATION_ID },
65 }
66}
67
68/** The label a crumb uses for a section, in the site's own words. */
69const SECTIONS: Record<string, string> = {
70 results: 'Results',
71 coaching: 'Coaching',
72}
73
74/** The label for the last crumb, which is the page itself. */
75function leafName(path: string, title: string): string {
76 const [, section, slug] = path.split('/')
77 if (section === 'coaching' && slug)
78 return coachingModule(slug)?.title ?? title
79 // A results page is titled for its edition; the part before the first
80 // separator is the name without the site's suffix.
81 return title.split(' - ')[0]?.split(' | ')[0]?.trim() || title
82}
83
84/**
85 * Where a nested page sits. Only for pages that have a parent: a breadcrumb
86 * of one item describes nothing, and the top-level pages are all siblings.
87 */
88export function breadcrumbs(path: string, title: string): Record<string, unknown> | null {
89 const segments = path.split('/').filter(Boolean)
90 if (segments.length < 2)
91 return null
92
93 const section = segments[0]!
94 const sectionName = SECTIONS[section]
95 if (!sectionName)
96 return null
97
98 return {
99 '@type': 'BreadcrumbList',
100 'itemListElement': [
101 { '@type': 'ListItem', 'position': 1, 'name': race.name, 'item': absolute('/') },
102 { '@type': 'ListItem', 'position': 2, 'name': sectionName, 'item': absolute(`/${section}`) },
103 { '@type': 'ListItem', 'position': 3, 'name': leafName(path, title) },
104 ],
105 }
106}
107
108/** Everything one page publishes, as a single graph. */
109export function structuredData(path: string, title: string): Record<string, unknown> {
110 const graph: Record<string, unknown>[] = [organization(), website()]
111 const crumbs = breadcrumbs(path, title)
112 if (crumbs)
113 graph.push(crumbs)
114
115 return { '@context': 'https://schema.org', '@graph': graph }
116}
117
118/**
119 * Serialised for a script element. `<` is escaped so a value can never close
120 * the element it sits in, which is the one way JSON-LD becomes an injection.
121 */
122export function structuredDataJson(path: string, title: string): string {
123 return JSON.stringify(structuredData(path, title)).replace(/</g, '\\u003c')
124}
resources/functions/coaching-brand.tsadded+182-0
Changes to resources/functions/coaching-brand.ts
@@ -0,0 +1,182 @@
1/**
2 * The published brand, read from HQ's one public endpoint.
3 *
4 * `GET {HQ}/api/brands/{slug}` is the only coaching endpoint this site may
5 * call: everything else is account-scoped and its tokens never cross the
6 * embedding boundary. What comes back is another service's JSON, so nothing
7 * here trusts it. Every field is checked, anything unexpected drops the whole
8 * payload, and a null brand renders the draft state rather than a half page.
9 *
10 * The published theme is deliberately NOT read. HQ applies it inside its own
11 * frame, and this site's chrome is LSU's - black, #dd0f15, Anton over Barlow -
12 * held in CSS where it cannot depend on a request completing. A site whose
13 * palette arrives over the network paints unstyled when that network is slow.
14 */
15
16export interface CoachingProduct {
17 id: number
18 name: string
19 description: string
20 priceCents: number
21 currency: string
22 interval: string
23 trialDays: number
24}
25
26export interface CoachingMethod {
27 id: number
28 name: string
29 summary: string
30 version: number
31 /** Where a runner reaches this coach directly. Always absolute https. */
32 contactUrl: string | null
33}
34
35export interface CoachingOffer {
36 label: string
37 method: CoachingMethod
38 coach: string
39 product: CoachingProduct
40}
41
42export interface CoachingBrand {
43 slug: string
44 name: string
45 headline: string
46 description: string
47 /** 'en' or 'de'. HQ publishes no other locale. */
48 locale: 'en' | 'de'
49 /** HQ module keys the brand has enabled. */
50 modules: string[]
51 offers: CoachingOffer[]
52 supportUrl: string | null
53 privacyUrl: string | null
54 termsUrl: string | null
55}
56
57function text(value: unknown, max = 400): string | null {
58 return typeof value === 'string' && value.trim().length > 0 && value.length <= max ? value.trim() : null
59}
60
61function count(value: unknown): number | null {
62 return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null
63}
64
65/**
66 * An absolute http(s) URL, or null. A relative or javascript: URL from a
67 * remote payload has no business reaching an href on this site.
68 */
69function link(value: unknown): string | null {
70 if (typeof value !== 'string') return null
71 try {
72 const url = new URL(value)
73 return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null
74 }
75 catch {
76 return null
77 }
78}
79
80/** ISO 4217, upper case. Anything else and the price is not printed. */
81function currency(value: unknown): string | null {
82 return typeof value === 'string' && /^[A-Z]{3}$/.test(value) ? value : null
83}
84
85function parseOffer(raw: unknown): CoachingOffer | null {
86 if (!raw || typeof raw !== 'object') return null
87 const row = raw as Record<string, any>
88
89 const label = text(row.label, 80)
90 const methodName = text(row.method?.name, 120)
91 const methodSummary = text(row.method?.summary, 600)
92 const methodId = count(row.method?.id)
93 const version = count(row.method?.version)
94 const productId = count(row.product?.id)
95 const productName = text(row.product?.name, 120)
96 const priceCents = count(row.product?.price_cents)
97 const unit = currency(row.product?.currency)
98 const interval = text(row.product?.billing_interval, 16)
99
100 if (label === null || methodName === null || methodSummary === null) return null
101 if (methodId === null || version === null || productId === null) return null
102 if (productName === null || priceCents === null || unit === null || interval === null) return null
103
104 return {
105 label,
106 coach: text(row.coach?.name, 120) ?? 'The coach',
107 method: { id: methodId, name: methodName, summary: methodSummary, version, contactUrl: link(row.method?.contact_url) },
108 product: {
109 id: productId,
110 name: productName,
111 description: text(row.product?.description, 600) ?? '',
112 priceCents,
113 currency: unit,
114 interval,
115 trialDays: count(row.product?.trial_days) ?? 0,
116 },
117 }
118}
119
120/**
121 * The published brand, or null.
122 *
123 * Null covers every failure the same way on purpose: an unpublished brand, an
124 * HQ that is down, and a payload this site does not recognise all mean the
125 * same thing to a visitor, which is that there is nothing to buy yet.
126 */
127export function parseBrand(raw: unknown, expectedSlug: string): CoachingBrand | null {
128 if (!raw || typeof raw !== 'object') return null
129 const body = raw as Record<string, any>
130 const settings = body.settings
131 if (!settings || typeof settings !== 'object') return null
132
133 // A payload for another brand is a misconfiguration, not content.
134 if (body.slug !== expectedSlug) return null
135
136 const name = text(settings.name, 120)
137 const headline = text(settings.headline, 200)
138 if (name === null || headline === null) return null
139
140 const modules = Array.isArray(settings.modules)
141 ? settings.modules.filter((value: unknown): value is string => typeof value === 'string' && /^[a-z]+$/.test(value))
142 : []
143
144 const offers = Array.isArray(body.offers)
145 ? body.offers.map(parseOffer).filter((offer: CoachingOffer | null): offer is CoachingOffer => offer !== null)
146 : []
147
148 return {
149 slug: expectedSlug,
150 name,
151 headline,
152 description: text(settings.description, 600) ?? '',
153 locale: settings.locale === 'de' ? 'de' : 'en',
154 modules,
155 offers,
156 supportUrl: link(settings.support_url),
157 privacyUrl: link(settings.privacy_url),
158 termsUrl: link(settings.terms_url),
159 }
160}
161
162/** The site renders in en-GB; a German brand prints German money. */
163const LOCALES: Record<'en' | 'de', string> = { en: 'en-GB', de: 'de-DE' }
164
165/** "€19.99 a month", from the coach's own product. This site sets no price. */
166export function formatPrice(product: CoachingProduct, locale: 'en' | 'de' = 'en'): string {
167 const amount = new Intl.NumberFormat(LOCALES[locale], {
168 style: 'currency',
169 currency: product.currency,
170 // Whole prices read better without the trailing zeroes the race uses nowhere else.
171 minimumFractionDigits: product.priceCents % 100 === 0 ? 0 : 2,
172 }).format(product.priceCents / 100)
173
174 const every = product.interval === 'month' ? 'a month' : product.interval === 'year' ? 'a year' : `per ${product.interval}`
175 return `${amount} ${every}`
176}
177
178/** "14 days free first", or nothing at all when the coach set no trial. */
179export function formatTrial(product: CoachingProduct): string {
180 if (product.trialDays <= 0) return ''
181 return product.trialDays === 1 ? '1 day free first' : `${product.trialDays} days free first`
182}
resources/functions/coaching-source.tsadded+152-0
Changes to resources/functions/coaching-source.ts
@@ -0,0 +1,152 @@
1import type { CoachingBrand } from './coaching-brand'
2import coaching from '../../config/coaching'
3import type { CoachingPageMeta } from '../data/coaching'
4import { coachingModule, coachingPage } from '../data/coaching'
5import { parseBrand } from './coaching-brand'
6
7/**
8 * Fetching the published brand, once, for a whole render.
9 *
10 * Nine pages on this site want the same payload, and a page render must never
11 * wait on another service to finish: a slow HQ would otherwise hold the whole
12 * coaching section open, and a down HQ would hold it open until the proxy gave
13 * up. So the call is bounded, it never throws, and the answer is remembered
14 * for a short while.
15 *
16 * A miss is remembered too, for a shorter time. An outage that is not cached
17 * turns one visitor per render into one upstream request per render, which is
18 * precisely the load an outage cannot absorb.
19 */
20
21/** Long enough that a visitor moving between modules makes one request. */
22const HIT_TTL_MS = 60_000
23/** Short enough that a brand going live shows up within a minute. */
24const MISS_TTL_MS = 10_000
25/** A render blocked on another service is a broken page, not a slow one. */
26const TIMEOUT_MS = 2500
27
28interface Entry {
29 brand: CoachingBrand | null
30 until: number
31}
32
33const cache = new Map<string, Entry>()
34/** One in-flight request per brand, so a cold cache does not stampede. */
35const inFlight = new Map<string, Promise<CoachingBrand | null>>()
36
37/**
38 * The configured HQ origin, checked.
39 *
40 * An HQ reached over plain HTTP would carry a runner's training session in
41 * clear, and a misconfigured HQ_ORIGIN is a deployment mistake that should
42 * stop the build rather than quietly ship. Loopback is exempt so the local
43 * preview stack works.
44 */
45export function coachingOrigin(value = coaching.origin): string {
46 const url = new URL(value)
47 if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname))
48 throw new Error('HQ_ORIGIN must use HTTPS')
49 return url.origin
50}
51
52/** The configured brand slug, checked against HQ's own slug shape. */
53export function coachingBrand(value = coaching.brand): string {
54 if (!/^[a-z0-9][a-z0-9-]{0,139}$/.test(value))
55 throw new Error('HQ_BRAND_SLUG must be an organization slug')
56 return value
57}
58
59/**
60 * A section name HQ will accept, or ''. The registry is the list: a section
61 * this site does not serve has no business being passed through to HQ, and
62 * keeping a second copy of the eight names here is how the two drift.
63 */
64export function coachingSection(value: unknown): string {
65 return typeof value === 'string' && coachingModule(value) ? value : ''
66}
67
68/** The endpoint, built from config rather than from anything a visitor sends. */
69export function brandEndpoint(origin = coaching.origin, slug = coaching.brand): string {
70 return new URL(`/api/brands/${encodeURIComponent(slug)}`, origin).toString()
71}
72
73/** Where the portal lives, for a link that works when the frame does not. */
74export function portalUrl(section: unknown = '', origin = coaching.origin, slug = coaching.brand): string {
75 const url = new URL(`/brands/${encodeURIComponent(coachingBrand(slug))}`, coachingOrigin(origin))
76 const named = coachingSection(section)
77 if (named) url.searchParams.set('section', named)
78 return url.toString()
79}
80
81/** The host the panel is loaded from, for a page that says so out loud. */
82export function portalHost(origin = coaching.origin): string {
83 return new URL(coachingOrigin(origin)).host
84}
85
86/** The custom element HQ generates for embedding its portal. */
87export function portalElementUrl(origin = coaching.origin): string {
88 return `${coachingOrigin(origin)}/assets/elements/hq-coaching.js`
89}
90
91async function request(slug: string, origin: string): Promise<CoachingBrand | null> {
92 try {
93 const response = await fetch(brandEndpoint(origin, slug), {
94 headers: { accept: 'application/json' },
95 signal: AbortSignal.timeout(TIMEOUT_MS),
96 })
97 // 404 is the correct answer for a brand that is not published yet.
98 if (!response.ok) return null
99 return parseBrand(await response.json(), slug)
100 }
101 catch {
102 // A timeout, a DNS failure and malformed JSON are all "nothing to show".
103 return null
104 }
105}
106
107/**
108 * The published brand, or null. Never throws, never waits more than a moment,
109 * and never makes the same call twice in a row.
110 */
111export async function loadBrand(slug = coaching.brand, origin = coaching.origin): Promise<CoachingBrand | null> {
112 const key = `${origin}|${slug}`
113 const cached = cache.get(key)
114 if (cached && cached.until > Date.now()) return cached.brand
115
116 const existing = inFlight.get(key)
117 if (existing) return existing
118
119 const pending = request(slug, origin)
120 .then((brand) => {
121 cache.set(key, { brand, until: Date.now() + (brand ? HIT_TTL_MS : MISS_TTL_MS) })
122 return brand
123 })
124 .finally(() => inFlight.delete(key))
125
126 inFlight.set(key, pending)
127 return pending
128}
129
130/** For tests, and for a dev server that should not hold a stale brand. */
131export function forgetBrand(): void {
132 cache.clear()
133 inFlight.clear()
134}
135
136/**
137 * Head metadata for a module route, with a preconnect when the page is going
138 * to load HQ's element.
139 *
140 * The browser opens DNS, TCP and TLS to hq.training before the parser reaches
141 * the module script in the body, which is the one request this page's content
142 * waits on. It is only promised when the brand publishes this module: a
143 * preconnect to an origin the page never calls is a socket held open for
144 * nothing, and with no brand published these pages frame nothing at all.
145 */
146export async function coachingPageMeta(slug: string): Promise<CoachingPageMeta> {
147 const page = coachingPage(slug)
148 const brand = await loadBrand()
149 if (!brand?.modules.includes(slug))
150 return page
151 return { ...page, preconnect: [coachingOrigin()] }
152}
resources/layouts/LSU.stxmodified+2-2
Changes to resources/layouts/LSU.stx
@@ -4,9 +4,9 @@
44<body class="lsu-scope-site">
55 <a class="lsu-skip" href="#main">Skip to content</a>
66 <div class="lsu-scope-site page">
7 <SiteNav />
7 <SiteNav current="" />
88 <main id="main" tabindex="-1">@slot('content')</main>
9 <SiteFooter />
9 <SiteFooter current="" />
1010 </div>
1111</body>
1212</html>
resources/views/contact.stx resources/partials/contact-body.stxcopied+6-8
Changes to resources/partials/contact-body.stx
@@ -1,10 +1,9 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { productionPages } from '~/resources/data/production-pages'
5const currentPage = productionPages["/contact"]
6const canonicalPath = "/contact"
7</script>
1{{-- The questions page, shared by /contact and /faq.
2
3 The two views were byte-identical 238-line files. Both URLs are published
4 and both are linked from elsewhere, so neither can simply go; what could
5 go is the second copy of the answers, which is the thing that would have
6 drifted the first time somebody edited one of them. --}}
87<section class="lsu-scope-contact phero">
98<div class="lsu-scope-contact phero-copy">
109<div class="kicker lsu-scope-contact">We reply within 24 hours</div>
@@ -235,4 +234,3 @@ const canonicalPath = "/contact"
235234<a href="mailto:mail@lastsoulultra.com" class="btn btn-lg lsu-scope-contact">Email the team</a>
236235</div>
237236</section>
238@endsection
resources/partials/head.stxmodified+23-1
Changes to resources/partials/head.stx
@@ -1,7 +1,16 @@
11<script server>
2import { CARD_HEIGHT, CARD_WIDTH, shareCard } from '../data/share'
23import { absolute } from '../data/site'
4import { structuredDataJson } from '../data/structured'
35const metaPage = typeof currentPage !== 'undefined' && currentPage ? currentPage : { title: 'Last Soul Ultra', description: 'The backyard ultra World Series. There is no finish line.', styles: ['/assets/styles/production/base.css'] }
46const pagePath = typeof canonicalPath !== 'undefined' ? canonicalPath : '/'
7const card = shareCard(pagePath)
8// Not on a noindex page: nothing indexes it, so the graph is bytes a reader
9// pays for and no crawler reads.
10const graph = metaPage.noindex ? '' : structuredDataJson(pagePath, metaPage.title)
11// Origins the page says it will open a connection to, warmed before the
12// parser reaches the element that needs them.
13const warm = Array.isArray(metaPage.preconnect) ? metaPage.preconnect : []
514</script>
615<meta charset="utf-8">
716<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -17,13 +26,26 @@ const pagePath = typeof canonicalPath !== 'undefined' ? canonicalPath : '/'
1726<meta property="og:title" content="">
1827<meta property="og:description" content="">
1928<meta property="og:url" content="">
20<meta property="og:image" content="">
29<meta property="og:image" content="">
30<meta property="og:image:type" content="image/jpeg">
31<meta property="og:image:width" content="1200">
32<meta property="og:image:height" content="630">
33<meta property="og:image:alt" content="">
2134<meta name="twitter:card" content="summary_large_image">
35<meta name="twitter:image" content="">
36<meta name="twitter:image:alt" content="">
2237<link rel="icon" href="/assets/lsu/images/favicon.png" type="image/png">
2338<link rel="apple-touch-icon" href="/assets/lsu/images/apple-touch-icon.png">
39@foreach(warm as origin)
40<link rel="dns-prefetch" href="">
41<link rel="preconnect" href="" crossorigin>
42@endforeach
2443<link rel="preload" href="/assets/lsu/fonts/anton-400-latin.woff2" as="font" type="font/woff2" crossorigin>
2544<link rel="preload" href="/assets/lsu/fonts/barlow-400-latin.woff2" as="font" type="font/woff2" crossorigin>
2645@foreach(metaPage.styles as sheet)
2746<link rel="stylesheet" href="">
2847@endforeach
2948<link rel="stylesheet" href="/assets/styles/stx-site.css">
49@if(graph)
50<script type="application/ld+json">{!! graph !!}</script>
51@endif
resources/views/404.stxmodified+28-26
Changes to resources/views/404.stx
@@ -1,31 +1,33 @@
1@extends('layouts/LSU')
2@section('content')
13<script server>
2import { liveRaceState } from '../data/live'
3import { race } from '../data/race'
4import { liveRaceState } from '~/resources/data/live'
45
5const pageTitle = 'not found - Last Soul Ultra'
6const pageDescription = 'That page is not here.'
6/* The head partial reads `currentPage`. This page used to declare pageTitle,
7 pageDescription and noindex as loose names nothing looked at, so a 404
8 served the site's default title and description and invited indexing. */
9const currentPage = {
10 title: 'not found - Last Soul Ultra',
11 description: 'That page is not here.',
12 styles: ['/assets/styles/production/base.css', '/assets/styles/production/not-found.css'],
13 noindex: true,
14}
715const canonicalPath = '/404'
8const noindex = true
916const state = await liveRaceState()
1017</script>
11<!DOCTYPE html>
12<html lang="en">
13 <head>@include('head')</head>
14 <body>
15 <SiteNav current="/404" />
16 <main id="main" class="mx-auto px-5 py-20 lg:px-8 lg:py-32 max-w-[1100px]">
17 <p class="faint label">404</p>
18 <h1 class="mt-4 h-page max-w-[14ch] display ink">you missed the cut-off.</h1>
19 <p class="mt-6 max-w-[46ch] leading-relaxed text-base muted">That page is not here. The race, thankfully, still is.</p>
20 <div class="flex flex-wrap gap-3 mt-9">
21 <a class="btn btn-accent" href="/">back to the start</a>
22 @if(state !== 'upcoming')
23 <a class="btn btn-outline" href="/results">{{ state === 'finished' ? 'the result' : 'live results' }}</a>
24 @else
25 <a class="btn btn-outline" href="/format">the format</a>
26 @endif
27 </div>
28 </main>
29 <SiteFooter />
30 </body>
31</html>
18<section class="phero">
19 <div class="phero-copy">
20 <div class="kicker">404</div>
21 <h1>You missed the cut-off.</h1>
22 <p>That page is not here. The race, thankfully, still is.</p>
23 <div class="not-found-actions">
24 <a class="btn" href="/">Back to the start</a>
25 @if(state !== 'upcoming')
26 <a class="btn-outline" href="/results">{{ state === 'finished' ? 'The result' : 'Live results' }}</a>
27 @else
28 <a class="btn-outline" href="/format">The format</a>
29 @endif
30 </div>
31 </div>
32</section>
33@endsection
resources/views/coaching.stxmodified+31-4
Changes to resources/views/coaching.stx
@@ -1,10 +1,22 @@
11@extends('layouts/LSU')
22@section('content')
33<script server>
4import { coachingHubStyles } from '~/resources/data/coaching'
5import { coachingOrigin, loadBrand } from '~/resources/functions/coaching-source'
6
47// The home, race-entry and next-loop sheets are each suffixed with their own
5// page scope, so none of their rules could ever match here. The coaching styles
6// live in the site-wide stx-site.css that the head always loads.
7const currentPage = { title: 'Coaching - Last Soul Ultra', description: 'Send a voice message after your run and an AI rebuilds next week around what you said, inside a coach\'s method.', styles: ['/assets/styles/production/base.css'] }
8// page scope, so none of their rules could ever match here. This page's own
9// rules are coaching-page.css, loaded by this route alone; the storefront and
10// module index it carries are styled by the surface sheet, which the eight
11// module routes load too.
12/* The embed, and the connection warmed for it, only when the brand has
13 published something. The branded subscription return lands on this page
14 with its receipt hint and the element is what passes that back to HQ - but
15 an unpublished brand has no subscriptions, so there is no receipt to carry
16 and no reason to load a remote script to frame a 404. */
17const hubBrand = await loadBrand()
18const hubLive = (hubBrand?.modules.length ?? 0) > 0
19const currentPage = { title: 'Coaching - Last Soul Ultra', description: 'Send a voice message after your run and an AI rebuilds next week around what you said, inside a coach\'s method.', styles: coachingHubStyles, ...(hubLive ? { preconnect: [coachingOrigin()] } : {}) }
820const canonicalPath = '/coaching'
921</script>
1022<div class="coaching-page">
@@ -14,7 +26,11 @@ const canonicalPath = '/coaching'
1426 <span class="kicker">Last Soul Ultra / Coaching</span>
1527 <h1>Say how it felt.<br><span class="accent">The plan changes.</span></h1>
1628 <p>Send a voice message after your run. An AI rebuilds next week around what you said, inside a coach's method.</p>
29 @if(hubLive)
1730 <a class="btn btn-lg" href="#training">Open the portal</a>
31 @else
32 <a class="btn btn-lg" href="#training">See what is coming</a>
33 @endif
1834 </div>
1935 <div class="coaching-hero-shot">
2036 <img src="/assets/lsu/images/recap-06-810.webp" srcset="/assets/lsu/images/recap-06-540.webp 540w, /assets/lsu/images/recap-06-810.webp 810w, /assets/lsu/images/recap-06-1080.webp 1080w" sizes="(min-width: 900px) 42vw, 100vw" alt="A runner in a white sun hat and neck cover looks down at his watch." width="1080" height="1440" loading="eager" fetchpriority="high" decoding="async">
@@ -84,10 +100,21 @@ const canonicalPath = '/coaching'
84100
85101 <section id="training">
86102 <div class="coaching-head">
103 @if(hubLive)
104 <h2 class="h2-sm">What is open.<br><span class="accent">And where it runs.</span></h2>
105 <p>The tiers a coach has published, then the eight panels your training lives in once you are in one.</p>
106 @else
87107 <h2 class="h2-sm">Not open yet.<br><span class="accent">It opens here.</span></h2>
88 <p>No method has been published and no price has been set. When the first tier opens, it appears in this panel.</p>
108 <p>When a coach publishes a method, its tiers appear on this page, and the panels your training runs in open below them.</p>
109 @endif
110 </div>
111 <div class="coaching-surface">
112 <CoachingOffers />
113 <CoachingIndex />
89114 </div>
115 @if(hubLive)
90116 <div class="coaching-portal"><CoachingPortal /></div>
117 @endif
91118 </section>
92119
93120 <section id="coaches" class="coaching-coaches">
resources/views/coaching/account.stxadded+10-0
Changes to resources/views/coaching/account.stx
@@ -0,0 +1,10 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { modulePath } from '~/resources/data/coaching'
5import { coachingPageMeta } from '~/resources/functions/coaching-source'
6const currentPage = await coachingPageMeta('account')
7const canonicalPath = modulePath('account')
8</script>
9<CoachingModule slug="account" />
10@endsection
resources/views/coaching/calendar.stxadded+10-0
Changes to resources/views/coaching/calendar.stx
@@ -0,0 +1,10 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { modulePath } from '~/resources/data/coaching'
5import { coachingPageMeta } from '~/resources/functions/coaching-source'
6const currentPage = await coachingPageMeta('calendar')
7const canonicalPath = modulePath('calendar')
8</script>
9<CoachingModule slug="calendar" />
10@endsection
resources/views/coaching/feedback.stxadded+10-0
Changes to resources/views/coaching/feedback.stx
@@ -0,0 +1,10 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { modulePath } from '~/resources/data/coaching'
5import { coachingPageMeta } from '~/resources/functions/coaching-source'
6const currentPage = await coachingPageMeta('feedback')
7const canonicalPath = modulePath('feedback')
8</script>
9<CoachingModule slug="feedback" />
10@endsection
resources/views/coaching/health.stxadded+10-0
Changes to resources/views/coaching/health.stx
@@ -0,0 +1,10 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { modulePath } from '~/resources/data/coaching'
5import { coachingPageMeta } from '~/resources/functions/coaching-source'
6const currentPage = await coachingPageMeta('health')
7const canonicalPath = modulePath('health')
8</script>
9<CoachingModule slug="health" />
10@endsection
resources/views/coaching/library.stxadded+10-0
Changes to resources/views/coaching/library.stx
@@ -0,0 +1,10 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { modulePath } from '~/resources/data/coaching'
5import { coachingPageMeta } from '~/resources/functions/coaching-source'
6const currentPage = await coachingPageMeta('library')
7const canonicalPath = modulePath('library')
8</script>
9<CoachingModule slug="library" />
10@endsection
resources/views/coaching/messages.stxadded+10-0
Changes to resources/views/coaching/messages.stx
@@ -0,0 +1,10 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { modulePath } from '~/resources/data/coaching'
5import { coachingPageMeta } from '~/resources/functions/coaching-source'
6const currentPage = await coachingPageMeta('messages')
7const canonicalPath = modulePath('messages')
8</script>
9<CoachingModule slug="messages" />
10@endsection
resources/views/coaching/plans.stxadded+10-0
Changes to resources/views/coaching/plans.stx
@@ -0,0 +1,10 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { modulePath } from '~/resources/data/coaching'
5import { coachingPageMeta } from '~/resources/functions/coaching-source'
6const currentPage = await coachingPageMeta('plans')
7const canonicalPath = modulePath('plans')
8</script>
9<CoachingModule slug="plans" />
10@endsection
resources/views/coaching/progress.stxadded+10-0
Changes to resources/views/coaching/progress.stx
@@ -0,0 +1,10 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { modulePath } from '~/resources/data/coaching'
5import { coachingPageMeta } from '~/resources/functions/coaching-source'
6const currentPage = await coachingPageMeta('progress')
7const canonicalPath = modulePath('progress')
8</script>
9<CoachingModule slug="progress" />
10@endsection
resources/views/contact.stxmodified+1-230
Changes to resources/views/contact.stx
@@ -5,234 +5,5 @@ import { productionPages } from '~/resources/data/production-pages'
55const currentPage = productionPages["/contact"]
66const canonicalPath = "/contact"
77</script>
8<section class="lsu-scope-contact phero">
9<div class="lsu-scope-contact phero-copy">
10<div class="kicker lsu-scope-contact">We reply within 24 hours</div>
11<h1 class="lsu-scope-contact">Contact &amp; FAQ</h1>
12</div>
13</section>
14<section class="contact-section lsu-scope-contact section">
15<div class="contact lsu-scope-contact shell">
16<div class="lsu-scope-contact mail">
17<div class="kicker-sm lsu-scope-contact">Email</div>
18<a href="mailto:mail@lastsoulultra.com" class="lsu-scope-contact mail-link"> mail@lastsoulultra.com </a>
19<p class="lsu-scope-contact mail-body">Entries, media and general questions. Up to 24 hours for a reply.</p>
20<button type="button" class="btn btn-outline lsu-scope-contact mail-copy" data-copy="mail@lastsoulultra.com" data-copied="Copied" aria-live="polite" hidden> Copy address </button>
21</div>
22<div class="follow lsu-scope-contact">
23<div class="kicker-sm lsu-scope-contact">Follow the race</div>
24<ul class="lsu-scope-contact">
25<li class="lsu-scope-contact">
26<a href="https://www.instagram.com/lastsoulultra" class="follow-row lsu-scope-contact">
27<span class="follow-text lsu-scope-contact">
28<span class="follow-name lsu-scope-contact"> Instagram · @lastsoulultra </span>
29<span class="follow-body lsu-scope-contact">Updates, stories and behind the scenes during the race.</span>
30</span>
31<span class="follow-arrow lsu-scope-contact" aria-hidden="true">
32↗
33</span>
34</a>
35</li>
36<li class="lsu-scope-contact">
37<a href="https://www.instagram.com/lastsoulultra" class="follow-row lsu-scope-contact">
38<span class="follow-text lsu-scope-contact">
39<span class="follow-name lsu-scope-contact"> Live coverage · Coming soon · @lastsoulultra </span>
40<span class="follow-body lsu-scope-contact">Live coverage is coming soon. Follow @lastsoulultra for updates.</span>
41</span>
42<span class="follow-arrow lsu-scope-contact" aria-hidden="true">
43↗
44</span>
45</a>
46</li>
47<li class="lsu-scope-contact">
48<a href="https://linktr.ee/lastsoulultra" class="follow-row lsu-scope-contact">
49<span class="follow-text lsu-scope-contact">
50<span class="follow-name lsu-scope-contact"> Everything else · linktr.ee </span>
51<span class="follow-body lsu-scope-contact">Streams, results service and partner channels.</span>
52</span>
53<span class="follow-arrow lsu-scope-contact" aria-hidden="true">
54↗
55</span>
56</a>
57</li>
58</ul>
59</div>
60</div>
61</section>
62<section id="faq" class="faq-section lsu-scope-contact">
63<div class="faq lsu-scope-contact shell">
64<nav class="faq-rail lsu-scope-contact" aria-label="Questions by topic">
65<ul class="lsu-scope-contact">
66<li class="lsu-scope-contact">
67<a href="#faq-1" data-rail="1" class="lsu-scope-contact">
68<span class="lsu-scope-contact">Athletes</span>
69<small class="lsu-scope-contact">8</small>
70</a>
71</li>
72<li class="lsu-scope-contact">
73<a href="#faq-2" data-rail="2" class="lsu-scope-contact">
74<span class="lsu-scope-contact">Athlete crew</span>
75<small class="lsu-scope-contact">3</small>
76</a>
77</li>
78<li class="lsu-scope-contact">
79<a href="#faq-3" data-rail="3" class="lsu-scope-contact">
80<span class="lsu-scope-contact">Spectators</span>
81<small class="lsu-scope-contact">4</small>
82</a>
83</li>
84</ul>
85</nav>
86<div class="faq-main lsu-scope-contact">
87<div id="faq-1" class="faq-group lsu-scope-contact" data-group="1">
88<div class="faq-head lsu-scope-contact">
89<h2 class="h2-sm lsu-scope-contact">Athletes</h2>
90<span class="faq-count lsu-scope-contact"> 8 questions </span>
91</div>
92<details class="faq-item lsu-scope-contact" name="lsu-faq">
93<summary class="lsu-scope-contact">
94<span class="faq-q lsu-scope-contact">How does the Last Soul Ultra work?</span>
95<span class="faq-mark lsu-scope-contact" aria-hidden="true">
96</span>
97</summary>
98<div class="faq-a lsu-scope-contact">It follows the backyard ultra format. Every hour, all runners start together on a 6.706 km loop. Anyone who does not finish the loop and return to the corral before the next hour is eliminated. The winner must complete one more loop than every other runner, within the hour. If no runner can do that, there is no winner. All other runners are recorded as DNF.</div>
99</details>
100<details class="faq-item lsu-scope-contact" name="lsu-faq">
101<summary class="lsu-scope-contact">
102<span class="faq-q lsu-scope-contact">When does the race start?</span>
103<span class="faq-mark lsu-scope-contact" aria-hidden="true">
104</span>
105</summary>
106<div class="faq-a lsu-scope-contact">Each race date and location is published on the World Series page once confirmed.</div>
107</details>
108<details class="faq-item lsu-scope-contact" name="lsu-faq">
109<summary class="lsu-scope-contact">
110<span class="faq-q lsu-scope-contact">Is there electricity, water or food?</span>
111<span class="faq-mark lsu-scope-contact" aria-hidden="true">
112</span>
113</summary>
114<div class="faq-a lsu-scope-contact">There is no electrical hookup. Water is provided in containers for each athlete. No organiser-provided race support is planned. This may change, but do not plan on it. Bring your own food, equipment and supplies. Your nutrition, recovery and race setup are your responsibility.</div>
115</details>
116<details class="faq-item lsu-scope-contact" name="lsu-faq">
117<summary class="lsu-scope-contact">
118<span class="faq-q lsu-scope-contact">What are the participation requirements?</span>
119<span class="faq-mark lsu-scope-contact" aria-hidden="true">
120</span>
121</summary>
122<div class="faq-a lsu-scope-contact">Before the start, every athlete signs a waiver. The document, including all terms and conditions, is made available ahead of time.</div>
123</details>
124<details class="faq-item lsu-scope-contact" name="lsu-faq">
125<summary class="lsu-scope-contact">
126<span class="faq-q lsu-scope-contact">Is there a time limit or maximum race duration?</span>
127<span class="faq-mark lsu-scope-contact" aria-hidden="true">
128</span>
129</summary>
130<div class="faq-a lsu-scope-contact">There is no overall time limit or fixed finish distance, but every loop has a strict one-hour limit. Miss a start or fail to finish within the hour, and your race is over. The race goes on until only one person is able to complete a loop.</div>
131</details>
132<details class="faq-item lsu-scope-contact" name="lsu-faq">
133<summary class="lsu-scope-contact">
134<span class="faq-q lsu-scope-contact">How is base camp organised?</span>
135<span class="faq-mark lsu-scope-contact" aria-hidden="true">
136</span>
137</summary>
138<div class="faq-a lsu-scope-contact">Each participant is allocated a 3 × 3 metre space, marked in advance and assigned first come, first served. As runners drop out, remaining camps may be moved closer to the start line.</div>
139</details>
140<details class="faq-item lsu-scope-contact" name="lsu-faq">
141<summary class="lsu-scope-contact">
142<span class="faq-q lsu-scope-contact">What mandatory gear is required?</span>
143<span class="faq-mark lsu-scope-contact" aria-hidden="true">
144</span>
145</summary>
146<div class="faq-a lsu-scope-contact">A working headlamp must be worn when running in twilight or darkness. Participation without this mandatory gear is not allowed.</div>
147</details>
148<details class="faq-item lsu-scope-contact" name="lsu-faq">
149<summary class="lsu-scope-contact">
150<span class="faq-q lsu-scope-contact">Who is participating?</span>
151<span class="faq-mark lsu-scope-contact" aria-hidden="true">
152</span>
153</summary>
154<div class="faq-a lsu-scope-contact">The field is made up of international ultrarunners, athletes, well-known sports personalities, content creators and athletes selected through entry requests.</div>
155</details>
156</div>
157<div id="faq-2" class="faq-group lsu-scope-contact" data-group="2">
158<div class="faq-head lsu-scope-contact">
159<h2 class="h2-sm lsu-scope-contact">Athlete crew</h2>
160<span class="faq-count lsu-scope-contact"> 3 questions </span>
161</div>
162<details class="faq-item lsu-scope-contact" name="lsu-faq">
163<summary class="lsu-scope-contact">
164<span class="faq-q lsu-scope-contact">How much support can an athlete receive?</span>
165<span class="faq-mark lsu-scope-contact" aria-hidden="true">
166</span>
167</summary>
168<div class="faq-a lsu-scope-contact">In base camp, crew can fully assist their athlete with setup, nutrition and preparation for the next loop. Once the athlete is on the course, any kind of support is prohibited. Handing over food or gear during a loop results in immediate disqualification.</div>
169</details>
170<details class="faq-item lsu-scope-contact" name="lsu-faq">
171<summary class="lsu-scope-contact">
172<span class="faq-q lsu-scope-contact">Can crew members enter the track?</span>
173<span class="faq-mark lsu-scope-contact" aria-hidden="true">
174</span>
175</summary>
176<div class="faq-a lsu-scope-contact">No. Crew are not allowed on the track. If the rule is broken, the athlete is automatically disqualified.</div>
177</details>
178<details class="faq-item lsu-scope-contact" name="lsu-faq">
179<summary class="lsu-scope-contact">
180<span class="faq-q lsu-scope-contact">How many crew members are allowed?</span>
181<span class="faq-mark lsu-scope-contact" aria-hidden="true">
182</span>
183</summary>
184<div class="faq-a lsu-scope-contact">You may register four crew members in total. Up to three may be on site at the same time; the fourth may rotate in. Having a crew is optional.</div>
185</details>
186</div>
187<div id="faq-3" class="faq-group lsu-scope-contact" data-group="3">
188<div class="faq-head lsu-scope-contact">
189<h2 class="h2-sm lsu-scope-contact">Spectators</h2>
190<span class="faq-count lsu-scope-contact"> 4 questions </span>
191</div>
192<details class="faq-item lsu-scope-contact" name="lsu-faq">
193<summary class="lsu-scope-contact">
194<span class="faq-q lsu-scope-contact">Can people watch the event in person?</span>
195<span class="faq-mark lsu-scope-contact" aria-hidden="true">
196</span>
197</summary>
198<div class="faq-a lsu-scope-contact">No. On-site spectators are not allowed, which keeps the race safe and the operation smooth.</div>
199</details>
200<details class="faq-item lsu-scope-contact" name="lsu-faq">
201<summary class="lsu-scope-contact">
202<span class="faq-q lsu-scope-contact">Where can I follow the race?</span>
203<span class="faq-mark lsu-scope-contact" aria-hidden="true">
204</span>
205</summary>
206<div class="faq-a lsu-scope-contact">Highlights, updates and information go out on @lastsoulultra, as well as the channels of Kim Gottwald and rappid. Live coverage is coming soon — follow @lastsoulultra for updates.</div>
207</details>
208<details class="faq-item lsu-scope-contact" name="lsu-faq">
209<summary class="lsu-scope-contact">
210<span class="faq-q lsu-scope-contact">Will the race be streamed live?</span>
211<span class="faq-mark lsu-scope-contact" aria-hidden="true">
212</span>
213</summary>
214<div class="faq-a lsu-scope-contact">Live coverage is coming soon. Follow @lastsoulultra for updates.</div>
215</details>
216<details class="faq-item lsu-scope-contact" name="lsu-faq">
217<summary class="lsu-scope-contact">
218<span class="faq-q lsu-scope-contact">Why is the location kept secret?</span>
219<span class="faq-mark lsu-scope-contact" aria-hidden="true">
220</span>
221</summary>
222<div class="faq-a lsu-scope-contact">The venue is withheld to guarantee the safety and smooth running of the event.</div>
223</details>
224</div>
225</div>
226</div>
227</section>
228<section class="cta lsu-scope-contact">
229<div class="cta-bg lsu-scope-contact" style="background-image:url(&#x27;/assets/lsu/images/rappid-47.webp&#x27;);background-position:center 35%" data-px="0.36">
230</div>
231<div class="cta-scrim lsu-scope-contact">
232</div>
233<div class="cta-copy lsu-scope-contact" data-px="-0.06">
234<h2 class="h2 lsu-scope-contact">Still have<br class="lsu-scope-contact">questions?</h2>
235<a href="mailto:mail@lastsoulultra.com" class="btn btn-lg lsu-scope-contact">Email the team</a>
236</div>
237</section>
8@include('contact-body')
2389@endsection
resources/views/faq.stxmodified+1-230
Changes to resources/views/faq.stx
@@ -5,234 +5,5 @@ import { productionPages } from '~/resources/data/production-pages'
55const currentPage = productionPages["/contact"]
66const canonicalPath = "/contact"
77</script>
8<section class="lsu-scope-contact phero">
9<div class="lsu-scope-contact phero-copy">
10<div class="kicker lsu-scope-contact">We reply within 24 hours</div>
11<h1 class="lsu-scope-contact">Contact &amp; FAQ</h1>
12</div>
13</section>
14<section class="contact-section lsu-scope-contact section">
15<div class="contact lsu-scope-contact shell">
16<div class="lsu-scope-contact mail">
17<div class="kicker-sm lsu-scope-contact">Email</div>
18<a href="mailto:mail@lastsoulultra.com" class="lsu-scope-contact mail-link"> mail@lastsoulultra.com </a>
19<p class="lsu-scope-contact mail-body">Entries, media and general questions. Up to 24 hours for a reply.</p>
20<button type="button" class="btn btn-outline lsu-scope-contact mail-copy" data-copy="mail@lastsoulultra.com" data-copied="Copied" aria-live="polite" hidden> Copy address </button>
21</div>
22<div class="follow lsu-scope-contact">
23<div class="kicker-sm lsu-scope-contact">Follow the race</div>
24<ul class="lsu-scope-contact">
25<li class="lsu-scope-contact">
26<a href="https://www.instagram.com/lastsoulultra" class="follow-row lsu-scope-contact">
27<span class="follow-text lsu-scope-contact">
28<span class="follow-name lsu-scope-contact"> Instagram · @lastsoulultra </span>
29<span class="follow-body lsu-scope-contact">Updates, stories and behind the scenes during the race.</span>
30</span>
31<span class="follow-arrow lsu-scope-contact" aria-hidden="true">
32↗
33</span>
34</a>
35</li>
36<li class="lsu-scope-contact">
37<a href="https://www.instagram.com/lastsoulultra" class="follow-row lsu-scope-contact">
38<span class="follow-text lsu-scope-contact">
39<span class="follow-name lsu-scope-contact"> Live coverage · Coming soon · @lastsoulultra </span>
40<span class="follow-body lsu-scope-contact">Live coverage is coming soon. Follow @lastsoulultra for updates.</span>
41</span>
42<span class="follow-arrow lsu-scope-contact" aria-hidden="true">
43↗
44</span>
45</a>
46</li>
47<li class="lsu-scope-contact">
48<a href="https://linktr.ee/lastsoulultra" class="follow-row lsu-scope-contact">
49<span class="follow-text lsu-scope-contact">
50<span class="follow-name lsu-scope-contact"> Everything else · linktr.ee </span>
51<span class="follow-body lsu-scope-contact">Streams, results service and partner channels.</span>
52</span>
53<span class="follow-arrow lsu-scope-contact" aria-hidden="true">
54↗
55</span>
56</a>
57</li>
58</ul>
59</div>
60</div>
61</section>
62<section id="faq" class="faq-section lsu-scope-contact">
63<div class="faq lsu-scope-contact shell">
64<nav class="faq-rail lsu-scope-contact" aria-label="Questions by topic">
65<ul class="lsu-scope-contact">
66<li class="lsu-scope-contact">
67<a href="#faq-1" data-rail="1" class="lsu-scope-contact">
68<span class="lsu-scope-contact">Athletes</span>
69<small class="lsu-scope-contact">8</small>
70</a>
71</li>
72<li class="lsu-scope-contact">
73<a href="#faq-2" data-rail="2" class="lsu-scope-contact">
74<span class="lsu-scope-contact">Athlete crew</span>
75<small class="lsu-scope-contact">3</small>
76</a>
77</li>
78<li class="lsu-scope-contact">
79<a href="#faq-3" data-rail="3" class="lsu-scope-contact">
80<span class="lsu-scope-contact">Spectators</span>
81<small class="lsu-scope-contact">4</small>
82</a>
83</li>
84</ul>
85</nav>
86<div class="faq-main lsu-scope-contact">
87<div id="faq-1" class="faq-group lsu-scope-contact" data-group="1">
88<div class="faq-head lsu-scope-contact">
89<h2 class="h2-sm lsu-scope-contact">Athletes</h2>
90<span class="faq-count lsu-scope-contact"> 8 questions </span>
91</div>
92<details class="faq-item lsu-scope-contact" name="lsu-faq">
93<summary class="lsu-scope-contact">
94<span class="faq-q lsu-scope-contact">How does the Last Soul Ultra work?</span>
95<span class="faq-mark lsu-scope-contact" aria-hidden="true">
96</span>
97</summary>
98<div class="faq-a lsu-scope-contact">It follows the backyard ultra format. Every hour, all runners start together on a 6.706 km loop. Anyone who does not finish the loop and return to the corral before the next hour is eliminated. The winner must complete one more loop than every other runner, within the hour. If no runner can do that, there is no winner. All other runners are recorded as DNF.</div>
99</details>
100<details class="faq-item lsu-scope-contact" name="lsu-faq">
101<summary class="lsu-scope-contact">
102<span class="faq-q lsu-scope-contact">When does the race start?</span>
103<span class="faq-mark lsu-scope-contact" aria-hidden="true">
104</span>
105</summary>
106<div class="faq-a lsu-scope-contact">Each race date and location is published on the World Series page once confirmed.</div>
107</details>
108<details class="faq-item lsu-scope-contact" name="lsu-faq">
109<summary class="lsu-scope-contact">
110<span class="faq-q lsu-scope-contact">Is there electricity, water or food?</span>
111<span class="faq-mark lsu-scope-contact" aria-hidden="true">
112</span>
113</summary>
114<div class="faq-a lsu-scope-contact">There is no electrical hookup. Water is provided in containers for each athlete. No organiser-provided race support is planned. This may change, but do not plan on it. Bring your own food, equipment and supplies. Your nutrition, recovery and race setup are your responsibility.</div>
115</details>
116<details class="faq-item lsu-scope-contact" name="lsu-faq">
117<summary class="lsu-scope-contact">
118<span class="faq-q lsu-scope-contact">What are the participation requirements?</span>
119<span class="faq-mark lsu-scope-contact" aria-hidden="true">
120</span>
121</summary>
122<div class="faq-a lsu-scope-contact">Before the start, every athlete signs a waiver. The document, including all terms and conditions, is made available ahead of time.</div>
123</details>
124<details class="faq-item lsu-scope-contact" name="lsu-faq">
125<summary class="lsu-scope-contact">
126<span class="faq-q lsu-scope-contact">Is there a time limit or maximum race duration?</span>
127<span class="faq-mark lsu-scope-contact" aria-hidden="true">
128</span>
129</summary>
130<div class="faq-a lsu-scope-contact">There is no overall time limit or fixed finish distance, but every loop has a strict one-hour limit. Miss a start or fail to finish within the hour, and your race is over. The race goes on until only one person is able to complete a loop.</div>
131</details>
132<details class="faq-item lsu-scope-contact" name="lsu-faq">
133<summary class="lsu-scope-contact">
134<span class="faq-q lsu-scope-contact">How is base camp organised?</span>
135<span class="faq-mark lsu-scope-contact" aria-hidden="true">
136</span>
137</summary>
138<div class="faq-a lsu-scope-contact">Each participant is allocated a 3 × 3 metre space, marked in advance and assigned first come, first served. As runners drop out, remaining camps may be moved closer to the start line.</div>
139</details>
140<details class="faq-item lsu-scope-contact" name="lsu-faq">
141<summary class="lsu-scope-contact">
142<span class="faq-q lsu-scope-contact">What mandatory gear is required?</span>
143<span class="faq-mark lsu-scope-contact" aria-hidden="true">
144</span>
145</summary>
146<div class="faq-a lsu-scope-contact">A working headlamp must be worn when running in twilight or darkness. Participation without this mandatory gear is not allowed.</div>
147</details>
148<details class="faq-item lsu-scope-contact" name="lsu-faq">
149<summary class="lsu-scope-contact">
150<span class="faq-q lsu-scope-contact">Who is participating?</span>
151<span class="faq-mark lsu-scope-contact" aria-hidden="true">
152</span>
153</summary>
154<div class="faq-a lsu-scope-contact">The field is made up of international ultrarunners, athletes, well-known sports personalities, content creators and athletes selected through entry requests.</div>
155</details>
156</div>
157<div id="faq-2" class="faq-group lsu-scope-contact" data-group="2">
158<div class="faq-head lsu-scope-contact">
159<h2 class="h2-sm lsu-scope-contact">Athlete crew</h2>
160<span class="faq-count lsu-scope-contact"> 3 questions </span>
161</div>
162<details class="faq-item lsu-scope-contact" name="lsu-faq">
163<summary class="lsu-scope-contact">
164<span class="faq-q lsu-scope-contact">How much support can an athlete receive?</span>
165<span class="faq-mark lsu-scope-contact" aria-hidden="true">
166</span>
167</summary>
168<div class="faq-a lsu-scope-contact">In base camp, crew can fully assist their athlete with setup, nutrition and preparation for the next loop. Once the athlete is on the course, any kind of support is prohibited. Handing over food or gear during a loop results in immediate disqualification.</div>
169</details>
170<details class="faq-item lsu-scope-contact" name="lsu-faq">
171<summary class="lsu-scope-contact">
172<span class="faq-q lsu-scope-contact">Can crew members enter the track?</span>
173<span class="faq-mark lsu-scope-contact" aria-hidden="true">
174</span>
175</summary>
176<div class="faq-a lsu-scope-contact">No. Crew are not allowed on the track. If the rule is broken, the athlete is automatically disqualified.</div>
177</details>
178<details class="faq-item lsu-scope-contact" name="lsu-faq">
179<summary class="lsu-scope-contact">
180<span class="faq-q lsu-scope-contact">How many crew members are allowed?</span>
181<span class="faq-mark lsu-scope-contact" aria-hidden="true">
182</span>
183</summary>
184<div class="faq-a lsu-scope-contact">You may register four crew members in total. Up to three may be on site at the same time; the fourth may rotate in. Having a crew is optional.</div>
185</details>
186</div>
187<div id="faq-3" class="faq-group lsu-scope-contact" data-group="3">
188<div class="faq-head lsu-scope-contact">
189<h2 class="h2-sm lsu-scope-contact">Spectators</h2>
190<span class="faq-count lsu-scope-contact"> 4 questions </span>
191</div>
192<details class="faq-item lsu-scope-contact" name="lsu-faq">
193<summary class="lsu-scope-contact">
194<span class="faq-q lsu-scope-contact">Can people watch the event in person?</span>
195<span class="faq-mark lsu-scope-contact" aria-hidden="true">
196</span>
197</summary>
198<div class="faq-a lsu-scope-contact">No. On-site spectators are not allowed, which keeps the race safe and the operation smooth.</div>
199</details>
200<details class="faq-item lsu-scope-contact" name="lsu-faq">
201<summary class="lsu-scope-contact">
202<span class="faq-q lsu-scope-contact">Where can I follow the race?</span>
203<span class="faq-mark lsu-scope-contact" aria-hidden="true">
204</span>
205</summary>
206<div class="faq-a lsu-scope-contact">Highlights, updates and information go out on @lastsoulultra, as well as the channels of Kim Gottwald and rappid. Live coverage is coming soon — follow @lastsoulultra for updates.</div>
207</details>
208<details class="faq-item lsu-scope-contact" name="lsu-faq">
209<summary class="lsu-scope-contact">
210<span class="faq-q lsu-scope-contact">Will the race be streamed live?</span>
211<span class="faq-mark lsu-scope-contact" aria-hidden="true">
212</span>
213</summary>
214<div class="faq-a lsu-scope-contact">Live coverage is coming soon. Follow @lastsoulultra for updates.</div>
215</details>
216<details class="faq-item lsu-scope-contact" name="lsu-faq">
217<summary class="lsu-scope-contact">
218<span class="faq-q lsu-scope-contact">Why is the location kept secret?</span>
219<span class="faq-mark lsu-scope-contact" aria-hidden="true">
220</span>
221</summary>
222<div class="faq-a lsu-scope-contact">The venue is withheld to guarantee the safety and smooth running of the event.</div>
223</details>
224</div>
225</div>
226</div>
227</section>
228<section class="cta lsu-scope-contact">
229<div class="cta-bg lsu-scope-contact" style="background-image:url(&#x27;/assets/lsu/images/rappid-47.webp&#x27;);background-position:center 35%" data-px="0.36">
230</div>
231<div class="cta-scrim lsu-scope-contact">
232</div>
233<div class="cta-copy lsu-scope-contact" data-px="-0.06">
234<h2 class="h2 lsu-scope-contact">Still have<br class="lsu-scope-contact">questions?</h2>
235<a href="mailto:mail@lastsoulultra.com" class="btn btn-lg lsu-scope-contact">Email the team</a>
236</div>
237</section>
8@include('contact-body')
2389@endsection
resources/views/index.stxmodified+1-1
Changes to resources/views/index.stx
@@ -258,7 +258,7 @@ Miss the next start or fail to finish a loop within the hour, and your race is o
258258</div>
259259</div>
260260<div class="carousel lsu-scope-home" data-carousel>
261<div class="carousel-track lsu-scope-home" ref="lsuGallery" @scroll="lsuGalleryChanged()" @keydown.left.prevent="lsuGalleryMove(-1)" @keydown.right.prevent="lsuGalleryMove(1)" tabindex="0" data-track tabindex="0" role="group" aria-roledescription="carousel" aria-label="Season 2 Recap">
261<div class="carousel-track lsu-scope-home" ref="lsuGallery" @scroll="lsuGalleryChanged()" @keydown.left.prevent="lsuGalleryMove(-1)" @keydown.right.prevent="lsuGalleryMove(1)" tabindex="0" data-track role="group" aria-roledescription="carousel" aria-label="Season 2 Recap">
262262<div class="carousel-slide lsu-scope-home">
263263<img src="/assets/lsu/images/recap-01-810.webp" srcset="/assets/lsu/images/recap-01-540.webp 540w, /assets/lsu/images/recap-01-810.webp 810w, /assets/lsu/images/recap-01-1080.webp 1080w" sizes="(min-width: 1000px) 30vw, (min-width: 640px) 46vw, 100vw" alt="A runner in a dark shirt walks out of the red-lit start gantry at night." width="1080" height="1440" loading="lazy" decoding="async" class="lsu-scope-home">
264264</div>
resources/views/lottery.stxmodified+1-4
Changes to resources/views/lottery.stx
@@ -14,9 +14,7 @@ const canonicalPath = "/lottery"
1414</section>
1515<section id="form" class="entry lsu-scope-lottery">
1616<div class="entry-shell lsu-scope-lottery">
17<div class="form lsu-scope-lottery portal" data-lottery-portal data-lang="en" aria-live="polite">
18<div class="lsu-scope-lottery portal-message">Loading account access…</div>
19</div>
17<RacePortal />
2018</div>
2119</section>
2220<section class="lsu-scope-lottery open-now section">
@@ -106,5 +104,4 @@ const canonicalPath = "/lottery"
106104<br class="lsu-scope-lottery">Selection is by LSU team and a random draw. </p>
107105</div>
108106</section>
109<section class="section"><div class="shell"><RacePortal /></div></section>
110107@endsection
resources/views/soul-races.stxmodified+3-3
Changes to resources/views/soul-races.stx
@@ -117,11 +117,11 @@ onMount(async () => {
117117</section>
118118<div class="lsu-scope-soul-races sr-side">
119119<p class="lsu-scope-soul-races sr-result-count" :text="srFiltered.length + ' races'" data-result-count aria-live="polite">0 races</p>
120<div class="lsu-scope-soul-races sr-list" data-list aria-label="Soul Races">
120<section class="lsu-scope-soul-races sr-list" data-list aria-label="Soul Races">
121121<p :if="!srRaces.length" class="lsu-scope-soul-races sr-empty">No Soul Races are listed yet. Be the first.</p>
122122<p :if="srRaces.length && !srFiltered.length" class="lsu-scope-soul-races sr-empty">No Soul Races match your search.</p>
123<article :for="race in srFiltered" class="lsu-scope-soul-races sr-card"><h3 :text="race.name" class="lsu-scope-soul-races"></h3><p :text="race.city + ', ' + race.country_code" class="lsu-scope-soul-races"></p><p :text="race.summary" class="lsu-scope-soul-races"></p><a :href="race.registration_url" rel="noopener" class="lsu-scope-soul-races">View race and entry details</a></article>
124</div>
123<article :for="race in srFiltered" class="lsu-scope-soul-races sr-card"><h2 :text="race.name" class="lsu-scope-soul-races"></h2><p :text="race.city + ', ' + race.country_code" class="lsu-scope-soul-races"></p><p :text="race.summary" class="lsu-scope-soul-races"></p><a :href="race.registration_url" rel="noopener" class="lsu-scope-soul-races">View race and entry details</a></article>
124</section>
125125</div>
126126</div>
127127<section class="lsu-scope-soul-races sr-host">
scripts/audit-html.tsadded+367-0
Changes to scripts/audit-html.ts
@@ -0,0 +1,367 @@
1/* eslint-disable no-console */
2/**
3 * The mistakes a template cannot catch about itself.
4 *
5 * A page renders perfectly well with a link to a page that was renamed, two
6 * elements sharing an id, an h3 under an h1, a link whose only content is an
7 * icon, or an image whose alt was never written. Nothing errors and nothing
8 * fails to build; it is noticed by the first person using a keyboard or a
9 * screen reader, which is to say not by us.
10 *
11 * The rules live here and run twice. tests/html-audit.test.ts applies them to
12 * the rendered views, so the suite catches a regression with no build; this
13 * script applies them to dist, so a release can be checked against what was
14 * actually produced rather than what the source implies:
15 *
16 * bun run build && bun run audit:html
17 *
18 * Exits non-zero on any finding.
19 */
20
21import { existsSync, readFileSync, readdirSync } from 'node:fs'
22import { join, relative } from 'node:path'
23import process from 'node:process'
24import server from '../config/server'
25
26const ROOT = 'dist'
27
28export interface Finding {
29 page: string
30 rule: string
31 detail: string
32}
33
34/** Built pages, depth first. `_`-prefixed directories are build internals. */
35export function builtPages(dir: string = ROOT): string[] {
36 return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
37 const path = join(dir, entry.name)
38 if (entry.isDirectory()) return entry.name.startsWith('_') ? [] : builtPages(path)
39 return entry.name.endsWith('.html') ? [path] : []
40 })
41}
42
43/** The URL a built file is served at. */
44export function urlOf(file: string): string {
45 const path = `/${relative(ROOT, file).replace(/\.html$/, '')}`
46 return path === '/index' ? '/' : path
47}
48
49/** Strip comments, script and style so their contents are not read as markup. */
50function visible(html: string): string {
51 return html
52 .replace(/<!--[\s\S]*?-->/g, '')
53 .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
54 .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '')
55}
56
57function attr(tag: string, name: string): string | null {
58 const match = new RegExp(`\\s${name}\\s*=\\s*"([^"]*)"`, 'i').exec(tag)
59 ?? new RegExp(`\\s${name}\\s*=\\s*'([^']*)'`, 'i').exec(tag)
60 if (match) return match[1]!
61 return new RegExp(`\\s${name}(?=[\\s/>])`, 'i').test(tag) ? '' : null
62}
63
64/** Every URL the deployed site answers, which is what a link may point at. */
65export function redirectSources(): string[] {
66 return Object.keys(server.redirects ?? {})
67}
68
69/**
70 * Whether a referenced asset is on disk. `public/` is the source of truth and
71 * `dist/` holds what a build also generated, so both count.
72 */
73const assetSeen = new Map<string, boolean>()
74export function assetExists(url: string): boolean {
75 const path = url.split(/[?#]/)[0]!.replace(/^\/+/, '')
76 if (!assetSeen.has(path))
77 assetSeen.set(path, existsSync(join('public', path)) || existsSync(join(ROOT, path)))
78 return assetSeen.get(path)!
79}
80
81/** Every site-absolute asset URL a page names, srcset entries included. */
82export function assetUrls(html: string): string[] {
83 const urls = [
84 ...[...html.matchAll(/(?:src|href)="(\/assets\/[^"]+)"/gi)].map(match => match[1]!),
85 ...[...html.matchAll(/url\((['"]?)(\/[^)'"]+)\1\)/gi)].map(match => match[2]!),
86 ]
87 for (const [, set] of html.matchAll(/srcset="([^"]+)"/gi)) {
88 for (const candidate of set!.split(',')) {
89 const url = candidate.trim().split(/\s+/)[0]
90 if (url?.startsWith('/')) urls.push(url)
91 }
92 }
93 return [...new Set(urls)]
94}
95
96/**
97 * The attribute names on one tag, in order, skipping quoted values.
98 *
99 * A regex over `name=` finds names inside values too - an stx binding like
100 * `:text="a === 'x' ? b : a === 'y'"` reads as an attribute called `a` twice
101 * - so this walks the string instead.
102 */
103export function attributeNames(attrs: string): string[] {
104 const names: string[] = []
105 let i = 0
106 while (i < attrs.length) {
107 while (i < attrs.length && /\s/.test(attrs[i]!)) i += 1
108 const start = i
109 while (i < attrs.length && !/[\s=/>]/.test(attrs[i]!)) i += 1
110 const name = attrs.slice(start, i)
111 if (!name) { i += 1; continue }
112 names.push(name.toLowerCase())
113 while (i < attrs.length && /\s/.test(attrs[i]!)) i += 1
114 if (attrs[i] !== '=') continue
115 i += 1
116 while (i < attrs.length && /\s/.test(attrs[i]!)) i += 1
117 const quote = attrs[i]
118 if (quote === '"' || quote === '\'') {
119 i += 1
120 while (i < attrs.length && attrs[i] !== quote) i += 1
121 i += 1
122 }
123 else {
124 while (i < attrs.length && !/\s/.test(attrs[i]!)) i += 1
125 }
126 }
127 return names
128}
129
130/** The rules, over one page. */
131export function auditPage(page: string, raw: string, served: Set<string>): Finding[] {
132 const findings: Finding[] = []
133 const body = visible(raw)
134
135 // 1. Internal links point at something this site serves.
136 for (const [, href] of raw.matchAll(/<a\b[^>]*\shref="(\/[^"#?]*)[^"]*"/gi)) {
137 const target = href!.length > 1 ? href!.replace(/\/$/, '') : href!
138 if (served.has(target)) continue
139 // Assets are files on disk rather than pages.
140 if (/\.[a-z0-9]{2,5}$/i.test(target) || target.startsWith('/assets/')) continue
141 findings.push({ page, rule: 'dead-link', detail: target })
142 }
143
144 // 2. Every image says what it shows, or declares itself decorative.
145 for (const [tag] of body.matchAll(/<img\b[^>]*>/gi)) {
146 if (attr(tag, 'alt') === null)
147 findings.push({ page, rule: 'img-no-alt', detail: attr(tag, 'src') ?? tag.slice(0, 60) })
148 }
149
150 // 3. One id, one element.
151 const ids = [...body.matchAll(/\sid="([^"]+)"/g)].map(match => match[1]!)
152 for (const id of new Set(ids)) {
153 if (ids.filter(value => value === id).length > 1)
154 findings.push({ page, rule: 'duplicate-id', detail: id })
155 }
156
157 // 4. One h1, and no level skipped on the way down.
158 const levels = [...body.matchAll(/<h([1-6])\b/gi)].map(match => Number(match[1]))
159 const h1s = levels.filter(level => level === 1).length
160 if (h1s !== 1)
161 findings.push({ page, rule: 'h1-count', detail: String(h1s) })
162 for (let index = 1; index < levels.length; index++) {
163 if (levels[index]! - levels[index - 1]! > 1)
164 findings.push({ page, rule: 'heading-skip', detail: `h${levels[index - 1]} to h${levels[index]}` })
165 }
166
167 // 5. A link a screen reader can announce.
168 for (const [, tag, inner] of body.matchAll(/(<a\b[^>]*>)([\s\S]*?)<\/a>/gi)) {
169 const text = inner!.replace(/<[^>]+>/g, '').replace(/&[a-z]+;/gi, ' ').trim()
170 if (text) continue
171 if (attr(tag!, 'aria-label') || attr(tag!, 'title')) continue
172 if (/<img\b[^>]*\salt="[^"]+"/i.test(inner!)) continue
173 if (attr(tag!, 'aria-hidden') === 'true') continue
174 findings.push({ page, rule: 'link-no-name', detail: attr(tag!, 'href') ?? '' })
175 }
176
177 // 6. A document that states its language.
178 if (!/<html\b[^>]*\slang="[^"]+"/i.test(raw))
179 findings.push({ page, rule: 'no-lang', detail: '' })
180
181 // 7. A new tab the opener cannot be steered from. Without rel=noopener the
182 // opened page gets window.opener and can navigate this one elsewhere.
183 for (const [tag] of body.matchAll(/<a\b[^>]*>/gi)) {
184 if (attr(tag, 'target') !== '_blank') continue
185 if ((attr(tag, 'rel') ?? '').split(/\s+/).includes('noopener')) continue
186 findings.push({ page, rule: 'blank-no-noopener', detail: attr(tag, 'href') ?? '' })
187 }
188
189 // 8. An outbound link over plain HTTP, which a network between the reader
190 // and the host can read and rewrite. Loopback is the local preview.
191 for (const [, href] of body.matchAll(/<a\b[^>]*\shref="(http:\/\/[^"]+)"/gi)) {
192 const host = new URL(href!).hostname
193 if (host === 'localhost' || host === '127.0.0.1') continue
194 findings.push({ page, rule: 'insecure-link', detail: href! })
195 }
196
197 // 9. A control a screen reader can announce: an explicit label, an ARIA
198 // name, or the <label> it sits inside.
199 for (const match of body.matchAll(/<(input|select|textarea)\b[^>]*>/gi)) {
200 const tag = match[0]
201 const type = attr(tag, 'type') ?? ''
202 if (['hidden', 'submit', 'button', 'reset', 'image'].includes(type)) continue
203 if (attr(tag, 'aria-label') || attr(tag, 'aria-labelledby') || attr(tag, 'title')) continue
204 const id = attr(tag, 'id')
205 if (id && new RegExp(`<label\\b[^>]*\\sfor="${id}"`, 'i').test(body)) continue
206 // Wrapped in a label: the nearest <label> before it is still open.
207 const before = body.slice(0, match.index)
208 const opened = before.lastIndexOf('<label')
209 if (opened !== -1 && !before.slice(opened).includes('</label>')) continue
210 findings.push({ page, rule: 'control-no-label', detail: `${match[1]} ${attr(tag, 'name') ?? type}` })
211 }
212
213 // 10. A button with a name. Text bound at runtime counts: the markup is
214 // empty on purpose and the binding fills it.
215 for (const [, tag, inner] of body.matchAll(/(<button\b[^>]*>)([\s\S]*?)<\/button>/gi)) {
216 if (inner!.replace(/<[^>]+>/g, '').replace(/&[a-z]+;/gi, ' ').trim()) continue
217 if (attr(tag!, 'aria-label') || attr(tag!, 'title')) continue
218 if (/\s(?::text|x-text)=/i.test(tag!)) continue
219 if (/<img\b[^>]*\salt="[^"]+"/i.test(inner!)) continue
220 findings.push({ page, rule: 'button-no-name', detail: tag!.slice(0, 60) })
221 }
222
223 // 11. What the page says it is, for a search engine and a share sheet.
224 if (!/<meta name="description" content="[^"]{20,}"/i.test(raw))
225 findings.push({ page, rule: 'no-description', detail: '' })
226 const canonical = /<link rel="canonical" href="([^"]+)"/i.exec(raw)?.[1]
227 if (!canonical || !canonical.startsWith('http'))
228 findings.push({ page, rule: 'no-canonical', detail: canonical ?? '' })
229
230 // 12. The same attribute written twice on one tag. A parser keeps the
231 // first and drops the rest, so the second value is silently ignored -
232 // which is indistinguishable from it having been applied.
233 for (const [, tag, attrs] of body.matchAll(/<([a-z][\w-]*)\b([^>]*)>/gi)) {
234 const names = attributeNames(attrs!)
235 for (const name of new Set(names)) {
236 if (names.filter(other => other === name).length > 1)
237 findings.push({ page, rule: 'duplicate-attribute', detail: `${name} on <${tag!.toLowerCase()}>` })
238 }
239 }
240
241 // 13. An asset the page names and the deploy does not carry. A stylesheet,
242 // a font or a photograph that 404s is silent: the page still renders,
243 // just without it, and nothing in a build log says so.
244 for (const url of assetUrls(raw)) {
245 if (!assetExists(url))
246 findings.push({ page, rule: 'missing-asset', detail: url })
247 }
248
249 return findings
250}
251
252/**
253 * Class names the markup uses that no stylesheet defines.
254 *
255 * This is how /lottery's entry steps and open events, /404's whole heading
256 * block and the results tables' .h3 were found: markup written against a
257 * design system that was never shipped renders as unstyled body text, and
258 * nothing errors. Read against the BUILT css, so the generated utilities
259 * count as defined.
260 *
261 * The allowlist is a ratchet, not an exemption: it stops the set growing
262 * while leaving what is already there visible. Every entry was opened in a
263 * browser and checked to render correctly, with the note saying what carries
264 * the styling instead - an unverified allowlist is how the next one of these
265 * gets waved through.
266 */
267const UNSTYLED_ALLOWED = new Set([
268 // Hooks with nothing of their own to paint: a sibling class or a child
269 // carries the look. .seg-count and .enter-count wrap a .seg segment
270 // display, .lap-link also carries .btn, .ls-cmp-yr is painted by a rule on
271 // .ls-cmp-row, and .race-board and .carousel are scripting targets.
272 'race-board', 'lap-link', 'carousel', 'seg-count', 'enter-count', 'ls-cmp-yr',
273 // State modifiers the design does not act on: on /world-series the open
274 // event is told apart by its red entry button against the others' "coming
275 // soon" pill, not by the list item around it.
276 'ws-soon', 'ws-apply',
277 // Blocks whose typography comes from what is around them: the three leads
278 // from .phero, .tiers-title from .h2 beside it, .begin-band from .band,
279 // and .edon-l is a caption under a stat rather than a label.
280 'sr-lead', 'ws-lead', 'tiers-title', 'begin-band', 'edon-l',
281 // Utilities inside @stacksjs/components' Dialog, generated from that
282 // package's own source rather than from this app's markup.
283 'bg-black/25', 'dark:bg-black/50',
284])
285
286/** Every class name any shipped stylesheet keys a rule on. */
287export function definedClasses(files: string[]): Set<string> {
288 const names = new Set<string>()
289 for (const file of files) {
290 for (const [, name] of readFileSync(file, 'utf8').matchAll(/\.(-?[_a-zA-Z][\w-]*)/g))
291 names.add(name!)
292 }
293 return names
294}
295
296export function unstyledClasses(page: string, raw: string, defined: Set<string>): Finding[] {
297 const findings: Finding[] = []
298 const body = visible(raw)
299 const seen = new Set<string>()
300 for (const [, value] of body.matchAll(/\sclass="([^"]+)"/g)) {
301 for (const name of value!.split(/\s+/)) {
302 // stx writes a scope class per render and a wrapper id per component.
303 if (!name || name.startsWith('lsu-sz') || name.startsWith('stx_')) continue
304 if (defined.has(name) || UNSTYLED_ALLOWED.has(name) || seen.has(name)) continue
305 seen.add(name)
306 findings.push({ page, rule: 'unstyled-class', detail: name })
307 }
308 }
309 return findings
310}
311
312/**
313 * An indexed page nothing links to.
314 *
315 * A URL in the sitemap with no inbound link is one a reader can only reach
316 * from a search result. It is also the shape of a page that was added and
317 * then forgotten: /coaching/plans was listed for a week with nothing on the
318 * site pointing at it, because the component that linked it hid itself.
319 */
320export function orphanPages(rendered: Map<string, string>, indexed: string[]): Finding[] {
321 return indexed
322 .filter((target) => {
323 for (const [page, html] of rendered) {
324 if (page === target) continue
325 if (new RegExp(`href="${target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:["#?])`).test(html))
326 return false
327 }
328 return true
329 })
330 .map(target => ({ page: target, rule: 'orphan-page', detail: 'in the sitemap, linked from nowhere' }))
331}
332
333/** Findings grouped by rule, for a terminal. */
334export function formatFindings(findings: Finding[]): string {
335 const byRule = new Map<string, Finding[]>()
336 for (const finding of findings) {
337 if (!byRule.has(finding.rule)) byRule.set(finding.rule, [])
338 byRule.get(finding.rule)!.push(finding)
339 }
340
341 return [...byRule]
342 .sort((a, b) => b[1].length - a[1].length)
343 .map(([rule, rows]) => [
344 `${rule} (${rows.length})`,
345 ...rows.slice(0, 12).map(row => ` ${row.page} ${row.detail}`),
346 ...(rows.length > 12 ? [` ... ${rows.length - 12} more`] : []),
347 ].join('\n'))
348 .join('\n\n')
349}
350
351if (import.meta.main) {
352 const files = builtPages()
353 const served = new Set([...files.map(urlOf), ...redirectSources()])
354 const styles = [...new Bun.Glob('**/*.css').scanSync({ cwd: ROOT, absolute: true })]
355 const defined = definedClasses(styles)
356 const rendered = new Map(files.map(file => [urlOf(file), readFileSync(file, 'utf8')]))
357 const findings = [...rendered].flatMap(([page, html]) =>
358 [...auditPage(page, html, served), ...unstyledClasses(page, html, defined)])
359
360 const sitemap = readFileSync('public/sitemap.xml', 'utf8')
361 const indexed = [...sitemap.matchAll(/<loc>https?:\/\/[^/]+([^<]*)<\/loc>/g)].map(match => match[1]!)
362 findings.push(...orphanPages(rendered, indexed))
363
364 console.log(`audited ${files.length} pages`)
365 console.log(findings.length === 0 ? '\nno findings' : `\n${formatFindings(findings)}`)
366 process.exit(findings.length === 0 ? 0 : 1)
367}
scripts/externalize-page-bundles.tsadded+87-0
Changes to scripts/externalize-page-bundles.ts
@@ -0,0 +1,87 @@
1/* eslint-disable no-console */
2/**
3 * Lift a page's own module bundle out of its HTML.
4 *
5 * The build externalises the blobs that repeat across pages - the signals
6 * runtime, the router, the generated stylesheet - and leaves a bundle that
7 * only one page uses inline. That is the wrong way round for the biggest one
8 * here: /world-series carries @ts-maps inline, 844 KB of minified JavaScript
9 * in the first 1% of the document, which the browser must download and parse
10 * before it paints anything below it, on every single visit, because an
11 * inline script cannot be cached.
12 *
13 * Moving it to a file named by its contents makes it cacheable, lets the
14 * document stream, and costs one request. `/_stx/modules.<hash>.js` is the
15 * shape stx's own SPA router already recognises as a module bundle, so a
16 * client-side navigation that fails to load it recovers the page rather than
17 * running a setup that can only throw.
18 *
19 * The tag keeps its attributes and gains no defer or async: an inline script
20 * runs at its position in document order, and so does an external one without
21 * those, so execution order is unchanged.
22 *
23 * bun run build # runs this after ./buddy build
24 */
25
26import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
27import { join, relative } from 'node:path'
28import process from 'node:process'
29
30const ROOT = 'dist'
31const ASSET_DIR = join(ROOT, '_stx')
32/** Below this, a request costs more than the bytes saved. */
33const MIN_BYTES = 16 * 1024
34
35if (!existsSync(ROOT)) {
36 console.error(`${ROOT}/ does not exist. Run the build first.`)
37 process.exit(1)
38}
39
40function pages(dir = ROOT): string[] {
41 return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
42 const path = join(dir, entry.name)
43 if (entry.isDirectory()) return entry.name === '_stx' ? [] : pages(path)
44 return entry.name.endsWith('.html') ? [path] : []
45 })
46}
47
48/** The name stx's router matches as a module bundle: 16 hex characters. */
49function bundleName(code: string): string {
50 return `modules.${new Bun.CryptoHasher('sha256').update(code).digest('hex').slice(0, 16)}.js`
51}
52
53const written = new Map<string, number>()
54let movedBytes = 0
55let touchedPages = 0
56
57for (const file of pages()) {
58 const html = readFileSync(file, 'utf8')
59 let changed = false
60
61 const next = html.replace(
62 /<script([^>]*\bdata-stx-modules\b[^>]*)>([\s\S]*?)<\/script>/gi,
63 (whole, attrs: string, code: string) => {
64 // Already a reference, or small enough to leave where it is.
65 if (/\bsrc\s*=/i.test(attrs) || code.length < MIN_BYTES) return whole
66
67 const name = bundleName(code)
68 if (!written.has(name)) {
69 mkdirSync(ASSET_DIR, { recursive: true })
70 writeFileSync(join(ASSET_DIR, name), code)
71 written.set(name, code.length)
72 }
73 changed = true
74 movedBytes += code.length
75 return `<script${attrs} src="/_stx/${name}"></script>`
76 },
77 )
78
79 if (!changed) continue
80 writeFileSync(file, next)
81 touchedPages += 1
82 console.log(` ${relative(ROOT, file)} ${(html.length / 1024).toFixed(0)}KB -> ${(next.length / 1024).toFixed(0)}KB`)
83}
84
85console.log(written.size === 0
86 ? 'No page-local module bundle was large enough to externalize.'
87 : `Externalized ${written.size} page module bundle(s) from ${touchedPages} page(s) (${(movedBytes / 1024).toFixed(1)}KB removed from HTML)`)
scripts/serve-preview.tsmodified+19-1
Changes to scripts/serve-preview.ts
@@ -1,6 +1,23 @@
11/* eslint-disable no-console */
22import { resolve, sep } from 'node:path'
33import { compressResponse } from '@stacksjs/stx/compression'
4import server from '../config/server'
5
6/**
7 * The same per-path headers the production server applies from
8 * config/server.ts, matched the same way: `path/*` is a prefix, anything
9 * else is exact. A preview that grants the microphone where production does
10 * not is a preview that cannot find the bug, so this reads the one rule set
11 * rather than keeping a second hard-coded origin beside it.
12 */
13function configuredHeaders(pathname: string): Record<string, string> {
14 const matched: Record<string, string> = {}
15 for (const [pattern, headers] of Object.entries(server.headers ?? {})) {
16 const hit = pattern.endsWith('/*') ? pathname.startsWith(pattern.slice(0, -1)) : pathname === pattern
17 if (hit) Object.assign(matched, headers)
18 }
19 return matched
20}
421
522const root = resolve('dist')
623Bun.serve({ hostname: '127.0.0.1', port: 3942, async fetch(request) {
@@ -15,6 +32,7 @@ Bun.serve({ hostname: '127.0.0.1', port: 3942, async fetch(request) {
1532 let file = Bun.file(path)
1633 let status = 200
1734 if (!await file.exists()) { file = Bun.file(resolve(root, '404.html')); status = 404 }
18 return compressResponse(request, new Response(file, { status, headers: { 'Cache-Control': 'no-store', 'Permissions-Policy': 'microphone=(self "http://localhost:3940")' } }))
35 const headers = { 'Cache-Control': 'no-store', ...configuredHeaders(pathname.replace(/(.)\/$/, '$1')) }
36 return compressResponse(request, new Response(file, { status, headers }))
1937} })
2038console.log('LSU preview listening at http://localhost:3942')
tests/coaching-source.test.tsadded+108-0
Changes to tests/coaching-source.test.ts
@@ -0,0 +1,108 @@
1import type { Server } from 'bun'
2import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
3import { brandEndpoint, forgetBrand, loadBrand } from '../resources/functions/coaching-source'
4
5/**
6 * These stand a throwaway server in for HQ. The point is not the happy path -
7 * coaching-surface.test.ts pins that against a recorded payload - it is that
8 * every unhappy path ends in a null brand quickly and quietly, because a
9 * coaching page that waits on another service is a page that does not render.
10 */
11
12let server: Server
13let hits = 0
14
15function serve(handler: (request: Request) => Response | Promise<Response>): string {
16 hits = 0
17 server = Bun.serve({
18 port: 0,
19 fetch(request) {
20 hits += 1
21 return handler(request)
22 },
23 })
24 return `http://127.0.0.1:${server.port}`
25}
26
27const published = {
28 slug: 'last-soul-ultra',
29 settings: { name: 'Last Soul Ultra', headline: 'Train for the next start.', modules: ['plans'] },
30 offers: [],
31}
32
33beforeEach(() => forgetBrand())
34afterEach(async () => { await server?.stop(true) })
35
36describe('loading the published brand', () => {
37 it('asks the configured origin for the configured slug', () => {
38 expect(brandEndpoint('https://hq.training', 'last-soul-ultra'))
39 .toBe('https://hq.training/api/brands/last-soul-ultra')
40 })
41
42 it('escapes a slug rather than letting it reshape the path', () => {
43 expect(brandEndpoint('https://hq.training', '../org/experience'))
44 .toBe('https://hq.training/api/brands/..%2Forg%2Fexperience')
45 })
46
47 it('reads a published brand', async () => {
48 const origin = serve(() => Response.json(published))
49 expect((await loadBrand('last-soul-ultra', origin))?.name).toBe('Last Soul Ultra')
50 })
51
52 it('answers null for a brand that is not published yet', async () => {
53 const origin = serve(() => new Response('not found', { status: 404 }))
54 expect(await loadBrand('last-soul-ultra', origin)).toBeNull()
55 })
56
57 it('answers null when HQ errors, rather than failing the page', async () => {
58 const origin = serve(() => new Response('boom', { status: 500 }))
59 expect(await loadBrand('last-soul-ultra', origin)).toBeNull()
60 })
61
62 it('answers null for a response that is not JSON', async () => {
63 const origin = serve(() => new Response('<html>maintenance</html>', { headers: { 'content-type': 'text/html' } }))
64 expect(await loadBrand('last-soul-ultra', origin)).toBeNull()
65 })
66
67 it('answers null when nothing is listening at all', async () => {
68 // Port 9 (discard) refuses fast and needs no server of its own.
69 server = Bun.serve({ port: 0, fetch: () => new Response('') })
70 expect(await loadBrand('last-soul-ultra', 'http://127.0.0.1:9')).toBeNull()
71 })
72
73 it('gives up on an HQ that never answers', async () => {
74 const origin = serve(async () => { await Bun.sleep(30_000); return new Response('late') })
75 const started = performance.now()
76
77 expect(await loadBrand('last-soul-ultra', origin)).toBeNull()
78 expect(performance.now() - started).toBeLessThan(5000)
79 }, 10_000)
80
81 it('makes one request for a whole render, not one per page', async () => {
82 const origin = serve(() => Response.json(published))
83
84 await loadBrand('last-soul-ultra', origin)
85 await loadBrand('last-soul-ultra', origin)
86 await loadBrand('last-soul-ultra', origin)
87
88 expect(hits).toBe(1)
89 })
90
91 it('does not stampede a cold cache', async () => {
92 const origin = serve(async () => { await Bun.sleep(50); return Response.json(published) })
93
94 const all = await Promise.all([1, 2, 3, 4, 5].map(() => loadBrand('last-soul-ultra', origin)))
95
96 expect(hits).toBe(1)
97 expect(all.every(brand => brand?.name === 'Last Soul Ultra')).toBe(true)
98 })
99
100 it('caches an outage too, so a down HQ is not asked once per visitor', async () => {
101 const origin = serve(() => new Response('boom', { status: 503 }))
102
103 await loadBrand('last-soul-ultra', origin)
104 await loadBrand('last-soul-ultra', origin)
105
106 expect(hits).toBe(1)
107 })
108})
tests/coaching-surface.test.tsadded+237-0
Changes to tests/coaching-surface.test.ts
@@ -0,0 +1,237 @@
1import { describe, expect, it } from 'bun:test'
2import { coachingModule, coachingModules, enabledModules, indexableModulePaths, modulePath } from '../resources/data/coaching'
3import { formatPrice, formatTrial, parseBrand } from '../resources/functions/coaching-brand'
4import coaching from '../config/coaching'
5import server from '../config/server'
6import { coachingBrand, coachingOrigin, coachingSection, portalElementUrl, portalUrl } from '../resources/functions/coaching-source'
7
8describe('the coaching module registry', () => {
9 it('uses each HQ module key once, as its own URL segment', () => {
10 const slugs = coachingModules.map(module => module.slug)
11 expect(new Set(slugs).size).toBe(slugs.length)
12 for (const slug of slugs) {
13 expect(slug).toMatch(/^[a-z]+$/)
14 expect(modulePath(slug)).toBe(`/coaching/${slug}`)
15 }
16 })
17
18 it('covers the eight modules HQ publishes, and nothing invented', () => {
19 expect(coachingModules.map(module => module.slug).sort()).toEqual(
20 ['account', 'calendar', 'feedback', 'health', 'library', 'messages', 'plans', 'progress'],
21 )
22 })
23
24 it('shows only the storefront when no brand is published', () => {
25 // An unreachable or draft brand must not advertise panels that cannot load.
26 expect(enabledModules(undefined).map(module => module.slug)).toEqual(['plans'])
27 expect(enabledModules([]).map(module => module.slug)).toEqual(['plans'])
28 })
29
30 it('keeps this site\'s order rather than the order the brand happens to list', () => {
31 expect(enabledModules(['account', 'plans', 'calendar']).map(module => module.slug))
32 .toEqual(['plans', 'calendar', 'account'])
33 })
34
35 it('ignores a module the brand names that this site does not serve', () => {
36 expect(enabledModules(['plans', 'whiteboard']).map(module => module.slug)).toEqual(['plans'])
37 })
38
39 it('indexes only what a visitor without an account can actually read', () => {
40 expect(indexableModulePaths()).toEqual(['/coaching/plans'])
41 for (const module of coachingModules) {
42 if (module.scope !== 'public')
43 expect(indexableModulePaths()).not.toContain(modulePath(module.slug))
44 }
45 })
46
47 it('marks health and messages as account-private, because a brand never sees them', () => {
48 expect(coachingModule('health')?.scope).toBe('private')
49 expect(coachingModule('messages')?.scope).toBe('private')
50 })
51
52 it('says what each panel does in a full sentence', () => {
53 for (const module of coachingModules) {
54 expect(module.summary.length).toBeGreaterThan(40)
55 expect(module.summary.endsWith('.')).toBe(true)
56 // The site's one hard typographic rule.
57 expect(module.summary).not.toContain('—')
58 expect(module.title).not.toContain('—')
59 }
60 })
61
62 it('does not know about a module that is not registered', () => {
63 expect(coachingModule('nope')).toBeUndefined()
64 })
65})
66
67/**
68 * The fixture is a verbatim capture of `GET /api/brands/last-soul-ultra` from
69 * the HQ preview stack, preview wording and sample price included. It is here
70 * to pin the shape of the contract, not to describe LSU's real offer.
71 */
72const payload = await Bun.file('tests/fixtures/brand-last-soul-ultra.json').json()
73
74/** A deep copy, so a test that breaks one field cannot leak into the next. */
75function sample(): any {
76 return structuredClone(payload)
77}
78
79describe('reading the published brand', () => {
80 it('reads the real HQ payload', () => {
81 const brand = parseBrand(sample(), 'last-soul-ultra')
82
83 expect(brand?.name).toBe('Last Soul Ultra Preview')
84 expect(brand?.headline).toBe('Train for the next start.')
85 expect(brand?.modules).toContain('calendar')
86 expect(brand?.offers).toHaveLength(1)
87 expect(brand?.offers[0].coach).toBe('LSU Preview Coach')
88 expect(brand?.offers[0].method.version).toBe(1)
89 expect(brand?.offers[0].product.priceCents).toBe(1999)
90 })
91
92 it('refuses a payload published for a different brand', () => {
93 expect(parseBrand(sample(), 'another-race')).toBeNull()
94 })
95
96 it('treats an unpublished brand, a broken payload and an outage alike', () => {
97 for (const raw of [null, undefined, 'nope', 42, {}, { settings: {} }, { slug: 'last-soul-ultra' }])
98 expect(parseBrand(raw, 'last-soul-ultra')).toBeNull()
99 })
100
101 it('drops one malformed offer rather than the whole storefront', () => {
102 const raw = sample()
103 raw.offers.push({ label: 'Broken', method: { name: 'x' } })
104 const brand = parseBrand(raw, 'last-soul-ultra')
105
106 expect(brand?.offers.map((offer: { label: string }) => offer.label)).toEqual(['Preview running'])
107 })
108
109 it('refuses an offer with no price rather than printing a free one', () => {
110 const raw = sample()
111 delete raw.offers[0].product.price_cents
112 expect(parseBrand(raw, 'last-soul-ultra')?.offers).toEqual([])
113 })
114
115 it('keeps a javascript: or relative URL out of an href', () => {
116 const raw = sample()
117 raw.settings.support_url = 'javascript:alert(1)'
118 raw.settings.privacy_url = '/privacy'
119 raw.offers[0].method.contact_url = 'data:text/html,<script>'
120 const brand = parseBrand(raw, 'last-soul-ultra')
121
122 expect(brand?.supportUrl).toBeNull()
123 expect(brand?.privacyUrl).toBeNull()
124 expect(brand?.offers[0].method.contactUrl).toBeNull()
125 expect(brand?.termsUrl).toBe('https://www.lastsoulultra.com/terms')
126 })
127
128 it('ignores a module key that is not a plain lower-case word', () => {
129 const raw = sample()
130 raw.settings.modules = ['plans', '../admin', 42, 'calendar']
131 expect(parseBrand(raw, 'last-soul-ultra')?.modules).toEqual(['plans', 'calendar'])
132 })
133
134 it('falls back to English for a locale HQ does not publish', () => {
135 const raw = sample()
136 raw.settings.locale = 'fr'
137 expect(parseBrand(raw, 'last-soul-ultra')?.locale).toBe('en')
138 })
139})
140
141describe('printing a coach\'s price', () => {
142 const base = { id: 1, name: 'Running', description: '', priceCents: 1999, currency: 'EUR', interval: 'month', trialDays: 0 }
143
144 it('prints the coach\'s own amount and interval', () => {
145 expect(formatPrice(base)).toBe('€19.99 a month')
146 expect(formatPrice({ ...base, priceCents: 12000, interval: 'year' })).toBe('€120 a year')
147 expect(formatPrice({ ...base, currency: 'GBP', priceCents: 2500 })).toBe('£25 a month')
148 })
149
150 it('prints a German brand\'s price the German way', () => {
151 expect(formatPrice(base, 'de')).toContain('19,99')
152 })
153
154 it('names an interval it does not have a phrase for', () => {
155 expect(formatPrice({ ...base, interval: 'week' })).toBe('€19.99 per week')
156 })
157
158 it('says nothing about a trial the coach did not set', () => {
159 expect(formatTrial(base)).toBe('')
160 expect(formatTrial({ ...base, trialDays: 1 })).toBe('1 day free first')
161 expect(formatTrial({ ...base, trialDays: 14 })).toBe('14 days free first')
162 })
163})
164
165describe('where the portal is', () => {
166 it('insists on HTTPS anywhere but loopback', () => {
167 expect(coachingOrigin('https://hq.training')).toBe('https://hq.training')
168 expect(coachingOrigin('http://localhost:3940')).toBe('http://localhost:3940')
169 expect(coachingOrigin('http://127.0.0.1:3940')).toBe('http://127.0.0.1:3940')
170 expect(() => coachingOrigin('http://hq.training')).toThrow('HQ_ORIGIN must use HTTPS')
171 // A host that merely contains "localhost" is not loopback.
172 expect(() => coachingOrigin('http://localhost.attacker.test')).toThrow()
173 })
174
175 it('refuses a brand slug that is not one', () => {
176 expect(coachingBrand('last-soul-ultra')).toBe('last-soul-ultra')
177 for (const value of ['', '-leading', 'Upper', 'has space', '../org', 'a'.repeat(141)])
178 expect(() => coachingBrand(value)).toThrow('HQ_BRAND_SLUG must be an organization slug')
179 })
180
181 it('passes through only a section this site actually serves', () => {
182 expect(coachingSection('calendar')).toBe('calendar')
183 for (const value of ['', 'whiteboard', 'moderation', 42, null, undefined, ['plans']])
184 expect(coachingSection(value)).toBe('')
185 })
186
187 it('links to the same section the embed is showing', () => {
188 expect(portalUrl('calendar', 'https://hq.training', 'last-soul-ultra'))
189 .toBe('https://hq.training/brands/last-soul-ultra?section=calendar')
190 expect(portalUrl('', 'https://hq.training', 'last-soul-ultra'))
191 .toBe('https://hq.training/brands/last-soul-ultra')
192 expect(portalUrl('nonsense', 'https://hq.training', 'last-soul-ultra'))
193 .toBe('https://hq.training/brands/last-soul-ultra')
194 })
195
196 it('loads the element from the same origin it frames', () => {
197 expect(portalElementUrl('https://hq.training')).toBe('https://hq.training/assets/elements/hq-coaching.js')
198 })
199})
200
201describe('the microphone the feedback module needs', () => {
202 /** Exactly how @stacksjs/server matches config.server.headers. */
203 function headersFor(pathname: string): Record<string, string> {
204 const matched: Record<string, string> = {}
205 for (const [pattern, headers] of Object.entries(server.headers ?? {})) {
206 const hit = pattern.endsWith('/*') ? pathname.startsWith(pattern.slice(0, -1)) : pathname === pattern
207 if (hit) Object.assign(matched, headers)
208 }
209 return matched
210 }
211
212 it('is delegated on the two routes that frame it', () => {
213 for (const path of ['/coaching', '/coaching/feedback']) {
214 expect(headersFor(path)['Permissions-Policy']).toBe(`microphone=(self "${new URL(coaching.origin).origin}")`)
215 }
216 })
217
218 it('names the origin the panel is actually framed from', () => {
219 // A policy naming a different host than the iframe src grants nothing.
220 const policy = headersFor('/coaching/feedback')['Permissions-Policy'] ?? ''
221 expect(policy).toContain(new URL(portalUrl('feedback')).origin)
222 })
223
224 it('is not handed to a route with no microphone in it', () => {
225 for (const path of ['/', '/format', '/coaching/calendar', '/coaching/account'])
226 expect(headersFor(path)['Permissions-Policy']).toBeUndefined()
227 })
228})
229
230describe('reaching coaching from the rest of the site', () => {
231 const chrome = ['resources/components/SiteNav.stx', 'resources/components/SiteFooter.stx']
232
233 it('is linked from the navigation and the footer', async () => {
234 for (const path of chrome)
235 expect(await Bun.file(path).text()).toContain('href="/coaching"')
236 })
237})
tests/coaching-views.test.tsadded+280-0
Changes to tests/coaching-views.test.ts
@@ -0,0 +1,280 @@
1import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2import { resolve } from 'node:path'
3import { processDirectives } from '@stacksjs/stx'
4import { coachingModules } from '../resources/data/coaching'
5import { absolute } from '../resources/data/site'
6import { coachingOrigin, forgetBrand } from '../resources/functions/coaching-source'
7
8/**
9 * The eight module routes, rendered the way the site renders them.
10 *
11 * Each view is four lines that name a slug three times, so the failure these
12 * guard against is a copied file that still says 'calendar' somewhere: a page
13 * whose nav highlights the wrong tab, whose canonical points at its neighbour,
14 * or which quietly loses its noindex.
15 */
16
17const payload = await Bun.file('tests/fixtures/brand-last-soul-ultra.json').json()
18const realFetch = globalThis.fetch
19
20/** Answer the brand endpoint from the fixture; refuse everything else. */
21function stubHq(body: unknown = payload): void {
22 globalThis.fetch = (async (input: any) => {
23 const url = String(typeof input === 'string' ? input : input.url)
24 if (url.includes('/api/brands/'))
25 return body === null ? new Response('not found', { status: 404 }) : Response.json(body)
26 throw new Error(`unexpected request: ${url}`)
27 }) as typeof fetch
28}
29
30async function render(slug: string): Promise<string> {
31 const path = resolve(`resources/views/coaching/${slug}.stx`)
32 return processDirectives(await Bun.file(path).text(), {}, path, {
33 enabled: true,
34 cache: false,
35 componentsDir: resolve('resources/components'),
36 layoutsDir: resolve('resources/layouts'),
37 partialsDir: resolve('resources/partials'),
38 }, new Set())
39}
40
41beforeEach(() => forgetBrand())
42afterEach(() => { globalThis.fetch = realFetch })
43
44describe('a module route, with a brand published', () => {
45 beforeEach(() => stubHq())
46
47 it('has a view on disk for every registered module', async () => {
48 for (const module of coachingModules)
49 expect(await Bun.file(`resources/views/coaching/${module.slug}.stx`).exists()).toBe(true)
50 })
51
52 it('titles, describes and canonicalises itself from the registry', async () => {
53 for (const module of coachingModules) {
54 const html = await render(module.slug)
55
56 expect(html).toContain(`<title>${module.title} - Last Soul Ultra coaching</title>`)
57 expect(html).toContain(`content="${module.summary}"`)
58 expect(html).toContain(`rel="canonical" href="${absolute(`/coaching/${module.slug}`)}"`)
59 }
60 })
61
62 it('highlights its own tab, once', async () => {
63 for (const module of coachingModules) {
64 const html = await render(module.slug)
65 const current = [...html.matchAll(/href="\/coaching\/([a-z]+)"\s+aria-current="page"/g)].map(match => match[1])
66
67 expect(current).toEqual([module.slug])
68 }
69 })
70
71 it('links out to the section it is standing in for', async () => {
72 for (const module of coachingModules) {
73 const html = await render(module.slug)
74 expect(html).toContain(`/brands/last-soul-ultra?section=${module.slug}`)
75 }
76 })
77
78 it('keeps the modules that need an account out of search results', async () => {
79 for (const module of coachingModules) {
80 const html = await render(module.slug)
81 const noindex = html.includes('name="robots" content="noindex,nofollow"')
82
83 expect(noindex).toBe(module.scope !== 'public')
84 }
85 })
86
87 it('says which scope a panel is before the click', async () => {
88 expect(await render('plans')).not.toContain('coach-scope')
89 expect(await render('calendar')).toContain('Needs a training account')
90 expect(await render('health')).toContain('Only you see this')
91 expect(await render('messages')).toContain('Only you see this')
92 })
93
94 it('renders the storefront on the storefront, and nowhere else', async () => {
95 const plans = await render('plans')
96
97 expect(plans).toContain('coach-offers')
98 expect(plans).toContain('€19.99 a month')
99 expect(await render('calendar')).not.toContain('coach-offers')
100 })
101
102 it('frames the panel on every published module', async () => {
103 for (const module of coachingModules) {
104 const html = await render(module.slug)
105 expect(html).toContain(`<hq-coaching brand="last-soul-ultra"`)
106 expect(html).not.toContain('Not open yet')
107 }
108 })
109})
110
111describe('a module route, with nothing published', () => {
112 beforeEach(() => stubHq(null))
113
114 it('says so rather than showing an empty storefront', async () => {
115 const plans = await render('plans')
116
117 expect(plans).toContain('Nothing is open yet')
118 expect(plans).not.toContain('coach-offers')
119 })
120
121 it('offers no nav to panels that cannot load', async () => {
122 const plans = await render('plans')
123
124 expect(plans).not.toContain('coach-nav')
125 expect(plans).not.toContain('/coaching/calendar')
126 })
127
128 it('still renders the page it was asked for', async () => {
129 const calendar = await render('calendar')
130
131 expect(calendar).toContain('<title>Your week - Last Soul Ultra coaching</title>')
132 expect(calendar).toContain('Not open yet')
133 })
134
135 it('frames no panel, and loads no remote script, for a module nobody published', async () => {
136 const calendar = await render('calendar')
137
138 expect(calendar).not.toContain('<hq-coaching')
139 expect(calendar).not.toContain('hq-coaching.js')
140 })
141
142 it('drops the notice that promised an account would open the panel', async () => {
143 // With nothing to sign in to, "needs a training account" is an excuse,
144 // not an explanation.
145 expect(await render('calendar')).not.toContain('Needs a training account')
146 expect(await render('health')).not.toContain('Only you see this')
147 })
148
149 it('leaves the storefront one empty state, not two', async () => {
150 const plans = await render('plans')
151
152 expect([...plans.matchAll(/class="coach-empty"/g)]).toHaveLength(1)
153 expect(plans).toContain('Nothing is open yet')
154 })
155})
156
157describe('getting back out of a module', () => {
158 it('links to the hub from every module, published or not', async () => {
159 stubHq(null)
160 for (const module of coachingModules)
161 expect(await render(module.slug)).toMatch(/<a class="[^"]*coach-up[^"]*" href="\/coaching">Coaching<\/a>/)
162 })
163
164 it('leaves the storefront a way back when the nav has hidden itself', async () => {
165 stubHq(null)
166 const plans = await render('plans')
167
168 // The nav only renders with more than one module to move between.
169 expect(plans).not.toContain('coach-nav')
170 expect(plans).toContain('href="/coaching"')
171 })
172})
173
174describe('warming the connection to HQ', () => {
175 it('promises it on a module the brand publishes', async () => {
176 stubHq()
177 const html = await render('calendar')
178
179 expect(html).toContain(`<link rel="preconnect" href="${coachingOrigin()}" crossorigin>`)
180 expect(html).toContain(`<link rel="dns-prefetch" href="${coachingOrigin()}">`)
181 })
182
183 it('promises nothing on a page that will not call HQ', async () => {
184 // With nothing published the module frames nothing, so a held-open
185 // socket would be a connection made for no request.
186 stubHq(null)
187 const html = await render('calendar')
188
189 expect(html).not.toContain('rel="preconnect"')
190 expect(html).not.toContain('rel="dns-prefetch"')
191 })
192
193 it('warms it before the element that needs it', async () => {
194 stubHq()
195 const html = await render('calendar')
196
197 expect(html.indexOf('rel="preconnect"')).toBeLessThan(html.indexOf('hq-coaching.js'))
198 })
199})
200
201describe('what a tier says it is', () => {
202 it('prints what the tier includes and how the coach trains', async () => {
203 stubHq()
204 const plans = await render('plans')
205
206 expect(plans).toContain('€19.99 is a sample price')
207 expect(plans).toContain('Demonstration content, not an approved LSU coach method.')
208 })
209
210 it('prints one paragraph when HQ carries the same sentence in both', async () => {
211 const same = structuredClone(payload)
212 same.offers[0].method.summary = same.offers[0].product.description
213 stubHq(same)
214 const plans = await render('plans')
215
216 expect(plans).not.toContain('coach-offer-method')
217 expect([...plans.matchAll(/sample price/g)]).toHaveLength(1)
218 })
219})
220
221describe('the coaching hub', () => {
222 async function hub(): Promise<string> {
223 const path = resolve('resources/views/coaching.stx')
224 return processDirectives(await Bun.file(path).text(), {}, path, {
225 enabled: true,
226 cache: false,
227 componentsDir: resolve('resources/components'),
228 layoutsDir: resolve('resources/layouts'),
229 partialsDir: resolve('resources/partials'),
230 }, new Set())
231 }
232
233 it('embeds the portal and warms the connection when something is published', async () => {
234 stubHq()
235 const html = await hub()
236
237 expect(html).toContain('<hq-coaching')
238 expect(html).toContain('rel="preconnect"')
239 })
240
241 it('loads no remote script when there is nothing to show', async () => {
242 // No published brand means no subscriptions, so no receipt can arrive
243 // here either, and the element has nothing left to do.
244 stubHq(null)
245 const html = await hub()
246
247 expect(html).not.toContain('<hq-coaching')
248 expect(html).not.toContain('hq-coaching.js')
249 expect(html).not.toContain('rel="preconnect"')
250 expect(html).toContain('Nothing is open yet')
251 })
252})
253
254describe('what the hub promises', () => {
255 async function hubHtml(): Promise<string> {
256 const path = resolve('resources/views/coaching.stx')
257 return processDirectives(await Bun.file(path).text(), {}, path, {
258 enabled: true,
259 cache: false,
260 componentsDir: resolve('resources/components'),
261 layoutsDir: resolve('resources/layouts'),
262 partialsDir: resolve('resources/partials'),
263 }, new Set())
264 }
265
266 it('offers the portal only when there is one', async () => {
267 stubHq()
268 expect(await hubHtml()).toContain('Open the portal')
269 })
270
271 it('promises neither a portal nor eight panels it is not showing', async () => {
272 stubHq(null)
273 const html = await hubHtml()
274
275 expect(html).not.toContain('Open the portal')
276 expect(html).not.toContain('the eight panels your training lives in')
277 expect(html).toContain('See what is coming')
278 expect(html).toContain('Not open yet')
279 })
280})
tests/contrast.test.tsadded+108-0
Changes to tests/contrast.test.ts
@@ -0,0 +1,108 @@
1import { describe, expect, it } from 'bun:test'
2
3/**
4 * Text contrast, for the sheets this branch added.
5 *
6 * Two of the site's tokens do not clear WCAG AA for body text: --lsu-dim-3
7 * at 3.66:1 on black, and the brand red at 4.15:1. Both are used that way
8 * across the rest of the site - .kicker, .sr-date, .tile-n and about thirty
9 * others - which is the organiser's own design and not something to rewrite
10 * from here. What these tests hold is the narrower line: the sheets added
11 * here do not add to it.
12 */
13
14type Rgb = [number, number, number]
15
16function relativeLuminance([r, g, b]: Rgb): number {
17 const channel = (value: number): number => {
18 const v = value / 255
19 return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
20 }
21 return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
22}
23
24function contrast(a: Rgb, b: Rgb): number {
25 const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x)
26 return (hi! + 0.05) / (lo! + 0.05)
27}
28
29/** White at `alpha` composited over an opaque background. */
30function dim(alpha: number, over: Rgb): Rgb {
31 return over.map(channel => Math.round(255 * alpha + channel * (1 - alpha))) as Rgb
32}
33
34const BLACK: Rgb = [0, 0, 0]
35const RED: Rgb = [221, 15, 21]
36/** AA: 4.5 for body text, 3.0 at 24px or 18.66px bold and above. */
37const BODY = 4.5
38const LARGE = 3
39
40describe('the palette', () => {
41 it('is safe for body text down to --lsu-dim-2', () => {
42 expect(contrast([255, 255, 255], BLACK)).toBeGreaterThan(BODY)
43 expect(contrast(dim(0.65, BLACK), BLACK)).toBeGreaterThan(BODY)
44 expect(contrast(dim(0.55, BLACK), BLACK)).toBeGreaterThan(BODY)
45 })
46
47 it('is not safe for body text at --lsu-dim-3 or the brand red', () => {
48 // Recorded rather than asserted away: this is why the rules below exist.
49 expect(contrast(dim(0.4, BLACK), BLACK)).toBeLessThan(BODY)
50 expect(contrast(RED, BLACK)).toBeLessThan(BODY)
51 })
52
53 it('is safe for large text at both', () => {
54 expect(contrast(dim(0.4, BLACK), BLACK)).toBeGreaterThan(LARGE)
55 expect(contrast(RED, BLACK)).toBeGreaterThan(LARGE)
56 })
57
58 it('holds on the card surface as well as on black', () => {
59 const card: Rgb = [13, 13, 15]
60 expect(contrast(dim(0.55, card), card)).toBeGreaterThan(BODY)
61 expect(contrast(RED, card)).toBeLessThan(BODY)
62 })
63})
64
65describe('the sheets this branch added', () => {
66 const own = ['coaching.css', 'coaching-page.css', 'not-found.css']
67
68 async function rules(name: string): Promise<{ selector: string, body: string }[]> {
69 const css = await Bun.file(`resources/assets/styles/site/${name}`).text()
70 return [...css.matchAll(/([^{}]+)\{([^}]*)\}/g)]
71 .map(match => ({ selector: match[1]!.trim().split('\n').pop()!, body: match[2]! }))
72 }
73
74 it('never sets the dimmest token as a text colour', async () => {
75 for (const name of own) {
76 const offenders = (await rules(name))
77 .filter(rule => rule.body.includes('color:var(--lsu-dim-3)'))
78 .map(rule => `${name}: ${rule.selector}`)
79
80 expect(offenders).toEqual([])
81 }
82 })
83
84 /**
85 * One relocated rule, not a new one: .coaching-loop h3 came across from
86 * stx-site.css with the rest of the marketing page and starts at 22px,
87 * which is under the 24px bar at the narrowest viewport. Listed so it stays
88 * visible rather than excluding the whole sheet it lives in.
89 */
90 const RELOCATED = new Set(['.coaching-page .coaching-loop h3'])
91
92 it('uses the brand red for text only where it is large', async () => {
93 for (const name of own) {
94 const offenders = (await rules(name))
95 .filter((rule) => {
96 if (!/(?<![a-z-])color:var\(--lsu-red\)/.test(rule.body)) return false
97 const size = /font(?:-size)?:[^;]*?(\d+)px/.exec(rule.body)
98 // No size in the rule means it inherits one; .kicker is 19px bold,
99 // which is itself over the large-text bar.
100 if (RELOCATED.has(rule.selector)) return false
101 return size ? Number(size[1]) < 24 : false
102 })
103 .map(rule => `${name}: ${rule.selector}`)
104
105 expect(offenders).toEqual([])
106 }
107 })
108})
tests/entry.test.tsadded+48-0
Changes to tests/entry.test.ts
@@ -0,0 +1,48 @@
1import { describe, expect, it } from 'bun:test'
2import { resolve } from 'node:path'
3import { processDirectives } from '@stacksjs/stx'
4
5/**
6 * The entry page's job is one form. It shipped with two: a placeholder that
7 * read "Loading account access…" in the section the page calls #form, which
8 * nothing ever replaced, and the working portal more than a thousand pixels
9 * below it. Nothing errored, and the page looked like it was still loading.
10 */
11
12async function render(view: string): Promise<string> {
13 const path = resolve(`resources/views/${view}.stx`)
14 return processDirectives(await Bun.file(path).text(), {}, path, {
15 enabled: true,
16 cache: false,
17 componentsDir: resolve('resources/components'),
18 layoutsDir: resolve('resources/layouts'),
19 partialsDir: resolve('resources/partials'),
20 }, new Set())
21}
22
23describe('the entry page', () => {
24 it('puts the form in the section it calls the form', async () => {
25 const html = await render('lottery')
26 const form = html.slice(html.indexOf('id="form"'))
27
28 expect(form.indexOf('race-portal')).toBeGreaterThan(-1)
29 // Before the next section begins.
30 expect(form.indexOf('race-portal')).toBeLessThan(form.indexOf('open-now'))
31 })
32
33 it('has one portal, not a working one and a decorative one', async () => {
34 const html = await render('lottery')
35 expect([...html.matchAll(/class="race-portal"/g)]).toHaveLength(1)
36 })
37
38 it('shows no loading state that nothing ever clears', async () => {
39 const html = await render('lottery')
40
41 expect(html).not.toContain('Loading account access')
42 expect(html).not.toContain('data-lottery-portal')
43 })
44
45 it('offers the same account on the profile page', async () => {
46 expect(await render('profile')).toContain('race-portal')
47 })
48})
tests/fixtures/brand-last-soul-ultra.jsonadded+98-0
Changes to tests/fixtures/brand-last-soul-ultra.json
@@ -0,0 +1,98 @@
1{
2 "slug": "last-soul-ultra",
3 "settings": {
4 "name": "Last Soul Ultra Preview",
5 "locale": "en",
6 "headline": "Train for the next start.",
7 "description": "Test mode. Sample plans and €19.99 monthly test price. No live charges. LSU methods and pricing are not approved.",
8 "logo_url": "http://localhost:3942/assets/lsu/images/lsu-logo.svg",
9 "home_url": "http://localhost:3942/coaching",
10 "support_url": "https://www.lastsoulultra.com/contact",
11 "privacy_url": "https://www.lastsoulultra.com/privacy",
12 "terms_url": "https://www.lastsoulultra.com/terms",
13 "embed_origins": [
14 "http://localhost:3942"
15 ],
16 "theme": {
17 "background": "#000000",
18 "surface": "#0d0d0f",
19 "text": "#ffffff",
20 "muted": "#b6b6bd",
21 "accent": "#dd0f15",
22 "accent_text": "#ffffff",
23 "border": "#333338",
24 "font": "barlow",
25 "heading_font": "anton",
26 "radius": 0,
27 "density": "comfortable"
28 },
29 "modules": [
30 "plans",
31 "calendar",
32 "feedback",
33 "progress",
34 "library",
35 "health",
36 "messages",
37 "account"
38 ],
39 "navigation": [
40 {
41 "module": "plans",
42 "label": "Store",
43 "symbol": "bag"
44 },
45 {
46 "module": "calendar",
47 "label": "Workouts",
48 "symbol": "figure.run"
49 },
50 {
51 "module": "health",
52 "label": "Health"
53 },
54 {
55 "module": "messages",
56 "label": "Messages"
57 },
58 {
59 "module": "account",
60 "label": "Me"
61 }
62 ],
63 "offers": [
64 {
65 "product_id": 1,
66 "method_id": 1,
67 "label": "Preview running"
68 }
69 ],
70 "show_powered_by": false
71 },
72 "app_scheme": "lsupreview",
73 "offers": [
74 {
75 "label": "Preview running",
76 "method": {
77 "id": 1,
78 "name": "LSU preview method",
79 "summary": "Demonstration content, not an approved LSU coach method.",
80 "version": 1,
81 "contact_url": "https://www.lastsoulultra.com/contact"
82 },
83 "coach": {
84 "name": "LSU Preview Coach"
85 },
86 "product": {
87 "id": 1,
88 "slug": "lsu-preview-running",
89 "name": "Preview running subscription",
90 "description": "Stripe TEST MODE. €19.99 is a sample price, not LSU’s approved pricing.",
91 "price_cents": 1999,
92 "currency": "EUR",
93 "billing_interval": "month",
94 "trial_days": 0
95 }
96 }
97 ]
98}
tests/html-audit.test.tsadded+226-0
Changes to tests/html-audit.test.ts
@@ -0,0 +1,226 @@
1import { describe, expect, it } from 'bun:test'
2import { readdirSync } from 'node:fs'
3import { join, resolve } from 'node:path'
4import { processDirectives } from '@stacksjs/stx'
5import { assetExists, assetUrls, attributeNames, auditPage, definedClasses, formatFindings, orphanPages, redirectSources, unstyledClasses } from '../scripts/audit-html'
6import { indexablePaths } from '../resources/data/site'
7
8/**
9 * The same rules scripts/audit-html.ts runs over dist, run here over the
10 * rendered views so a regression fails the suite rather than waiting for
11 * somebody to build and remember to check.
12 *
13 * One gap worth knowing: component resolution here is not the build's.
14 * `<dialog>` on /soul-races resolves through the @stacksjs/components bun
15 * plugin at build time and renders as nothing in this pass, so what is inside
16 * that dialog is checked by the script against dist rather than here.
17 */
18
19function viewFiles(dir = 'resources/views', prefix = ''): { url: string, file: string }[] {
20 return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
21 if (entry.isDirectory())
22 return viewFiles(join(dir, entry.name), `${prefix}/${entry.name}`)
23 if (!entry.name.endsWith('.stx'))
24 return []
25 const name = entry.name.slice(0, -'.stx'.length)
26 return [{ url: name === 'index' && prefix === '' ? '/' : `${prefix}/${name}`, file: join(dir, entry.name) }]
27 })
28}
29
30const views = viewFiles()
31const served = new Set([...views.map(view => view.url), ...redirectSources()])
32
33async function render(file: string): Promise<string> {
34 const path = resolve(file)
35 return processDirectives(await Bun.file(path).text(), {}, path, {
36 enabled: true,
37 cache: false,
38 componentsDir: resolve('resources/components'),
39 layoutsDir: resolve('resources/layouts'),
40 partialsDir: resolve('resources/partials'),
41 }, new Set())
42}
43
44describe('every page the site serves', () => {
45 it('has no dead internal link, duplicate id, skipped heading or unnamed link', async () => {
46 const findings = (await Promise.all(views.map(async view => auditPage(view.url, await render(view.file), served)))).flat()
47
48 expect(formatFindings(findings)).toBe('')
49 }, 120_000)
50
51 it('checks every view, not a handful', () => {
52 expect(views.length).toBeGreaterThanOrEqual(23)
53 expect(views.map(view => view.url)).toContain('/coaching/feedback')
54 })
55})
56
57/**
58 * A rule whose pattern stopped matching reports nothing, which looks exactly
59 * like a clean site. Each one is given a page it must complain about and a
60 * page it must not.
61 */
62describe('each rule', () => {
63 const served = new Set(['/', '/format'])
64
65 function rules(html: string): string[] {
66 const page = `<!DOCTYPE html><html lang="en"><head><title>t</title><meta name="description" content="A description long enough to count as one."><link rel="canonical" href="https://example.test/"></head><body><h1>One</h1>${html}</body></html>`
67 return auditPage('/t', page, served).map(finding => finding.rule)
68 }
69
70 it('catches a link to a page that is not served', () => {
71 expect(rules('<a href="/gone">gone</a>')).toContain('dead-link')
72 expect(rules('<a href="/format">here</a>')).not.toContain('dead-link')
73 expect(rules('<a href="/assets/x.css">asset</a>')).not.toContain('dead-link')
74 })
75
76 it('catches an image with no alt', () => {
77 expect(rules('<img src="/a.png">')).toContain('img-no-alt')
78 expect(rules('<img src="/a.png" alt="">')).not.toContain('img-no-alt')
79 })
80
81 it('catches two elements sharing an id', () => {
82 expect(rules('<p id="x"></p><p id="x"></p>')).toContain('duplicate-id')
83 expect(rules('<p id="x"></p><p id="y"></p>')).not.toContain('duplicate-id')
84 })
85
86 it('catches a skipped heading level and a second h1', () => {
87 expect(rules('<h3>skip</h3>')).toContain('heading-skip')
88 expect(rules('<h2>fine</h2><h3>fine</h3>')).not.toContain('heading-skip')
89 expect(rules('<h1>two</h1>')).toContain('h1-count')
90 })
91
92 it('catches a link with nothing to announce', () => {
93 expect(rules('<a href="/format"><span class="icon"></span></a>')).toContain('link-no-name')
94 expect(rules('<a href="/format" aria-label="Home"><span></span></a>')).not.toContain('link-no-name')
95 expect(rules('<a href="/format"><img src="/a.png" alt="Home"></a>')).not.toContain('link-no-name')
96 })
97
98 it('catches a new tab the opener can be steered from', () => {
99 expect(rules('<a href="/format" target="_blank">x</a>')).toContain('blank-no-noopener')
100 expect(rules('<a href="/format" target="_blank" rel="noopener">x</a>')).not.toContain('blank-no-noopener')
101 })
102
103 it('catches an outbound link over plain HTTP, but not the local preview', () => {
104 expect(rules('<a href="http://hq.training/x">x</a>')).toContain('insecure-link')
105 expect(rules('<a href="http://localhost:3940/x">x</a>')).not.toContain('insecure-link')
106 expect(rules('<a href="https://hq.training/x">x</a>')).not.toContain('insecure-link')
107 })
108
109 it('catches a control with no label, in any of the four ways of giving it one', () => {
110 expect(rules('<input name="email">')).toContain('control-no-label')
111 expect(rules('<label>Email<input name="email"></label>')).not.toContain('control-no-label')
112 expect(rules('<label for="e">Email</label><input id="e" name="email">')).not.toContain('control-no-label')
113 expect(rules('<input name="email" aria-label="Email">')).not.toContain('control-no-label')
114 expect(rules('<input type="hidden" name="token">')).not.toContain('control-no-label')
115 })
116
117 it('catches a button with no name, and allows one whose text is bound', () => {
118 expect(rules('<button type="submit"></button>')).toContain('button-no-name')
119 expect(rules('<button :text="label"></button>')).not.toContain('button-no-name')
120 expect(rules('<button aria-label="Close">×</button>')).not.toContain('button-no-name')
121 })
122
123 it('catches a page that does not say what it is', () => {
124 const bare = auditPage('/t', '<!DOCTYPE html><html lang="en"><body><h1>x</h1></body></html>', served).map(f => f.rule)
125 expect(bare).toContain('no-description')
126 expect(bare).toContain('no-canonical')
127 expect(rules('')).not.toContain('no-description')
128 expect(rules('')).not.toContain('no-canonical')
129 })
130
131 it('catches an asset the deploy does not carry', () => {
132 expect(rules('<img src="/assets/lsu/images/nope.png" alt="x">')).toContain('missing-asset')
133 expect(rules('<img src="/assets/lsu/images/lsu-logo.svg" alt="x">')).not.toContain('missing-asset')
134 })
135
136 it('reads a srcset candidate and a CSS url, not only src', () => {
137 expect(assetUrls('<img srcset="/a.png 1x, /b.png 2x">')).toEqual(['/a.png', '/b.png'])
138 expect(assetUrls('<div style="background-image:url(\'/c.webp\')">')).toEqual(['/c.webp'])
139 expect(rules('<div style="background-image:url(/assets/lsu/images/nope.webp)"></div>')).toContain('missing-asset')
140 })
141
142 it('catches the same attribute written twice on one tag', () => {
143 expect(rules('<div tabindex="0" data-track tabindex="0"></div>')).toContain('duplicate-attribute')
144 expect(rules('<div tabindex="0" data-track></div>')).not.toContain('duplicate-attribute')
145 })
146
147 it('does not read an attribute name out of a value', () => {
148 // An stx binding mentions its own state names inside the quoted value.
149 const binding = `<button :text="mode === 'new' ? a : mode === 'old' ? b : c" :disabled="busy"></button>`
150 expect(rules(binding)).not.toContain('duplicate-attribute')
151
152 expect(attributeNames(` :text="mode === 'new'" :disabled="busy" data-x`))
153 .toEqual([':text', ':disabled', 'data-x'])
154 expect(attributeNames(` a=1 b='two words' c`)).toEqual(['a', 'b', 'c'])
155 })
156
157 it('catches a page with no language', () => {
158 expect(auditPage('/t', '<html><body><h1>x</h1></body></html>', served).map(f => f.rule)).toContain('no-lang')
159 expect(rules('')).not.toContain('no-lang')
160 })
161})
162
163describe('classes the markup uses and no stylesheet defines', () => {
164 const sheets = readdirSync('resources/assets/styles/site')
165 .filter(name => name.endsWith('.css'))
166 .map(name => join('resources/assets/styles/site', name))
167 const defined = definedClasses(sheets)
168
169 it('reads the real sheets, not an empty set', () => {
170 expect(defined.size).toBeGreaterThan(300)
171 expect(defined.has('kicker')).toBe(true)
172 expect(defined.has('h3')).toBe(true)
173 })
174
175 it('names a class nothing defines', () => {
176 const findings = unstyledClasses('/t', '<p class="kicker made-up-class">x</p>', defined)
177 expect(findings.map(finding => finding.detail)).toEqual(['made-up-class'])
178 })
179
180 it('names it once however often it appears', () => {
181 const html = '<p class="made-up-class"></p><p class="made-up-class"></p>'
182 expect(unstyledClasses('/t', html, defined)).toHaveLength(1)
183 })
184
185 it('ignores the scope and wrapper names stx writes per render', () => {
186 const html = '<p class="lsu-sz7xmlte stx_race_portal_2_1r3g0n"></p>'
187 expect(unstyledClasses('/t', html, defined)).toEqual([])
188 })
189
190 it('allows the hooks that are on the ratchet, and nothing else', () => {
191 expect(unstyledClasses('/t', '<div class="race-board"></div>', defined)).toEqual([])
192 expect(unstyledClasses('/t', '<div class="race-boardx"></div>', defined)).toHaveLength(1)
193 })
194
195 it('does not look inside a script', () => {
196 expect(unstyledClasses('/t', '<script>el.className = "made-up-class"</script>', defined)).toEqual([])
197 })
198})
199
200describe('indexed pages nothing links to', () => {
201 it('finds a URL with no inbound link, and not one that has one', () => {
202 const rendered = new Map([
203 ['/', '<a href="/format">f</a>'],
204 ['/format', '<a href="/">home</a>'],
205 ])
206
207 expect(orphanPages(rendered, ['/', '/format']).map(f => f.page)).toEqual([])
208 expect(orphanPages(rendered, ['/', '/format', '/lonely']).map(f => f.page)).toEqual(['/lonely'])
209 })
210
211 it('does not count a page linking to itself', () => {
212 const rendered = new Map([['/a', '<a href="/a">me</a>'], ['/b', '']])
213 expect(orphanPages(rendered, ['/a']).map(f => f.page)).toEqual(['/a'])
214 })
215
216 it('counts a link carrying a fragment or a query', () => {
217 const rendered = new Map([['/a', '<a href="/b#x">x</a>'], ['/b', '']])
218 expect(orphanPages(rendered, ['/b'])).toEqual([])
219 })
220
221 it('leaves no indexed page of this site unlinked', async () => {
222 const pairs = await Promise.all(views.map(async view => [view.url, await render(view.file)] as const))
223
224 expect(orphanPages(new Map(pairs), indexablePaths).map(f => f.page)).toEqual([])
225 }, 120_000)
226})
tests/navigation.test.tsadded+65-0
Changes to tests/navigation.test.ts
@@ -0,0 +1,65 @@
1import { describe, expect, it } from 'bun:test'
2import { resolve } from 'node:path'
3import { processDirectives } from '@stacksjs/stx'
4
5/**
6 * The chrome every page shares marked nothing, so the navigation read the
7 * same on all twenty-five routes and no assistive technology could say which
8 * one you were on.
9 */
10
11async function render(view: string): Promise<string> {
12 const path = resolve(`resources/views/${view}.stx`)
13 return processDirectives(await Bun.file(path).text(), {}, path, {
14 enabled: true,
15 cache: false,
16 componentsDir: resolve('resources/components'),
17 layoutsDir: resolve('resources/layouts'),
18 partialsDir: resolve('resources/partials'),
19 }, new Set())
20}
21
22/** Every href marked as the current page, deduplicated. */
23async function marked(view: string): Promise<string[]> {
24 const html = await render(view)
25 return [...new Set([...html.matchAll(/<a href="([^"]+)"[^>]*aria-current="page"/g)].map(match => match[1]!))]
26}
27
28describe('knowing which page you are on', () => {
29 it('marks the page itself', async () => {
30 expect(await marked('format')).toEqual(['/format'])
31 expect(await marked('sponsorship')).toEqual(['/sponsorship'])
32 })
33
34 it('marks home only at the root, not on every path under it', async () => {
35 expect(await marked('index')).toEqual(['/'])
36 expect(await marked('format')).not.toContain('/')
37 })
38
39 it('marks the section a nested page belongs to', async () => {
40 expect(await marked('coaching/calendar')).toContain('/coaching')
41 expect(await marked('results/last-soul-ultra-2026')).toContain('/results')
42 })
43
44 it('follows the canonical, so an alias marks the page it points at', async () => {
45 // /faq renders the contact page and canonicalises to /contact.
46 expect(await marked('faq')).toEqual(await marked('contact'))
47 expect(await marked('faq')).toContain('/contact')
48 })
49
50 it('marks nothing on a page that is in neither nav', async () => {
51 expect(await marked('404')).toEqual([])
52 })
53
54 it('marks it in the drawer and the footer too, not just the bar', async () => {
55 const html = await render('format')
56 // Three copies of the Format link: the bar, the mobile drawer, the footer.
57 expect([...html.matchAll(/href="\/format"[^>]*aria-current="page"/g)].length).toBeGreaterThanOrEqual(3)
58 })
59
60 it('tells it apart by more than colour', async () => {
61 const css = await Bun.file('resources/assets/styles/site/stx-site.css').text()
62 expect(css).toContain('[aria-current=page]{color:var(--lsu-white);box-shadow')
63 expect(css).toContain('text-decoration:underline')
64 })
65})
tests/questions.test.tsadded+51-0
Changes to tests/questions.test.ts
@@ -0,0 +1,51 @@
1import { describe, expect, it } from 'bun:test'
2import { resolve } from 'node:path'
3import { processDirectives } from '@stacksjs/stx'
4import { absolute } from '../resources/data/site'
5
6/**
7 * /contact and /faq are the same page under two published URLs. They used to
8 * be two byte-identical 238-line views, which is the arrangement where one of
9 * them silently stops matching the other the first time an answer is edited.
10 */
11
12async function render(view: string): Promise<string> {
13 const path = resolve(`resources/views/${view}.stx`)
14 return processDirectives(await Bun.file(path).text(), {}, path, {
15 enabled: true,
16 cache: false,
17 componentsDir: resolve('resources/components'),
18 layoutsDir: resolve('resources/layouts'),
19 partialsDir: resolve('resources/partials'),
20 }, new Set())
21}
22
23describe('the questions page', () => {
24 it('renders the same answers at both URLs', async () => {
25 // stx stamps each render with its own scope id, which is not content.
26 const scrub = (html: string): string => html.replace(/[a-z0-9]{6,}(?=["'\s])/gi, '')
27
28 expect(scrub(await render('faq'))).toBe(scrub(await render('contact')))
29 })
30
31 it('keeps one copy of the answers, not one per view', async () => {
32 for (const view of ['contact', 'faq'])
33 expect((await Bun.file(`resources/views/${view}.stx`).text()).split('\n').length).toBeLessThan(15)
34
35 const body = await Bun.file('resources/partials/contact-body.stx').text()
36 expect(body).toContain('How does the Last Soul Ultra work?')
37 })
38
39 it('still answers the fifteen questions', async () => {
40 const html = await render('contact')
41 expect([...html.matchAll(/class="faq-q[^"]*">/g)]).toHaveLength(15)
42 })
43
44 it('canonicalises both to /contact', async () => {
45 // Built from APP_URL rather than written out: CI materialises a .env from
46 // .env.example, where APP_URL is stacks.localhost, so a hard-coded
47 // production origin passes on a laptop and fails on a runner.
48 for (const view of ['contact', 'faq'])
49 expect(await render(view)).toContain(`rel="canonical" href="${absolute('/contact')}"`)
50 })
51})
tests/race-facts.test.tsadded+123-0
Changes to tests/race-facts.test.ts
@@ -0,0 +1,123 @@
1import { describe, expect, it } from 'bun:test'
2import { resolve } from 'node:path'
3import { processDirectives } from '@stacksjs/stx'
4
5/**
6 * The results pages state figures that are derived from the race database in
7 * resources/data/results-<year>.json. The README's one content rule is that
8 * race facts are not replaced with sample content; this is what checks it.
9 *
10 * Everything here is recomputed from the JSON and then looked for in the
11 * rendered page, so a page that drifts from its own source fails, and so
12 * does a page whose numbers were edited by hand.
13 */
14
15interface Row { name: string, yards: number, status: string }
16
17async function season(year: number) {
18 const data = await Bun.file(`resources/data/results-${year}.json`).json()
19 const rows: Row[] = data.results
20 const ordered = [...rows].sort((a, b) => b.yards - a.yards)
21 const loopMetres: number = data.event.loop_metres
22 const winner = ordered[0]!
23
24 return {
25 date: data.event.start_date as string,
26 started: data.totals.started as number,
27 loopMetres,
28 winner,
29 assist: ordered[1]!,
30 third: ordered[2]!,
31 kilometres: Math.round(winner.yards * loopMetres / 100) / 10,
32 /** A yard an hour, so the whole days the race ran for. */
33 days: Math.ceil(winner.yards / 24),
34 }
35}
36
37const page = await (async () => {
38 const path = resolve('resources/views/results.stx')
39 return processDirectives(await Bun.file(path).text(), {}, path, {
40 enabled: true,
41 cache: false,
42 componentsDir: resolve('resources/components'),
43 layoutsDir: resolve('resources/layouts'),
44 partialsDir: resolve('resources/partials'),
45 }, new Set())
46})()
47
48/**
49 * One season's own section, so a figure from the other year cannot satisfy
50 * an assertion about this one. Each is `<section id="<year>" ...>`.
51 */
52function sectionFor(year: number): string {
53 const start = page.indexOf(`<section id="${year}"`)
54 expect(start).toBeGreaterThan(-1)
55 const next = page.indexOf('<section id="', start + 1)
56 return page.slice(start, next === -1 ? undefined : next)
57}
58
59/**
60 * The page's stat blocks are a value element followed by its label. The
61 * source has them on separate lines and the build minifies that away, so the
62 * gap between them is optional.
63 */
64function stat(section: string, label: string): string | undefined {
65 return new RegExp(`<div class="stat-v">([^<]+)</div>\\s*<div class="stat-l">${label}</div>`).exec(section)?.[1]
66}
67
68describe.each([2025, 2026])('the %i results', (year) => {
69 it('names the winner the database names, at the yards it records', async () => {
70 const { winner } = await season(year)
71 const section = sectionFor(year)
72
73 expect(winner.status).toBe('1st')
74 expect(section).toContain(winner.name)
75 expect(stat(section, 'winning yards')).toBe(String(winner.yards))
76 })
77
78 it('counts the field the database counted', async () => {
79 const { started } = await season(year)
80 const section = sectionFor(year)
81
82 expect(section).toContain(`${started} starters`)
83 expect(stat(section, 'athletes on the start line')).toBe(String(started))
84 })
85
86 it('converts yards to kilometres with the loop the database records', async () => {
87 const { kilometres, loopMetres } = await season(year)
88 const section = sectionFor(year)
89
90 expect(stat(section, 'kilometres covered')).toBe(String(kilometres))
91 expect(stat(section, 'km per hourly loop')).toBe(String(loopMetres / 1000))
92 })
93
94 it('counts the days the race actually ran', async () => {
95 const { days } = await season(year)
96 expect(stat(sectionFor(year), 'days of racing')).toBe(String(days))
97 })
98
99 it('names the two behind the winner, in the order they finished', async () => {
100 const { winner, assist, third } = await season(year)
101 const section = sectionFor(year)
102
103 expect(assist.yards).toBeGreaterThanOrEqual(third.yards)
104 expect(section.indexOf(winner.name)).toBeLessThan(section.indexOf(assist.name))
105 expect(section.indexOf(assist.name)).toBeLessThan(section.indexOf(third.name))
106 })
107})
108
109describe('the edition comparison', () => {
110 it('states differences that are the difference between the two seasons', async () => {
111 const a = await season(2025)
112 const b = await season(2026)
113
114 expect(page).toContain(`+${b.winner.yards - a.winner.yards}`)
115 expect(page).toContain(`+${b.started - a.started}`)
116 })
117
118 it('agrees with the loop length the rest of the site uses', async () => {
119 const { race } = await import('../resources/data/race')
120 expect((await season(2026)).loopMetres).toBe(race.loopMetres)
121 expect((await season(2025)).loopMetres).toBe(race.loopMetres)
122 })
123})
tests/seo.test.tsmodified+130-15
Changes to tests/seo.test.ts
@@ -1,6 +1,10 @@
11import { describe, expect, it } from 'bun:test'
2import { readdirSync } from 'node:fs'
3import { legalPages } from '../resources/data/legal'
2import { readFileSync, readdirSync } from 'node:fs'
3import { join, resolve } from 'node:path'
4import { processDirectives } from '@stacksjs/stx'
5import server from '../config/server'
6import { coachingPage } from '../resources/data/coaching'
7import { productionPages } from '../resources/data/production-pages'
48import { indexablePaths } from '../resources/data/site'
59import { publishedOrigin, robotsTxt, sitemapXml } from '../scripts/seo'
610
@@ -22,26 +26,91 @@ describe('seo artefacts', () => {
2226 expect(committed).toBe(robotsTxt(publishedOrigin))
2327 })
2428
29 /** Every view on disk, as the path the site serves it at. */
30 function viewPaths(dir = 'resources/views', prefix = ''): string[] {
31 return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
32 if (entry.isDirectory())
33 return viewPaths(join(dir, entry.name), `${prefix}/${entry.name}`)
34 if (!entry.name.endsWith('.stx'))
35 return []
36 const name = entry.name.slice(0, -'.stx'.length)
37 return [name === 'index' && prefix === '' ? '/' : `${prefix}/${name}`]
38 })
39 }
40
41 /**
42 * The canonical a view declares, read from its source. Views write it as a
43 * literal, and the coaching modules build it from their slug.
44 */
45 function canonicalOf(path: string): string {
46 const file = `resources/views${path === '/' ? '/index' : path}.stx`
47 const source = readFileSync(file, 'utf8')
48 const literal = /const canonicalPath = ['"]([^'"]+)['"]/.exec(source)
49 if (literal)
50 return literal[1]!
51 const built = /const canonicalPath = modulePath\(['"]([^'"]+)['"]\)/.exec(source)
52 if (built)
53 return `/coaching/${built[1]}`
54 throw new Error(`${file} declares no canonicalPath`)
55 }
56
57 /** A page that declares itself noindex is not expected in the sitemap. */
58 function noindex(path: string): boolean {
59 if (path === '/404')
60 return true
61 if (path.startsWith('/coaching/'))
62 return coachingPage(path.slice('/coaching/'.length)).noindex === true
63 return productionPages[path]?.noindex === true
64 }
65
2566 it('lists every view that is not dynamic, private or noindex', () => {
2667 // Views on disk are the ground truth for what the site actually serves.
27 const views = readdirSync('resources/views')
28 .filter(name => name.endsWith('.stx'))
29 .map(name => name.slice(0, -'.stx'.length))
30
31 const expected = views
32 // 404 is noindex on purpose, and index is the root path.
33 .filter(name => !['404', 'profile'].includes(name))
34 .map(name => (name === 'index' ? '/' : `/${name}`))
68 // Recursive: /results and /coaching both hold pages a flat read missed.
69 const expected = viewPaths().filter(path => !noindex(path) && canonicalOf(path) === path)
3570
71 expect(expected.length).toBeGreaterThan(13)
3672 for (const path of expected)
3773 expect(indexablePaths).toContain(path)
3874 })
3975
40 it('lists the canonical legal pages rather than the legacy redirects', () => {
41 for (const page of legalPages)
42 expect(indexablePaths).toContain(`/${page.slug}`)
43 for (const page of legalPages)
44 expect(indexablePaths).not.toContain(`/legal/${page.slug}`)
76 it('does not list a page that points its canonical somewhere else', () => {
77 // /faq renders the contact page and canonicalises to /contact. Listing a
78 // URL that tells a crawler it is not the canonical one asks for the page
79 // to be ignored, and spends crawl budget doing it.
80 const aliases = viewPaths().filter(path => !noindex(path) && canonicalOf(path) !== path)
81
82 expect(aliases).toContain('/faq')
83 for (const path of aliases) {
84 expect(indexablePaths).not.toContain(path)
85 // An alias must still point at a page the sitemap does list.
86 expect(indexablePaths).toContain(canonicalOf(path))
87 }
88 })
89
90 it('keeps the modules that need an account out of the sitemap', () => {
91 const modules = viewPaths().filter(path => path.startsWith('/coaching/'))
92
93 expect(modules).toHaveLength(8)
94 for (const path of modules) {
95 if (noindex(path))
96 expect(indexablePaths).not.toContain(path)
97 }
98 expect(indexablePaths).toContain('/coaching/plans')
99 })
100
101 it('lists where a redirect lands, never the URL it leaves', () => {
102 // The redirect table is the list of URLs this site used to publish.
103 // Submitting one of those is submitting a 301, which costs a crawl and
104 // indexes nothing.
105 const rules = Object.entries(server.redirects ?? {})
106
107 expect(rules.length).toBeGreaterThan(0)
108 for (const [from, to] of rules) {
109 const target = typeof to === 'string' ? to : to.to
110 expect(indexablePaths).not.toContain(from)
111 if (target.startsWith('/'))
112 expect(indexablePaths).toContain(target)
113 }
45114 })
46115
47116 it('publishes absolute https URLs on a single origin', () => {
@@ -60,4 +129,50 @@ describe('seo artefacts', () => {
60129 it('does not list the noindex 404 page', () => {
61130 expect(indexablePaths).not.toContain('/404')
62131 })
132
133 it('gives every view the one metadata object the head reads', () => {
134 // 404.stx used to declare pageTitle, pageDescription and noindex as loose
135 // names nothing looked at, so the page served the site's default title and
136 // description and did not carry its noindex.
137 for (const path of viewPaths()) {
138 const file = `resources/views${path === '/' ? '/index' : path}.stx`
139 expect(readFileSync(file, 'utf8')).toContain('const currentPage')
140 }
141 })
142})
143
144describe('the page that is not found', () => {
145 it('says so, and asks not to be indexed', async () => {
146 const path = resolve('resources/views/404.stx')
147 const html = await processDirectives(await Bun.file(path).text(), {}, path, {
148 enabled: true,
149 cache: false,
150 componentsDir: resolve('resources/components'),
151 layoutsDir: resolve('resources/layouts'),
152 partialsDir: resolve('resources/partials'),
153 }, new Set())
154
155 expect(html).toContain('<title>not found - Last Soul Ultra</title>')
156 expect(html).toContain('<meta name="robots" content="noindex,nofollow">')
157 expect(html).toContain('content="That page is not here."')
158 // Nothing crawls it, so it carries no graph.
159 expect(html).not.toContain('application/ld+json')
160 })
161
162 it('wears the same chrome as every other page', async () => {
163 const path = resolve('resources/views/404.stx')
164 const html = await processDirectives(await Bun.file(path).text(), {}, path, {
165 enabled: true,
166 cache: false,
167 componentsDir: resolve('resources/components'),
168 layoutsDir: resolve('resources/layouts'),
169 partialsDir: resolve('resources/partials'),
170 }, new Set())
171
172 // It used to write its own html, head and body, and so was the one page
173 // with no skip link, no focusable main and the wrong lang.
174 expect(html).toContain('class="lsu-skip"')
175 expect(html).toContain('<main id="main" tabindex="-1">')
176 expect(html).toContain('lang="en-GB"')
177 })
63178})
tests/setup.tsmodified+9-0
Changes to tests/setup.ts
@@ -10,6 +10,15 @@
1010if (!Bun.env.STRIPE_SECRET_KEY)
1111 Bun.env.STRIPE_SECRET_KEY = 'sk_test_fake_key_for_testing'
1212
13// config/coaching defaults HQ_ORIGIN to the real hq.training, and the coaching
14// components read the published brand while rendering. Left alone, a suite run
15// would make live requests to another service - slow, flaky, and answered
16// differently depending on who is running it. Loopback port 1 refuses at once,
17// so the default is a deterministic "nothing published"; a test that wants a
18// brand stubs fetch itself.
19if (!Bun.env.HQ_ORIGIN)
20 Bun.env.HQ_ORIGIN = 'http://127.0.0.1:1'
21
1322import { applyRuntimeDirectoryEnv } from '@stacksjs/path'
1423import { setupTestEnvironment } from '@stacksjs/testing'
1524
tests/share.test.tsadded+97-0
Changes to tests/share.test.ts
@@ -0,0 +1,97 @@
1import { describe, expect, it } from 'bun:test'
2import { resolve } from 'node:path'
3import { processDirectives } from '@stacksjs/stx'
4import { CARD_HEIGHT, CARD_WIDTH, cards, shareCard } from '../resources/data/share'
5import { indexablePaths } from '../resources/data/site'
6
7/**
8 * Share cards are the one part of a page nobody on the team ever sees: they
9 * render in someone else's app, days later, and a wrong one looks deliberate.
10 * The site shipped nine cards it never referenced for exactly that reason, so
11 * these tests check the file on disk rather than the mapping alone.
12 */
13
14/** Width and height off the JPEG's own SOF marker, not off a filename. */
15function jpegSize(bytes: Uint8Array): { width: number, height: number } {
16 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
17 let offset = 2 // skip SOI
18 while (offset < bytes.length) {
19 if (view.getUint8(offset) !== 0xFF)
20 throw new Error('not a JPEG segment boundary')
21 const marker = view.getUint8(offset + 1)
22 const length = view.getUint16(offset + 2)
23 // SOF0..SOF15, minus the four markers in that range that are not frames.
24 if (marker >= 0xC0 && marker <= 0xCF && ![0xC4, 0xC8, 0xCC].includes(marker))
25 return { height: view.getUint16(offset + 5), width: view.getUint16(offset + 7) }
26 offset += 2 + length
27 }
28 throw new Error('no frame header')
29}
30
31describe('share cards', () => {
32 it('serves every card from a file that is actually on disk', async () => {
33 for (const card of Object.values(cards)) {
34 const file = Bun.file(`public${card.src}`)
35 expect(await file.exists()).toBe(true)
36 }
37 })
38
39 it('ships each card at the size the card slot crops to', async () => {
40 for (const card of Object.values(cards)) {
41 const bytes = new Uint8Array(await Bun.file(`public${card.src}`).arrayBuffer())
42 expect(jpegSize(bytes)).toEqual({ width: CARD_WIDTH, height: CARD_HEIGHT })
43 }
44 })
45
46 it('gives every indexable page a card', () => {
47 for (const path of indexablePaths)
48 expect(shareCard(path).src.endsWith('.jpg')).toBe(true)
49 })
50
51 it('falls back to the default card rather than to nothing', () => {
52 expect(shareCard('/404')).toEqual(cards.default)
53 expect(shareCard('/a-page-that-does-not-exist')).toEqual(cards.default)
54 })
55
56 it('describes what each card reads, not just the photograph behind it', () => {
57 for (const card of Object.values(cards)) {
58 expect(card.alt.length).toBeGreaterThan(40)
59 expect(card.alt).toContain('captioned')
60 }
61 })
62})
63
64describe('the head partial', () => {
65 async function head(): Promise<string> {
66 return processDirectives('@include(\'head\')', {}, resolve('resources/views/index.stx'), {
67 enabled: true,
68 cache: false,
69 partialsDir: resolve('resources/partials'),
70 componentsDir: resolve('resources/components'),
71 }, new Set())
72 }
73
74 it('points both card slots at the same absolute JPEG', async () => {
75 const html = await head()
76 const og = html.match(/property="og:image" content="([^"]+)"/)?.[1]
77 const twitter = html.match(/name="twitter:image" content="([^"]+)"/)?.[1]
78
79 expect(og).toBe(twitter)
80 expect(og?.startsWith('http')).toBe(true)
81 expect(og?.endsWith('.jpg')).toBe(true)
82 })
83
84 it('declares the card size, so a scraper need not fetch it to lay it out', async () => {
85 const html = await head()
86 expect(html).toContain(`content="${CARD_WIDTH}"`)
87 expect(html).toContain(`content="${CARD_HEIGHT}"`)
88 })
89
90 it('escapes the quotes inside an alt rather than ending the attribute early', async () => {
91 const html = await head()
92 const alt = html.match(/property="og:image:alt" content="([^"]+)"/)?.[1] ?? ''
93
94 expect(alt).toContain('&quot;')
95 expect(alt).not.toContain('"')
96 })
97})
tests/structured.test.tsadded+97-0
Changes to tests/structured.test.ts
@@ -0,0 +1,97 @@
1import { describe, expect, it } from 'bun:test'
2import { breadcrumbs, organization, structuredData, structuredDataJson } from '../resources/data/structured'
3
4/**
5 * Structured data makes claims on the site's behalf in places nobody on the
6 * team will ever look. The rule this suite enforces is that it may only claim
7 * what the site already prints in visible text, because markup that outruns
8 * the page is what gets a site's rich results withdrawn.
9 */
10
11const imprint = await Bun.file('resources/views/imprint.stx').text()
12const footer = await Bun.file('resources/components/SiteFooter.stx').text()
13
14describe('the organisation', () => {
15 it('states nothing the imprint does not', () => {
16 const org = organization() as any
17
18 expect(imprint).toContain(org.name)
19 expect(imprint).toContain(org.email)
20 expect(imprint).toContain(org.telephone)
21 expect(imprint).toContain(org.address.streetAddress)
22 expect(imprint).toContain(org.address.postalCode)
23 expect(imprint).toContain(org.address.addressLocality)
24 })
25
26 it('claims only the profiles that belong to the company', () => {
27 const org = organization() as any
28
29 for (const profile of org.sameAs)
30 expect(footer).toContain(new URL(profile).host)
31 // The footer also links a person's YouTube and Twitch. Listing those as
32 // the company's own accounts is a claim the imprint does not make.
33 expect(org.sameAs.join(' ')).not.toContain('youtube')
34 expect(org.sameAs.join(' ')).not.toContain('twitch')
35 })
36
37 it('is one node the rest of the graph points at, not a copy per page', () => {
38 const graph = structuredData('/results/last-soul-ultra-2026', 'x') as any
39 const ids = graph['@graph'].map((node: any) => node['@id']).filter(Boolean)
40
41 expect(new Set(ids).size).toBe(ids.length)
42 expect(graph['@graph'][1].publisher['@id']).toBe(graph['@graph'][0]['@id'])
43 })
44})
45
46describe('breadcrumbs', () => {
47 it('describes where a nested page sits', () => {
48 const crumbs = breadcrumbs('/results/last-soul-ultra-2025', 'Last Soul Ultra 2025 results | Last Soul Ultra') as any
49
50 expect(crumbs.itemListElement.map((item: any) => item.name))
51 .toEqual(['Last Soul Ultra', 'Results', 'Last Soul Ultra 2025 results'])
52 expect(crumbs.itemListElement.map((item: any) => item.position)).toEqual([1, 2, 3])
53 // The page itself carries no item: it is the URL being described.
54 expect(crumbs.itemListElement[2].item).toBeUndefined()
55 })
56
57 it('names a coaching module the way the module pages do', () => {
58 const crumbs = breadcrumbs('/coaching/account', 'Billing and renewal - Last Soul Ultra coaching') as any
59 expect(crumbs.itemListElement[2].name).toBe('Billing and renewal')
60 })
61
62 it('says nothing about a page with no parent', () => {
63 for (const path of ['/', '/format', '/coaching', '/results'])
64 expect(breadcrumbs(path, 'x')).toBeNull()
65 })
66
67 it('says nothing about a section it has no name for', () => {
68 expect(breadcrumbs('/some/unknown', 'x')).toBeNull()
69 })
70})
71
72describe('what is deliberately not published', () => {
73 it('describes no event, no FAQ and no offer', () => {
74 // The 2026 edition has been run, the FAQ answers live in a view rather
75 // than in data, and coaching prices are the coach's and served from HQ.
76 const json = structuredDataJson('/coaching/plans', 'What is open - Last Soul Ultra coaching')
77
78 for (const type of ['Event', 'SportsEvent', 'FAQPage', 'Offer', 'Product', 'Review'])
79 expect(json).not.toContain(`"${type}"`)
80 })
81})
82
83describe('serialising it', () => {
84 it('parses, and declares the vocabulary', () => {
85 const parsed = JSON.parse(structuredDataJson('/', 'Last Soul Ultra'))
86 expect(parsed['@context']).toBe('https://schema.org')
87 expect(Array.isArray(parsed['@graph'])).toBe(true)
88 })
89
90 it('cannot close the script element it sits in', () => {
91 const json = structuredDataJson('/results/last-soul-ultra-2026', '</script><img src=x onerror=alert(1)>')
92
93 expect(json).not.toContain('<')
94 expect(json).toContain('\\u003c')
95 expect(JSON.parse(json)['@graph'][2].itemListElement[2].name).toContain('</script>')
96 })
97})
tests/styles.test.tsadded+69-0
Changes to tests/styles.test.ts
@@ -0,0 +1,69 @@
1import { describe, expect, it } from 'bun:test'
2import { readdirSync } from 'node:fs'
3import { assetExists, assetUrls } from '../scripts/audit-html'
4
5/**
6 * stx-site.css is the one sheet every route downloads. It drifted into being
7 * the place page-specific rules went, and 7 of its 11.5 KB ended up being
8 * /coaching rules served to twenty-four pages that cannot match them.
9 */
10
11const siteWide = await Bun.file('resources/assets/styles/site/stx-site.css').text()
12
13/** Selectors a rule can be keyed on, one per top-level rule. */
14function selectors(css: string): string[] {
15 return [...css.matchAll(/(^|[}\n])([^{}@][^{}]*)\{/g)]
16 .flatMap(match => match[2]!.split(','))
17 .map(selector => selector.trim())
18 .filter(Boolean)
19}
20
21describe('the sheet every page downloads', () => {
22 it('carries nothing keyed to one page', () => {
23 // Page sheets live beside it and are named for their route.
24 const pages = readdirSync('resources/assets/styles/site')
25 .filter(name => name.endsWith('.css') && !['base.css', 'stx-site.css'].includes(name))
26 .map(name => name.slice(0, -'.css'.length))
27
28 const offenders = selectors(siteWide).filter(selector =>
29 pages.some(page => selector.includes(`.${page}-`) || selector.includes(`.lsu-scope-${page}`)))
30
31 expect(offenders).toEqual([])
32 })
33
34 it('stays small enough to be worth sending everywhere', () => {
35 expect(siteWide.length).toBeLessThan(5000)
36 })
37
38 it('still carries what every page needs', () => {
39 // The skip link, the shared portal chrome, and the one global motion rule.
40 expect(siteWide).toContain('.lsu-skip')
41 expect(siteWide).toContain('.race-portal')
42 expect(siteWide).toContain('prefers-reduced-motion')
43 })
44})
45
46describe('what the stylesheets reference', () => {
47 it('names only files the deploy carries', async () => {
48 const missing: string[] = []
49 for (const name of readdirSync('resources/assets/styles/site')) {
50 if (!name.endsWith('.css')) continue
51 const css = await Bun.file(`resources/assets/styles/site/${name}`).text()
52 for (const url of assetUrls(css)) {
53 if (!assetExists(url))
54 missing.push(`${name}: ${url}`)
55 }
56 }
57 expect(missing).toEqual([])
58 })
59
60 it('checks a real number of them, not zero', async () => {
61 // A regex that stopped matching would pass the test above silently.
62 let found = 0
63 for (const name of readdirSync('resources/assets/styles/site')) {
64 if (!name.endsWith('.css')) continue
65 found += assetUrls(await Bun.file(`resources/assets/styles/site/${name}`).text()).length
66 }
67 expect(found).toBeGreaterThan(20)
68 })
69})