also looking at this
LSU's own coaching surface, and what auditing the site turned up
#12
86 files
+3943
-1190
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -0,0 +1,78 @@ | ||
| 1 | /* The coaching surface: /coaching and the eight module routes under it. | |
| 2 | Loaded only by those routes, not from stx-site.css, which the whole site | |
| 3 | pays for on every page. | |
| 4 | ||
| 5 | The palette is the site's own, which is also what the HQ brand preset | |
| 6 | publishes: black, #0d0d0f surfaces, #dd0f15, Anton over Barlow, square | |
| 7 | corners. Nothing here reads a colour over the network - a page whose | |
| 8 | palette arrives with a fetch paints unstyled while that fetch is in flight. | |
| 9 | ||
| 10 | Prefixed .coach- throughout. The marketing page's .coaching-* rules live in | |
| 11 | stx-site.css and the two must not reach into one another. */ | |
| 12 | ||
| 13 | .coaching-surface {--coach-gutter: clamp(18px, 5vw, 40px); --coach-rule: 1px solid var(--lsu-line)} | |
| 14 | .coaching-surface .coach-shell {max-width:var(--lsu-max);margin:0 auto;padding:0 var(--coach-gutter)} | |
| 15 | /* The shell has no vertical padding, so the first child's top margin would | |
| 16 | collapse straight through it and sit flush against the nav rule. The band | |
| 17 | below the nav owns that space instead, and the panel carries none. */ | |
| 18 | .coaching-surface .coach-nav+.coach-shell {padding-top:clamp(26px,3vw,40px)} | |
| 19 | ||
| 20 | /* Header. A compact band rather than another full-height hero: the runner who | |
| 21 | reaches /coaching/calendar came to read this week's sessions, not to be | |
| 22 | sold the idea again. The red hairline under it carries the brand instead. */ | |
| 23 | .coaching-surface .coach-head {padding:clamp(104px,11vw,136px) 0 clamp(26px,3vw,38px)} | |
| 24 | .coaching-surface .coach-head .kicker {display:block;color:var(--lsu-red)} | |
| 25 | .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} | |
| 26 | .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)} | |
| 27 | ||
| 28 | /* Module nav. One row of words, the current one underlined in red. It scrolls | |
| 29 | sideways on a phone rather than wrapping into three ragged lines, and the | |
| 30 | scrollport keeps its own focus ring so a keyboard can reach the overflow. */ | |
| 31 | .coaching-surface .coach-nav {border-block:var(--coach-rule);background:#000} | |
| 32 | .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} | |
| 33 | .coaching-surface .coach-nav ul::-webkit-scrollbar {display:none} | |
| 34 | .coaching-surface .coach-nav li {scroll-snap-align:start} | |
| 35 | .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} | |
| 36 | .coaching-surface .coach-nav a:hover {color:var(--lsu-white)} | |
| 37 | .coaching-surface .coach-nav a[aria-current="page"] {border-bottom-color:var(--lsu-red);color:var(--lsu-white)} | |
| 38 | ||
| 39 | /* The panel. Space is reserved before the remote element upgrades, so HQ | |
| 40 | loading cannot shove the footer down the page under the reader. */ | |
| 41 | .coaching-surface .coach-panel {min-height:460px;margin:0 0 clamp(60px,8vw,120px)} | |
| 42 | .coaching-surface .coach-panel hq-coaching {display:block} | |
| 43 | .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)} | |
| 44 | .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)} | |
| 45 | ||
| 46 | /* Said before the click, not after it. A module that needs an account says so | |
| 47 | here; a private one says what this site cannot see, which is the whole | |
| 48 | point of health and messages reading the athlete's own account. */ | |
| 49 | .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)} | |
| 50 | .coaching-surface .coach-scope strong {font:400 13px/1 var(--lsu-body);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-white)} | |
| 51 | .coaching-surface .coach-scope p {margin:0;max-width:62ch;color:var(--lsu-dim-1);font:400 15px/1.65 var(--lsu-body)} | |
| 52 | ||
| 53 | /* The storefront. One ruled row per tier, not a grid of pricing cards: there | |
| 54 | is one offer today and a three-column grid holding one card looks broken. */ | |
| 55 | .coaching-surface .coach-offers {display:grid;margin:0;padding:0;list-style:none;border-top:var(--coach-rule)} | |
| 56 | .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)} | |
| 57 | .coaching-surface .coach-offer h3 {margin:0;font:400 clamp(24px,2.8vw,36px)/1.1 var(--lsu-display);text-transform:uppercase} | |
| 58 | .coaching-surface .coach-offer-by {margin:10px 0 0;color:var(--lsu-red);font:400 13px/1 var(--lsu-body);letter-spacing:.14em;text-transform:uppercase} | |
| 59 | .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)} | |
| 60 | .coaching-surface .coach-offer-price {text-align:right;white-space:nowrap} | |
| 61 | .coaching-surface .coach-offer-price b {display:block;font:400 clamp(22px,2.4vw,30px)/1.1 var(--lsu-display);font-weight:400} | |
| 62 | .coaching-surface .coach-offer-trial {display:block;margin-top:8px;color:var(--lsu-dim-3);font:400 13px/1.4 var(--lsu-body)} | |
| 63 | .coaching-surface .coach-offer-note {margin:14px 0 0;color:var(--lsu-dim-3);font:400 13px/1.5 var(--lsu-body)} | |
| 64 | ||
| 65 | /* Nothing published yet. The honest state, and the one that ships today. */ | |
| 66 | .coaching-surface .coach-empty {padding:clamp(30px,5vw,56px);border:var(--coach-rule);background:var(--lsu-card)} | |
| 67 | .coaching-surface .coach-empty h2 {margin:0;font:400 clamp(24px,3vw,34px)/1.1 var(--lsu-display);text-transform:uppercase} | |
| 68 | .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)} | |
| 69 | ||
| 70 | /* Focus has to clear the black. A red ring on #000 is not reliably visible at | |
| 71 | small sizes, so the ring is white with a red cast behind it. */ | |
| 72 | .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)} | |
| 73 | ||
| 74 | @media (max-width:760px) { | |
| 75 | .coaching-surface .coach-offer {grid-template-columns:minmax(0,1fr);gap:18px} | |
| 76 | .coaching-surface .coach-offer-price {text-align:left} | |
| 77 | .coaching-surface .coach-panel {min-height:520px} | |
| 78 | } | |
| @@ -0,0 +1,78 @@ | ||
| 1 | /* The coaching surface: /coaching and the eight module routes under it. | |
| 2 | Loaded only by those routes, not from stx-site.css, which the whole site | |
| 3 | pays for on every page. | |
| 4 | ||
| 5 | The palette is the site's own, which is also what the HQ brand preset | |
| 6 | publishes: black, #0d0d0f surfaces, #dd0f15, Anton over Barlow, square | |
| 7 | corners. Nothing here reads a colour over the network - a page whose | |
| 8 | palette arrives with a fetch paints unstyled while that fetch is in flight. | |
| 9 | ||
| 10 | Prefixed .coach- throughout. The marketing page's .coaching-* rules live in | |
| 11 | stx-site.css and the two must not reach into one another. */ | |
| 12 | ||
| 13 | .coaching-surface {--coach-gutter: clamp(18px, 5vw, 40px); --coach-rule: 1px solid var(--lsu-line)} | |
| 14 | .coaching-surface .coach-shell {max-width:var(--lsu-max);margin:0 auto;padding:0 var(--coach-gutter)} | |
| 15 | /* The shell has no vertical padding, so the first child's top margin would | |
| 16 | collapse straight through it and sit flush against the nav rule. The band | |
| 17 | below the nav owns that space instead, and the panel carries none. */ | |
| 18 | .coaching-surface .coach-nav+.coach-shell {padding-top:clamp(26px,3vw,40px)} | |
| 19 | ||
| 20 | /* Header. A compact band rather than another full-height hero: the runner who | |
| 21 | reaches /coaching/calendar came to read this week's sessions, not to be | |
| 22 | sold the idea again. The red hairline under it carries the brand instead. */ | |
| 23 | .coaching-surface .coach-head {padding:clamp(104px,11vw,136px) 0 clamp(26px,3vw,38px)} | |
| 24 | .coaching-surface .coach-head .kicker {display:block;color:var(--lsu-red)} | |
| 25 | .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} | |
| 26 | .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)} | |
| 27 | ||
| 28 | /* Module nav. One row of words, the current one underlined in red. It scrolls | |
| 29 | sideways on a phone rather than wrapping into three ragged lines, and the | |
| 30 | scrollport keeps its own focus ring so a keyboard can reach the overflow. */ | |
| 31 | .coaching-surface .coach-nav {border-block:var(--coach-rule);background:#000} | |
| 32 | .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} | |
| 33 | .coaching-surface .coach-nav ul::-webkit-scrollbar {display:none} | |
| 34 | .coaching-surface .coach-nav li {scroll-snap-align:start} | |
| 35 | .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} | |
| 36 | .coaching-surface .coach-nav a:hover {color:var(--lsu-white)} | |
| 37 | .coaching-surface .coach-nav a[aria-current="page"] {border-bottom-color:var(--lsu-red);color:var(--lsu-white)} | |
| 38 | ||
| 39 | /* The panel. Space is reserved before the remote element upgrades, so HQ | |
| 40 | loading cannot shove the footer down the page under the reader. */ | |
| 41 | .coaching-surface .coach-panel {min-height:460px;margin:0 0 clamp(60px,8vw,120px)} | |
| 42 | .coaching-surface .coach-panel hq-coaching {display:block} | |
| 43 | .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)} | |
| 44 | .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)} | |
| 45 | ||
| 46 | /* Said before the click, not after it. A module that needs an account says so | |
| 47 | here; a private one says what this site cannot see, which is the whole | |
| 48 | point of health and messages reading the athlete's own account. */ | |
| 49 | .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)} | |
| 50 | .coaching-surface .coach-scope strong {font:400 13px/1 var(--lsu-body);letter-spacing:.14em;text-transform:uppercase;color:var(--lsu-white)} | |
| 51 | .coaching-surface .coach-scope p {margin:0;max-width:62ch;color:var(--lsu-dim-1);font:400 15px/1.65 var(--lsu-body)} | |
| 52 | ||
| 53 | /* The storefront. One ruled row per tier, not a grid of pricing cards: there | |
| 54 | is one offer today and a three-column grid holding one card looks broken. */ | |
| 55 | .coaching-surface .coach-offers {display:grid;margin:0;padding:0;list-style:none;border-top:var(--coach-rule)} | |
| 56 | .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)} | |
| 57 | .coaching-surface .coach-offer h3 {margin:0;font:400 clamp(24px,2.8vw,36px)/1.1 var(--lsu-display);text-transform:uppercase} | |
| 58 | .coaching-surface .coach-offer-by {margin:10px 0 0;color:var(--lsu-red);font:400 13px/1 var(--lsu-body);letter-spacing:.14em;text-transform:uppercase} | |
| 59 | .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)} | |
| 60 | .coaching-surface .coach-offer-price {text-align:right;white-space:nowrap} | |
| 61 | .coaching-surface .coach-offer-price b {display:block;font:400 clamp(22px,2.4vw,30px)/1.1 var(--lsu-display);font-weight:400} | |
| 62 | .coaching-surface .coach-offer-trial {display:block;margin-top:8px;color:var(--lsu-dim-3);font:400 13px/1.4 var(--lsu-body)} | |
| 63 | .coaching-surface .coach-offer-note {margin:14px 0 0;color:var(--lsu-dim-3);font:400 13px/1.5 var(--lsu-body)} | |
| 64 | ||
| 65 | /* Nothing published yet. The honest state, and the one that ships today. */ | |
| 66 | .coaching-surface .coach-empty {padding:clamp(30px,5vw,56px);border:var(--coach-rule);background:var(--lsu-card)} | |
| 67 | .coaching-surface .coach-empty h2 {margin:0;font:400 clamp(24px,3vw,34px)/1.1 var(--lsu-display);text-transform:uppercase} | |
| 68 | .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)} | |
| 69 | ||
| 70 | /* Focus has to clear the black. A red ring on #000 is not reliably visible at | |
| 71 | small sizes, so the ring is white with a red cast behind it. */ | |
| 72 | .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)} | |
| 73 | ||
| 74 | @media (max-width:760px) { | |
| 75 | .coaching-surface .coach-offer {grid-template-columns:minmax(0,1fr);gap:18px} | |
| 76 | .coaching-surface .coach-offer-price {text-align:left} | |
| 77 | .coaching-surface .coach-panel {min-height:520px} | |
| 78 | } | |
| @@ -0,0 +1,38 @@ | ||
| 1 | <script> | |
| 2 | interface Props { slug: string } | |
| 3 | const { slug } = defineProps<Props>() | |
| 4 | </script> | |
| 5 | <script server> | |
| 6 | import { coachingModule } from '../data/coaching' | |
| 7 | ||
| 8 | /* The registry is the only place a module's words live, so a page cannot | |
| 9 | disagree with the nav about what it is called. */ | |
| 10 | const panel = coachingModule(slug) | |
| 11 | if (!panel) throw new Error(`unknown coaching module: ${slug}`) | |
| 12 | </script> | |
| 13 | <div class="coaching-surface"> | |
| 14 | <header class="coach-head"> | |
| 15 | <div class="coach-shell"> | |
| 16 | <span class="kicker">Coaching</span> | |
| 17 | <h1>{{ panel.title }}</h1> | |
| 18 | <p>{{ panel.summary }}</p> | |
| 19 | </div> | |
| 20 | </header> | |
| 21 | <CoachingNav current="" /> | |
| 22 | <div class="coach-shell"> | |
| 23 | @if(panel.scope === 'private') | |
| 24 | <div class="coach-scope"> | |
| 25 | <strong>Only you see this</strong> | |
| 26 | <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> | |
| 27 | </div> | |
| 28 | @elseif(panel.scope === 'athlete') | |
| 29 | <div class="coach-scope"> | |
| 30 | <strong>Needs a training account</strong> | |
| 31 | <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> | |
| 32 | </div> | |
| 33 | @endif | |
| 34 | <div class="coach-panel"> | |
| 35 | <CoachingPortal section="" /> | |
| 36 | </div> | |
| 37 | </div> | |
| 38 | </div> | |
| @@ -116,3 +116,34 @@ export function enabledModules(published: readonly string[] | undefined): Coachi | ||
| 116 | 116 | export function indexableModulePaths(): string[] { |
| 117 | 117 | return coachingModules.filter(module => module.scope === 'public').map(module => modulePath(module.slug)) |
| 118 | 118 | } |
| 119 | ||
| 120 | /** Base plus the surface sheet. The marketing page's styles are elsewhere. */ | |
| 121 | export const coachingStyles = [ | |
| 122 | '/assets/styles/production/base.css', | |
| 123 | '/assets/styles/production/coaching.css', | |
| 124 | ] | |
| 125 | ||
| 126 | export interface CoachingPageMeta { | |
| 127 | title: string | |
| 128 | description: string | |
| 129 | styles: string[] | |
| 130 | noindex?: boolean | |
| 131 | } | |
| 132 | ||
| 133 | /** | |
| 134 | * Head metadata for a module route. A module that needs an account is noindex: | |
| 135 | * a crawler sees the chrome and a sign-in prompt, which is not a page worth | |
| 136 | * putting in a search result. | |
| 137 | */ | |
| 138 | export function coachingPage(slug: string): CoachingPageMeta { | |
| 139 | const module = coachingModule(slug) | |
| 140 | if (!module) | |
| 141 | throw new Error(`unknown coaching module: ${slug}`) | |
| 142 | ||
| 143 | return { | |
| 144 | title: `${module.title} - Last Soul Ultra coaching`, | |
| 145 | description: module.summary, | |
| 146 | styles: coachingStyles, | |
| 147 | noindex: module.scope !== 'public', | |
| 148 | } | |
| 149 | } | |
| @@ -0,0 +1,9 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { coachingPage, modulePath } from '~/resources/data/coaching' | |
| 5 | const currentPage = coachingPage('account') | |
| 6 | const canonicalPath = modulePath('account') | |
| 7 | </script> | |
| 8 | <CoachingModule slug="account" /> | |
| 9 | @endsection | |
| @@ -0,0 +1,9 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { coachingPage, modulePath } from '~/resources/data/coaching' | |
| 5 | const currentPage = coachingPage('calendar') | |
| 6 | const canonicalPath = modulePath('calendar') | |
| 7 | </script> | |
| 8 | <CoachingModule slug="calendar" /> | |
| 9 | @endsection | |
| @@ -0,0 +1,9 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { coachingPage, modulePath } from '~/resources/data/coaching' | |
| 5 | const currentPage = coachingPage('feedback') | |
| 6 | const canonicalPath = modulePath('feedback') | |
| 7 | </script> | |
| 8 | <CoachingModule slug="feedback" /> | |
| 9 | @endsection | |
| @@ -0,0 +1,9 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { coachingPage, modulePath } from '~/resources/data/coaching' | |
| 5 | const currentPage = coachingPage('health') | |
| 6 | const canonicalPath = modulePath('health') | |
| 7 | </script> | |
| 8 | <CoachingModule slug="health" /> | |
| 9 | @endsection | |
| @@ -0,0 +1,9 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { coachingPage, modulePath } from '~/resources/data/coaching' | |
| 5 | const currentPage = coachingPage('library') | |
| 6 | const canonicalPath = modulePath('library') | |
| 7 | </script> | |
| 8 | <CoachingModule slug="library" /> | |
| 9 | @endsection | |
| @@ -0,0 +1,9 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { coachingPage, modulePath } from '~/resources/data/coaching' | |
| 5 | const currentPage = coachingPage('messages') | |
| 6 | const canonicalPath = modulePath('messages') | |
| 7 | </script> | |
| 8 | <CoachingModule slug="messages" /> | |
| 9 | @endsection | |
| @@ -0,0 +1,9 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { coachingPage, modulePath } from '~/resources/data/coaching' | |
| 5 | const currentPage = coachingPage('plans') | |
| 6 | const canonicalPath = modulePath('plans') | |
| 7 | </script> | |
| 8 | <CoachingModule slug="plans" /> | |
| 9 | @endsection | |
| @@ -0,0 +1,9 @@ | ||
| 1 | @extends('layouts/LSU') | |
| 2 | @section('content') | |
| 3 | <script server> | |
| 4 | import { coachingPage, modulePath } from '~/resources/data/coaching' | |
| 5 | const currentPage = coachingPage('progress') | |
| 6 | const canonicalPath = modulePath('progress') | |
| 7 | </script> | |
| 8 | <CoachingModule slug="progress" /> | |
| 9 | @endsection | |