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.cssmodified+12-5
Changes to public/assets/styles/production/coaching.css
@@ -1,4 +1,11 @@
11/* 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.
29 Loaded only by those routes, not from stx-site.css, which the whole site
310 pays for on every page.
411
@@ -46,7 +53,7 @@
4653 below 1100px of empty iframe, where nobody finds it. This one says where
4754 the panel comes from, which the site's voice wants said anyway. */
4855.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)}
49.coaching-surface .coach-panel-bar p {margin:0;max-width:52ch;color:var(--lsu-dim-3);font:400 13px/1.5 var(--lsu-body)}
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)}
5057.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}
5158.coaching-surface .coach-panel-bar a:hover {color:var(--lsu-red)}
5259
@@ -72,13 +79,13 @@
7279.coaching-surface .coach-offers {display:grid;margin:0 0 clamp(40px,5vw,72px);padding:0;list-style:none;border-top:var(--coach-rule)}
7380.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)}
7481.coaching-surface .coach-offer-name {margin:0;font:400 clamp(24px,2.8vw,36px)/1.1 var(--lsu-display);text-transform:uppercase}
75.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}
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}
7683.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)}
7784.coaching-surface .coach-offer-price {text-align:right;white-space:nowrap}
78.coaching-surface .coach-offer-price b {display:block;font:400 clamp(22px,2.4vw,30px)/1.1 var(--lsu-display);font-weight:400}
79.coaching-surface .coach-offer-trial {display:block;margin-top:8px;color:var(--lsu-dim-3);font:400 13px/1.4 var(--lsu-body)}
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)}
8087.coaching-surface .coach-offer-method {color:var(--lsu-dim-2);font-size:15px}
81.coaching-surface .coach-offer-note {margin:14px 0 0;color:var(--lsu-dim-3);font:400 13px/1.5 var(--lsu-body)}
88.coaching-surface .coach-offer-note {margin:14px 0 0;color:var(--lsu-dim-2);font:400 13px/1.5 var(--lsu-body)}
8289
8390/* The hub's index of modules. A ruled list rather than a card grid: eight
8491 cards is a wall, and the sentence beside each name is the part that is
public/assets/styles/production/race-entry.cssmodified+5-1
Changes to public/assets/styles/production/race-entry.css
@@ -23,7 +23,11 @@
2323.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}
2424.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}
2525.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{color:var(--lsu-red);font-weight:600;letter-spacing:.08em;text-transform:uppercase}
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)}
2731.open-status.lsu-scope-lottery a.lsu-scope-lottery{color:var(--lsu-white);text-decoration:underline;text-underline-offset:4px}
2832.open-status.lsu-scope-lottery a.lsu-scope-lottery:hover{color:var(--lsu-red)}
2933
resources/assets/styles/site/coaching.cssmodified+12-5
Changes to resources/assets/styles/site/coaching.css
@@ -1,4 +1,11 @@
11/* 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.
29 Loaded only by those routes, not from stx-site.css, which the whole site
310 pays for on every page.
411
@@ -46,7 +53,7 @@
4653 below 1100px of empty iframe, where nobody finds it. This one says where
4754 the panel comes from, which the site's voice wants said anyway. */
4855.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)}
49.coaching-surface .coach-panel-bar p {margin:0;max-width:52ch;color:var(--lsu-dim-3);font:400 13px/1.5 var(--lsu-body)}
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)}
5057.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}
5158.coaching-surface .coach-panel-bar a:hover {color:var(--lsu-red)}
5259
@@ -72,13 +79,13 @@
7279.coaching-surface .coach-offers {display:grid;margin:0 0 clamp(40px,5vw,72px);padding:0;list-style:none;border-top:var(--coach-rule)}
7380.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)}
7481.coaching-surface .coach-offer-name {margin:0;font:400 clamp(24px,2.8vw,36px)/1.1 var(--lsu-display);text-transform:uppercase}
75.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}
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}
7683.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)}
7784.coaching-surface .coach-offer-price {text-align:right;white-space:nowrap}
78.coaching-surface .coach-offer-price b {display:block;font:400 clamp(22px,2.4vw,30px)/1.1 var(--lsu-display);font-weight:400}
79.coaching-surface .coach-offer-trial {display:block;margin-top:8px;color:var(--lsu-dim-3);font:400 13px/1.4 var(--lsu-body)}
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)}
8087.coaching-surface .coach-offer-method {color:var(--lsu-dim-2);font-size:15px}
81.coaching-surface .coach-offer-note {margin:14px 0 0;color:var(--lsu-dim-3);font:400 13px/1.5 var(--lsu-body)}
88.coaching-surface .coach-offer-note {margin:14px 0 0;color:var(--lsu-dim-2);font:400 13px/1.5 var(--lsu-body)}
8289
8390/* The hub's index of modules. A ruled list rather than a card grid: eight
8491 cards is a wall, and the sentence beside each name is the part that is
resources/assets/styles/site/race-entry.cssmodified+5-1
Changes to resources/assets/styles/site/race-entry.css
@@ -23,7 +23,11 @@
2323.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}
2424.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}
2525.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{color:var(--lsu-red);font-weight:600;letter-spacing:.08em;text-transform:uppercase}
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)}
2731.open-status.lsu-scope-lottery a.lsu-scope-lottery{color:var(--lsu-white);text-decoration:underline;text-underline-offset:4px}
2832.open-status.lsu-scope-lottery a.lsu-scope-lottery:hover{color:var(--lsu-red)}
2933
tests/contrast.test.tsadded+108-0
Changes to tests/contrast.test.ts
@@ -0,0 +1,108 @@
1import { describe, expect, it } from 'bun:test'
2
3/**
4 * Text contrast, for the sheets this branch added.
5 *
6 * Two of the site's tokens do not clear WCAG AA for body text: --lsu-dim-3
7 * at 3.66:1 on black, and the brand red at 4.15:1. Both are used that way
8 * across the rest of the site - .kicker, .sr-date, .tile-n and about thirty
9 * others - which is the organiser's own design and not something to rewrite
10 * from here. What these tests hold is the narrower line: the sheets added
11 * here do not add to it.
12 */
13
14type Rgb = [number, number, number]
15
16function relativeLuminance([r, g, b]: Rgb): number {
17 const channel = (value: number): number => {
18 const v = value / 255
19 return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
20 }
21 return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
22}
23
24function contrast(a: Rgb, b: Rgb): number {
25 const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x)
26 return (hi! + 0.05) / (lo! + 0.05)
27}
28
29/** White at `alpha` composited over an opaque background. */
30function dim(alpha: number, over: Rgb): Rgb {
31 return over.map(channel => Math.round(255 * alpha + channel * (1 - alpha))) as Rgb
32}
33
34const BLACK: Rgb = [0, 0, 0]
35const RED: Rgb = [221, 15, 21]
36/** AA: 4.5 for body text, 3.0 at 24px or 18.66px bold and above. */
37const BODY = 4.5
38const LARGE = 3
39
40describe('the palette', () => {
41 it('is safe for body text down to --lsu-dim-2', () => {
42 expect(contrast([255, 255, 255], BLACK)).toBeGreaterThan(BODY)
43 expect(contrast(dim(0.65, BLACK), BLACK)).toBeGreaterThan(BODY)
44 expect(contrast(dim(0.55, BLACK), BLACK)).toBeGreaterThan(BODY)
45 })
46
47 it('is not safe for body text at --lsu-dim-3 or the brand red', () => {
48 // Recorded rather than asserted away: this is why the rules below exist.
49 expect(contrast(dim(0.4, BLACK), BLACK)).toBeLessThan(BODY)
50 expect(contrast(RED, BLACK)).toBeLessThan(BODY)
51 })
52
53 it('is safe for large text at both', () => {
54 expect(contrast(dim(0.4, BLACK), BLACK)).toBeGreaterThan(LARGE)
55 expect(contrast(RED, BLACK)).toBeGreaterThan(LARGE)
56 })
57
58 it('holds on the card surface as well as on black', () => {
59 const card: Rgb = [13, 13, 15]
60 expect(contrast(dim(0.55, card), card)).toBeGreaterThan(BODY)
61 expect(contrast(RED, card)).toBeLessThan(BODY)
62 })
63})
64
65describe('the sheets this branch added', () => {
66 const own = ['coaching.css', 'coaching-page.css', 'not-found.css']
67
68 async function rules(name: string): Promise<{ selector: string, body: string }[]> {
69 const css = await Bun.file(`resources/assets/styles/site/${name}`).text()
70 return [...css.matchAll(/([^{}]+)\{([^}]*)\}/g)]
71 .map(match => ({ selector: match[1]!.trim().split('\n').pop()!, body: match[2]! }))
72 }
73
74 it('never sets the dimmest token as a text colour', async () => {
75 for (const name of own) {
76 const offenders = (await rules(name))
77 .filter(rule => rule.body.includes('color:var(--lsu-dim-3)'))
78 .map(rule => `${name}: ${rule.selector}`)
79
80 expect(offenders).toEqual([])
81 }
82 })
83
84 /**
85 * One relocated rule, not a new one: .coaching-loop h3 came across from
86 * stx-site.css with the rest of the marketing page and starts at 22px,
87 * which is under the 24px bar at the narrowest viewport. Listed so it stays
88 * visible rather than excluding the whole sheet it lives in.
89 */
90 const RELOCATED = new Set(['.coaching-page .coaching-loop h3'])
91
92 it('uses the brand red for text only where it is large', async () => {
93 for (const name of own) {
94 const offenders = (await rules(name))
95 .filter((rule) => {
96 if (!/(?<![a-z-])color:var\(--lsu-red\)/.test(rule.body)) return false
97 const size = /font(?:-size)?:[^;]*?(\d+)px/.exec(rule.body)
98 // No size in the rule means it inherits one; .kicker is 19px bold,
99 // which is itself over the large-text bar.
100 if (RELOCATED.has(rule.selector)) return false
101 return size ? Number(size[1]) < 24 : false
102 })
103 .map(rule => `${name}: ${rule.selector}`)
104
105 expect(offenders).toEqual([])
106 }
107 })
108})