Compare commits

...
2 Commits
Author SHA1 Message Date
logan fd32ce71d2 docs: record phase 2, and what a parked measurement was hiding
Build & publish Arch package / arch-package (push) Successful in 2m7s
CI / check (push) Successful in 2m14s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m23s
The audit's one 'borderline ~4.1:1' pair was nine of twelve failing
combinations across three ramps, 110 nodes on screen, worst 2.31:1. The
other never-measured item closed on measurement and stays dropped, now
for a reason with a number behind it. Two findings larger than either
are recorded and deliberately not fixed: the semantic colours are fixed
across ramps, and the light ramp is not a supported theme.
2026-08-13 00:36:09 -04:00
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
17 changed files with 425 additions and 41 deletions
+82
View File
@@ -1872,3 +1872,85 @@ inside a comment in a `css` tagged template literal ends the literal.**
Third session running. It is written in `CLAUDE.md`, in the skill, and Third session running. It is written in `CLAUDE.md`, in the skill, and
in `NOTES.md`, and it was read twice in the session it then cost a in `NOTES.md`, and it was read twice in the session it then cost a
cycle in. Knowledge is not working here; it wants a lint rule. cycle in. Knowledge is not working here; it wants a lint rule.
## A parked measurement is a finding of unknown size, and this one was nine times bigger
Plan 008 phase 2: the two items `a11y.md` never measured. One closed on
measurement; the other turned out to be nine times the size of its own
description and to contain two findings larger than itself.
The generalisation: **"worth measuring before planning" is a debt with
no stated size, and the estimate attached to it is not a bound.** The
audit said `--yj-text-tertiary` on `--yj-bg-surface` is "≈ 4.1:1,
borderline", from a hand calculation over two hex values in a file that
does not contain them. Every part of that sentence was approximately
true and the conclusion it invited — *borderline, low priority* — was
wrong by an order of magnitude:
| | audit | measured |
|---|---|---|
| pairs considered | 1 | 12 (three ramps × four surfaces) |
| failing | "borderline" | 9 of 12 |
| worst ratio | ≈ 4.1 | **2.31** (dark overlay), **2.55** (light) |
| failing nodes on screen | — | **110** across twelve views |
Nine things worth keeping:
- **A number quoted from the wrong file is still a number, and it
travels.** The audit cites the palette as `tokens.css.ts`. That file
holds the type scale and icon sizes and no colours at all; the ramps
live in `theme-store`, applied to `:root` at runtime — which also
means the `var(--yj-…, #fallback)` at ~500 call sites is dead code,
and four different fallbacks behind one name never mattered. I spent
twenty minutes concluding the tokens "are never defined" before
asking the *running app* what `:root` carried. Ask the app.
- **Measuring one state of three answers one third of the question.**
The whole first sweep was the `dark` ramp, because that is the
default. `light` was the worst of the three and had never been looked
at by the audit or by me. A palette is data — enumerate it.
- **A generated colour is a family, not a colour.** The avatar
background is `hsl(nameToHue(name), 45%, 35%)`, and 35 of the 360
hues put white text below 4.5:1. The rendered sweep found *two*,
because two artists happened to hash into the yellow-green band. Had
I fixed the two, the bug would have returned with the next search.
The unit of the fix is the generator; the unit of the test is all 360.
- **A fix that makes the ramp pass can also destroy the ramp.** Sizing
tertiary to clear 4.5:1 on `bgOverlay` needs a grey *lighter than
secondary*. Passing an automated check by inverting the visual
hierarchy is the kind of accessibility fix that makes the product
worse, so `bgOverlay` is documented as not a text surface and the one
component using it that way now uses primary. The test encodes the
exception rather than pretending it away, and a second case asserts
the ramp stays ordered.
- **My probe was wrong before the code was, twice, and a screenshot
caught both.** Source-over compositing that forces `a: 1` makes two
stacked `rgba(255,255,255,0.05)` surfaces composite to opaque white —
which reported a perfectly readable button as white-on-white at
1.00:1. And later I read a screenshot taken *after* a sweep had left
the app on a different ramp, and concluded the light theme was not
applying at all. Both times the tell was the same: **the picture and
the number disagreed**, and both times the number was mine.
- **The cheapest tier is blind to a whole class of change.** `make
ui-visual` passed unchanged across a palette rewrite, because the
component tier has no `:root` and renders the fallbacks. Six stored
screenshots said nothing at all about the change they most looked
like they were about.
- **A finding that closes on measurement is worth the measurement.**
`a11y.28` (mouse-only resize handles) was dropped by reading. At
800×600 the track list clips exactly one thing — the *Duration header
label* — and zero data cells, and that sort has a keyboard-reachable
dropdown anyway. Same conclusion, now with a number, and the next
reader does not have to re-derive it.
- **The measurement found two things larger than what it was measuring.**
The semantic colours are fixed across ramps, and one fixed colour
cannot serve both a near-black and a near-white surface — `--yj-error`
is 2.55:1 on dark's elevated. And with the greyscale fixed the light
ramp still fails 50 nodes: an invisible warning banner, a
white-on-yellow primary button, chrome that stays dark while the body
goes light. Recorded, not fixed. "Does the light theme ship?" is not
a question a contrast pass gets to answer on its own.
- **Fixing the ubiquitous case makes the rare ones visible.** With
tertiary raised, the remaining dark-ramp failures were three nodes
and every one was a *different* mechanism. A finding at 110 nodes
hides them; at 3 they are individually obvious. Cheap tail, only
reachable from the other side of the main fix.
+68 -2
View File
@@ -1,6 +1,6 @@
# 008 — The last audit, and the one binding that outlived six phases # 008 — The last audit, and the one binding that outlived six phases
**Status:** active — Phase 1 shipped (three landings). **Status:** active — Phases 1 and 2 shipped.
**Branch:** main **Branch:** main
**Created:** 2026-08-12 **Created:** 2026-08-12
**Follows:** 007-ui-reconciliation **Follows:** 007-ui-reconciliation
@@ -69,7 +69,7 @@ fixed until it has been reproduced in the running app.
| `24` | Minor | No `title` on the truncating element in `track-info`, `playlist-view`, `queue-panel` or `track-list`. | | `24` | Minor | No `title` on the truncating element in `track-info`, `playlist-view`, `queue-panel` or `track-list`. |
| `25` | Minor | `<wa-progress-bar value=…>` with no label, verbatim as filed. | | `25` | Minor | `<wa-progress-bar value=…>` with no label, verbatim as filed. |
| — | new | **Two unnamed native `<select>`s**, one of them `page-header`'s sort control on nine views. Not in the audit: `a11y.6` scanned `<button>`. Found in the AX tree while reproducing `14`. Belongs with `26`. | | — | new | **Two unnamed native `<select>`s**, one of them `page-header`'s sort control on nine views. Not in the audit: `a11y.6` scanned `<button>`. Found in the AX tree while reproducing `14`. Belongs with `26`. |
| `28` | dropped | Four `@mousedown` `<div>`s with no `role="separator"`. Never measured. | | `28` | ~~dropped~~ | **Measured, stays dropped.** One header *label* clips at 800×600; zero data cells do. |
| `29` | Polish | `<h3 class="subtitle">` for type size. | | `29` | Polish | `<h3 class="subtitle">` for type size. |
| `30` | Polish | No skip link anywhere. | | `30` | Polish | No skip link anywhere. |
| `32` | Polish | `title="Remove from queue"`, not identifying the track. | | `32` | Polish | `title="Remove from queue"`, not identifying the track. |
@@ -282,6 +282,67 @@ column may be the only way to read a value, which is function.
is worth as much as one that opens it, and this plan's predecessor got is worth as much as one that opens it, and this plan's predecessor got
about a third of its value from findings that evaporated. about a third of its value from findings that evaporated.
### Phase 2 — what the measurements said
One opened much wider than filed; one closed.
#### Contrast: worse than "borderline", and it was never one token
The audit's ≈ 4.1:1 was a hand calculation from two hex values, and
plan 007 filed it under "deliberately not planned — worth measuring
before planning". Measured against the rendered app across twelve views
and then across all three ramps: **110 failing nodes**, and
`textTertiary` failing AA in **nine of twelve** text/surface
combinations — 4.35:1 on dark's surface, 3.25:1 on its elevated,
2.31:1 on its overlay, and 2.55–3.32:1 on *every* surface of the light
ramp, which the audit never considered.
Fixed, and now **0 of 659 nodes** on dark and darker. Three mechanisms,
only the first of which is the finding:
- **The ramps.** `textTertiary` per ramp — `#a6a6a6` / `#949494` /
`#5c636a` — sized to the lightest surface it actually sits on and
keeping its hue.
- **The avatar generator**, which is not a colour but a *family* of
them: `hsl(hue, 45%, 35%)` behind white initials failed for **35 of
360 hues**, so which artists were unreadable depended on how their
names hashed. 32% clears every hue.
- **Jobs' local `#ff6b6b`**, 4.15:1 on elevated.
Pinned by `theme-contrast.test.ts` and `avatar-color.test.ts` — unit
tests over the data, not sweeps of the DOM. `make ui-test` 572 →
**608**.
#### `a11y.28`: the drop was right, and now for a measured reason
"Cosmetic preference, no function lost" holds. At the window minimum
(800×600, which is where the shell was measured in 007) the track list
clips exactly one thing: the **Duration header label**. Zero data cells
clip, and the sort that label names has a redundant keyboard-reachable
dropdown. The queue panel at its default 321px clips nothing either.
A keyboard-only user cannot change a panel width; they do not lose
access to any value by not being able to. **Stays dropped.**
#### Two things the measurements found that are not in the audit
Both are bigger than what they were found under, and neither is fixed:
- **The semantic colours are fixed across ramps, and a fixed colour
cannot serve a near-black and a near-white background.** `--yj-error`
is 3.42:1 on dark's surface and 2.55:1 on its elevated; `--yj-info`
is 3.10:1 and 2.31:1; success and warning fail on dark and light
both. As *backgrounds* under white text, success (3.45) and warning
(3.58) fail too. The fix is a per-ramp semantic palette, which is a
decision about the app's colour identity rather than a value.
- **The light ramp is not a supported theme.** With the greyscale fixed
it still has **50 failing nodes**: the accent yellow under white text
(1.43:1), the autotag diff's pale greens and reds on white
(1.36–2.59:1), and the header and player chrome staying dark while
the body goes light. Read in a screenshot — the "Low confidence pick"
banner is invisible and the primary button is white-on-yellow. This
is a design job, and the honest question it raises is whether the
light theme should ship at all in its current state.
--- ---
## Phase 3 — The tail ## Phase 3 — The tail
@@ -296,6 +357,11 @@ Two of them are not one-liners and should be treated as such:
to the app frame, and 007 phase 5 already measured the frame's real to the app frame, and 007 phase 5 already measured the frame's real
minimum at 800×600. Reflow at high zoom is the same question one minimum at 800×600. Reflow at high zoom is the same question one
variable over. It may want its own landing. variable over. It may want its own landing.
- **The unnamed `<select>`s** from Phase 1, with `26`.
- **The semantic palette** and **the light ramp**, from Phase 2. Both
are larger than the rest of this tail put together and may not belong
in it at all — the light ramp in particular is a question about
whether that theme ships, not a contrast fix.
- **`22`** asks for a non-colour marker on the playing row, which is a - **`22`** asks for a non-colour marker on the playing row, which is a
visual change to the densest list in the app and moves a baseline. visual change to the densest list in the app and moves a baseline.
+17
View File
@@ -505,6 +505,23 @@ first track arrives) and `job-indicator`, whose label swings between
"Scanning Music", "3 background jobs" and "Finished". The notification "Scanning Music", "3 background jobs" and "Finished". The notification
surface already had one from Phase 3. surface already had one from Phase 3.
**Contrast is a property of the ramp, and the ramps are data.**
`theme-store`'s `SHADE_PALETTES` — not `tokens.css.ts`, which holds only
the type scale and icon sizes — is where the colours live, applied to
`:root` at runtime, which is why the `var(--yj-…, #fallback)` at every
call site is dead in practice. Every text colour clears 4.5:1 against
every surface it can sit on, and `theme-contrast.test.ts` computes that
from the table rather than trusting it. Three rules hold it up.
**`bgOverlay` is not a text surface on the dark ramp** — sizing tertiary
to clear it needs a grey lighter than *secondary*, and an inverted ramp
is a worse answer than the problem, so the one component that put text
there uses primary. **A generated colour is a family, not a colour**:
`utils/avatar-color.ts` exists because `hsl(hue, 45%, 35%)` behind white
initials failed for 35 of the 360 hues, so the failure came and went
with how an artist's name hashed — the test walks all 360. And
**`make ui-visual` cannot see any of this**: the component tier renders
the fallbacks, because the theme only reaches `:root` in the real app.
**A stated motion preference outranks an app setting, and the state a **A stated motion preference outranks an app setting, and the state a
fix lands in is a state nobody has looked at.** `now-playing`'s marquee fix lands in is a state nobody has looked at.** `now-playing`'s marquee
ran for as long as a track played with no way to pause it (WCAG 2.2.2), ran for as long as a track played with no way to pause it (WCAG 2.2.2),
@@ -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;
} }
+1 -1
View File
@@ -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;
} }
+1 -1
View File
@@ -132,7 +132,7 @@ export class JobLogView extends LitElement {
} }
.entry.error .message { .entry.error .message {
color: #ff6b6b; color: #ff8787;
} }
.detail { .detail {
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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;
} }
+39 -5
View File
@@ -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)',
+36
View File
@@ -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));
},
);
});