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
@@ -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 {
@@ -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`
<div class="artist-header">
@@ -1898,7 +1890,7 @@ export class ExploreArtistDetails extends LitElement {
</button>
<div
class="artist-avatar"
style="background: hsl(${hue}, 45%, 35%)"
style="background: ${avatarBackground(this.artistName)}"
>
${this.artistImageURL
? html`<img
@@ -2392,7 +2384,6 @@ export class ExploreArtistDetails extends LitElement {
<h3 class="section-header">Similar Artists</h3>
<div class="similar-row ${collapsed ? 'collapsed' : ''}">
${visible.map((a) => {
const hue = nameToHue(a.name);
const imgURL = this.similarImageURLs.get(a.artistMbid);
return html`
<div
@@ -2412,7 +2403,7 @@ export class ExploreArtistDetails extends LitElement {
>
<div
class="similar-avatar"
style="background: hsl(${hue}, 45%, 35%)"
style="background: ${avatarBackground(a.name)}"
>
${imgURL
? html`<img
@@ -1,3 +1,4 @@
import { avatarBackground } from '@utils/avatar-color';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
import '@components/page-header/page-header';
@@ -61,15 +62,6 @@ const MAX_SECTION_RESULTS = 10;
const THUMBNAIL_CACHE_LIMIT = 96;
/** Hash a string to a hue value 0360 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. */
function formatDuration(ms: number): string {
if (!ms || ms <= 0) return '';
@@ -1734,7 +1726,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
: nothing}
<div class="horizontal-row">
${artists.map((a) => {
const hue = nameToHue(a.name);
return html`
<div
class="artist-card"
@@ -1750,7 +1741,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
>
<div
class="artist-avatar"
style="background: hsl(${hue}, 45%, 35%)"
style="background: ${avatarBackground(a.name)}"
>
${this.artistImageCache.get(a.mbid)
? html`<img
@@ -132,7 +132,7 @@ export class JobDetailsDrawer extends LitElement {
}
.stage.error {
color: #ff6b6b;
color: #ff8787;
}
.stage-count {
@@ -144,7 +144,7 @@ export class JobDetailsDrawer extends LitElement {
.stage-error {
grid-column: 2 / -1;
font-size: var(--yj-text-sm);
color: #ff6b6b;
color: #ff8787;
overflow-wrap: anywhere;
}
+1 -1
View File
@@ -154,7 +154,7 @@ export const jobStateStyles = css`
}
.tone-danger {
--job-tone: #ff6b6b;
--job-tone: #ff8787;
}
.tone-success {
@@ -153,7 +153,7 @@ export class JobIndicator extends LitElement {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ff6b6b;
background: #ff8787;
flex-shrink: 0;
}
+1 -1
View File
@@ -132,7 +132,7 @@ export class JobLogView extends LitElement {
}
.entry.error .message {
color: #ff6b6b;
color: #ff8787;
}
.detail {
+2 -2
View File
@@ -180,7 +180,7 @@ export class JobRow extends LitElement {
.error {
margin-top: 0.45em;
font-size: var(--yj-text-sm);
color: #ff6b6b;
color: #ff8787;
overflow-wrap: anywhere;
}
@@ -214,7 +214,7 @@ export class JobRow extends LitElement {
}
button.danger:hover:not(:disabled) {
color: #ff6b6b;
color: #ff8787;
}
button:disabled {
+1 -1
View File
@@ -201,7 +201,7 @@ export class JobsView extends ViewLifecycleMixin(LitElement) {
}
button.action.danger {
color: #ff6b6b;
color: #ff8787;
border-color: rgba(255, 107, 107, 0.35);
}
@@ -287,7 +287,7 @@ export class SmartPlaylistEditor extends LitElement {
}
.remove-btn:hover {
color: #ff6b6b;
color: #ff8787;
background: rgba(255, 107, 107, 0.1);
}
@@ -411,7 +411,7 @@ export class SmartPlaylistEditor extends LitElement {
.preview-error {
font-size: var(--yj-text-sm);
color: #ff6b6b;
color: #ff8787;
padding: 6px 0;
}