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.
| @@ -36,9 +36,21 @@ | ||
| 36 | 36 | .coaching-surface .coach-nav a:hover {color:var(--lsu-white)} |
| 37 | 37 | .coaching-surface .coach-nav a[aria-current="page"] {border-bottom-color:var(--lsu-red);color:var(--lsu-white)} |
| 38 | 38 | |
| 39 | /* Above the panel, always. A cross-origin frame can decline to load for | |
| 40 | reasons this page cannot detect, and the embed's own escape hatch sits | |
| 41 | below 1100px of empty iframe, where nobody finds it. This one says where | |
| 42 | the panel comes from, which the site's voice wants said anyway. */ | |
| 43 | .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)} | |
| 44 | .coaching-surface .coach-panel-bar p {margin:0;max-width:52ch;color:var(--lsu-dim-3);font:400 13px/1.5 var(--lsu-body)} | |
| 45 | .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} | |
| 46 | .coaching-surface .coach-panel-bar a:hover {color:var(--lsu-red)} | |
| 47 | ||
| 48 | /* Reachable by a screen reader, invisible to everyone else. */ | |
| 49 | .coaching-surface .coach-sr {position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap} | |
| 50 | ||
| 39 | 51 | /* The panel. Space is reserved before the remote element upgrades, so HQ |
| 40 | 52 | 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)} | |
| 53 | .coaching-surface .coach-panel {min-height:460px;margin:clamp(20px,2vw,28px) 0 clamp(60px,8vw,120px)} | |
| 42 | 54 | .coaching-surface .coach-panel hq-coaching {display:block} |
| 43 | 55 | .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 | 56 | .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)} |
| @@ -52,7 +64,7 @@ | ||
| 52 | 64 | |
| 53 | 65 | /* The storefront. One ruled row per tier, not a grid of pricing cards: there |
| 54 | 66 | 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)} | |
| 67 | .coaching-surface .coach-offers {display:grid;margin:0 0 clamp(40px,5vw,72px);padding:0;list-style:none;border-top:var(--coach-rule)} | |
| 56 | 68 | .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 | 69 | .coaching-surface .coach-offer h3 {margin:0;font:400 clamp(24px,2.8vw,36px)/1.1 var(--lsu-display);text-transform:uppercase} |
| 58 | 70 | .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} |
| @@ -63,7 +75,7 @@ | ||
| 63 | 75 | .coaching-surface .coach-offer-note {margin:14px 0 0;color:var(--lsu-dim-3);font:400 13px/1.5 var(--lsu-body)} |
| 64 | 76 | |
| 65 | 77 | /* 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)} | |
| 78 | .coaching-surface .coach-empty {margin:0 0 clamp(40px,5vw,72px);padding:clamp(30px,5vw,56px);border:var(--coach-rule);background:var(--lsu-card)} | |
| 67 | 79 | .coaching-surface .coach-empty h2 {margin:0;font:400 clamp(24px,3vw,34px)/1.1 var(--lsu-display);text-transform:uppercase} |
| 68 | 80 | .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 | 81 | |
| @@ -36,9 +36,21 @@ | ||
| 36 | 36 | .coaching-surface .coach-nav a:hover {color:var(--lsu-white)} |
| 37 | 37 | .coaching-surface .coach-nav a[aria-current="page"] {border-bottom-color:var(--lsu-red);color:var(--lsu-white)} |
| 38 | 38 | |
| 39 | /* Above the panel, always. A cross-origin frame can decline to load for | |
| 40 | reasons this page cannot detect, and the embed's own escape hatch sits | |
| 41 | below 1100px of empty iframe, where nobody finds it. This one says where | |
| 42 | the panel comes from, which the site's voice wants said anyway. */ | |
| 43 | .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)} | |
| 44 | .coaching-surface .coach-panel-bar p {margin:0;max-width:52ch;color:var(--lsu-dim-3);font:400 13px/1.5 var(--lsu-body)} | |
| 45 | .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} | |
| 46 | .coaching-surface .coach-panel-bar a:hover {color:var(--lsu-red)} | |
| 47 | ||
| 48 | /* Reachable by a screen reader, invisible to everyone else. */ | |
| 49 | .coaching-surface .coach-sr {position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap} | |
| 50 | ||
| 39 | 51 | /* The panel. Space is reserved before the remote element upgrades, so HQ |
| 40 | 52 | 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)} | |
| 53 | .coaching-surface .coach-panel {min-height:460px;margin:clamp(20px,2vw,28px) 0 clamp(60px,8vw,120px)} | |
| 42 | 54 | .coaching-surface .coach-panel hq-coaching {display:block} |
| 43 | 55 | .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 | 56 | .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)} |
| @@ -52,7 +64,7 @@ | ||
| 52 | 64 | |
| 53 | 65 | /* The storefront. One ruled row per tier, not a grid of pricing cards: there |
| 54 | 66 | 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)} | |
| 67 | .coaching-surface .coach-offers {display:grid;margin:0 0 clamp(40px,5vw,72px);padding:0;list-style:none;border-top:var(--coach-rule)} | |
| 56 | 68 | .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 | 69 | .coaching-surface .coach-offer h3 {margin:0;font:400 clamp(24px,2.8vw,36px)/1.1 var(--lsu-display);text-transform:uppercase} |
| 58 | 70 | .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} |
| @@ -63,7 +75,7 @@ | ||
| 63 | 75 | .coaching-surface .coach-offer-note {margin:14px 0 0;color:var(--lsu-dim-3);font:400 13px/1.5 var(--lsu-body)} |
| 64 | 76 | |
| 65 | 77 | /* 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)} | |
| 78 | .coaching-surface .coach-empty {margin:0 0 clamp(40px,5vw,72px);padding:clamp(30px,5vw,56px);border:var(--coach-rule);background:var(--lsu-card)} | |
| 67 | 79 | .coaching-surface .coach-empty h2 {margin:0;font:400 clamp(24px,3vw,34px)/1.1 var(--lsu-display);text-transform:uppercase} |
| 68 | 80 | .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 | 81 | |
| @@ -4,11 +4,19 @@ const { slug } = defineProps<Props>() | ||
| 4 | 4 | </script> |
| 5 | 5 | <script server> |
| 6 | 6 | import { coachingModule } from '../data/coaching' |
| 7 | import { portalHost, portalUrl } from '../functions/coaching-source' | |
| 7 | 8 | |
| 8 | 9 | /* The registry is the only place a module's words live, so a page cannot |
| 9 | 10 | disagree with the nav about what it is called. */ |
| 10 | 11 | const panel = coachingModule(slug) |
| 11 | 12 | if (!panel) throw new Error(`unknown coaching module: ${slug}`) |
| 13 | ||
| 14 | /* The panel is a cross-origin frame, and a frame can decline to load for | |
| 15 | reasons this page cannot see: an origin the brand has not allowed, a | |
| 16 | browser blocking third-party frames, an extension. When that happens the | |
| 17 | embed leaves a tall empty box, so the way out never lives inside it. */ | |
| 18 | const panelHref = portalUrl(panel.slug) | |
| 19 | const panelHost = portalHost() | |
| 12 | 20 | </script> |
| 13 | 21 | <div class="coaching-surface"> |
| 14 | 22 | <header class="coach-head"> |
| @@ -31,6 +39,13 @@ if (!panel) throw new Error(`unknown coaching module: ${slug}`) | ||
| 31 | 39 | <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 | 40 | </div> |
| 33 | 41 | @endif |
| 42 | @if(panel.slug === 'plans') | |
| 43 | <CoachingOffers /> | |
| 44 | @endif | |
| 45 | <div class="coach-panel-bar"> | |
| 46 | <p>Loaded from {{ panelHost }}. Your training account lives there, not here.</p> | |
| 47 | <a href="" target="_blank" rel="noopener">Open it there<span class="coach-sr"> (opens in a new tab)</span></a> | |
| 48 | </div> | |
| 34 | 49 | <div class="coach-panel"> |
| 35 | 50 | <CoachingPortal section="" /> |
| 36 | 51 | </div> |
| @@ -0,0 +1,39 @@ | ||
| 1 | <script server> | |
| 2 | import { formatPrice, formatTrial } from '../functions/coaching-brand' | |
| 3 | import { loadBrand, portalHost } from '../functions/coaching-source' | |
| 4 | ||
| 5 | /* The one piece of coaching this site renders itself. The published brand is | |
| 6 | public, so the tiers, the coach behind each one and the coach's own price | |
| 7 | can be read here rather than only inside the frame - a search result, a | |
| 8 | shared link and a visitor with frames blocked all get the same answer. | |
| 9 | Nothing is composed: no price, no trial and no coach name originates here. */ | |
| 10 | const offerBrand = await loadBrand() | |
| 11 | const offers = offerBrand?.offers ?? [] | |
| 12 | const offerLocale = offerBrand?.locale ?? 'en' | |
| 13 | const offerHost = portalHost() | |
| 14 | </script> | |
| 15 | @if(offers.length > 0) | |
| 16 | <ul class="coach-offers"> | |
| 17 | @foreach(offers as offer) | |
| 18 | <li class="coach-offer"> | |
| 19 | <div> | |
| 20 | <h3>{{ offer.label }}</h3> | |
| 21 | <p class="coach-offer-by">{{ offer.coach }}</p> | |
| 22 | <p>{{ offer.method.summary }}</p> | |
| 23 | <p class="coach-offer-note">{{ offer.method.name }}, version {{ offer.method.version }}. Bought and billed at {{ offerHost }}.</p> | |
| 24 | </div> | |
| 25 | <div class="coach-offer-price"> | |
| 26 | <b>{{ formatPrice(offer.product, offerLocale) }}</b> | |
| 27 | @if(formatTrial(offer.product)) | |
| 28 | <span class="coach-offer-trial">{{ formatTrial(offer.product) }}</span> | |
| 29 | @endif | |
| 30 | </div> | |
| 31 | </li> | |
| 32 | @endforeach | |
| 33 | </ul> | |
| 34 | @else | |
| 35 | <div class="coach-empty"> | |
| 36 | <h2>Nothing is open yet</h2> | |
| 37 | <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> | |
| 38 | </div> | |
| 39 | @endif | |
| @@ -77,6 +77,11 @@ export function portalUrl(section: unknown = '', origin = coaching.origin, slug | ||
| 77 | 77 | return url.toString() |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | /** The host the panel is loaded from, for a page that says so out loud. */ | |
| 81 | export function portalHost(origin = coaching.origin): string { | |
| 82 | return new URL(coachingOrigin(origin)).host | |
| 83 | } | |
| 84 | ||
| 80 | 85 | /** The custom element HQ generates for embedding its portal. */ |
| 81 | 86 | export function portalElementUrl(origin = coaching.origin): string { |
| 82 | 87 | return `${coachingOrigin(origin)}/assets/elements/hq-coaching.js` |