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.
| @@ -1,4 +1,11 @@ | ||
| 1 | 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. | |
| 2 | 9 | Loaded only by those routes, not from stx-site.css, which the whole site |
| 3 | 10 | pays for on every page. |
| 4 | 11 | |
| @@ -46,7 +53,7 @@ | ||
| 46 | 53 | below 1100px of empty iframe, where nobody finds it. This one says where |
| 47 | 54 | the panel comes from, which the site's voice wants said anyway. */ |
| 48 | 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)} |
| 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)} | |
| 50 | 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} |
| 51 | 58 | .coaching-surface .coach-panel-bar a:hover {color:var(--lsu-red)} |
| 52 | 59 | |
| @@ -72,13 +79,13 @@ | ||
| 72 | 79 | .coaching-surface .coach-offers {display:grid;margin:0 0 clamp(40px,5vw,72px);padding:0;list-style:none;border-top:var(--coach-rule)} |
| 73 | 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)} |
| 74 | 81 | .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} | |
| 76 | 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)} |
| 77 | 84 | .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)} | |
| 80 | 87 | .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)} | |
| 82 | 89 | |
| 83 | 90 | /* The hub's index of modules. A ruled list rather than a card grid: eight |
| 84 | 91 | cards is a wall, and the sentence beside each name is the part that is |
| @@ -23,7 +23,11 @@ | ||
| 23 | 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 | 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 | 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{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)} | |
| 27 | 31 | .open-status.lsu-scope-lottery a.lsu-scope-lottery{color:var(--lsu-white);text-decoration:underline;text-underline-offset:4px} |
| 28 | 32 | .open-status.lsu-scope-lottery a.lsu-scope-lottery:hover{color:var(--lsu-red)} |
| 29 | 33 | |
| @@ -1,4 +1,11 @@ | ||
| 1 | 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. | |
| 2 | 9 | Loaded only by those routes, not from stx-site.css, which the whole site |
| 3 | 10 | pays for on every page. |
| 4 | 11 | |
| @@ -46,7 +53,7 @@ | ||
| 46 | 53 | below 1100px of empty iframe, where nobody finds it. This one says where |
| 47 | 54 | the panel comes from, which the site's voice wants said anyway. */ |
| 48 | 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)} |
| 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)} | |
| 50 | 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} |
| 51 | 58 | .coaching-surface .coach-panel-bar a:hover {color:var(--lsu-red)} |
| 52 | 59 | |
| @@ -72,13 +79,13 @@ | ||
| 72 | 79 | .coaching-surface .coach-offers {display:grid;margin:0 0 clamp(40px,5vw,72px);padding:0;list-style:none;border-top:var(--coach-rule)} |
| 73 | 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)} |
| 74 | 81 | .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} | |
| 76 | 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)} |
| 77 | 84 | .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)} | |
| 80 | 87 | .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)} | |
| 82 | 89 | |
| 83 | 90 | /* The hub's index of modules. A ruled list rather than a card grid: eight |
| 84 | 91 | cards is a wall, and the sentence beside each name is the part that is |
| @@ -23,7 +23,11 @@ | ||
| 23 | 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 | 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 | 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{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)} | |
| 27 | 31 | .open-status.lsu-scope-lottery a.lsu-scope-lottery{color:var(--lsu-white);text-decoration:underline;text-underline-offset:4px} |
| 28 | 32 | .open-status.lsu-scope-lottery a.lsu-scope-lottery:hover{color:var(--lsu-red)} |
| 29 | 33 | |
| @@ -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 | }) | |