also looking at this
LSU's own coaching surface, and what auditing the site turned up
#12
86 files
+3943
-1190
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # Last Soul Ultra on Stacks |
| 2 | 2 | |
| 3 | The 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. | |
| 3 | The 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. | |
| 4 | 4 | |
| 5 | 5 | ## Develop and verify |
| 6 | 6 | |
| @@ -14,7 +14,9 @@ bun scripts/build-site-assets.ts | ||
| 14 | 14 | ./buddy test |
| 15 | 15 | ``` |
| 16 | 16 | |
| 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. | |
| 18 | 20 | |
| 19 | 21 | `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. |
| 20 | 22 | |
| @@ -29,12 +31,42 @@ HQ_ORIGIN=https://hq.training | ||
| 29 | 31 | HQ_BRAND_SLUG=last-soul-ultra |
| 30 | 32 | ``` |
| 31 | 33 | |
| 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 | ||
| 36 | The 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. | |
| 33 | 41 | |
| 34 | 42 | HQ'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. |
| 35 | 43 | |
| 36 | 44 | HQ'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. |
| 37 | 45 | |
| 46 | ## Built, with nothing on the site reaching it | |
| 47 | ||
| 48 | Two working features have no entry point in the current pages. Both were | |
| 49 | added and then lost when the site was rebuilt from the production capture, so | |
| 50 | they 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 | |
| 54 | serves - and `routes/api.ts` serves it at `GET /api/live`, cached for a minute | |
| 55 | and answering 503 with `ok: false` rather than a page of zeroes when upstream | |
| 56 | is down. Nothing fetches it. The only consumer left is the 404 page, which | |
| 57 | asks it whether the race is running to decide what to link to. Restoring the | |
| 58 | standings means rendering `livePayload` on `/results` and polling that | |
| 59 | endpoint; the shape is the same server-side and in the browser so the table | |
| 60 | cannot drift between them. | |
| 61 | ||
| 62 | **The newsletter.** `app/Actions/SubscriberEmailAction.ts`, | |
| 63 | `ConfirmSubscriptionAction.ts`, both `app/Mail/Subscription*` classes and the | |
| 64 | three 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 | |
| 66 | page has a signup form, so nobody can start the flow. Adding one also needs | |
| 67 | privacy copy: the current `/privacy` documents analytics, server logs, | |
| 68 | accounts and entries, and says nothing about a mailing list. | |
| 69 | ||
| 38 | 70 | ## Existing race service |
| 39 | 71 | |
| 40 | 72 | `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. |
| @@ -1,4 +1,11 @@ | ||
| 1 | 1 | import type { ServerConfig } from '@stacksjs/types' |
| 2 | import 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 | */ | |
| 8 | const hqOrigin = new URL(coaching.origin).origin | |
| 2 | 9 | |
| 3 | 10 | /** |
| 4 | 11 | * **Server Configuration** |
| @@ -10,6 +17,27 @@ import type { ServerConfig } from '@stacksjs/types' | ||
| 10 | 17 | * Discord or GitHub Discussions. |
| 11 | 18 | */ |
| 12 | 19 | export 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 | ||
| 13 | 41 | /** |
| 14 | 42 | * **API Proxy** |
| 15 | 43 | * |
| @@ -27,7 +27,7 @@ | ||
| 27 | 27 | "upgrade": "./buddy upgrade", |
| 28 | 28 | "dev": "./buddy dev", |
| 29 | 29 | "development": "./buddy dev", |
| 30 | "build": "bun run build:assets && ./buddy build", | |
| 30 | "build": "bun run build:assets && ./buddy build && bun run build:bundles", | |
| 31 | 31 | "prod": "./buddy build", |
| 32 | 32 | "deploy": "./buddy deploy", |
| 33 | 33 | "lint": "./buddy lint", |
| @@ -72,7 +72,9 @@ | ||
| 72 | 72 | "deps:lockfile:check": "bun .github/scripts/check-lockfile-version.ts", |
| 73 | 73 | "bud": "./buddy", |
| 74 | 74 | "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" | |
| 76 | 78 | }, |
| 77 | 79 | "dependencies": { |
| 78 | 80 | "@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.
| @@ -1,1 +1,7 @@ | ||
| 1 | 1 | @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} | |
| @@ -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}} | |
| 2 | 28 | |
| 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 | 29 | .coaching-page .accent{color:var(--lsu-red)} |
| 24 | 30 | .coaching-page .coaching-body>*+*{margin-top:clamp(56px,7vw,104px)} |
| 25 | 31 | .coaching-page .coaching-head{display:flex;flex-direction:column;gap:18px;max-width:65ch} |
| @@ -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 | } | |
| @@ -1,1 +1,12 @@ | ||
| 1 | 1 | .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} | |
| @@ -1,1 +1,5 @@ | ||
| 1 | 1 | .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} | |
| @@ -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)} | |
| @@ -1,1 +1,53 @@ | ||
| 1 | 1 | .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 | } | |
| @@ -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}} | |
| @@ -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} | |
| @@ -1,1 +1,7 @@ | ||
| 1 | 1 | @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} | |
| @@ -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}} | |
| 2 | 28 | |
| 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 | 29 | .coaching-page .accent{color:var(--lsu-red)} |
| 24 | 30 | .coaching-page .coaching-body>*+*{margin-top:clamp(56px,7vw,104px)} |
| 25 | 31 | .coaching-page .coaching-head{display:flex;flex-direction:column;gap:18px;max-width:65ch} |
| @@ -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 | } | |
| @@ -1,1 +1,12 @@ | ||
| 1 | 1 | .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} | |
| @@ -1,1 +1,5 @@ | ||
| 1 | 1 | .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} | |
| @@ -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)} | |
| @@ -1,1 +1,53 @@ | ||
| 1 | 1 | .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 | } | |
| @@ -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}} | |
| @@ -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} | |
| @@ -0,0 +1,28 @@ | ||
| 1 | <script server> | |
| 2 | import { enabledModules, modulePath } from '../data/coaching' | |
| 3 | import { 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. */ | |
| 14 | const indexBrand = await loadBrand() | |
| 15 | const 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 | |
| @@ -0,0 +1,70 @@ | ||
| 1 | <script> | |
| 2 | interface Props { slug: string } | |
| 3 | const { slug } = defineProps<Props>() | |
| 4 | </script> | |
| 5 | <script server> | |
| 6 | import { coachingModule } from '../data/coaching' | |
| 7 | import { 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. */ | |
| 11 | const panel = coachingModule(slug) | |
| 12 | if (!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. */ | |
| 19 | const panelBrand = await loadBrand() | |
| 20 | const panelLive = Boolean(panelBrand?.modules.includes(panel.slug)) | |
| 21 | const 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. */ | |
| 27 | const panelHref = portalUrl(panel.slug) | |
| 28 | const 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> | |
| @@ -0,0 +1,75 @@ | ||
| 1 | <script> | |
| 2 | interface Props { | |
| 3 | /** Heading level for a tier name: 2 under a page heading, 3 under a section. */ | |
| 4 | level?: string | |
| 5 | } | |
| 6 | const { level = '3' } = defineProps<Props>() | |
| 7 | </script> | |
| 8 | <script server> | |
| 9 | import { formatPrice, formatTrial } from '../functions/coaching-brand' | |
| 10 | import { 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. */ | |
| 17 | const offerBrand = await loadBrand() | |
| 18 | const offers = offerBrand?.offers ?? [] | |
| 19 | const offerLocale = offerBrand?.locale ?? 'en' | |
| 20 | const 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. */ | |
| 25 | const 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. */ | |
| 32 | const 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 | |
| @@ -4,20 +4,24 @@ const { section = '' } = defineProps<Props>() | ||
| 4 | 4 | </script> |
| 5 | 5 | <script server> |
| 6 | 6 | import coaching from '../../config/coaching' |
| 7 | const coachingOrigin = new URL(coaching.origin).origin | |
| 8 | if (new URL(coachingOrigin).protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(new URL(coachingOrigin).hostname)) throw new Error('HQ_ORIGIN must use HTTPS') | |
| 9 | if (!/^[a-z0-9][a-z0-9-]{0,139}$/.test(coaching.brand)) throw new Error('HQ_BRAND_SLUG must be an organization slug') | |
| 10 | const coachingSection = ['plans', 'calendar', 'feedback', 'progress', 'library', 'health', 'messages', 'account'].includes(section) ? section : '' | |
| 11 | const portalUrl = `${coachingOrigin}/brands/${coaching.brand}` | |
| 12 | const elementUrl = `${coachingOrigin}/assets/elements/hq-coaching.js` | |
| 7 | import { 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. */ | |
| 12 | const portalBrand = coachingBrand() | |
| 13 | const portalApi = coachingOrigin() | |
| 14 | const portalNamedSection = coachingSection(section) | |
| 15 | const portalHref = portalUrl(portalNamedSection) | |
| 16 | const portalElement = portalElementUrl() | |
| 13 | 17 | </script> |
| 14 | 18 | <script client> |
| 15 | 19 | import { publicSubscriptionReceipt } from '../functions/coaching-return' |
| 16 | 20 | const coachingSearch = useSearchParams() |
| 17 | 21 | const coachingSubscription = memo(() => publicSubscriptionReceipt(coachingSearch.get('hq_subscription'))) |
| 18 | 22 | </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"> | |
| 21 | 25 | <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> | |
| 23 | 27 | </hq-coaching> |
| @@ -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 | ||
| 20 | export type ModuleScope = 'public' | 'athlete' | 'private' | |
| 21 | ||
| 22 | export 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 | ||
| 34 | export 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 | ||
| 93 | const bySlug = new Map(coachingModules.map(module => [module.slug, module])) | |
| 94 | ||
| 95 | export function coachingModule(slug: string): CoachingModule | undefined { | |
| 96 | return bySlug.get(slug) | |
| 97 | } | |
| 98 | ||
| 99 | /** The URL this site serves a module at. */ | |
| 100 | export 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 | */ | |
| 109 | export 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. */ | |
| 116 | export 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. */ | |
| 121 | export 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 | */ | |
| 130 | export const coachingHubStyles = [ | |
| 131 | ...coachingStyles, | |
| 132 | '/assets/styles/production/coaching-page.css', | |
| 133 | ] | |
| 134 | ||
| 135 | export 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 | */ | |
| 149 | export 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
| @@ -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 | ||
| 11 | export interface Question { | |
| 12 | q: string | |
| 13 | /** Paragraphs, so a long answer keeps its breaks. */ | |
| 14 | a: string[] | |
| 15 | } | |
| 16 | ||
| 17 | export interface FaqGroup { | |
| 18 | slug: string | |
| 19 | title: string | |
| 20 | blurb: string | |
| 21 | questions: Question[] | |
| 22 | } | |
| 23 | ||
| 24 | export 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 | ||
| 130 | export function groupBySlug(slug: string): FaqGroup | undefined { | |
| 131 | return faq.find(group => group.slug === slug) | |
| 132 | } | |
| 133 | ||
| 134 | export function questionCount(): number { | |
| 135 | return faq.reduce((total, group) => total + group.questions.length, 0) | |
| 136 | } | |
resources/data/legal.tsdeleted+0-100
| @@ -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 | ||
| 13 | export interface LegalBlock { | |
| 14 | kind: 'heading' | 'text' | |
| 15 | value: string | |
| 16 | } | |
| 17 | ||
| 18 | export 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 | ||
| 28 | export 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 | ||
| 98 | export function legalBySlug(slug: string): LegalPage | undefined { | |
| 99 | return legalPages.find(page => page.slug === slug) | |
| 100 | } | |
resources/data/media.tsdeleted+0-82
| @@ -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 | ||
| 21 | export 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. */ | |
| 31 | export const WIDTHS = [540, 810, 1080] as const | |
| 32 | ||
| 33 | const DIR = '/assets/images/race' | |
| 34 | ||
| 35 | export 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 | */ | |
| 71 | export 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. */ | |
| 77 | export 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. */ | |
| 82 | export const recap: Shot[] = [shots.board, shots.loop, shots.refuel, shots.night] | |
| @@ -1,11 +1,15 @@ | ||
| 1 | 1 | import { env } from '@stacksjs/env' |
| 2 | import { at, shots } from './media' | |
| 2 | import { indexableModulePaths } from './coaching' | |
| 3 | 3 | import { race } from './race' |
| 4 | 4 | |
| 5 | 5 | /** |
| 6 | * Site-level constants: origin, navigation, footer. | |
| 6 | * Site-level constants: the origin, and the list of what the site publishes. | |
| 7 | 7 | * |
| 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. | |
| 9 | 13 | */ |
| 10 | 14 | |
| 11 | 15 | /** |
| @@ -28,24 +32,6 @@ export function absolute(path: string): string { | ||
| 28 | 32 | return `${siteOrigin}${path.startsWith('/') ? path : `/${path}`}` |
| 29 | 33 | } |
| 30 | 34 | |
| 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. */ | |
| 39 | export const socialImage = absolute(at(shots.board, 1080)) | |
| 40 | export const socialImageAlt = shots.board.alt | |
| 41 | ||
| 42 | export interface NavItem { | |
| 43 | label: string | |
| 44 | href: string | |
| 45 | /** Sends the visitor off-site. */ | |
| 46 | external?: boolean | |
| 47 | } | |
| 48 | ||
| 49 | 35 | /** |
| 50 | 36 | * Every indexable URL on the site, in one place. |
| 51 | 37 | * |
| @@ -59,27 +45,15 @@ export interface NavItem { | ||
| 59 | 45 | export const indexablePaths: string[] = [ |
| 60 | 46 | '/', '/format', '/world-series', '/soul-races', '/sponsorship', '/contact', |
| 61 | 47 | '/lottery', '/results', '/results/last-soul-ultra-2026', '/results/last-soul-ultra-2025', |
| 62 | '/imprint', '/privacy', '/terms', '/coaching', '/faq', | |
| 63 | ] | |
| 64 | ||
| 65 | export 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 | ||
| 73 | export 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 | ||
| 80 | export 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(), | |
| 83 | 57 | ] |
| 84 | 58 | |
| 85 | 59 | /** "14 august 2026", lowercase like the rest of the site's voice. */ |
| @@ -0,0 +1,124 @@ | ||
| 1 | import { coachingModule } from './coaching' | |
| 2 | import { race } from './race' | |
| 3 | import { 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 | ||
| 25 | const ORGANIZATION_ID = `${siteOrigin}/#organization` | |
| 26 | const 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 | */ | |
| 36 | export 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 | ||
| 57 | export 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. */ | |
| 69 | const SECTIONS: Record<string, string> = { | |
| 70 | results: 'Results', | |
| 71 | coaching: 'Coaching', | |
| 72 | } | |
| 73 | ||
| 74 | /** The label for the last crumb, which is the page itself. */ | |
| 75 | function 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 | */ | |
| 88 | export 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. */ | |
| 109 | export 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 | */ | |
| 122 | export function structuredDataJson(path: string, title: string): string { | |
| 123 | return JSON.stringify(structuredData(path, title)).replace(/</g, '\\u003c') | |
| 124 | } | |
| @@ -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 | ||
| 16 | export 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 | ||
| 26 | export 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 | ||
| 35 | export interface CoachingOffer { | |
| 36 | label: string | |
| 37 | method: CoachingMethod | |
| 38 | coach: string | |
| 39 | product: CoachingProduct | |
| 40 | } | |
| 41 | ||
| 42 | export 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 | ||
| 57 | function text(value: unknown, max = 400): string | null { | |
| 58 | return typeof value === 'string' && value.trim().length > 0 && value.length <= max ? value.trim() : null | |
| 59 | } | |
| 60 | ||
| 61 | function 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 | */ | |
| 69 | function 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. */ | |
| 81 | function currency(value: unknown): string | null { | |
| 82 | return typeof value === 'string' && /^[A-Z]{3}$/.test(value) ? value : null | |
| 83 | } | |
| 84 | ||
| 85 | function 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 | */ | |
| 127 | export 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. */ | |
| 163 | const 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. */ | |
| 166 | export 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. */ | |
| 179 | export 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 | } | |
| @@ -0,0 +1,152 @@ | ||
| 1 | import type { CoachingBrand } from './coaching-brand' | |
| 2 | import coaching from '../../config/coaching' | |
| 3 | import type { CoachingPageMeta } from '../data/coaching' | |
| 4 | import { coachingModule, coachingPage } from '../data/coaching' | |
| 5 | import { 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. */ | |
| 22 | const HIT_TTL_MS = 60_000 | |
| 23 | /** Short enough that a brand going live shows up within a minute. */ | |
| 24 | const MISS_TTL_MS = 10_000 | |
| 25 | /** A render blocked on another service is a broken page, not a slow one. */ | |
| 26 | const TIMEOUT_MS = 2500 | |
| 27 | ||
| 28 | interface Entry { | |
| 29 | brand: CoachingBrand | null | |
| 30 | until: number | |
| 31 | } | |
| 32 | ||
| 33 | const cache = new Map<string, Entry>() | |
| 34 | /** One in-flight request per brand, so a cold cache does not stampede. */ | |
| 35 | const 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 | */ | |
| 45 | export 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. */ | |
| 53 | export 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 | */ | |
| 64 | export 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. */ | |
| 69 | export 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. */ | |
| 74 | export 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. */ | |
| 82 | export 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. */ | |
| 87 | export function portalElementUrl(origin = coaching.origin): string { | |
| 88 | return `${coachingOrigin(origin)}/assets/elements/hq-coaching.js` | |
| 89 | } | |
| 90 | ||
| 91 | async 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 | */ | |
| 111 | export 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. */ | |
| 131 | export 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 | */ | |
| 146 | export 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 | } | |
| @@ -4,9 +4,9 @@ | ||
| 4 | 4 | <body class="lsu-scope-site"> |
| 5 | 5 | <a class="lsu-skip" href="#main">Skip to content</a> |
| 6 | 6 | <div class="lsu-scope-site page"> |
| 7 | <SiteNav /> | |
| 7 | <SiteNav current="" /> | |
| 8 | 8 | <main id="main" tabindex="-1">@slot('content')</main> |
| 9 | <SiteFooter /> | |
| 9 | <SiteFooter current="" /> | |
| 10 | 10 | </div> |
| 11 | 11 | </body> |
| 12 | 12 | </html> |
| @@ -1,10 +1,9 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { productionPages } from '~/resources/data/production-pages' | |
| 5 | const currentPage = productionPages["/contact"] | |
| 6 | const 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. --}} | |
| 8 | 7 | <section class="lsu-scope-contact phero"> |
| 9 | 8 | <div class="lsu-scope-contact phero-copy"> |
| 10 | 9 | <div class="kicker lsu-scope-contact">We reply within 24 hours</div> |
| @@ -235,4 +234,3 @@ const canonicalPath = "/contact" | ||
| 235 | 234 | <a href="mailto:mail@lastsoulultra.com" class="btn btn-lg lsu-scope-contact">Email the team</a> |
| 236 | 235 | </div> |
| 237 | 236 | </section> |
| 238 | @endsection | |
| @@ -1,7 +1,16 @@ | ||
| 1 | 1 | <script server> |
| 2 | import { CARD_HEIGHT, CARD_WIDTH, shareCard } from '../data/share' | |
| 2 | 3 | import { absolute } from '../data/site' |
| 4 | import { structuredDataJson } from '../data/structured' | |
| 3 | 5 | const 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'] } |
| 4 | 6 | const pagePath = typeof canonicalPath !== 'undefined' ? canonicalPath : '/' |
| 7 | const 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. | |
| 10 | const 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. | |
| 13 | const warm = Array.isArray(metaPage.preconnect) ? metaPage.preconnect : [] | |
| 5 | 14 | </script> |
| 6 | 15 | <meta charset="utf-8"> |
| 7 | 16 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| @@ -17,13 +26,26 @@ const pagePath = typeof canonicalPath !== 'undefined' ? canonicalPath : '/' | ||
| 17 | 26 | <meta property="og:title" content=""> |
| 18 | 27 | <meta property="og:description" content=""> |
| 19 | 28 | <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=""> | |
| 21 | 34 | <meta name="twitter:card" content="summary_large_image"> |
| 35 | <meta name="twitter:image" content=""> | |
| 36 | <meta name="twitter:image:alt" content=""> | |
| 22 | 37 | <link rel="icon" href="/assets/lsu/images/favicon.png" type="image/png"> |
| 23 | 38 | <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 | |
| 24 | 43 | <link rel="preload" href="/assets/lsu/fonts/anton-400-latin.woff2" as="font" type="font/woff2" crossorigin> |
| 25 | 44 | <link rel="preload" href="/assets/lsu/fonts/barlow-400-latin.woff2" as="font" type="font/woff2" crossorigin> |
| 26 | 45 | @foreach(metaPage.styles as sheet) |
| 27 | 46 | <link rel="stylesheet" href=""> |
| 28 | 47 | @endforeach |
| 29 | 48 | <link rel="stylesheet" href="/assets/styles/stx-site.css"> |
| 49 | @if(graph) | |
| 50 | <script type="application/ld+json">{!! graph !!}</script> | |
| 51 | @endif | |
| @@ -1,31 +1,33 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 1 | 3 | <script server> |
| 2 | import { liveRaceState } from '../data/live' | |
| 3 | import { race } from '../data/race' | |
| 4 | import { liveRaceState } from '~/resources/data/live' | |
| 4 | 5 | |
| 5 | const pageTitle = 'not found - Last Soul Ultra' | |
| 6 | const 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. */ | |
| 9 | const 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 | } | |
| 7 | 15 | const canonicalPath = '/404' |
| 8 | const noindex = true | |
| 9 | 16 | const state = await liveRaceState() |
| 10 | 17 | </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 | |
| @@ -1,10 +1,22 @@ | ||
| 1 | 1 | @extends('layouts/LSU') |
| 2 | 2 | @section('content') |
| 3 | 3 | <script server> |
| 4 | import { coachingHubStyles } from '~/resources/data/coaching' | |
| 5 | import { coachingOrigin, loadBrand } from '~/resources/functions/coaching-source' | |
| 6 | ||
| 4 | 7 | // 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. | |
| 7 | const 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. */ | |
| 17 | const hubBrand = await loadBrand() | |
| 18 | const hubLive = (hubBrand?.modules.length ?? 0) > 0 | |
| 19 | const 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()] } : {}) } | |
| 8 | 20 | const canonicalPath = '/coaching' |
| 9 | 21 | </script> |
| 10 | 22 | <div class="coaching-page"> |
| @@ -14,7 +26,11 @@ const canonicalPath = '/coaching' | ||
| 14 | 26 | <span class="kicker">Last Soul Ultra / Coaching</span> |
| 15 | 27 | <h1>Say how it felt.<br><span class="accent">The plan changes.</span></h1> |
| 16 | 28 | <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) | |
| 17 | 30 | <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 | |
| 18 | 34 | </div> |
| 19 | 35 | <div class="coaching-hero-shot"> |
| 20 | 36 | <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' | ||
| 84 | 100 | |
| 85 | 101 | <section id="training"> |
| 86 | 102 | <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 | |
| 87 | 107 | <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 /> | |
| 89 | 114 | </div> |
| 115 | @if(hubLive) | |
| 90 | 116 | <div class="coaching-portal"><CoachingPortal /></div> |
| 117 | @endif | |
| 91 | 118 | </section> |
| 92 | 119 | |
| 93 | 120 | <section id="coaches" class="coaching-coaches"> |
| @@ -0,0 +1,10 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { modulePath } from '~/resources/data/coaching' | |
| 5 | import { coachingPageMeta } from '~/resources/functions/coaching-source' | |
| 6 | const currentPage = await coachingPageMeta('account') | |
| 7 | const canonicalPath = modulePath('account') | |
| 8 | </script> | |
| 9 | <CoachingModule slug="account" /> | |
| 10 | @endsection | |
| @@ -0,0 +1,10 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { modulePath } from '~/resources/data/coaching' | |
| 5 | import { coachingPageMeta } from '~/resources/functions/coaching-source' | |
| 6 | const currentPage = await coachingPageMeta('calendar') | |
| 7 | const canonicalPath = modulePath('calendar') | |
| 8 | </script> | |
| 9 | <CoachingModule slug="calendar" /> | |
| 10 | @endsection | |
| @@ -0,0 +1,10 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { modulePath } from '~/resources/data/coaching' | |
| 5 | import { coachingPageMeta } from '~/resources/functions/coaching-source' | |
| 6 | const currentPage = await coachingPageMeta('feedback') | |
| 7 | const canonicalPath = modulePath('feedback') | |
| 8 | </script> | |
| 9 | <CoachingModule slug="feedback" /> | |
| 10 | @endsection | |
| @@ -0,0 +1,10 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { modulePath } from '~/resources/data/coaching' | |
| 5 | import { coachingPageMeta } from '~/resources/functions/coaching-source' | |
| 6 | const currentPage = await coachingPageMeta('health') | |
| 7 | const canonicalPath = modulePath('health') | |
| 8 | </script> | |
| 9 | <CoachingModule slug="health" /> | |
| 10 | @endsection | |
| @@ -0,0 +1,10 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { modulePath } from '~/resources/data/coaching' | |
| 5 | import { coachingPageMeta } from '~/resources/functions/coaching-source' | |
| 6 | const currentPage = await coachingPageMeta('library') | |
| 7 | const canonicalPath = modulePath('library') | |
| 8 | </script> | |
| 9 | <CoachingModule slug="library" /> | |
| 10 | @endsection | |
| @@ -0,0 +1,10 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { modulePath } from '~/resources/data/coaching' | |
| 5 | import { coachingPageMeta } from '~/resources/functions/coaching-source' | |
| 6 | const currentPage = await coachingPageMeta('messages') | |
| 7 | const canonicalPath = modulePath('messages') | |
| 8 | </script> | |
| 9 | <CoachingModule slug="messages" /> | |
| 10 | @endsection | |
| @@ -0,0 +1,10 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { modulePath } from '~/resources/data/coaching' | |
| 5 | import { coachingPageMeta } from '~/resources/functions/coaching-source' | |
| 6 | const currentPage = await coachingPageMeta('plans') | |
| 7 | const canonicalPath = modulePath('plans') | |
| 8 | </script> | |
| 9 | <CoachingModule slug="plans" /> | |
| 10 | @endsection | |
| @@ -0,0 +1,10 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { modulePath } from '~/resources/data/coaching' | |
| 5 | import { coachingPageMeta } from '~/resources/functions/coaching-source' | |
| 6 | const currentPage = await coachingPageMeta('progress') | |
| 7 | const canonicalPath = modulePath('progress') | |
| 8 | </script> | |
| 9 | <CoachingModule slug="progress" /> | |
| 10 | @endsection | |
| @@ -5,234 +5,5 @@ import { productionPages } from '~/resources/data/production-pages' | ||
| 5 | 5 | const currentPage = productionPages["/contact"] |
| 6 | 6 | const canonicalPath = "/contact" |
| 7 | 7 | </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 & 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('/assets/lsu/images/rappid-47.webp');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') | |
| 238 | 9 | @endsection |
| @@ -5,234 +5,5 @@ import { productionPages } from '~/resources/data/production-pages' | ||
| 5 | 5 | const currentPage = productionPages["/contact"] |
| 6 | 6 | const canonicalPath = "/contact" |
| 7 | 7 | </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 & 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('/assets/lsu/images/rappid-47.webp');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') | |
| 238 | 9 | @endsection |
| @@ -258,7 +258,7 @@ Miss the next start or fail to finish a loop within the hour, and your race is o | ||
| 258 | 258 | </div> |
| 259 | 259 | </div> |
| 260 | 260 | <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"> | |
| 262 | 262 | <div class="carousel-slide lsu-scope-home"> |
| 263 | 263 | <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"> |
| 264 | 264 | </div> |
| @@ -14,9 +14,7 @@ const canonicalPath = "/lottery" | ||
| 14 | 14 | </section> |
| 15 | 15 | <section id="form" class="entry lsu-scope-lottery"> |
| 16 | 16 | <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 /> | |
| 20 | 18 | </div> |
| 21 | 19 | </section> |
| 22 | 20 | <section class="lsu-scope-lottery open-now section"> |
| @@ -106,5 +104,4 @@ const canonicalPath = "/lottery" | ||
| 106 | 104 | <br class="lsu-scope-lottery">Selection is by LSU team and a random draw. </p> |
| 107 | 105 | </div> |
| 108 | 106 | </section> |
| 109 | <section class="section"><div class="shell"><RacePortal /></div></section> | |
| 110 | 107 | @endsection |
| @@ -117,11 +117,11 @@ onMount(async () => { | ||
| 117 | 117 | </section> |
| 118 | 118 | <div class="lsu-scope-soul-races sr-side"> |
| 119 | 119 | <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"> | |
| 121 | 121 | <p :if="!srRaces.length" class="lsu-scope-soul-races sr-empty">No Soul Races are listed yet. Be the first.</p> |
| 122 | 122 | <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> | |
| 125 | 125 | </div> |
| 126 | 126 | </div> |
| 127 | 127 | <section class="lsu-scope-soul-races sr-host"> |
| @@ -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 | ||
| 21 | import { existsSync, readFileSync, readdirSync } from 'node:fs' | |
| 22 | import { join, relative } from 'node:path' | |
| 23 | import process from 'node:process' | |
| 24 | import server from '../config/server' | |
| 25 | ||
| 26 | const ROOT = 'dist' | |
| 27 | ||
| 28 | export interface Finding { | |
| 29 | page: string | |
| 30 | rule: string | |
| 31 | detail: string | |
| 32 | } | |
| 33 | ||
| 34 | /** Built pages, depth first. `_`-prefixed directories are build internals. */ | |
| 35 | export 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. */ | |
| 44 | export 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. */ | |
| 50 | function 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 | ||
| 57 | function 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. */ | |
| 65 | export 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 | */ | |
| 73 | const assetSeen = new Map<string, boolean>() | |
| 74 | export 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. */ | |
| 82 | export 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 | */ | |
| 103 | export 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. */ | |
| 131 | export 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 | */ | |
| 267 | const 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. */ | |
| 287 | export 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 | ||
| 296 | export 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 | */ | |
| 320 | export 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. */ | |
| 334 | export 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 | ||
| 351 | if (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 | } | |
| @@ -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 | ||
| 26 | import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' | |
| 27 | import { join, relative } from 'node:path' | |
| 28 | import process from 'node:process' | |
| 29 | ||
| 30 | const ROOT = 'dist' | |
| 31 | const ASSET_DIR = join(ROOT, '_stx') | |
| 32 | /** Below this, a request costs more than the bytes saved. */ | |
| 33 | const MIN_BYTES = 16 * 1024 | |
| 34 | ||
| 35 | if (!existsSync(ROOT)) { | |
| 36 | console.error(`${ROOT}/ does not exist. Run the build first.`) | |
| 37 | process.exit(1) | |
| 38 | } | |
| 39 | ||
| 40 | function 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. */ | |
| 49 | function bundleName(code: string): string { | |
| 50 | return `modules.${new Bun.CryptoHasher('sha256').update(code).digest('hex').slice(0, 16)}.js` | |
| 51 | } | |
| 52 | ||
| 53 | const written = new Map<string, number>() | |
| 54 | let movedBytes = 0 | |
| 55 | let touchedPages = 0 | |
| 56 | ||
| 57 | for (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 | ||
| 85 | console.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)`) | |
| @@ -1,6 +1,23 @@ | ||
| 1 | 1 | /* eslint-disable no-console */ |
| 2 | 2 | import { resolve, sep } from 'node:path' |
| 3 | 3 | import { compressResponse } from '@stacksjs/stx/compression' |
| 4 | import 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 | */ | |
| 13 | function 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 | } | |
| 4 | 21 | |
| 5 | 22 | const root = resolve('dist') |
| 6 | 23 | Bun.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) { | ||
| 15 | 32 | let file = Bun.file(path) |
| 16 | 33 | let status = 200 |
| 17 | 34 | 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 })) | |
| 19 | 37 | } }) |
| 20 | 38 | console.log('LSU preview listening at http://localhost:3942') |
| @@ -0,0 +1,108 @@ | ||
| 1 | import type { Server } from 'bun' | |
| 2 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 3 | import { 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 | ||
| 12 | let server: Server | |
| 13 | let hits = 0 | |
| 14 | ||
| 15 | function 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 | ||
| 27 | const published = { | |
| 28 | slug: 'last-soul-ultra', | |
| 29 | settings: { name: 'Last Soul Ultra', headline: 'Train for the next start.', modules: ['plans'] }, | |
| 30 | offers: [], | |
| 31 | } | |
| 32 | ||
| 33 | beforeEach(() => forgetBrand()) | |
| 34 | afterEach(async () => { await server?.stop(true) }) | |
| 35 | ||
| 36 | describe('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 | }) | |
| @@ -0,0 +1,237 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { coachingModule, coachingModules, enabledModules, indexableModulePaths, modulePath } from '../resources/data/coaching' | |
| 3 | import { formatPrice, formatTrial, parseBrand } from '../resources/functions/coaching-brand' | |
| 4 | import coaching from '../config/coaching' | |
| 5 | import server from '../config/server' | |
| 6 | import { coachingBrand, coachingOrigin, coachingSection, portalElementUrl, portalUrl } from '../resources/functions/coaching-source' | |
| 7 | ||
| 8 | describe('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 | */ | |
| 72 | const 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. */ | |
| 75 | function sample(): any { | |
| 76 | return structuredClone(payload) | |
| 77 | } | |
| 78 | ||
| 79 | describe('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 | ||
| 141 | describe('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 | ||
| 165 | describe('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 | ||
| 201 | describe('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 | ||
| 230 | describe('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 | }) | |
| @@ -0,0 +1,280 @@ | ||
| 1 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 2 | import { resolve } from 'node:path' | |
| 3 | import { processDirectives } from '@stacksjs/stx' | |
| 4 | import { coachingModules } from '../resources/data/coaching' | |
| 5 | import { absolute } from '../resources/data/site' | |
| 6 | import { 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 | ||
| 17 | const payload = await Bun.file('tests/fixtures/brand-last-soul-ultra.json').json() | |
| 18 | const realFetch = globalThis.fetch | |
| 19 | ||
| 20 | /** Answer the brand endpoint from the fixture; refuse everything else. */ | |
| 21 | function 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 | ||
| 30 | async 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 | ||
| 41 | beforeEach(() => forgetBrand()) | |
| 42 | afterEach(() => { globalThis.fetch = realFetch }) | |
| 43 | ||
| 44 | describe('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 | ||
| 111 | describe('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 | ||
| 157 | describe('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 | ||
| 174 | describe('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 | ||
| 201 | describe('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 | ||
| 221 | describe('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 | ||
| 254 | describe('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 | }) | |
| @@ -0,0 +1,108 @@ | ||
| 1 | import { 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 | ||
| 14 | type Rgb = [number, number, number] | |
| 15 | ||
| 16 | function 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 | ||
| 24 | function 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. */ | |
| 30 | function dim(alpha: number, over: Rgb): Rgb { | |
| 31 | return over.map(channel => Math.round(255 * alpha + channel * (1 - alpha))) as Rgb | |
| 32 | } | |
| 33 | ||
| 34 | const BLACK: Rgb = [0, 0, 0] | |
| 35 | const RED: Rgb = [221, 15, 21] | |
| 36 | /** AA: 4.5 for body text, 3.0 at 24px or 18.66px bold and above. */ | |
| 37 | const BODY = 4.5 | |
| 38 | const LARGE = 3 | |
| 39 | ||
| 40 | describe('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 | ||
| 65 | describe('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 | }) | |
| @@ -0,0 +1,48 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { resolve } from 'node:path' | |
| 3 | import { 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 | ||
| 12 | async 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 | ||
| 23 | describe('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 | }) | |
| @@ -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 | } | |
| @@ -0,0 +1,226 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { readdirSync } from 'node:fs' | |
| 3 | import { join, resolve } from 'node:path' | |
| 4 | import { processDirectives } from '@stacksjs/stx' | |
| 5 | import { assetExists, assetUrls, attributeNames, auditPage, definedClasses, formatFindings, orphanPages, redirectSources, unstyledClasses } from '../scripts/audit-html' | |
| 6 | import { 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 | ||
| 19 | function 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 | ||
| 30 | const views = viewFiles() | |
| 31 | const served = new Set([...views.map(view => view.url), ...redirectSources()]) | |
| 32 | ||
| 33 | async 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 | ||
| 44 | describe('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 | */ | |
| 62 | describe('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 | ||
| 163 | describe('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 | ||
| 200 | describe('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 | }) | |
| @@ -0,0 +1,51 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { resolve } from 'node:path' | |
| 3 | import { processDirectives } from '@stacksjs/stx' | |
| 4 | import { 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 | ||
| 12 | async 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 | ||
| 23 | describe('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 | }) | |
| @@ -0,0 +1,123 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { resolve } from 'node:path' | |
| 3 | import { 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 | ||
| 15 | interface Row { name: string, yards: number, status: string } | |
| 16 | ||
| 17 | async 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 | ||
| 37 | const 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 | */ | |
| 52 | function 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 | */ | |
| 64 | function 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 | ||
| 68 | describe.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 | ||
| 109 | describe('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 | }) | |
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | import { describe, expect, it } from 'bun:test' |
| 2 | import { readdirSync } from 'node:fs' | |
| 3 | import { legalPages } from '../resources/data/legal' | |
| 2 | import { readFileSync, readdirSync } from 'node:fs' | |
| 3 | import { join, resolve } from 'node:path' | |
| 4 | import { processDirectives } from '@stacksjs/stx' | |
| 5 | import server from '../config/server' | |
| 6 | import { coachingPage } from '../resources/data/coaching' | |
| 7 | import { productionPages } from '../resources/data/production-pages' | |
| 4 | 8 | import { indexablePaths } from '../resources/data/site' |
| 5 | 9 | import { publishedOrigin, robotsTxt, sitemapXml } from '../scripts/seo' |
| 6 | 10 | |
| @@ -22,26 +26,91 @@ describe('seo artefacts', () => { | ||
| 22 | 26 | expect(committed).toBe(robotsTxt(publishedOrigin)) |
| 23 | 27 | }) |
| 24 | 28 | |
| 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 | ||
| 25 | 66 | it('lists every view that is not dynamic, private or noindex', () => { |
| 26 | 67 | // 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) | |
| 35 | 70 | |
| 71 | expect(expected.length).toBeGreaterThan(13) | |
| 36 | 72 | for (const path of expected) |
| 37 | 73 | expect(indexablePaths).toContain(path) |
| 38 | 74 | }) |
| 39 | 75 | |
| 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 | } | |
| 45 | 114 | }) |
| 46 | 115 | |
| 47 | 116 | it('publishes absolute https URLs on a single origin', () => { |
| @@ -60,4 +129,50 @@ describe('seo artefacts', () => { | ||
| 60 | 129 | it('does not list the noindex 404 page', () => { |
| 61 | 130 | expect(indexablePaths).not.toContain('/404') |
| 62 | 131 | }) |
| 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 | ||
| 144 | describe('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 | }) | |
| 63 | 178 | }) |
| @@ -10,6 +10,15 @@ | ||
| 10 | 10 | if (!Bun.env.STRIPE_SECRET_KEY) |
| 11 | 11 | Bun.env.STRIPE_SECRET_KEY = 'sk_test_fake_key_for_testing' |
| 12 | 12 | |
| 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. | |
| 19 | if (!Bun.env.HQ_ORIGIN) | |
| 20 | Bun.env.HQ_ORIGIN = 'http://127.0.0.1:1' | |
| 21 | ||
| 13 | 22 | import { applyRuntimeDirectoryEnv } from '@stacksjs/path' |
| 14 | 23 | import { setupTestEnvironment } from '@stacksjs/testing' |
| 15 | 24 | |
| @@ -0,0 +1,97 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { 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 | ||
| 11 | const imprint = await Bun.file('resources/views/imprint.stx').text() | |
| 12 | const footer = await Bun.file('resources/components/SiteFooter.stx').text() | |
| 13 | ||
| 14 | describe('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 | ||
| 46 | describe('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 | ||
| 72 | describe('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 | ||
| 83 | describe('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 | }) | |
| @@ -0,0 +1,69 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { readdirSync } from 'node:fs' | |
| 3 | import { 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 | ||
| 11 | const 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. */ | |
| 14 | function 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 | ||
| 21 | describe('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 | ||
| 46 | describe('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 | }) | |