ReviewOS

also looking at this

stacks/lastsoulultra

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

#12
Merged glennmichael123 wants to merge claude/last-soul-ultra-web-6e0718 into main
86 files +3943 -1190

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.

public/assets/styles/production/coaching.cssadded+78-0
Changes to public/assets/styles/production/coaching.css
@@ -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}
resources/assets/styles/site/coaching.cssadded+78-0
Changes to resources/assets/styles/site/coaching.css
@@ -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}
resources/components/CoachingModule.stxadded+38-0
Changes to resources/components/CoachingModule.stx
@@ -0,0 +1,38 @@
1<script>
2interface Props { slug: string }
3const { slug } = defineProps<Props>()
4</script>
5<script server>
6import { 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. */
10const panel = coachingModule(slug)
11if (!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>
resources/components/CoachingNav.stxadded+26-0
Changes to resources/components/CoachingNav.stx
@@ -0,0 +1,26 @@
1<script>
2interface Props { current?: string }
3const { current = '' } = defineProps<Props>()
4</script>
5<script server>
6import { enabledModules, modulePath } from '../data/coaching'
7import { loadBrand } from '../functions/coaching-source'
8
9/* The brand decides which modules exist; this site decides their order and
10 their names. An unreachable or draft brand leaves the storefront alone,
11 so the nav never offers a panel that cannot load. */
12const navBrand = await loadBrand()
13const navModules = enabledModules(navBrand?.modules)
14const navCurrent = current
15</script>
16@if(navModules.length > 1)
17<nav class="coach-nav" aria-label="Coaching">
18 <div class="coach-shell">
19 <ul>
20 @foreach(navModules as item)
21 <li><a href="" @if(item.slug === navCurrent) aria-current="page" @endif>{{ item.label }}</a></li>
22 @endforeach
23 </ul>
24 </div>
25</nav>
26@endif
resources/data/coaching.tsmodified+31-0
Changes to resources/data/coaching.ts
@@ -116,3 +116,34 @@ export function enabledModules(published: readonly string[] | undefined): Coachi
116116export function indexableModulePaths(): string[] {
117117 return coachingModules.filter(module => module.scope === 'public').map(module => modulePath(module.slug))
118118}
119
120/** Base plus the surface sheet. The marketing page's styles are elsewhere. */
121export const coachingStyles = [
122 '/assets/styles/production/base.css',
123 '/assets/styles/production/coaching.css',
124]
125
126export 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 */
138export 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}
resources/views/coaching/account.stxadded+9-0
Changes to resources/views/coaching/account.stx
@@ -0,0 +1,9 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { coachingPage, modulePath } from '~/resources/data/coaching'
5const currentPage = coachingPage('account')
6const canonicalPath = modulePath('account')
7</script>
8<CoachingModule slug="account" />
9@endsection
resources/views/coaching/calendar.stxadded+9-0
Changes to resources/views/coaching/calendar.stx
@@ -0,0 +1,9 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { coachingPage, modulePath } from '~/resources/data/coaching'
5const currentPage = coachingPage('calendar')
6const canonicalPath = modulePath('calendar')
7</script>
8<CoachingModule slug="calendar" />
9@endsection
resources/views/coaching/feedback.stxadded+9-0
Changes to resources/views/coaching/feedback.stx
@@ -0,0 +1,9 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { coachingPage, modulePath } from '~/resources/data/coaching'
5const currentPage = coachingPage('feedback')
6const canonicalPath = modulePath('feedback')
7</script>
8<CoachingModule slug="feedback" />
9@endsection
resources/views/coaching/health.stxadded+9-0
Changes to resources/views/coaching/health.stx
@@ -0,0 +1,9 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { coachingPage, modulePath } from '~/resources/data/coaching'
5const currentPage = coachingPage('health')
6const canonicalPath = modulePath('health')
7</script>
8<CoachingModule slug="health" />
9@endsection
resources/views/coaching/library.stxadded+9-0
Changes to resources/views/coaching/library.stx
@@ -0,0 +1,9 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { coachingPage, modulePath } from '~/resources/data/coaching'
5const currentPage = coachingPage('library')
6const canonicalPath = modulePath('library')
7</script>
8<CoachingModule slug="library" />
9@endsection
resources/views/coaching/messages.stxadded+9-0
Changes to resources/views/coaching/messages.stx
@@ -0,0 +1,9 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { coachingPage, modulePath } from '~/resources/data/coaching'
5const currentPage = coachingPage('messages')
6const canonicalPath = modulePath('messages')
7</script>
8<CoachingModule slug="messages" />
9@endsection
resources/views/coaching/plans.stxadded+9-0
Changes to resources/views/coaching/plans.stx
@@ -0,0 +1,9 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { coachingPage, modulePath } from '~/resources/data/coaching'
5const currentPage = coachingPage('plans')
6const canonicalPath = modulePath('plans')
7</script>
8<CoachingModule slug="plans" />
9@endsection
resources/views/coaching/progress.stxadded+9-0
Changes to resources/views/coaching/progress.stx
@@ -0,0 +1,9 @@
1@extends('layouts/LSU')
2@section('content')
3<script server>
4import { coachingPage, modulePath } from '~/resources/data/coaching'
5const currentPage = coachingPage('progress')
6const canonicalPath = modulePath('progress')
7</script>
8<CoachingModule slug="progress" />
9@endsection