Files
yellowjacket/frontend/test/components/avatar-color.test.ts
T
logan 533c084f8a 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.
2026-08-13 00:33:50 -04:00

78 lines
2.4 KiB
TypeScript

/**
* A letter avatar's background clears 4.5:1 against white for *every*
* hue it can generate.
*
* The two failures a rendered sweep found were not the finding. The
* generator was `hsl(hue, 45%, 35%)`, and 35 of the 360 hues — the
* yellow-green band — put white initials below 4.5:1, bottoming out at
* 4.08:1. Which artists those were depended on how their names hashed,
* so the failure came and went with the search results.
*
* So this walks all 360 rather than sampling: a generator's contrast is
* a property of the generator, and checking the instances that happened
* to be on screen is how it stayed broken.
*/
import { describe, expect, it } from 'vitest';
import { avatarBackground, nameToHue } from '@utils/avatar-color';
/** Resolve an `hsl(...)` string to sRGB via the browser's own parser. */
function toRgb(color: string): [number, number, number] {
const probe = document.createElement('div');
probe.style.color = color;
document.body.append(probe);
const computed = getComputedStyle(probe).color;
probe.remove();
const [r, g, b] = computed
.slice(computed.indexOf('(') + 1, computed.indexOf(')'))
.split(/[,\s/]+/)
.filter(Boolean)
.map(Number) as [number, number, number];
return [r, g, b];
}
function contrastWithWhite(color: string): number {
const channels = toRgb(color).map((v) => v / 255);
const [r, g, b] = channels.map((v) =>
v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4,
) as [number, number, number];
const l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return 1.05 / (l + 0.05);
}
describe('avatar colours', () => {
it('clears 4.5:1 against white at every hue', () => {
const ratios = Array.from({ length: 360 }, (_, hue) =>
contrastWithWhite(`hsl(${hue}, 45%, 32%)`),
);
expect(Math.min(...ratios)).toBeGreaterThanOrEqual(4.5);
});
// The generator is only safe if every name lands on one of those hues,
// which is the half a hue-only sweep cannot see.
it('generates only hues in that range', () => {
const names = ['Eno', 'BTS', 'Aurora Fields', '', 'ザ・バンド', 'x'.repeat(200)];
const hues = names.map((n) => nameToHue(n));
expect(hues.every((h) => Number.isInteger(h) && h >= 0 && h < 360)).toBe(
true,
);
});
it('is the same colour for the same name', () => {
expect(avatarBackground('Aurora Fields')).toBe(
avatarBackground('Aurora Fields'),
);
});
});