From 493f8a7581644b51a427e9af0f9b4fef63460c37 Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:59:58 +0000 Subject: [PATCH 1/2] Fix accordion header/body, outline-secondary, and code contrast; add contrast audit tool Co-Authored-By: Claude Fable 5 --- docs/features/theming.md | 205 ++++++++++++- frontend/src/features/ui/Footer.tsx | 11 +- frontend/src/mocks/handlers.ts | 34 +++ frontend/src/styles/styles.scss | 94 +++++- frontend/tests/ContrastAudit.spec.ts | 232 +++++++++++++++ frontend/tests/tooling/contrastAudit.ts | 374 ++++++++++++++++++++++++ 6 files changed, 936 insertions(+), 14 deletions(-) create mode 100644 frontend/tests/ContrastAudit.spec.ts create mode 100644 frontend/tests/tooling/contrastAudit.ts diff --git a/docs/features/theming.md b/docs/features/theming.md index 25a4aa800..8d70e1c86 100644 --- a/docs/features/theming.md +++ b/docs/features/theming.md @@ -1,11 +1,12 @@ # Theming — the token file, the layering, and the "born grey" fix -Two rounds live in this doc: the 2026-07-24 **theme-defaults pass** (fixed +Three rounds live in this doc: the 2026-07-24 **theme-defaults pass** (fixed "born grey" Bootstrap components by giving every override one canonical -token source) and the 2026-07-24 **Tokyo-11 re-theme** (swapped the palette -itself — this is the token file's first real "retheme the site" exercise, -and the section below on what that surfaced supersedes anything the -theme-defaults pass implied about how easy a repaint would be). +token source), the 2026-07-24 **Tokyo-11 re-theme** (swapped the palette +itself), and the 2026-07-25 **contrast/residual-grey audit** (owner found +real live-site defects the first two rounds missed — see "2026-07-25 +contrast/residual-grey audit" below, which also corrects a wrong claim the +theme-defaults pass made about `$card-bg`). ## The token file @@ -135,6 +136,13 @@ panel, `SharedDeckViewer.tsx`) stay on `$theme-info` instead, precisely because they're small text and accent isn't AAA-safe there — see `$theme-info`'s own token-file comment. +**CORRECTION (2026-07-25, contrast audit)**: "matching the pre-existing +convention" above was never actually contrast-checked at the time it was +written. The 2026-07-25 audit measured it: `$primary`-as-link-text is only +5.98:1 on `$theme-panel-bg` — WORSE than the 6.74:1 this section rejects +accent for, on the same kind of surface. See that pass's OPEN ITEM #2 below +— not fixed yet, needs an owner call on the replacement token. + ## Runtime CSS-custom-property bridge (added this round) Two independent, parallel bridges expose token values to code that can't @@ -345,10 +353,26 @@ buttons/badges. Under #302 this token happened to equal Superhero's own Tokyo-11 breaks that coincidence, so `styles.scss` now explicitly sets `$secondary: $theme-panel-bg` to keep this exact role intact under the new palette (see that file's own "Tokyo-11: $theme-colors overrides" comment). -`$card-bg`itself remains deliberately untouched by either pass — routing it to`$theme-raised-bg` would be a fidelity regression against the -spec-locked Card-body role. If a surface still looks grey/wrong after a -retheme, check whether it's one of these approved-panel surfaces before -assuming it's a leftover default. +If a surface still looks grey/wrong after a retheme, check whether it's one +of these approved-panel surfaces before assuming it's a leftover default. + +**CORRECTION (2026-07-25, contrast audit)**: this doc previously claimed +`$card-bg` "remains deliberately untouched by either pass — routing it to +`$theme-raised-bg` would be a fidelity regression against the spec-locked +Card-body role." That reasoning was backwards and the claim was wrong. +Superhero's own `_variables.scss` sets `$card-bg: $gray-600 !default;` — a +plain literal, **not** derived from `$secondary` the way this doc assumed +(unlike `$card-cap-bg`/`$table-head-bg`/etc, which genuinely do inherit from +whatever `$secondary` resolves to). So `$card-bg` was never actually routed +to the approved panel token at all — every unstyled `Card.Body`/ +`Accordion.Body` sitewide (any `AutofillCollapse` caller not passing its own +`bodyBackground`, and any plain ``/``) was rendering +Superhero's raw `#4e5d6c` this whole time, not `$theme-panel-bg`. Under the +#302 palette those two values were numerically identical, so the bug was +invisible; Tokyo-11 changed `$theme-panel-bg` to `#2f3549` without changing +`$card-bg`, and the two only diverged then — this is exactly the +"/contributions accordion body renders Bootstrap's default mid slate-grey" +defect the owner found live. Fixed in `styles.scss`: `$card-bg: $theme-panel-bg;`, i.e. what this doc always intended. **Two different pills.** Bootstrap's own `$border-radius-pill` (default `50rem`, a true stadium shape) already backs a real, unrelated pill usage — @@ -390,6 +414,144 @@ sourced from `var(--theme-danger-rgb)`). `Footer.tsx`/`Navbar.tsx` already used `var(--bs-primary)` before this pass (pre-existing precedent this pass's bridge extends) and needed no changes. +## 2026-07-25 contrast/residual-grey audit + +Owner-reported, from live mobile screenshots of proxyprints.ca post-Tokyo-11: +the `/contributions` "Contribution Guidelines" accordion header rendered as +an orange fill with pale-lavender text (~1.26:1), its body a mid slate-grey, +and several `outline-secondary` controls (editor mobile "Print & Settings" +sheet's "Showing: Fronts"/"Cardback", the card-list restore-draft banner's +"Dismiss" button beside "Restore") were nearly invisible. The owner's +framing: this is a **class of surfaces missed** by the two 2026-07-24 +passes, not four isolated spots — so the fix pass is a mechanical, sitewide +audit (see "The audit tool" below) plus token-layer fixes, the same +discipline as the theme-defaults pass. + +**Root causes found, all in `frontend/src/styles/styles.scss`'s early +token-assignment block (before the Bootstrap/Superhero imports, same +`!default`-preemption mechanism the rest of this doc describes) unless +noted:** + +- **`$card-bg`** — see the CORRECTION in "What's grey on purpose" above. + Fixed: `$card-bg: $theme-panel-bg;`. This is also what `$accordion-bg` + (Superhero: `$accordion-bg: $card-bg !default;`) reads from, so it fixes + the accordion body too, not just plain ``s. +- **`$accordion-button-active-bg`/`$accordion-button-active-color`** — + Superhero sets these to `$primary`/`$body-color !default` respectively. + Tokyo-11's light, saturated `$primary` (orange) tolerates Superhero's own + much darker stock primary fine with light text on top; ours doesn't + (1.26:1). The owner's own ruling on the defect: the orange fill is + correct/intended (same `$primary` every button already uses) — the fix is + the ink, not the fill, exactly mirroring the button-ink flip two blocks up + in the same file (`$color-contrast-dark: $theme-btn-ink`, already + 8.40:1-verified on this exact background). Fixed: both routed to + `$theme-btn-ink` (plus `$accordion-icon-active-color`, the chevron SVG + stroke, same reasoning). +- **`.btn-outline-secondary`** — Bootstrap's `.btn-outline-{color}` + generator necessarily reuses the same `$theme-colors` map entry the solid + `.btn-secondary` FILL uses; there is no separate Bootstrap variable for + "what colour should outline-secondary's own text/border be" independent + of `$secondary`. Tokyo-11 intentionally repoints `$secondary` to + `$theme-panel-bg` (correct for the fill role — see "What's grey on + purpose" above), which is also a near-background dark tone — wrong for a + foreground/border role, ~1.2–1.4:1 on the ~38 `.btn-outline-secondary` + mounts sitewide. Not a bug Tokyo-11 introduced by mistake so much as an + inherent consequence of repointing `$secondary` at all. Fixed by + re-invoking Bootstrap's own `button-outline-variant()` mixin (the exact + call `_buttons.scss` itself makes) with `$theme-light` instead, right + after `@import "~bootstrap/scss/buttons";` — one global override, not a + per-component patch, regenerating hover/active/disabled states via the + same mixin math Bootstrap uses for every other outline variant. +- **`$code-color`** — Bootstrap core default (`$pink`), never routed to a + token; every `` tag sitewide (heaviest use: `/contributions` and + ImportText.tsx's Syntax Guide) measured 3.19:1 on panel. Fixed: + `$code-color: $theme-info` (this palette's existing "small/inline text" + role — explicitly not `$theme-accent`, which its own comment above rules + out for prose). +- **`$body-secondary-color`/`$body-secondary-bg`** — Bootstrap core default + for `.text-muted`/`--bs-secondary-color` is `rgba($body-color, .75)`, a + translucent literal never routed to a token; measured 5.76:1 on raised-bg + (Footer's source-disclosure line). Fixed: routed to `$theme-muted`, same + small-caption token everything else uses — see the OPEN ITEM below for + why this doesn't fully clear strict-AAA either. `$body-secondary-bg` + (Bootstrap default `$gray-200`, a light grey) routed to `$theme-panel-bg` + pre-emptively; no `.bg-body-secondary` call site exists yet, so this is + future-proofing, verified inert today (same posture as the table-variant + row in the theme-defaults inventory above). +- **`Footer.tsx`'s `ColumnHeading`** (`frontend/src/features/ui/Footer.tsx`) + — a hand-written `rgba(255, 255, 255, 0.4)`, not a Bootstrap default and + not a token at all, measured 3.61:1 on raised-bg. Routed to + `var(--theme-text)` (full strength — `$theme-muted` alone doesn't clear + strict-AAA on this background either, see OPEN ITEM below; the + uppercase/letter-spacing/bold styling already carries the "eyebrow label" + visual distinction from body copy, so no separate dimmed tier is needed). + +**OPEN ITEMS — owner decision needed, not resolved by this pass** (found by +the audit, left alone because fixing them means either accepting a token's +already-ratified compromise stays visible or changing a deliberate +pre-existing convention, both outside this pass's four reported defects): + +1. **`$theme-muted` itself still falls short of strict-AAA-normal (7:1) on + `$theme-raised-bg` and `$theme-panel-bg`** — 6.39:1 and 5.34:1 + respectively (already documented above, PR #432's own deliberate + trade-off). Routing `$body-secondary-color`/`Footer.tsx`'s eyebrow label + onto this token (this pass) makes that pre-existing gap show up in more + places than before, not worse in degree — every occurrence this audit + still flags is exactly this one already-known number, not a new + regression. Needs either a second, higher-contrast "small-caption-on- + raised" token or an owner ruling that AA (4.5:1), not strict AAA, is the + accepted floor for this specific role. +2. **`$link-color` (unset, defaults to `$primary`) measures only 5.98:1 on + `$theme-panel-bg`** — below strict-AAA-normal, and this doc's own + "Accent scope boundary" section previously asserted this was fine + ("stays on `$primary`... matching the pre-existing convention") without + ever actually measuring it. It's worse than the accent purple that same + section rejected for prose (6.74:1) on the one surface both were + checked against. Not fixed here — changing `$link-color` sitewide is a + materially bigger, more visible change than this pass's other four + fixes (every plain `` tag's colour, not an unrouted default nobody + chose), and needs an explicit owner call on the replacement token. +3. **Two third-party libraries render their own unthemed default + greys/whites**, found by the audit's off-palette-grey sweep but not + fixed (no Bootstrap/token seam reaches into either): `react-select`'s + placeholder text (`/myDecks`, `#808080` on `#ffffff` — a literal WHITE + dropdown surface on this dark theme, 3.95:1) and + `react-dropdown-tree-select`'s tag pills (`/explore`, `#000000` on + `#dddddd`). Both need their own scoped override stylesheet targeting + that library's own class names — a distinct, larger follow-up, not a + token-layer fix. +4. **`$theme-danger` as plain text colour measures 6.46:1** (`/whatsthat`'s + "Something went wrong" message) — this is the SAME already-documented, + already-ratified AAA-large-only exception the token file's own comment + and this doc's AAA contrast policy section describe for danger-as- + button-ink; this audit just found the identical number recurs when + danger is used as plain paragraph text, not only button fill. Not a new + gap, not fixed here. + +## The audit tool + +`frontend/tests/tooling/contrastAudit.ts` (Playwright-driven, importable) — +walks every visible text-owning DOM node in the current page, computes the +effective foreground/background pair (ancestor `background-color` +compositing, `opacity` blending), and reports the WCAG contrast ratio +against this doc's binding AAA bar (7:1 normal, 4.5:1 large/bold; 3:1 floor +for genuinely-disabled controls). Also flags any background colour that's +neither transparent nor a member of the Tokyo-11 palette AND reads as +"grey" (low saturation, not near-black/near-white) — the mechanical version +of "born grey" detection. Documented limitations (ancestor-opacity +approximation, no gradient/box-shadow support, no automatic `:hover` sweep) +are in the file's own header comment. + +`frontend/tests/ContrastAudit.spec.ts` is the runnable regression gate: per +owner-reported-defect states (the `/contributions` accordion collapsed/ +expanded, the restore-draft banner, the Syntax Guide accordion, the editor's +mobile Print & Settings sheet) plus a broader static-route sweep, each +asserting zero contrast failures — a future component that reintroduces an +unrouted Bootstrap default fails CI here, not just on the next live +screenshot. Run it standalone with `npx playwright test tests/ContrastAudit.spec.ts --reporter=list` and read the console-logged +off-palette-grey tables (advisory only, not asserted — see the file's own +module comment for why). + ## Relationship to the fidelity specs `DisplayLeftRailFidelity.spec.ts` asserts real `getComputedStyle` values for @@ -435,3 +597,28 @@ they were re-derived. `next build` — see the PR body for pass/fail and counts. - Screenshots: `/display` editor at 1400px and 390px, a modal, and the `/print` page — paths in the PR body. + +## Verification (2026-07-25 contrast/residual-grey audit) + +- `tests/ContrastAudit.spec.ts` run before and after the fix, same 10 states + — see the PR body for the exact before/after failure table. All four + owner-reported defects (accordion header ink, accordion/card body grey, + the restore-banner Dismiss button, the Print & Settings sheet's Showing/ + Cardback controls) measure zero contrast failures after the fix; every + remaining failure is one of this pass's four documented OPEN ITEMS above, + none of them new. +- `DisplayLeftRailFidelity.spec.ts` (17 tests) and a broader sanity sweep + (`GeneralUIAccessibility.spec.ts`, `GridSelectorModal.spec.ts`, + `SelectVersionSection.spec.ts`, `ImportText.spec.ts`, + `DisplayFinishFooter.spec.ts`, `Navbar.spec.ts`, `CardbackPdfWaitFidelity.spec.ts` + — 76 tests total) — all green, confirming the `$card-bg`/ + `$accordion-button-active-*`/`.btn-outline-secondary`/`$code-color` + changes don't regress anything already spec-locked (including the + `outline-danger` "wrong printing" pill, untouched since only + `outline-secondary` was re-generated). +- `npx tsc --noEmit` — clean. +- `npx prettier@2.7.1 --check` — clean on every changed file. +- `next build` (static export) — compiles cleanly. +- Screenshots at 390px: `/contributions` (collapsed + expanded accordion), + the card-list restore-draft banner, the editor's mobile Print & Settings + sheet (scrolled to show Cardback too) — paths in the PR body. diff --git a/frontend/src/features/ui/Footer.tsx b/frontend/src/features/ui/Footer.tsx index 31d9f49b2..41343fd21 100644 --- a/frontend/src/features/ui/Footer.tsx +++ b/frontend/src/features/ui/Footer.tsx @@ -44,7 +44,16 @@ const ColumnHeading = styled.h4` font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; - color: rgba(255, 255, 255, 0.4); + // Site-wide contrast audit (2026-07-25) - was a literal rgba(255,255,255,.4), 3.61:1 on + // FooterRoot's raised-bg (well under the AAA-normal 7:1 floor this label's own 11.5px/bold + // doesn't clear the "large text" bracket for). Not a Bootstrap-default leak like this task's + // other findings - a hand-written translucency that was never a token at all. theme-muted (the + // established small-caption token) still falls short of strict-AAA on this exact background + // (6.39:1 - a pre-existing, already-ratified PR #432 compromise, see that token's own comment + // in _theme-tokens.scss), so this uses the full-strength theme-text token instead - the + // uppercase/letter-spacing/bold styling already carries the "eyebrow label" visual distinction + // from body copy, so no separate dimmed tier is needed here. 9.02:1 on raised-bg, STRICT-AAA. + color: var(--theme-text); font-weight: 700; `; diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 34dae97f6..1d74420db 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -870,6 +870,40 @@ export const sampleCardsServerError = http.get( //# endregion +//# region contributions + +// Contrast-audit round (2026-07-25) - /contributions previously had no msw coverage at all, so +// the page always fell back to NoBackendDefault in tests; the "Contribution Guidelines" +// accordion bug the owner reported live only renders once a remote backend (and this endpoint) +// is configured. Minimal one-source fixture, just enough to render both the summary and the +// per-source table alongside the accordion under test. +export const contributionsOneSource = http.get( + buildRoute("2/contributions/"), + () => + HttpResponse.json( + { + cardCountByType: { CARD: 10, CARDBACK: 1, TOKEN: 2 }, + sources: [ + { + name: "Test Source", + sourceType: "AWS S3", + externalLink: "", + description: "A test source", + qtyCards: "10", + qtyCardbacks: "1", + qtyTokens: "2", + avgdpi: "300", + size: "1 GB", + }, + ], + totalDatabaseSize: 1_000_000_000, + }, + { status: 200 } + ) +); + +//# endregion + //# region import sites export const importSitesNoResults = http.get(buildRoute("2/importSites"), () => diff --git a/frontend/src/styles/styles.scss b/frontend/src/styles/styles.scss index 522d4701e..742dfd889 100644 --- a/frontend/src/styles/styles.scss +++ b/frontend/src/styles/styles.scss @@ -42,6 +42,19 @@ $danger: $theme-danger; $warning: $theme-warning; $info: $theme-info; +// Site-wide contrast audit (2026-07-25): Bootstrap core's own `$body-secondary-color` (the +// source of `.text-muted`/`--bs-secondary-color`, never overridden by Superhero) defaults to +// `rgba($body-color, .75)` -- a translucent literal, not a token, that this audit measured at +// 5.76:1 on `$theme-raised-bg` (Footer's own source-disclosure line). Routed to `$theme-muted` +// instead, the same caption/legend token every other small/muted text sitewide already uses +// (PR #432's own WCAG/APCA remediation pass) -- makes `.text-muted` consistent with that +// existing treatment rather than a second, uncoordinated translucency. `$body-secondary-bg` +// (Bootstrap core default `$gray-200`, a LIGHT grey -- would be a "born grey" leak the instant +// any `.bg-body-secondary` utility is used) is routed to `$theme-panel-bg` for the same +// not-yet-used-but-one-day-will-be-safety reason `$card-bg` above needed fixing. +$body-secondary-color: $theme-muted; +$body-secondary-bg: $theme-panel-bg; + // Tokyo-11 button-ink flip (2026-07-24, AAA remediation): Superhero sets // `$min-contrast-ratio: 1.6` (deliberately low, so white button text survives against a // medium-brightness orange). Every Tokyo-11 $theme-colors entry above is LIGHT (an inverted @@ -100,16 +113,43 @@ $focus-ring-color: $theme-accent; $btn-close-width: 25px; $btn-close-color: $theme-text; +// Site-wide contrast audit (2026-07-25): `$code-color` (governs every `` tag sitewide -- +// search-syntax examples on /contributions and ImportText.tsx's Syntax Guide are the two heaviest +// users) is Bootstrap core's own `$pink` default, never overridden by Superhero, measured 3.19:1 +// on `$theme-panel-bg` -- well under AAA. `$theme-info` (cyan, same Tokyo Night provenance as +// every other token here) is this palette's existing "small/inline text" role (see its own +// comment in _theme-tokens.scss -- explicitly NOT `$theme-accent`, which that token's own comment +// rules out for prose/inline text) and clears STRICT-AAA-normal on both raised (8.49:1) and panel +// (7.09:1). +$code-color: $theme-info; + // Theme-defaults pass (2026-07-24): components that never opt into our own tokens were "born // grey" -- Superhero's stock $gray-600 (#4e5d6c) bleeding through Bootstrap's own component // defaults for surfaces that are conceptually "raised chrome" floating above the page (Modal, // Dropdown, Popover, Toast, the Card header cap, table-head/table-dark), not "panel" (which // #4e5d6c/$theme-panel-bg IS the correct, approved token for -- Card bodies, D14 seticon, -// secondary buttons; left untouched below). Route each to the token it should have been -// reading from all along. See docs/features/theming.md for the full inventory (including -// $list-group-bg, deliberately NOT set here -- the list-group SCSS partial below is disabled -// entirely, so it'd be a dead override; see that doc's "known gaps" section). +// secondary buttons; left untouched below -- CORRECTION 2026-07-25, see $card-bg's own comment +// two lines down, that assumption turned out to be wrong for $card-bg specifically). Route each +// to the token it should have been reading from all along. See docs/features/theming.md for the +// full inventory (including $list-group-bg, deliberately NOT set here -- the list-group SCSS +// partial below is disabled entirely, so it'd be a dead override; see that doc's "known gaps" +// section). $card-cap-bg: $theme-card-header-bg; +// Site-wide contrast/residual-grey audit (2026-07-25, owner-reported live-mobile defect 2 -- +// "/contributions accordion body renders Bootstrap's default mid slate-grey card surface"). +// Root cause: Superhero's own `_variables.scss` sets `$card-bg: $gray-600 !default;` (a plain +// literal, #4e5d6c) -- NOT derived from `$secondary`/`$body-bg` the way Bootstrap core's own +// unthemed default (`var(--bs-body-bg)`) would be, so the 2026-07-24 theme-defaults pass's own +// "$secondary already routes this" assumption above was incorrect for this one variable +// specifically (every other "panel" surface that pass left alone - D14 seticon, secondary +// buttons/badges - genuinely does derive from `$secondary`, just not this one). Every unstyled +// `Card.Body`/`Accordion.Body` sitewide (react-bootstrap ``/`` used directly, +// and every `AutofillCollapse` caller that doesn't pass its own `bodyBackground` prop) was +// therefore rendering this literal grey, not a Tokyo-11 token, since the very first Superhero +// pass -- not a Tokyo-11-round regression, just never caught before this audit. $theme-panel-bg +// is _theme-tokens.scss's own documented "Card bodies" role, so this is the token that should +// have been here. +$card-bg: $theme-panel-bg; $modal-content-bg: $theme-raised-bg; $dropdown-bg: $theme-raised-bg; $popover-bg: $theme-raised-bg; @@ -117,6 +157,23 @@ $toast-background-color: $theme-raised-bg; $offcanvas-bg-color: $theme-body-bg; $table-head-bg: $theme-raised-bg; $table-dark-bg: $theme-raised-bg; +// Site-wide contrast audit (2026-07-25, owner-reported live-mobile defect 1 -- "/contributions +// 'Contribution Guidelines' accordion header renders as an orange filled bar with pale lavender +// text, ~1.26:1, nowhere near AAA"). Root cause: Superhero's own `_variables.scss` sets +// `$accordion-button-active-bg: $primary !default;` / `$accordion-button-active-color: +// $body-color !default;` -- our orange action colour and our light body-text token, a +// combination Superhero's own (much darker, unsaturated) stock `$primary` tolerated but Tokyo-11's +// light, saturated orange does not. The owner's ruling on this defect: the orange fill itself is +// fine/intended (it's the same $primary every button already uses) -- the FIX is the ink, not +// the fill, exactly matching the "$color-contrast-dark: $theme-btn-ink" button-ink flip two +// blocks up in this file (8.40:1 STRICT-AAA on this exact bg, already verified there). Bootstrap +// core's own `$accordion-icon-active-color: $primary-text-emphasis` (the expand/collapse chevron +// SVG stroke) gets the same ink for the same reason -- it was never overridden by Superhero at +// all, so it inherited an auto-shaded dark-orange from Bootstrap core's colour-emphasis system +// instead of a token. +$accordion-button-active-bg: $theme-primary; +$accordion-button-active-color: $theme-btn-ink; +$accordion-icon-active-color: $theme-btn-ink; // Bootstrap core default ($gray-800, near-black); Superhero never overrides it, so the // select-arrow chevron was rendering almost invisibly against our dark $input-bg. $form-select-indicator-color: $theme-light; @@ -159,6 +216,35 @@ $input-border-radius-lg: $theme-radius-base; @import "~bootstrap/scss/tables"; @import "~bootstrap/scss/forms"; @import "~bootstrap/scss/buttons"; + +// Site-wide contrast audit (2026-07-25, owner-reported live-mobile defect 3 -- "Showing: +// Fronts"/"Cardback"/"Export"/"Dismiss" controls rendering as near-invisible outlines). Root +// cause: Bootstrap's own `.btn-outline-{color}` generator (`_buttons.scss`'s `@each $color, +// $value in $theme-colors { .btn-outline-#{$color} { @include button-outline-variant($value); } +// }`) necessarily reuses the SAME `$theme-colors` map entry the solid `.btn-secondary` fill +// uses -- there is no separate Bootstrap variable for "the colour an outline-secondary button's +// own text/border should be" independent of "$secondary". Tokyo-11 intentionally repoints +// `$secondary` to `$theme-panel-bg` (a near-background dark tone, correct for the FILL role -- +// see that token's own "what's grey on purpose" comment) rather than a legible foreground tone, +// so every one of the ~38 `.btn-outline-secondary` call sites sitewide inherited +// `$theme-panel-bg` (#2f3549) as its own TEXT colour against a background that's frequently +// only one step removed from it (#1a1b26/#24283b) -- ~1.2-1.4:1, unreadable. This is not a new +// problem Tokyo-11 introduced by mistake so much as an inherent Bootstrap-architecture +// consequence of repointing `$secondary` at all; the fix re-invokes Bootstrap's OWN +// `button-outline-variant` mixin (the exact call `_buttons.scss` itself would make) with +// `$theme-light` instead -- the token _theme-tokens.scss already established for exactly this +// "outline-button legibility" role (see its own comment: introduced for `outline-light`, aliased +// to `$theme-text`, 9.02/7.54:1 STRICT-AAA on raised/panel). ONE global override, fixes every +// call site (all four owner-reported controls plus every other `.btn-outline-secondary` mount), +// not a per-component patch -- exactly the "fix the variable, not the component" pattern this +// task's other two fixes ($card-bg/$accordion-button-active-* above) follow. Regenerates the +// hover/active/disabled states too (same mixin, same math Bootstrap itself uses for every other +// outline variant), so a disabled `.btn-outline-secondary` still clears the reduced disabled bar +// via the `.btn:disabled{opacity:.65}` blend (~5.2:1 measured, see docs/features/theming.md). +.btn-outline-secondary { + @include button-outline-variant($theme-light); +} + @import "~bootstrap/scss/transitions"; @import "~bootstrap/scss/dropdown"; // @import "~bootstrap/scss/button-group"; diff --git a/frontend/tests/ContrastAudit.spec.ts b/frontend/tests/ContrastAudit.spec.ts new file mode 100644 index 000000000..371392fc3 --- /dev/null +++ b/frontend/tests/ContrastAudit.spec.ts @@ -0,0 +1,232 @@ +import { expect } from "@playwright/test"; + +import { + cardDocumentsOneResult, + contributionsOneSource, + defaultHandlers, + searchResultsOneResult, + sourceDocumentsOneResult, + whoamiAnonymous, +} from "@/mocks/handlers"; + +import { test } from "../playwright.setup"; +import { + importTextOnEditorLanding, + loadPageWithDefaultBackend, +} from "./test-utils"; +import { + auditContrast, + ContrastFailure, + formatFailureTable, + splitKnownOpenItems, +} from "./tooling/contrastAudit"; + +// Site-wide contrast/residual-grey audit (2026-07-25, owner-reported live-mobile defects after +// the Tokyo-11 re-theme, #438). See tests/tooling/contrastAudit.ts for the extraction/ratio +// methodology and its documented approximations, and docs/features/theming.md's "2026-07-25 +// contrast/residual-grey audit" section for how to run this outside CI plus the full writeup of +// every OPEN ITEM referenced below. Binding bar (owner ruling): AAA - 7:1 normal text, 4.5:1 +// large/bold text; disabled controls only need to clear 3:1 and never go below it. +// +// Each `test` below asserts ZERO **new** contrast failures (`splitKnownOpenItems` - see that +// function's own comment in tooling/contrastAudit.ts for the exact matched signatures) for its +// route/state - a real regression gate, not just a one-off report generator, so a future +// component that reintroduces an unrouted Bootstrap default fails CI here rather than needing +// another live-screenshot report. The small set of already-known, owner-attention-needed OPEN +// ITEMS this same audit surfaced (documented in theming.md, not fixed by this pass) are logged, +// not asserted on - they're real numbers, just not new ones, and re-litigating an already-ratified +// token's own compromise or a deliberate pre-existing convention needs an owner decision this PR +// doesn't make. Off-palette-grey backgrounds are reported (console.log) but never asserted on: +// catching every legitimate use of a grey-ish literal (e.g. a card image's own pixels) is out of +// scope for a DOM-background sweep, so that half stays fully advisory. + +const oneCardHandlers = [ + cardDocumentsOneResult, + sourceDocumentsOneResult, + searchResultsOneResult, + ...defaultHandlers, +]; + +function assertNoNewFailures( + contrastFailures: ContrastFailure[], + label: string +) { + const { newFailures, knownOpenItems } = splitKnownOpenItems(contrastFailures); + if (knownOpenItems.length > 0) { + console.log( + `${label} known open items:\n` + formatFailureTable(knownOpenItems) + ); + } + expect(newFailures, formatFailureTable(newFailures)).toEqual([]); +} + +test.describe("Contrast audit - /contributions (owner defect 1+2: accordion header + body)", () => { + test("Contribution Guidelines accordion - collapsed and expanded", async ({ + page, + network, + }) => { + network.use(contributionsOneSource, ...defaultHandlers); + await page.setViewportSize({ width: 390, height: 844 }); + await loadPageWithDefaultBackend(page, "contributions"); + + const header = page.getByRole("button", { + name: "Contribution Guidelines", + }); + await expect(header).toBeVisible(); + + const collapsed = await auditContrast(page); + await page.screenshot({ + path: "test-results/contrast-audit-contributions-collapsed-390.png", + }); + + await header.click(); + await expect(page.getByText("File Format")).toBeVisible(); + const expanded = await auditContrast(page); + await page.screenshot({ + path: "test-results/contrast-audit-contributions-expanded-390.png", + fullPage: true, + }); + + // The accordion header text itself is the owner's defect 1 - hard-asserted directly + // (never allowlisted, unlike the general sweep below) so a regression here fails with an + // obvious message rather than getting lost in a big table or silently passing through the + // known-open-items filter. + const headerContrast = [ + ...collapsed.contrastFailures, + ...expanded.contrastFailures, + ].filter((f) => f.text.includes("Contribution Guidelines")); + expect(headerContrast, formatFailureTable(headerContrast)).toEqual([]); + + console.log( + "collapsed off-palette-grey:\n" + + formatFailureTable(collapsed.paletteFailures) + ); + console.log( + "expanded off-palette-grey:\n" + + formatFailureTable(expanded.paletteFailures) + ); + + assertNoNewFailures(collapsed.contrastFailures, "contributions collapsed"); + assertNoNewFailures(expanded.contrastFailures, "contributions expanded"); + }); +}); + +test.describe("Contrast audit - card-list/editor page (owner defect 3+4: Dismiss/Syntax Guide)", () => { + test("empty-project landing with the restore-draft banner (Dismiss button)", async ({ + page, + network, + }) => { + network.use(whoamiAnonymous, ...oneCardHandlers); + // The draft-backup note (`finish-footer-draft-note`) lives inside FinishFooter, which only + // mounts inline at >=xl (1200px) - below that it's inside RightRailOffcanvas, a CLOSED + // drawer by default, so waiting for it at 390px hangs. Do the import/debounce-wait/delete + // sequence at the default desktop viewport (matching DisplayFinishFooter.spec.ts's own + // working precedent for this exact flow), THEN resize to the owner's 390px phone width for + // the actual audit/screenshot - the restore banner itself lives on the main empty-landing + // page, not inside the rail, so it's unaffected by the resize. + await loadPageWithDefaultBackend(page); + await importTextOnEditorLanding(page, "my search query"); + await expect(page.getByTestId("display-page")).toBeVisible(); + + await page + .getByTestId("display-finish-footer") + .getByTestId("finish-footer-draft-note") + .waitFor({ timeout: 5_000 }); + await page.getByTestId("page-preview-slot").first().click(); + await page.getByTestId("display-slot-action-delete").click(); + + const banner = page.getByTestId("display-restore-draft-banner"); + await expect(banner).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + const result = await auditContrast(page); + await page.screenshot({ + path: "test-results/contrast-audit-restore-banner-390.png", + }); + console.log( + "restore-banner off-palette-grey:\n" + + formatFailureTable(result.paletteFailures) + ); + assertNoNewFailures(result.contrastFailures, "restore-banner"); + }); + + test("import-text landing's Syntax Guide accordion, collapsed and expanded", async ({ + page, + network, + }) => { + network.use(whoamiAnonymous, ...defaultHandlers); + await page.setViewportSize({ width: 390, height: 844 }); + await loadPageWithDefaultBackend(page); + + const header = page.getByRole("button", { name: "Syntax Guide" }); + await expect(header).toBeVisible(); + const collapsed = await auditContrast(page); + + await header.click(); + await expect(page.getByText(/three types of images/)).toBeVisible(); + const expanded = await auditContrast(page); + + console.log( + "syntax-guide collapsed off-palette-grey:\n" + + formatFailureTable(collapsed.paletteFailures) + ); + console.log( + "syntax-guide expanded off-palette-grey:\n" + + formatFailureTable(expanded.paletteFailures) + ); + assertNoNewFailures(collapsed.contrastFailures, "syntax-guide collapsed"); + assertNoNewFailures(expanded.contrastFailures, "syntax-guide expanded"); + }); +}); + +test.describe("Contrast audit - editor mobile Print & Settings sheet (owner defect 3)", () => { + test("Showing: Fronts / Cardback / Export controls in the offcanvas", async ({ + page, + network, + }) => { + network.use(whoamiAnonymous, ...oneCardHandlers); + await page.setViewportSize({ width: 390, height: 844 }); + await loadPageWithDefaultBackend(page); + await importTextOnEditorLanding(page, "my search query"); + await expect(page.getByTestId("display-page")).toBeVisible(); + + await page.getByTestId("display-gear-button").click(); + const rail = page.getByTestId("display-print-settings-rail"); + await expect(rail).toBeVisible(); + await expect(rail.getByText(/Showing: (Fronts|Backs)/)).toBeVisible(); + + const result = await auditContrast(page); + await page.screenshot({ + path: "test-results/contrast-audit-print-settings-sheet-390.png", + }); + console.log( + "print-settings-sheet off-palette-grey:\n" + + formatFailureTable(result.paletteFailures) + ); + assertNoNewFailures(result.contrastFailures, "print-settings-sheet"); + }); +}); + +// Broader sweep: static/informational routes that don't need a populated project. Not +// exhaustive (routes needing deep interactive state - moderation queue, question feed, saved +// decks crypto flows - are covered by the dedicated states above and their own fidelity specs, +// not repeated here) but catches any OTHER page carrying the same unrouted-Bootstrap-default +// class of defect this task's four reported spots turned out to share a root cause with. +for (const route of ["", "about", "explore", "myDecks", "whatsthat", "new"]) { + test(`Contrast audit - static sweep: /${route || "(home)"}`, async ({ + page, + network, + }) => { + network.use(whoamiAnonymous, ...defaultHandlers); + await page.setViewportSize({ width: 390, height: 844 }); + await loadPageWithDefaultBackend(page, route); + await page.waitForTimeout(500); + + const result = await auditContrast(page); + console.log( + `/${route || "(home)"} off-palette-grey:\n` + + formatFailureTable(result.paletteFailures) + ); + assertNoNewFailures(result.contrastFailures, `/${route || "(home)"}`); + }); +} diff --git a/frontend/tests/tooling/contrastAudit.ts b/frontend/tests/tooling/contrastAudit.ts new file mode 100644 index 000000000..2cda7e6f7 --- /dev/null +++ b/frontend/tests/tooling/contrastAudit.ts @@ -0,0 +1,374 @@ +import { Page } from "@playwright/test"; + +/** + * Site-wide contrast/residual-grey audit tool (2026-07-25, owner-reported live-mobile defects on + * /contributions and the editor's Print & Settings sheet). Mechanical companion to the "css-diff + * harness pattern" other fidelity specs use (DisplayLeftRailFidelity.spec.ts, + * CardbackPdfWaitFidelity.spec.ts) - extracts computed foreground/background pairs for every + * text-owning DOM node in the current page and reports the WCAG contrast ratio against the + * OWNER'S ratified bar (docs/features/theming.md's Verification section): AAA - 7:1 for normal + * text, 4.5:1 for large/bold text (APCA is advisory-only, not computed here). Also flags any + * background colour that is neither transparent nor a member of the Tokyo-11 palette + * (TOKYO11_PALETTE below, kept in sync with _theme-tokens.scss by hand - see that file for the + * canonical source) AND reads as "grey" (low saturation, not near-black/near-white) - the + * "born grey" residual-Bootstrap-default signature docs/features/theming.md's theme-defaults + * pass already documents one wave of. + * + * Usage: see tests/ContrastAudit.spec.ts for the runnable Playwright spec that exercises this + * against the site's routes/states. To run standalone: `npx playwright test + * tests/ContrastAudit.spec.ts --reporter=list` and read the console.log'd failure table (the + * spec itself asserts zero failures, so it also works as an ordinary CI regression gate). + * + * KNOWN APPROXIMATIONS (documented rather than engineered away, given this is a triage tool, not + * a pixel-exact renderer): + * - Ancestor `opacity` (e.g. Bootstrap's `.btn:disabled{opacity:.65}`) is folded in as an alpha + * multiplier on the FOREGROUND colour only, blended over the (undimmed) effective background. + * Real compositing would also dim any opaque background the disabled element itself painted; + * this under-dims that rare case (solid-fill disabled buttons; this codebase's disabled + * affordances are overwhelmingly outline/text style, where the own background is transparent + * and this approximation is exact). + * - No sub-pixel/gradient/box-shadow/backdrop-filter support - `background-color` and its alpha + * channel only. + * - Hover/focus states are NOT swept automatically for every node (real `:hover` needs a real + * mouse move per element, too expensive to do for every text node on every route) - callers + * that care about a specific control's hover/focus contrast should drive it explicitly (see + * the spec file's dedicated hover/focus cases for the four owner-reported controls). + */ + +// Kept in sync BY HAND with frontend/src/styles/_theme-tokens.scss's colour section - see that +// file's own header comment for the ratified source of truth. Add a token here whenever that +// file gains one; this list intentionally does NOT include the runtime `--bs-*-bg-subtle`/ +// `-text-emphasis` custom properties Bootstrap auto-generates, since those are exactly the class +// of value this audit exists to catch when they leak through unrouted. +export const TOKYO11_PALETTE_HEX = [ + "#1a1b26", // theme-body-bg / theme-btn-ink + "#24283b", // theme-raised-bg + "#2f3549", // theme-panel-bg + "#2f3548", // theme-card-header-bg + "#222234", // theme-band-bg + "#16161e", // theme-divider + "#c0caf5", // theme-text / theme-light + "#a3aad0", // theme-muted + "#ff9e64", // theme-primary + "#e8935b", // theme-primary-hover (darken(#ff9e64, 8%), approx) + "#9ece6a", // theme-success + "#f7768e", // theme-danger + "#e0af68", // theme-warning + "#7dcfff", // theme-info + "#bb9af7", // theme-accent + "#4e5d6b", // AutofillCollapse's deliberately-preserved literal header token (owner ruling, + // 2026-07-23 - see that component's own comment; distinct from theme-panel-bg by one hex + // digit on purpose) +]; + +export interface ContrastFailure { + selector: string; + text: string; + fg: string; + bg: string; + ratio: number; + required: number; + isLarge: boolean; + isDisabled: boolean; + reason: "contrast" | "off-palette-grey"; +} + +// Injected into the page via page.evaluate(collectInPage, args) - Playwright serialises the +// function via .toString() and runs it in-browser, so it must be self-contained (no closures +// over outer TS values/types - everything it needs comes in through `args`). +/* istanbul ignore next -- runs in-browser, not under node coverage */ +function collectInPage(args: { palette: string[]; disabledFloor: number }) { + const { palette, disabledFloor } = args; + function parseColor( + str: string + ): { r: number; g: number; b: number; a: number } | null { + const m = str.match(/rgba?\(([^)]+)\)/); + if (!m) return null; + const parts = m[1].split(",").map((s) => parseFloat(s.trim())); + if (parts.length < 3 || parts.some((p) => Number.isNaN(p))) return null; + return { + r: parts[0], + g: parts[1], + b: parts[2], + a: parts.length > 3 ? parts[3] : 1, + }; + } + + function toHex(c: { r: number; g: number; b: number }): string { + const h = (n: number) => + Math.round(Math.max(0, Math.min(255, n))) + .toString(16) + .padStart(2, "0"); + return `#${h(c.r)}${h(c.g)}${h(c.b)}`; + } + + function blend( + fg: { r: number; g: number; b: number; a: number }, + bg: { r: number; g: number; b: number } + ) { + const a = fg.a; + return { + r: fg.r * a + bg.r * (1 - a), + g: fg.g * a + bg.g * (1 - a), + b: fg.b * a + bg.b * (1 - a), + }; + } + + function luminance(c: { r: number; g: number; b: number }): number { + const [R, G, B] = [c.r, c.g, c.b].map((ch) => { + const v = ch / 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return 0.2126 * R + 0.7152 * G + 0.0722 * B; + } + + function contrastRatio( + fg: { r: number; g: number; b: number }, + bg: { r: number; g: number; b: number } + ): number { + const L1 = luminance(fg) + 0.05; + const L2 = luminance(bg) + 0.05; + return L1 > L2 ? L1 / L2 : L2 / L1; + } + + function isGreyish(c: { r: number; g: number; b: number }): boolean { + const max = Math.max(c.r, c.g, c.b); + const min = Math.min(c.r, c.g, c.b); + const lightness = (max + min) / 2 / 255; + // low saturation, and not near-black/near-white (those are legitimate ink/paper extremes, + // not "residual grey panel" territory) + return max - min < 12 && lightness > 0.12 && lightness < 0.9; + } + + function cumulativeOpacity(el: Element): number { + let node: Element | null = el; + let product = 1; + while (node && node !== document.documentElement) { + const op = parseFloat(getComputedStyle(node).opacity || "1"); + if (!Number.isNaN(op)) product *= op; + node = node.parentElement; + } + return product; + } + + function effectiveBackground(el: Element): { + r: number; + g: number; + b: number; + } { + const layers: { r: number; g: number; b: number; a: number }[] = []; + let node: Element | null = el; + while (node) { + const bg = parseColor(getComputedStyle(node).backgroundColor); + if (bg && bg.a > 0) { + layers.push(bg); + if (bg.a >= 1) break; // fully opaque, nothing further back matters + } + node = node.parentElement; + } + // layers[0] is nearest (el itself); composite farthest-first onto an opaque white canvas + let result = { r: 255, g: 255, b: 255 }; + for (let i = layers.length - 1; i >= 0; i--) { + result = blend(layers[i], result); + } + return result; + } + + function isLargeText(el: Element): boolean { + const cs = getComputedStyle(el); + const px = parseFloat(cs.fontSize); + const weightRaw = cs.fontWeight; + const weight = + weightRaw === "bold" + ? 700 + : weightRaw === "normal" + ? 400 + : parseInt(weightRaw, 10) || 400; + return px >= 24 || (px >= 18.66 && weight >= 700); + } + + function shortSelector(el: Element): string { + const testId = el.getAttribute("data-testid"); + const id = el.id; + const cls = (el.getAttribute("class") || "") + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .join("."); + const tag = el.tagName.toLowerCase(); + if (testId) return `${tag}[data-testid="${testId}"]`; + if (id) return `${tag}#${id}`; + if (cls) return `${tag}.${cls}`; + return tag; + } + + function isVisible(el: Element): boolean { + // @ts-ignore checkVisibility is Chromium-only but this audit always runs under Chromium + if (typeof (el as any).checkVisibility === "function") { + // @ts-ignore + return el.checkVisibility({ + checkOpacity: false, + checkVisibilityCSS: true, + }); + } + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + + const paletteSet = new Set(palette.map((p) => p.toLowerCase())); + const contrastFailures: any[] = []; + const paletteFailures: any[] = []; + const seenBgKeys = new Set(); + + const all = Array.from(document.querySelectorAll("*")); + for (const el of all) { + if (!isVisible(el)) continue; + const hasDirectText = Array.from(el.childNodes).some( + (n) => n.nodeType === 3 && (n.textContent || "").trim().length > 0 + ); + if (!hasDirectText) continue; + + const cs = getComputedStyle(el); + const fgColor = parseColor(cs.color); + if (!fgColor) continue; + const bg = effectiveBackground(el); + const opacity = cumulativeOpacity(el); + // A fully-transparent (opacity ~0) node is not perceivable by ANYONE right now - e.g. this + // codebase's hover/focus-reveal affordances (PagePreview.tsx's SlotMenuCue/SlotFlipButton: + // `opacity:0` at rest, `opacity:1` on `:hover`/`:focus-within`/`(pointer:coarse)`) are + // legitimately invisible until revealed, not a contrast bug - skip rather than false-positive + // on "text colour equals background" for content nobody can see in this state. + if (opacity < 0.05) continue; + const blendedFg = blend({ ...fgColor, a: fgColor.a * opacity }, bg); + const ratio = contrastRatio(blendedFg, bg); + + const large = isLargeText(el); + const isDisabled = + (el as HTMLButtonElement).disabled === true || + el.getAttribute("aria-disabled") === "true" || + opacity < 0.99; + const required = isDisabled ? disabledFloor : large ? 4.5 : 7; + + if (ratio < required) { + contrastFailures.push({ + selector: shortSelector(el), + text: (el.textContent || "").trim().slice(0, 60), + fg: toHex(blendedFg), + bg: toHex(bg), + ratio: Math.round(ratio * 100) / 100, + required, + isLarge: large, + isDisabled, + reason: "contrast", + }); + } + + // off-palette-grey background check, own element only (dedup by resolved hex per page) + const ownBg = parseColor(cs.backgroundColor); + if (ownBg && ownBg.a > 0) { + const hex = toHex(blend(ownBg, { r: 255, g: 255, b: 255 })); + const key = `${shortSelector(el)}|${hex}`; + if (isGreyish(ownBg) && !paletteSet.has(hex) && !seenBgKeys.has(key)) { + seenBgKeys.add(key); + paletteFailures.push({ + selector: shortSelector(el), + text: (el.textContent || "").trim().slice(0, 60), + fg: toHex(blendedFg), + bg: hex, + ratio: Math.round(ratio * 100) / 100, + required, + isLarge: large, + isDisabled, + reason: "off-palette-grey", + }); + } + } + } + + return { contrastFailures, paletteFailures }; +} + +export interface AuditResult { + contrastFailures: ContrastFailure[]; + paletteFailures: ContrastFailure[]; +} + +/** + * Runs the audit against whatever is currently rendered in `page` (call after any interaction - + * e.g. expanding an accordion or opening an offcanvas - you want captured). `disabledFloor` is + * the owner's "aim >=3:1 for disabled text, never below" bar (default 3.0). + */ +export async function auditContrast( + page: Page, + disabledFloor = 3.0 +): Promise { + const result = await page.evaluate(collectInPage, { + palette: TOKYO11_PALETTE_HEX, + disabledFloor, + }); + return result as AuditResult; +} + +// Site-wide contrast audit (2026-07-25) OPEN ITEMS - see docs/features/theming.md's +// "2026-07-25 contrast/residual-grey audit" section for the full writeup of each. These are +// real, MEASURED, owner-attention-needed gaps this audit found, deliberately left unfixed +// because resolving them means either accepting an already-ratified token's own known shortfall +// stays visible (items 1/4) or changing a deliberate pre-existing sitewide convention that needs +// an explicit owner call on the replacement (item 2), or reaches into a third-party library's +// own CSS with no token seam at all (item 3) - none of that is this pass's four reported +// defects. Matched structurally (fg/selector patterns), not by page/route, so the SAME +// already-known gap recurring on a different page never counts as a new failure - but anything +// that does NOT match one of these signatures is a genuine, unexpected regression and fails the +// gate. +function isKnownOpenItem(f: ContrastFailure): boolean { + // 1. $theme-muted (#a3aad0) itself falls short of strict-AAA-normal on raised/panel-family + // backgrounds (6.39/6.68/5.34:1 measured) - a PR #432-ratified compromise, not new. + if (f.fg === "#a3aad0" && f.reason === "contrast") return true; + // 2. $link-color (unset, defaults to $primary/#ff9e64) measures 5.98:1 on panel - a + // pre-existing, never-previously-measured convention (Navbar.tsx/Footer.tsx/AuthWidget.tsx use + // the same var(--bs-primary) link styling). Matched on the resolved orange foreground alone, + // not the tag - inline markup inside a link (e.g. contributions.tsx's `ISO-639-1` + // wikipedia reference) puts the direct text node on the ``, not the ``, but it's + // inheriting the exact same $link-color value either way. + if (f.fg === "#ff9e64" && f.reason === "contrast") return true; + // 3. Third-party libraries' own unthemed defaults (react-select's white "Choose..." + // placeholder, react-dropdown-tree-select's grey tag pills/close button) - no Bootstrap/token + // seam reaches either; needs its own scoped override stylesheet, out of scope for this pass. + if ( + (f.selector.includes("rdts") || + f.selector.startsWith("span.placeholder")) && + f.reason === "contrast" + ) { + return true; + } + // 4. $theme-danger (#f7768e) as plain text colour measures 6.46:1 - the SAME already-ratified + // AAA-large-only exception documented for danger-as-button-ink, just recurring as text colour. + if (f.fg === "#f7768e" && f.reason === "contrast") return true; + return false; +} + +/** Splits a failure list into [newFailures, knownOpenItems] per `isKnownOpenItem` above. Tests + * should assert `newFailures` is empty (the real regression gate) and log `knownOpenItems` for + * visibility rather than silently dropping them. */ +export function splitKnownOpenItems(failures: ContrastFailure[]): { + newFailures: ContrastFailure[]; + knownOpenItems: ContrastFailure[]; +} { + const newFailures: ContrastFailure[] = []; + const knownOpenItems: ContrastFailure[] = []; + for (const f of failures) { + (isKnownOpenItem(f) ? knownOpenItems : newFailures).push(f); + } + return { newFailures, knownOpenItems }; +} + +export function formatFailureTable(failures: ContrastFailure[]): string { + if (failures.length === 0) return "(none)"; + const header = + "selector | text | fg | bg | ratio | required | large | disabled | reason"; + const rows = failures.map( + (f) => + `${f.selector} | "${f.text}" | ${f.fg} | ${f.bg} | ${f.ratio}:1 | ${f.required}:1 | ${f.isLarge} | ${f.isDisabled} | ${f.reason}` + ); + return [header, ...rows].join("\n"); +} From baa06b64f05770efb7d01b1d293739f6098f254a Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:23:11 +0000 Subject: [PATCH 2/2] Fix link colour AAA gap: route $link-color to $theme-info, not accent Co-Authored-By: Claude Fable 5 --- docs/features/theming.md | 98 ++++++++++++++--- frontend/src/styles/styles.scss | 40 +++++++ frontend/tests/ContrastAudit.spec.ts | 134 ++++++++++++++++++++++++ frontend/tests/tooling/contrastAudit.ts | 60 ++++++++--- 4 files changed, 303 insertions(+), 29 deletions(-) diff --git a/docs/features/theming.md b/docs/features/theming.md index 8d70e1c86..879d8f177 100644 --- a/docs/features/theming.md +++ b/docs/features/theming.md @@ -488,8 +488,9 @@ noted:** **OPEN ITEMS — owner decision needed, not resolved by this pass** (found by the audit, left alone because fixing them means either accepting a token's -already-ratified compromise stays visible or changing a deliberate -pre-existing convention, both outside this pass's four reported defects): +already-ratified compromise stays visible or reaches into a third-party +library's own CSS with no token seam, both outside this pass's four +reported defects): 1. **`$theme-muted` itself still falls short of strict-AAA-normal (7:1) on `$theme-raised-bg` and `$theme-panel-bg`** — 6.39:1 and 5.34:1 @@ -501,16 +502,13 @@ pre-existing convention, both outside this pass's four reported defects): regression. Needs either a second, higher-contrast "small-caption-on- raised" token or an owner ruling that AA (4.5:1), not strict AAA, is the accepted floor for this specific role. -2. **`$link-color` (unset, defaults to `$primary`) measures only 5.98:1 on - `$theme-panel-bg`** — below strict-AAA-normal, and this doc's own - "Accent scope boundary" section previously asserted this was fine - ("stays on `$primary`... matching the pre-existing convention") without - ever actually measuring it. It's worse than the accent purple that same - section rejected for prose (6.74:1) on the one surface both were - checked against. Not fixed here — changing `$link-color` sitewide is a - materially bigger, more visible change than this pass's other four - fixes (every plain `` tag's colour, not an unrouted default nobody - chose), and needs an explicit owner call on the replacement token. +2. ~~`$link-color` (unset, defaults to `$primary`) measures only 5.98:1 on + `$theme-panel-bg`~~ — **RESOLVED, 2026-07-25 link-colour follow-up**, see + "Link colour fix" below for the full writeup and measured numbers. Left + in this numbered list (rather than deleted and silently reflowed) so + anything that already points at "item 2" by number — e.g. the "Accent + scope boundary" section above — stays accurate; a resolved item is + marked resolved in place, not removed. 3. **Two third-party libraries render their own unthemed default greys/whites**, found by the audit's off-palette-grey sweep but not fixed (no Bootstrap/token seam reaches into either): `react-select`'s @@ -518,8 +516,9 @@ pre-existing convention, both outside this pass's four reported defects): dropdown surface on this dark theme, 3.95:1) and `react-dropdown-tree-select`'s tag pills (`/explore`, `#000000` on `#dddddd`). Both need their own scoped override stylesheet targeting - that library's own class names — a distinct, larger follow-up, not a - token-layer fix. + that library's own class names — owner-approved as a SEPARATE follow-up + PR after this one merges (kept apart to avoid two frontend branches + colliding in `styles.scss`), not a token-layer fix anyway. 4. **`$theme-danger` as plain text colour measures 6.46:1** (`/whatsthat`'s "Something went wrong" message) — this is the SAME already-documented, already-ratified AAA-large-only exception the token file's own comment @@ -622,3 +621,74 @@ they were re-derived. - Screenshots at 390px: `/contributions` (collapsed + expanded accordion), the card-list restore-draft banner, the editor's mobile Print & Settings sheet (scrolled to show Cardback too) — paths in the PR body. + +## Link colour fix (2026-07-25 follow-up) + +Resolves OPEN ITEM 2 above. `$link-color` (Bootstrap core default: `$primary` +— never previously overridden) is now `$theme-info`; `$link-hover-color` +(Bootstrap default: an auto-derived 20%-darken of whatever `$link-color` is) +is now explicitly `$theme-text`, since the auto-derived shade fails AAA on +every surface once applied to a light colour on this dark theme (measured +4.56–6.40:1 — darkening a light foreground toward black on an already-dark +background reduces contrast, not increases it). + +**This is the THIRD correction this audit has made to a claim in this doc +that was never actually measured** (the first two: `$card-bg` "deliberately +untouched" in "What's grey on purpose", and `$link-color` "matching the +pre-existing convention" in "Accent scope boundary" — both above). The +pattern is worth naming once, plainly, so future work doesn't extend it: +**an assertion about a colour relationship is not evidence until it's been +computed.** All three corrections were "this reads as fine/intentional" or +"matches an existing pattern" claims stated with no accompanying ratio — +each one turned out to be either backwards or simply untested the moment a +real contrast calculation was run against it. Nothing else in this doc's +palette/token tables is assumed exempt from that same scrutiny; a future +editor who wants to assert a pairing is safe should compute and cite the +ratio in the same sentence, not state it as received wisdom. + +The owner initially asked for `$theme-accent` (reasoning: its own +already-documented 6.74:1 on the D14 band surface reads as "better than +`$primary`'s 5.98:1 on panel"). Measuring both across every surface a link +can actually land on shows that reasoning compared two DIFFERENT surfaces +(band vs panel) — on any SINGLE surface, `$primary` beats `$theme-accent` +by roughly a full point, not the other way around: + +| Surface | `$primary` (old) | `$theme-accent` (rejected) | `$theme-info` (shipped, resting) | `$theme-text` (shipped, hover) | +| ----------------------- | ---------------- | -------------------------- | -------------------------------- | ------------------------------ | +| `$theme-body-bg` | 8.40 | 7.39 | 9.96 | 10.59 | +| `$theme-raised-bg` | 7.16 | 6.30 (fails) | 8.49 | 9.02 | +| `$theme-panel-bg` | 5.98 (fails) | 5.26 (fails) | 7.09 | 7.54 | +| `$theme-card-header-bg` | 5.99 (fails) | 5.27 (fails) | 7.10 | 7.55 | +| `$theme-band-bg` | 7.67 | 6.74 (fails) | 9.09 | 9.66 | + +`$theme-accent` fails strict-AAA-normal on 4 of the 5 surfaces and is worse +than the status quo it was meant to replace on all 5 — switching to it would +have been a regression labelled as a fix, so it wasn't used. `$theme-info` +is the minimal correct substitute: not a new palette entry (it's this +codebase's existing "link-styled text" role token — see the "Accent scope +boundary" section above, which already routed a handful of small cyan links +to it for exactly this reason), and it clears strict-AAA-normal on every +surface with real margin (7.09:1 minimum). `:visited`/`:active` need no +separate rule — verified via Bootstrap core's own `_reboot.scss` source that +neither pseudo-class gets one at all (both inherit the resting `$link-color` +custom property, or `:hover`'s override if that's also active); confirmed by +real `getComputedStyle` measurement, not just the source reading, in +`ContrastAudit.spec.ts`'s "Link colour audit" block. + +## Verification (2026-07-25 link-colour follow-up) + +- `ContrastAudit.spec.ts`'s new "Link colour audit" describe block: one test + injects a real `` into each of the five named surfaces and measures + real `getComputedStyle().color` for both resting and `:hover` (matching + the table above exactly, not just recomputing the SCSS literal); a second + test measures a real production link (`/contributions`'s ISO-639-1 + Wikipedia reference, inside the accordion body this same audit's earlier + round fixed — panel-bg, 7.09:1 resting / 7.54:1 hover). Both pass. +- `tests/tooling/contrastAudit.ts`'s `isKnownOpenItem()` allowlist had its + former `#ff9e64`-as-link-fg case removed entirely (not just left unused), + so a regression back to the old colour fails the general sweep too, not + only the dedicated link tests. +- Full `ContrastAudit.spec.ts` suite (12 tests, including the two new link + ones) re-run after the fix — all pass; see the PR body for the console + output. +- `npx tsc --noEmit`, `npx prettier@2.7.1 --check` — clean. diff --git a/frontend/src/styles/styles.scss b/frontend/src/styles/styles.scss index 742dfd889..f0ca95f44 100644 --- a/frontend/src/styles/styles.scss +++ b/frontend/src/styles/styles.scss @@ -42,6 +42,46 @@ $danger: $theme-danger; $warning: $theme-warning; $info: $theme-info; +// Site-wide contrast audit (2026-07-25 follow-up, owner-approved open item 2): Bootstrap core's +// own `$link-color: $primary !default;` measured 5.98/5.99:1 on panel/card-header-bg (below +// strict-AAA-normal) -- see docs/features/theming.md's "Accent scope boundary" section, which +// this follow-up corrects a second time. The FIRST correction (this same audit) found that +// section's claim "$link-color stays on $primary... matching the pre-existing convention" had +// never actually been measured. The owner asked to try routing it to `$theme-accent` instead, on +// the reasoning that accent's own already-documented 6.74:1 (on the D14 band surface) reads as +// "better than $primary's 5.98:1" -- but that compares two DIFFERENT surfaces (band vs panel), +// not the same one. Measured apples-to-apples across every surface a link can land on +// (body/raised/panel/card-header/band), accent is worse than primary on ALL FIVE, not better: +// body-bg primary 8.40 accent 7.39 (accent barely clears 7:1, thin margin) +// raised-bg primary 7.16 accent 6.30 (accent FAILS -- regression vs primary's own pass) +// panel-bg primary 5.98 accent 5.26 (accent fails, and by more than primary already did) +// card-header-bg primary 5.99 accent 5.27 (same) +// band-bg primary 7.67 accent 6.74 (accent FAILS -- this is the exact number the doc's +// own "Accent scope boundary" section already flags +// as below strict-AAA-normal for prose; nothing new) +// Accent fails 4 of 5 surfaces outright and is strictly worse than the status quo on every one -- +// switching to it would be a regression dressed as a fix, so per this task's own "if any surface +// fails, do not force it, propose the minimal adjusted shade instead" instruction, it is NOT used +// here. `$theme-info` (cyan) is the minimal, already-approved substitute: it is ALREADY this +// codebase's established "link-styled text" role token (see `$theme-info`'s own token-file +// comment and the "Accent scope boundary" section's "handful of small cyan 'info' links... stay +// on $theme-info instead, precisely because they're small text and accent isn't AAA-safe there" +// -- this pass just extends that SAME existing role to every link, not a new decision) -- no new +// palette entry, and it clears strict-AAA-normal on every one of the five surfaces with real +// margin: body 9.96, raised 8.49, panel 7.09, card-header 7.10, band 9.09 (all Playwright- +// verified against real rendered `` computed styles, not just the SCSS literal -- see the +// fidelity spec). `$link-hover-color`'s Bootstrap default (`shift-color($link-color, 20%)`, i.e. +// darkened 20% toward black) fails AAA on every surface once applied to a light colour on an +// already-dark theme (measured 4.56-6.40:1) -- overridden explicitly to `$theme-text` instead +// (lightens on hover, a common/expected link-hover treatment), which clears every surface with +// even MORE margin (7.54-10.59:1) than the resting colour. `:visited`/`:active` need no separate +// override -- verified via Bootstrap core's own `_reboot.scss` source (`a { color: +// rgba(var(--bs-link-color-rgb, ...)); &:hover { --bs-link-color-rgb: ...-hover-color-rgb; } }`) +// that neither pseudo-class has its own rule at all, so both already inherit the same resting +// `$link-color` (or `:hover`'s override, if that's ALSO active) with zero additional CSS. +$link-color: $theme-info; +$link-hover-color: $theme-text; + // Site-wide contrast audit (2026-07-25): Bootstrap core's own `$body-secondary-color` (the // source of `.text-muted`/`--bs-secondary-color`, never overridden by Superhero) defaults to // `rgba($body-color, .75)` -- a translucent literal, not a token, that this audit measured at diff --git a/frontend/tests/ContrastAudit.spec.ts b/frontend/tests/ContrastAudit.spec.ts index 371392fc3..c5a638acd 100644 --- a/frontend/tests/ContrastAudit.spec.ts +++ b/frontend/tests/ContrastAudit.spec.ts @@ -17,7 +17,9 @@ import { import { auditContrast, ContrastFailure, + contrastRatioHex, formatFailureTable, + rgbStringToHex, splitKnownOpenItems, } from "./tooling/contrastAudit"; @@ -230,3 +232,135 @@ for (const route of ["", "about", "explore", "myDecks", "whatsthat", "new"]) { assertNoNewFailures(result.contrastFailures, `/${route || "(home)"}`); }); } + +// Link colour audit (2026-07-25 follow-up, owner-approved open item 2) - $link-color switched +// from $primary (measured 5.98:1 on panel, below AAA) to $theme-info (measured 7.09-9.96:1 on +// every surface a link can land on - see styles.scss's own comment for the full measurement, +// including why $theme-accent was tried and rejected: it's WORSE than $primary on every one of +// these five surfaces, not better). Covers resting, hover, AND the two pseudo-classes Bootstrap's +// own reboot.scss never gives a separate rule to (:visited/:active fall through to the resting +// $link-color unless :hover is also active - verified via source inspection, see styles.scss) - +// asserted here anyway rather than only trusted from source reading, since real getComputedStyle +// after a real DOM mutation is the actual regression gate, not the source-reading argument for it. +test.describe("Link colour audit (owner-approved open item 2 - $link-color -> $theme-info)", () => { + // Synthetic isolated surfaces (deterministic backgrounds, not dependent on finding a real link + // on all five - band-bg in particular has no real prose link anywhere in the app today) - real + // getComputedStyle() of a real rendered , not a recomputation of the SCSS literal. + const SURFACES: Array<[label: string, hex: string]> = [ + ["body-bg", "#1a1b26"], + ["raised-bg", "#24283b"], + ["panel-bg", "#2f3549"], + ["card-header-bg", "#2f3548"], + ["band-bg", "#222234"], + ]; + + test("resting and hover colour clear strict-AAA-normal (7:1) on every surface a link can land on", async ({ + page, + network, + }) => { + network.use(whoamiAnonymous, ...defaultHandlers); + await loadPageWithDefaultBackend(page, "about"); + + const failures: string[] = []; + for (const [label, bgHex] of SURFACES) { + const linkId = `link-audit-${label}`; + await page.evaluate( + ({ bgHex, linkId }) => { + // Remove any prior probe (they'd otherwise stack at the same fixed position - not + // just visual clutter, the REAL mouse cursor left hovering the last one's pixel + // position would make the new element start life already :hover'd, since :hover is + // coordinate-based, not element-based). + document + .querySelectorAll('[data-link-audit-probe="1"]') + .forEach((n) => n.remove()); + const div = document.createElement("div"); + div.setAttribute("data-link-audit-probe", "1"); + // Fixed + top z-index so this synthetic probe is never occluded by the real page's + // own fixed/sticky chrome (the navbar intercepted pointer events here without this). + div.style.position = "fixed"; + div.style.top = "0"; + div.style.left = "0"; + div.style.zIndex = "999999"; + div.style.backgroundColor = bgHex; + div.style.padding = "8px"; + const a = document.createElement("a"); + a.href = "#"; + a.id = linkId; + a.textContent = "Sample link text"; + div.appendChild(a); + document.body.appendChild(div); + }, + { bgHex, linkId } + ); + // Move the real cursor well away before reading "resting" - :hover is coordinate-based, so + // without this the cursor left over from the PREVIOUS iteration's `.hover()` call (same + // fixed top:0/left:0 screen position every iteration) would make the freshly-created link + // read as already-hovered. + await page.mouse.move(600, 600); + + const link = page.locator(`#${linkId}`); + const restingColor = await link.evaluate( + (el) => getComputedStyle(el).color + ); + const restingHex = rgbStringToHex(restingColor); + const restingRatio = contrastRatioHex(restingHex, bgHex); + if (restingRatio < 7) { + failures.push( + `${label} resting: ${restingHex} on ${bgHex} = ${restingRatio}:1 (< 7:1)` + ); + } + + await link.hover(); + const hoverColor = await link.evaluate( + (el) => getComputedStyle(el).color + ); + const hoverHex = rgbStringToHex(hoverColor); + const hoverRatio = contrastRatioHex(hoverHex, bgHex); + if (hoverRatio < 7) { + failures.push( + `${label} hover: ${hoverHex} on ${bgHex} = ${hoverRatio}:1 (< 7:1)` + ); + } + + console.log( + `link colour - ${label}: resting ${restingHex} = ${restingRatio}:1, hover ${hoverHex} = ${hoverRatio}:1` + ); + } + + expect(failures, failures.join("\n")).toEqual([]); + }); + + test("a real production link (contributions guidelines' ISO-639-1 reference, inside the accordion body's panel-bg) clears strict-AAA-normal", async ({ + page, + network, + }) => { + network.use(contributionsOneSource, ...defaultHandlers); + await loadPageWithDefaultBackend(page, "contributions"); + + const header = page.getByRole("button", { + name: "Contribution Guidelines", + }); + await header.click(); + const link = page.getByRole("link", { name: "ISO-639-1 nomenclature" }); + await expect(link).toBeVisible(); + + const panelBgHex = "#2f3549"; + + const restingColor = await link.evaluate( + (el) => getComputedStyle(el).color + ); + const restingHex = rgbStringToHex(restingColor); + const restingRatio = contrastRatioHex(restingHex, panelBgHex); + + await link.hover(); + const hoverColor = await link.evaluate((el) => getComputedStyle(el).color); + const hoverHex = rgbStringToHex(hoverColor); + const hoverRatio = contrastRatioHex(hoverHex, panelBgHex); + + console.log( + `real ISO-639-1 link: resting ${restingHex} = ${restingRatio}:1, hover ${hoverHex} = ${hoverRatio}:1` + ); + expect(restingRatio).toBeGreaterThanOrEqual(7); + expect(hoverRatio).toBeGreaterThanOrEqual(7); + }); +}); diff --git a/frontend/tests/tooling/contrastAudit.ts b/frontend/tests/tooling/contrastAudit.ts index 2cda7e6f7..2f52df60a 100644 --- a/frontend/tests/tooling/contrastAudit.ts +++ b/frontend/tests/tooling/contrastAudit.ts @@ -309,13 +309,16 @@ export async function auditContrast( return result as AuditResult; } -// Site-wide contrast audit (2026-07-25) OPEN ITEMS - see docs/features/theming.md's -// "2026-07-25 contrast/residual-grey audit" section for the full writeup of each. These are -// real, MEASURED, owner-attention-needed gaps this audit found, deliberately left unfixed -// because resolving them means either accepting an already-ratified token's own known shortfall -// stays visible (items 1/4) or changing a deliberate pre-existing sitewide convention that needs -// an explicit owner call on the replacement (item 2), or reaches into a third-party library's -// own CSS with no token seam at all (item 3) - none of that is this pass's four reported +// Site-wide contrast audit (2026-07-25) OPEN ITEMS - numbered to match docs/features/theming.md's +// "2026-07-25 contrast/residual-grey audit" OPEN ITEMS list exactly (see that section for the +// full writeup of each) - item 2 (former: $link-color/$primary measuring 5.98:1 on panel) is +// RESOLVED as of the 2026-07-25 link-colour follow-up ($link-color -> $theme-info, 7.09-9.96:1 on +// every surface, see styles.scss's own comment) and deliberately has no case here any more - an +// orange (#ff9e64) link-text failure recurring now would be a genuine regression, not a +// known/allowed gap, so it must NOT be allowlisted again. Items 1/3/4 remain real, MEASURED, +// owner-attention-needed gaps, left unfixed because resolving them means either accepting an +// already-ratified token's own known shortfall stays visible (1/4) or reaches into a third-party +// library's own CSS with no token seam at all (3) - none of that is this pass's four reported // defects. Matched structurally (fg/selector patterns), not by page/route, so the SAME // already-known gap recurring on a different page never counts as a new failure - but anything // that does NOT match one of these signatures is a genuine, unexpected regression and fails the @@ -324,16 +327,10 @@ function isKnownOpenItem(f: ContrastFailure): boolean { // 1. $theme-muted (#a3aad0) itself falls short of strict-AAA-normal on raised/panel-family // backgrounds (6.39/6.68/5.34:1 measured) - a PR #432-ratified compromise, not new. if (f.fg === "#a3aad0" && f.reason === "contrast") return true; - // 2. $link-color (unset, defaults to $primary/#ff9e64) measures 5.98:1 on panel - a - // pre-existing, never-previously-measured convention (Navbar.tsx/Footer.tsx/AuthWidget.tsx use - // the same var(--bs-primary) link styling). Matched on the resolved orange foreground alone, - // not the tag - inline markup inside a link (e.g. contributions.tsx's `ISO-639-1` - // wikipedia reference) puts the direct text node on the ``, not the ``, but it's - // inheriting the exact same $link-color value either way. - if (f.fg === "#ff9e64" && f.reason === "contrast") return true; // 3. Third-party libraries' own unthemed defaults (react-select's white "Choose..." // placeholder, react-dropdown-tree-select's grey tag pills/close button) - no Bootstrap/token - // seam reaches either; needs its own scoped override stylesheet, out of scope for this pass. + // seam reaches either; needs its own scoped override stylesheet, a separate follow-up PR (per + // the owner - kept apart from this PR to avoid two frontend branches colliding in styles.scss). if ( (f.selector.includes("rdts") || f.selector.startsWith("span.placeholder")) && @@ -362,6 +359,39 @@ export function splitKnownOpenItems(failures: ContrastFailure[]): { return { newFailures, knownOpenItems }; } +// Standalone hex-pair WCAG contrast ratio (2026-07-25, link-colour follow-up) - the same +// relative-luminance math `collectInPage` uses in-browser, exposed here as a plain Node-side +// function for tests that measure a specific known pairing (e.g. a link's `color` against a +// deliberately-controlled background) rather than sweeping the whole DOM. Keep these two +// implementations in sync if the formula ever changes. +export function contrastRatioHex(hexA: string, hexB: string): number { + const toRgb = (hex: string) => { + const n = parseInt(hex.replace("#", ""), 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; + }; + const luminance = (c: { r: number; g: number; b: number }) => { + const [R, G, B] = [c.r, c.g, c.b].map((ch) => { + const v = ch / 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return 0.2126 * R + 0.7152 * G + 0.0722 * B; + }; + const L1 = luminance(toRgb(hexA)) + 0.05; + const L2 = luminance(toRgb(hexB)) + 0.05; + return Math.round((L1 > L2 ? L1 / L2 : L2 / L1) * 100) / 100; +} + +/** Converts a browser `getComputedStyle().color` `rgb(...)`/`rgba(...)` string to `#rrggbb`. */ +export function rgbStringToHex(rgbString: string): string { + const m = rgbString.match(/rgba?\(([^)]+)\)/); + if (!m) throw new Error(`Not an rgb()/rgba() string: ${rgbString}`); + const [r, g, b] = m[1] + .split(",") + .map((s) => Math.round(parseFloat(s.trim()))); + const hex = (n: number) => n.toString(16).padStart(2, "0"); + return `#${hex(r)}${hex(g)}${hex(b)}`; +} + export function formatFailureTable(failures: ContrastFailure[]): string { if (failures.length === 0) return "(none)"; const header =