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:
@@ -203,7 +203,11 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
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 {
|
.section-hint {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { avatarBackground } from '@utils/avatar-color';
|
||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
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) ── */
|
/* ── 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 {
|
function extractYear(dateStr: string): string {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
return dateStr.substring(0, 4);
|
return dateStr.substring(0, 4);
|
||||||
@@ -1884,7 +1877,6 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
/* ── Render ── */
|
/* ── Render ── */
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const hue = nameToHue(this.artistName);
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="artist-header">
|
<div class="artist-header">
|
||||||
@@ -1898,7 +1890,7 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
class="artist-avatar"
|
class="artist-avatar"
|
||||||
style="background: hsl(${hue}, 45%, 35%)"
|
style="background: ${avatarBackground(this.artistName)}"
|
||||||
>
|
>
|
||||||
${this.artistImageURL
|
${this.artistImageURL
|
||||||
? html`<img
|
? html`<img
|
||||||
@@ -2392,7 +2384,6 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
<h3 class="section-header">Similar Artists</h3>
|
<h3 class="section-header">Similar Artists</h3>
|
||||||
<div class="similar-row ${collapsed ? 'collapsed' : ''}">
|
<div class="similar-row ${collapsed ? 'collapsed' : ''}">
|
||||||
${visible.map((a) => {
|
${visible.map((a) => {
|
||||||
const hue = nameToHue(a.name);
|
|
||||||
const imgURL = this.similarImageURLs.get(a.artistMbid);
|
const imgURL = this.similarImageURLs.get(a.artistMbid);
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
@@ -2412,7 +2403,7 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="similar-avatar"
|
class="similar-avatar"
|
||||||
style="background: hsl(${hue}, 45%, 35%)"
|
style="background: ${avatarBackground(a.name)}"
|
||||||
>
|
>
|
||||||
${imgURL
|
${imgURL
|
||||||
? html`<img
|
? html`<img
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { avatarBackground } from '@utils/avatar-color';
|
||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, state, query as litQuery } from 'lit/decorators.js';
|
import { customElement, state, query as litQuery } from 'lit/decorators.js';
|
||||||
import '@components/page-header/page-header';
|
import '@components/page-header/page-header';
|
||||||
@@ -61,15 +62,6 @@ const MAX_SECTION_RESULTS = 10;
|
|||||||
const THUMBNAIL_CACHE_LIMIT = 96;
|
const THUMBNAIL_CACHE_LIMIT = 96;
|
||||||
|
|
||||||
|
|
||||||
/** Hash a string to a hue value 0–360 for avatar coloring. */
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format milliseconds as mm:ss. */
|
/** Format milliseconds as mm:ss. */
|
||||||
function formatDuration(ms: number): string {
|
function formatDuration(ms: number): string {
|
||||||
if (!ms || ms <= 0) return '';
|
if (!ms || ms <= 0) return '';
|
||||||
@@ -1734,7 +1726,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
|
|||||||
: nothing}
|
: nothing}
|
||||||
<div class="horizontal-row">
|
<div class="horizontal-row">
|
||||||
${artists.map((a) => {
|
${artists.map((a) => {
|
||||||
const hue = nameToHue(a.name);
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="artist-card"
|
class="artist-card"
|
||||||
@@ -1750,7 +1741,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="artist-avatar"
|
class="artist-avatar"
|
||||||
style="background: hsl(${hue}, 45%, 35%)"
|
style="background: ${avatarBackground(a.name)}"
|
||||||
>
|
>
|
||||||
${this.artistImageCache.get(a.mbid)
|
${this.artistImageCache.get(a.mbid)
|
||||||
? html`<img
|
? html`<img
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export class JobDetailsDrawer extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stage.error {
|
.stage.error {
|
||||||
color: #ff6b6b;
|
color: #ff8787;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-count {
|
.stage-count {
|
||||||
@@ -144,7 +144,7 @@ export class JobDetailsDrawer extends LitElement {
|
|||||||
.stage-error {
|
.stage-error {
|
||||||
grid-column: 2 / -1;
|
grid-column: 2 / -1;
|
||||||
font-size: var(--yj-text-sm);
|
font-size: var(--yj-text-sm);
|
||||||
color: #ff6b6b;
|
color: #ff8787;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export const jobStateStyles = css`
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tone-danger {
|
.tone-danger {
|
||||||
--job-tone: #ff6b6b;
|
--job-tone: #ff8787;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tone-success {
|
.tone-success {
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export class JobIndicator extends LitElement {
|
|||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #ff6b6b;
|
background: #ff8787;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export class JobLogView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entry.error .message {
|
.entry.error .message {
|
||||||
color: #ff6b6b;
|
color: #ff8787;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail {
|
.detail {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export class JobRow extends LitElement {
|
|||||||
.error {
|
.error {
|
||||||
margin-top: 0.45em;
|
margin-top: 0.45em;
|
||||||
font-size: var(--yj-text-sm);
|
font-size: var(--yj-text-sm);
|
||||||
color: #ff6b6b;
|
color: #ff8787;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ export class JobRow extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button.danger:hover:not(:disabled) {
|
button.danger:hover:not(:disabled) {
|
||||||
color: #ff6b6b;
|
color: #ff8787;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export class JobsView extends ViewLifecycleMixin(LitElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button.action.danger {
|
button.action.danger {
|
||||||
color: #ff6b6b;
|
color: #ff8787;
|
||||||
border-color: rgba(255, 107, 107, 0.35);
|
border-color: rgba(255, 107, 107, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ export class SmartPlaylistEditor extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.remove-btn:hover {
|
.remove-btn:hover {
|
||||||
color: #ff6b6b;
|
color: #ff8787;
|
||||||
background: rgba(255, 107, 107, 0.1);
|
background: rgba(255, 107, 107, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,7 +411,7 @@ export class SmartPlaylistEditor extends LitElement {
|
|||||||
|
|
||||||
.preview-error {
|
.preview-error {
|
||||||
font-size: var(--yj-text-sm);
|
font-size: var(--yj-text-sm);
|
||||||
color: #ff6b6b;
|
color: #ff8787;
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type Subscriber = () => void;
|
|||||||
* Shade palettes keyed by BackgroundShade.
|
* Shade palettes keyed by BackgroundShade.
|
||||||
* Each defines the base grayscale ramp used throughout the UI.
|
* Each defines the base grayscale ramp used throughout the UI.
|
||||||
*/
|
*/
|
||||||
interface ShadePalette {
|
export interface ShadePalette {
|
||||||
bgBase: string;
|
bgBase: string;
|
||||||
bgSurface: string;
|
bgSurface: string;
|
||||||
bgElevated: string;
|
bgElevated: string;
|
||||||
@@ -34,7 +34,36 @@ interface ShadePalette {
|
|||||||
selectionBg: string;
|
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: {
|
darker: {
|
||||||
bgBase: '#000000',
|
bgBase: '#000000',
|
||||||
bgSurface: '#121212',
|
bgSurface: '#121212',
|
||||||
@@ -42,7 +71,8 @@ const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
|
|||||||
bgOverlay: '#2a2a2a',
|
bgOverlay: '#2a2a2a',
|
||||||
textPrimary: '#ffffff',
|
textPrimary: '#ffffff',
|
||||||
textSecondary: '#b3b3b3',
|
textSecondary: '#b3b3b3',
|
||||||
textTertiary: '#888888',
|
// 4.05:1 on bgOverlay at #888888.
|
||||||
|
textTertiary: '#949494',
|
||||||
border: '#333333',
|
border: '#333333',
|
||||||
borderSubtle: '#222222',
|
borderSubtle: '#222222',
|
||||||
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
|
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
|
||||||
@@ -55,7 +85,9 @@ const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
|
|||||||
bgOverlay: '#495057',
|
bgOverlay: '#495057',
|
||||||
textPrimary: '#ffffff',
|
textPrimary: '#ffffff',
|
||||||
textSecondary: '#b3b3b3',
|
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',
|
border: '#444444',
|
||||||
borderSubtle: '#333333',
|
borderSubtle: '#333333',
|
||||||
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
|
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
|
||||||
@@ -68,7 +100,9 @@ const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
|
|||||||
bgOverlay: '#dee2e6',
|
bgOverlay: '#dee2e6',
|
||||||
textPrimary: '#212529',
|
textPrimary: '#212529',
|
||||||
textSecondary: '#495057',
|
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',
|
border: '#ced4da',
|
||||||
borderSubtle: '#dee2e6',
|
borderSubtle: '#dee2e6',
|
||||||
hoverOverlay: 'rgba(0, 0, 0, 0.05)',
|
hoverOverlay: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
|||||||
@@ -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}%)`;
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user