a11y: --muted-2 text fails WCAG contrast (2.0:1); --muted also under AA (3.7:1) #72

Open
opened 2026-07-16 21:27:37 +02:00 by lz · 0 comments
Owner

Summary

--muted-2 used as a text colour is effectively illegible against --bg. Measured 2.0:1 — less than half the WCAG 2.1 AA threshold of 4.5:1 for small text. --muted is better but still fails at 3.7:1.

Found while designing the quota badge (spec), where the operator flagged 10px reset text in --muted-2 as "barely legible". It isn't a one-off — the token is used this way across the app. Scoped out of that work deliberately, since it's a palette-wide decision rather than something one badge should fix unilaterally.

Measurements

Computed via WCAG 2.1 relative luminance, against --bg: #0a0a0a (L = 0.00303):

Token Value Contrast vs --bg AA small text (4.5:1)
--muted-2 #444444 2.03:1 fails by >2×
--muted #6a6a6a 3.66:1 fails
#787878 4.51:1 passes (threshold)
--text #e8e8e8 16.9:1

#787878 is the lightest-failing/darkest-passing boundary: AA needs L ≥ 0.1887, which solves to v ≈ 120 (#787878).

Affected usages

--muted-213 of 16 usages are text, several at 10–11px:

File Usage
app.css:139 .card-meta .dim
app.css:179 .field input::placeholder
dock/DockWatermark.svelte:21 .sub, 11px
artifacts/ArtifactList.svelte:139 .empty .sub, 10px
file-explorer/FileTree.svelte:352 .node .ext
file-explorer/FileExplorerPanel.svelte:523,532 breadcrumb + .crumb-sep
config-explorer/InstallSkillModal.svelte:423,465,492,596 ×4
dock/SessionTab.svelte:139 close affordance
main-page/ViewButtons.svelte:60 button text
dock/dockview-theme.css:32 hidden-panel tab colour

