fix(a11y): make every text colour clear WCAG AA on every ramp

a11y.md flagged --yj-text-tertiary on --yj-bg-surface as 'borderline
(~4.1:1) but that needs a real measurement', and plan 007 parked it as
'worth measuring before planning'. Measured, against the rendered app
and then across all three background ramps: it failed AA in nine of
twelve text/surface combinations, as low as 2.31:1 on dark's overlay
and 2.55:1 on light's -- the app's most-used secondary text colour,
failing on every view. Not borderline. 110 failing nodes across twelve
views, now 0 of 659.

Three separate mechanisms, and only the first is the finding:

- The ramps. Tertiary is raised per ramp (#a6a6a6 dark, #949494 darker,
  #5c636a light), sized to the lightest surface it actually sits on and
  keeping its hue. Sizing it to bgOverlay too would need a grey lighter
  than secondary, so bgOverlay is documented as not a text surface and
  the one component that put text there uses primary.
- The avatar generator. hsl(hue, 45%, 35%) behind white initials failed
  for 35 of the 360 hues -- the yellow-green band -- so which artists
  were unreadable depended on how their names hashed. The two a sweep
  found were not the finding. 32% clears every hue.
- Jobs' local #ff6b6b, at 4.15:1 on elevated.

Pinned by a unit test over the palette table rather than a DOM sweep:
the ramps are pure data, and checking only what happens to be on screen
is exactly how the light ramp went unexamined. Note that make ui-visual
cannot see any of this -- the component tier renders the fallbacks,
because theme-store sets :root only in the real app.
This commit is contained in:
2026-08-13 00:33:50 -04:00
parent 31144e5dc7
commit 533c084f8a
14 changed files with 258 additions and 39 deletions
+39 -5
View File
@@ -20,7 +20,7 @@ type Subscriber = () => void;
* Shade palettes keyed by BackgroundShade.
* Each defines the base grayscale ramp used throughout the UI.
*/
interface ShadePalette {
export interface ShadePalette {
bgBase: string;
bgSurface: string;
bgElevated: string;
@@ -34,7 +34,36 @@ interface ShadePalette {
selectionBg: string;
}
const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
/**
* The grayscale ramps, and the one rule that constrains them.
*
* `textTertiary` used to be `#888888` on both dark ramps and `#868e96`
* on the light one, which `a11y.md` flagged as "borderline" from a hand
* calculation and parked as "worth measuring before planning".
* Measured, against the rendered app and then across all three ramps:
* it failed WCAG AA in **nine of twelve** text/surface combinations,
* as low as 2.31:1 on dark's overlay and 2.55:1 on light's. Not
* borderline — the app's most-used secondary text colour, failing on
* every view, and the light ramp (which the audit never considered) was
* the worst of the three.
*
* So: **every text colour clears 4.5:1 against every surface it can sit
* on**, and `theme-contrast.test.ts` computes that from this table
* rather than trusting it. Two things decided the values.
*
* `bgOverlay` is not a text surface on the dark ramp. Sizing tertiary
* to clear 4.5 against `#495057` needs `#c0c0c0`, which is *lighter
* than secondary* — an inverted hierarchy is a worse answer than the
* problem. Tertiary is sized to `bgElevated` there, and the one place
* that did put text on the overlay (the downloads notice) uses
* `textPrimary`, which clears 8.18:1.
*
* And the hue is kept. The light ramp's tertiary is a blue-grey, so it
* darkens along its own hue to `#5c636a` rather than flattening to a
* neutral that would have passed just as well and looked like a
* different palette.
*/
export const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
darker: {
bgBase: '#000000',
bgSurface: '#121212',
@@ -42,7 +71,8 @@ const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
bgOverlay: '#2a2a2a',
textPrimary: '#ffffff',
textSecondary: '#b3b3b3',
textTertiary: '#888888',
// 4.05:1 on bgOverlay at #888888.
textTertiary: '#949494',
border: '#333333',
borderSubtle: '#222222',
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
@@ -55,7 +85,9 @@ const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
bgOverlay: '#495057',
textPrimary: '#ffffff',
textSecondary: '#b3b3b3',
textTertiary: '#888888',
// 4.35:1 on bgSurface and 3.25:1 on bgElevated at #888888 — the
// measured version of the audit's estimate, on every view.
textTertiary: '#a6a6a6',
border: '#444444',
borderSubtle: '#333333',
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
@@ -68,7 +100,9 @@ const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
bgOverlay: '#dee2e6',
textPrimary: '#212529',
textSecondary: '#495057',
textTertiary: '#868e96',
// 3.32:1 at best and 2.55:1 at worst at #868e96 — the light ramp
// failed on all four of its own surfaces.
textTertiary: '#5c636a',
border: '#ced4da',
borderSubtle: '#dee2e6',
hoverOverlay: 'rgba(0, 0, 0, 0.05)',