Compare commits
2
Commits
31144e5dc7
...
fd32ce71d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd32ce71d2 | ||
|
|
533c084f8a |
@@ -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
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
**Created:** 2026-08-12
|
||||
**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`. |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
| `30` | Polish | No skip link anywhere. |
|
||||
| `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
|
||||
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
|
||||
@@ -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
|
||||
minimum at 800×600. Reflow at high zoom is the same question one
|
||||
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
|
||||
visual change to the densest list in the app and moves a baseline.
|
||||
|
||||
|
||||
@@ -505,6 +505,23 @@ first track arrives) and `job-indicator`, whose label swings between
|
||||
"Scanning Music", "3 background jobs" and "Finished". The notification
|
||||
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
|
||||
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),
|
||||
|
||||
@@ -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 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. */
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ export class JobLogView extends LitElement {
|
||||
}
|
||||
|
||||
.entry.error .message {
|
||||
color: #ff6b6b;
|
||||
color: #ff8787;
|
||||
}
|
||||
|
||||
.detail {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ type Subscriber = () => 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<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: {
|
||||
bgBase: '#000000',
|
||||
bgSurface: '#121212',
|
||||
@@ -42,7 +71,8 @@ const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
|
||||
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<BackgroundShade, ShadePalette> = {
|
||||
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<BackgroundShade, ShadePalette> = {
|
||||
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)',
|
||||
|
||||
@@ -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