Not affected (non-text, contrast rules don't apply): main-page/WorkspaceRail.svelte:164,180 use it as border-color / background.

--muted76 usages, incl. .nav-btn, .section-title, .card-meta, .field label, .empty, .loading.

Worst offenders are the 10–11px cases (ArtifactList empty subtitle, DockWatermark) — small text is exactly where the 4.5:1 bar applies and where 2.0:1 is most punishing. .field input::placeholder is arguably the most user-hostile: a form hint nobody can read.

Why this needs a decision, not just a patch

Bumping both tokens to pass AA is a visible restyle of the entire UI — 92 usages, and the muted/muted-2 distinction (the two-level hierarchy) partly collapses as both converge toward #787878+. That's a design call, not a mechanical find-and-replace.

Options, roughly in order of increasing blast radius:

  1. Fix --muted-2 text usages only (→ #787878), leave --muted alone. Removes the egregious 2.0:1 cases; leaves 76 usages at 3.66:1. Keeps the hierarchy by making --muted-2 lighter than --muted, which inverts the naming — confusing.
  2. Lift both--muted → ~#8a8a8a, --muted-2 → ~#787878. Everything passes; preserves ordering; the whole UI reads lighter and loses some of the current low-contrast character.
  3. Keep tokens, raise sizes. AA allows 3:1 for large text (≥18.66px bold / ≥24px). Not viable — these are 9–12px labels by design.
  4. Accept and document. Deliberate aesthetic choice for a single-operator local tool. Defensible, but should be written down rather than implicit.

No recommendation — this is an aesthetic call about how the product should look.

Repro

// WCAG 2.1 relative luminance for a grey #vvvvvv
const lin = v => { const c = v/255; return c <= 0.03928 ? c/12.92 : ((c+0.055)/1.055)**2.4 };
const ratio = (fg, bg) => (lin(fg)+0.05)/(lin(bg)+0.05);
ratio(0x44, 0x0a);  // 2.03  --muted-2
ratio(0x6a, 0x0a);  // 3.66  --muted
ratio(0x78, 0x0a);  // 4.51  passes

Out of scope

The quota badge (feat/quota) ships using --muted at 3.66:1, matching existing convention. It should adopt whatever this issue decides rather than diverge.

## Summary `--muted-2` used as a text colour is effectively illegible against `--bg`. Measured **2.0:1** — less than half the WCAG 2.1 AA threshold of 4.5:1 for small text. `--muted` is better but still fails at **3.7:1**. Found while designing the quota badge ([spec](../docs/superpowers/specs/2026-07-16-quota-statusline-design.md)), where the operator flagged 10px reset text in `--muted-2` as "barely legible". It isn't a one-off — the token is used this way across the app. Scoped out of that work deliberately, since it's a palette-wide decision rather than something one badge should fix unilaterally. ## Measurements Computed via WCAG 2.1 relative luminance, against `--bg: #0a0a0a` (L = 0.00303): | Token | Value | Contrast vs `--bg` | AA small text (4.5:1) | |---|---|---|---| | `--muted-2` | `#444444` | **2.03:1** | ❌ fails by >2× | | `--muted` | `#6a6a6a` | **3.66:1** | ❌ fails | | — | `#787878` | 4.51:1 | ✅ passes (threshold) | | `--text` | `#e8e8e8` | 16.9:1 | ✅ | `#787878` is the lightest-failing/darkest-passing boundary: AA needs L ≥ 0.1887, which solves to v ≈ 120 (`#787878`). ## Affected usages `--muted-2` — **13 of 16 usages are text**, several at 10–11px: | File | Usage | |---|---| | `app.css:139` | `.card-meta .dim` | | `app.css:179` | `.field input::placeholder` | | `dock/DockWatermark.svelte:21` | `.sub`, 11px | | `artifacts/ArtifactList.svelte:139` | `.empty .sub`, 10px | | `file-explorer/FileTree.svelte:352` | `.node .ext` | | `file-explorer/FileExplorerPanel.svelte:523,532` | breadcrumb + `.crumb-sep` | | `config-explorer/InstallSkillModal.svelte:423,465,492,596` | ×4 | | `dock/SessionTab.svelte:139` | close affordance | | `main-page/ViewButtons.svelte:60` | button text | | `dock/dockview-theme.css:32` | hidden-panel tab colour | Not affected (non-text, contrast rules don't apply): `main-page/WorkspaceRail.svelte:164,180` use it as `border-color` / `background`. `--muted` — **76 usages**, incl. `.nav-btn`, `.section-title`, `.card-meta`, `.field label`, `.empty`, `.loading`. Worst offenders are the 10–11px cases (`ArtifactList` empty subtitle, `DockWatermark`) — small text is exactly where the 4.5:1 bar applies and where `2.0:1` is most punishing. `.field input::placeholder` is arguably the most user-hostile: a form hint nobody can read. ## Why this needs a decision, not just a patch Bumping both tokens to pass AA is a **visible restyle of the entire UI** — 92 usages, and the muted/muted-2 distinction (the two-level hierarchy) partly collapses as both converge toward `#787878`+. That's a design call, not a mechanical find-and-replace. Options, roughly in order of increasing blast radius: 1. **Fix `--muted-2` text usages only** (→ `#787878`), leave `--muted` alone. Removes the egregious 2.0:1 cases; leaves 76 usages at 3.66:1. Keeps the hierarchy by making `--muted-2` *lighter* than `--muted`, which inverts the naming — confusing. 2. **Lift both** — `--muted` → ~`#8a8a8a`, `--muted-2` → ~`#787878`. Everything passes; preserves ordering; the whole UI reads lighter and loses some of the current low-contrast character. 3. **Keep tokens, raise sizes.** AA allows 3:1 for large text (≥18.66px bold / ≥24px). Not viable — these are 9–12px labels by design. 4. **Accept and document.** Deliberate aesthetic choice for a single-operator local tool. Defensible, but should be written down rather than implicit. No recommendation — this is an aesthetic call about how the product should look. ## Repro ```js // WCAG 2.1 relative luminance for a grey #vvvvvv const lin = v => { const c = v/255; return c <= 0.03928 ? c/12.92 : ((c+0.055)/1.055)**2.4 }; const ratio = (fg, bg) => (lin(fg)+0.05)/(lin(bg)+0.05); ratio(0x44, 0x0a); // 2.03 --muted-2 ratio(0x6a, 0x0a); // 3.66 --muted ratio(0x78, 0x0a); // 4.51 passes ``` ## Out of scope The quota badge (`feat/quota`) ships using `--muted` at 3.66:1, matching existing convention. It should adopt whatever this issue decides rather than diverge.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus#72
No description provided.