diff --git a/frontend/src/components/downloads-view/downloads-view.ts b/frontend/src/components/downloads-view/downloads-view.ts index 1566beb..a4ae703 100644 --- a/frontend/src/components/downloads-view/downloads-view.ts +++ b/frontend/src/components/downloads-view/downloads-view.ts @@ -203,7 +203,11 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) { border-radius: 6px; font-size: 12px; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); - color: var(--yj-text-secondary, #b3b3b3); + /* The only place in the app that puts text on bgOverlay, + which is too light on the dark ramp to carry anything + but primary: secondary measured 3.90:1 here. A notice + is the last thing that should be hard to read. */ + color: var(--yj-text-primary, #fff); } .section-hint { diff --git a/frontend/src/components/explore-artist-details/explore-artist-details.ts b/frontend/src/components/explore-artist-details/explore-artist-details.ts index dba15a3..877f5e6 100644 --- a/frontend/src/components/explore-artist-details/explore-artist-details.ts +++ b/frontend/src/components/explore-artist-details/explore-artist-details.ts @@ -1,3 +1,4 @@ +import { avatarBackground } from '@utils/avatar-color'; import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { designTokens } from '../../styles/tokens.css'; @@ -60,14 +61,6 @@ const NON_STUDIO_SECONDARY_TYPES = new Set([ /* ── Utility functions (duplicated from explore-view per design decision) ── */ -function nameToHue(name: string): number { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - } - return Math.abs(hash) % 360; -} - function extractYear(dateStr: string): string { if (!dateStr) return ''; return dateStr.substring(0, 4); @@ -1884,7 +1877,6 @@ export class ExploreArtistDetails extends LitElement { /* ── Render ── */ override render() { - const hue = nameToHue(this.artistName); return html`
@@ -1898,7 +1890,7 @@ export class ExploreArtistDetails extends LitElement {
${this.artistImageURL ? html`Similar Artists
${visible.map((a) => { - const hue = nameToHue(a.name); const imgURL = this.similarImageURLs.get(a.artistMbid); return html`
${imgURL ? html` ${artists.map((a) => { - const hue = nameToHue(a.name); return html`
${this.artistImageCache.get(a.mbid) ? html` 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 = { +/** + * 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 = { darker: { bgBase: '#000000', bgSurface: '#121212', @@ -42,7 +71,8 @@ const SHADE_PALETTES: Record = { 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 = { 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 = { 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)', diff --git a/frontend/src/utils/avatar-color.ts b/frontend/src/utils/avatar-color.ts new file mode 100644 index 0000000..d8b1473 --- /dev/null +++ b/frontend/src/utils/avatar-color.ts @@ -0,0 +1,36 @@ +/** + * The background for a letter avatar, derived from a name. + * + * Three call sites drew `hsl(nameToHue(name), 45%, 35%)` behind white + * initials, from two copies of the same hash function. Measured across + * all 360 hues: **35 of them** — the yellow-green band from about 53° + * to 88° — put white text below 4.5:1, bottoming out at 4.08:1. Which + * artists those were depended entirely on how their names hashed, so + * the app had a contrast failure that came and went with the search + * results, and the two instances that turned up in a sweep were not the + * finding. + * + * 32% lightness clears every hue with a floor of 4.75:1, so the fix is + * a property of the generator rather than of any colour it generates. + * `avatar-color.test.ts` walks all 360. + */ + +/** Hash a string to a hue value 0–360. */ +export function nameToHue(name: string): number { + let hash = 0; + + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + + return Math.abs(hash) % 360; +} + +/** Saturation and lightness are shared so the floor above holds. */ +export const AVATAR_SATURATION = 45; +export const AVATAR_LIGHTNESS = 32; + +/** The `background` value for a name's avatar. */ +export function avatarBackground(name: string): string { + return `hsl(${nameToHue(name)}, ${AVATAR_SATURATION}%, ${AVATAR_LIGHTNESS}%)`; +} diff --git a/frontend/test/components/avatar-color.test.ts b/frontend/test/components/avatar-color.test.ts new file mode 100644 index 0000000..ce2365f --- /dev/null +++ b/frontend/test/components/avatar-color.test.ts @@ -0,0 +1,77 @@ +/** + * 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'), + ); + }); +}); diff --git a/frontend/test/components/theme-contrast.test.ts b/frontend/test/components/theme-contrast.test.ts new file mode 100644 index 0000000..1f4f680 --- /dev/null +++ b/frontend/test/components/theme-contrast.test.ts @@ -0,0 +1,86 @@ +/** + * Every text colour clears WCAG AA against every surface it can sit on, + * on all three background ramps. + * + * `a11y.md` flagged one pair as "borderline (≈4.1:1) but that needs a + * real measurement", and plan 007 parked it as "worth measuring before + * planning". Measured: it failed in **nine of twelve** combinations, + * as low as 2.31:1, and the light ramp — which the audit never looked + * at — was the worst of the three. + * + * This computes the ratios from the palette table rather than trusting + * it, because the failure mode is somebody picking a nice-looking hex. + * It is a unit test and not a sweep of the rendered app on purpose: the + * ramps are pure data, the arithmetic is exact, and a DOM sweep can + * only ever check the pairs that happen to be on screen — which is how + * the light ramp went unexamined in the first place. + */ +import { describe, expect, it } from 'vitest'; + +import { SHADE_PALETTES } from '@store/theme-store'; +import type { ShadePalette } from '@store/theme-store'; + +/** WCAG 2.1 relative luminance. */ +function luminance(hex: string): number { + const h = hex.replace('#', ''); + const channels = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 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]; + + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [ + number, + number, + ]; + + return (hi + 0.05) / (lo + 0.05); +} + +const TEXT = ['textPrimary', 'textSecondary', 'textTertiary'] as const; + +/** + * `bgOverlay` is deliberately absent for tertiary on the dark ramp. + * Clearing 4.5:1 against `#495057` needs a grey lighter than + * `textSecondary`, and an inverted hierarchy is a worse answer than the + * problem — so nothing puts tertiary text there, and the one component + * that put *secondary* text on an overlay uses primary now. + */ +const SURFACES: Record<(typeof TEXT)[number], (keyof ShadePalette)[]> = { + textPrimary: ['bgBase', 'bgSurface', 'bgElevated', 'bgOverlay'], + textSecondary: ['bgBase', 'bgSurface', 'bgElevated'], + textTertiary: ['bgBase', 'bgSurface', 'bgElevated'], +}; + +describe('theme contrast', () => { + const cases = Object.entries(SHADE_PALETTES).flatMap(([shade, palette]) => + TEXT.flatMap((text) => + SURFACES[text].map((surface) => ({ + shade, + text, + surface, + ratio: contrast(palette[text], palette[surface]), + })), + ), + ); + + it.each(cases)('$shade: $text on $surface clears 4.5:1', ({ ratio }) => { + expect(ratio).toBeGreaterThanOrEqual(4.5); + }); + + // Sizing tertiary to clear 4.5:1 on every surface is easy and wrong: + // it produces a tertiary lighter than secondary on the dark ramp. The + // ramp has to stay a ramp, or "tertiary" stops meaning anything. + it.each(Object.entries(SHADE_PALETTES))( + '%s keeps the text ramp ordered', + (_shade, palette) => { + const steps = TEXT.map((t) => contrast(palette[t], palette.bgSurface)); + + expect(steps).toEqual([...steps].sort((a, b) => b - a)); + }, + ); +});