9.4 KiB
Frontend a11y audit — 2026-05-01
Surfaces audited: index.html (CSS theme + components), sortof-app.jsx (~1467 lines), tweaks-panel.jsx (dev-only, gated behind ?tweaks=1).
Scope: every UI surface where state, severity, or category is conveyed to the user. Findings ranked by user impact.
Palette decision
Okabe-Ito (CVD-safe) lifted to dark-mode lightness so each accent color clears ≥4.5:1 against --bg-1 (oklch 0.21). Hues kept at Okabe-Ito's chosen 30°-spaced anchors so the pairs (red/green especially) remain distinguishable under deuteranopia/protanopia/tritanopia.
--acc-success: oklch(0.78 0.13 165) /* bluish-green, "Okabe-Ito green" lifted */
--acc-warn: oklch(0.82 0.15 75) /* orange-yellow, warning */
--acc-error: oklch(0.70 0.18 35) /* vermillion, error */
--acc-info: oklch(0.78 0.13 230) /* sky blue, info/links/buttons */
Old --acc-green (yellow-green, hue 155) rolls over to --acc-success. Old --acc-red (hue 25) → --acc-error (hue 35, more chroma). Old --acc-amber and --acc-blue keep their hues but bump chroma. Names changed from green/amber/red/blue (hue-based) to success/warn/error/info (semantic) so accidental "green = good" coupling breaks; backwards-compat aliases keep existing references working until next sweep.
Foreground ramp lift
Current --fg-3 (oklch 0.45) on --bg-1 (oklch 0.21) is ~2.7:1 — WCAG AA fail for body text. Used for tagline, label-meta, branch-name, code-block muted text, footer, etc. — that's the "gray on gray" the user reported.
| Token | Before | After | Used for |
|---|---|---|---|
--fg |
0.95 | 0.95 (unchanged) | Primary text |
--fg-1 |
0.78 | 0.82 | Secondary text (panel headings) |
--fg-2 |
0.60 | 0.72 | Tertiary text (labels, tags). Now ~5.5:1 — passes AA. |
--fg-3 |
0.45 | 0.60 | Decorative only — chevrons, separators, dot-leds. Now ~4.4:1 — passes AA for non-text usage. |
Rule: any element with readable text content (>1 word) uses --fg-2 minimum. --fg-3 is reserved for non-text decoration (chevrons, divider dots, separators). Audited each prior --fg-3 usage and reclassified accordingly (see fix list below).
Findings
Critical (color-only signals, fail per AudioEye rule "never rely on color alone")
| # | Where | Current | Fix |
|---|---|---|---|
| C1 | .status-pill.cached/queued/parse/expanding/unknown/nonmod (index.html:302-314) |
Color-coded label + tiny dot. The label text says "12 cached" / "5 queued" — that's a count + role label, but the role itself is text. Acceptable per AudioEye rule (count ≠ encoding). However the dot-led is purely color. | Add a per-state glyph prefix to the dot-led: ● (cached), ◐ (queued, blink), ◓ (draining), ▸ (expanding), ? (unknown), − (nonmod). Glyph is the primary signal; color reinforces. |
| C2 | StatusStrip terminal pill text — state === 'failed' shows "job failed" with .idle class (index.html:308 + sortof-app.jsx:231) |
Plain text, no icon. Indistinguishable from idle ("ready when you are") at a glance. |
Prefix ✗ for failed, ✓ for done/success, ▸ for cold, … for idle. |
| C3 | .warn-section .badge red vs amber (index.html:578-592) |
Red and amber rounded badges visually differ only by hue. Palette change helps but adds no glyph. | Prefix the count with ! (red/error) or ⚠ (amber/warn). Both badges become ! 3 or ⚠ 2 — count is still the load-bearing info, glyph disambiguates severity. |
| C4 | .warn-list .w-tag red vs amber (index.html:621-622) |
Tag text is colored ("MISSING" red, "CYCLE" amber). Tag itself is the label; color is reinforcement. | Acceptable. But the tag color alone distinguishes severity within the list. Add a leading glyph: ! MISSING, ⚠ CYCLE, ⚠ CONFLICT. |
| C5 | .copy-btn.copied (index.html:409) |
Pure color shift to green. The IconCheck glyph IS shown when copied (sortof-app.jsx:130), so glyph signal exists. |
Acceptable. Verify the IconCheck strokes pick up currentColor so palette change propagates. |
| C6 | .warn-branch-btn.picked (index.html:695) |
Pure color shift to green + ★ star prefix in JSX (sortof-app.jsx:411). |
★ glyph already encodes "picked"; pass. |
| C7 | .diff-stat.add/rm/mv (index.html:495-498) |
Color + the prefix glyphs +, −, ↕ in JSX. |
Glyphs already encode meaning; color reinforces; pass. |
| C8 | err-banner and cold-banner (sortof-app.jsx:680-688, 666-673) |
"err" / "cold" text tag + message. No icon. The amber/red border conveys severity. | Add ⚠ glyph to err-tag content, ❄ to cold-tag (or ⏳). |
| C9 | .sort-btn[disabled] (index.html:269-274) |
Opacity drop + line color. No iconic disabled signal. | Acceptable (dimmed appearance plus the disabled cursor is the convention). Verify disabled attr is set, not just CSS. |
| C10 | .cancel-btn hover state (index.html:327) |
Hover turns red. No glyph. | Add ✗ prefix to button label: ✗ cancel. Already-red on hover then doesn't carry meaning alone. |
Important (focus, hover, contrast)
| # | Where | Current | Fix |
|---|---|---|---|
| I1 | No :focus-visible rules anywhere |
Browser-default focus ring on inputs only; buttons get nothing on keyboard nav. | Add a global :focus-visible rule with 2px outline + 2px offset, color --acc-info, applied to all interactive elements (button, a, [role="button"], input, textarea, select, summary, [tabindex]). |
| I2 | Hover-only color shifts on chrome elements (e.g. .icon-btn:hover) |
Color contrast pre-hover may pass; mouse-only convention. | Pair hover with subtle background tint (already in some places), keep. |
| I3 | .tagline color: var(--fg-3) (index.html:102) — gray on gray. |
Tagline is decorative; ratio fails AA but content is non-essential. | Lift to --fg-2 per the ramp rule. |
| I4 | .label-meta color: var(--fg-3) (index.html:190) — line meta info "12 lines" is informational text. |
Bump to --fg-2. |
|
| I5 | .branch-name var(--fg-3) (index.html:448) — mod display name in picker |
Information-bearing text. Bump to --fg-2. |
|
| I6 | .branch-deps/.branch-pos var(--fg-3) (index.html:449-450) |
Information-bearing. Bump to --fg-2. |
|
| I7 | code-block .ink-sep/.ink-mut var(--fg-3) (index.html:389-390) |
Code separator/muted ink — decorative + structural. Lifted --fg-3 (0.60) is now AA-OK; keep. |
|
| I8 | Footer: var(--fg-3) (index.html:140) — text "based on..." / "a thing by..." |
Information-bearing text. Bump to --fg-2. |
|
| I9 | .cb-meta, .cb-key, table count, table .chev all --fg-3 |
Mixed. Counts and keys are information; chevrons are decoration. Bump information-bearing to --fg-2. |
|
| I10 | .warn-list .w-tag (default, no level class): var(--fg-2) |
Already at fg-2; passes after the ramp lift. |
Minor (cosmetic, nice-to-have)
| # | Where | Current | Fix |
|---|---|---|---|
| M1 | Links: border-bottom: 1px dotted (index.html:65) |
Underline equivalent. OK. | Change dotted → solid on hover for sharper feedback. |
| M2 | cancel-btn no width-match for sort-btn neighbors |
Cosmetic. | Skip. |
| M3 | .cat.patch/.cat.map/.cat.lib pills |
Pill text is the label ("patch"/"map"/"lib"); color is reinforcement. | Verify new palette mappings hold. |
Out-of-scope / requires backend support
None. All findings are frontend-resolvable.
Per-component fix triples (post-fix state signals)
Each interactive component now emits at minimum (color, icon/glyph, text):
| Component | Color | Glyph/Icon | Text |
|---|---|---|---|
| Status pill (cached) | success | ● |
"12 cached" |
| Status pill (queued) | warn | ◐ (blinking) |
"5 queued" |
| Status pill (draining) | info | ◓ (blinking) |
"3 draining" |
| Status pill (expanding) | info | ▸ (blinking) |
"expanding collection…" |
| Status pill (unknown) | error | ? |
"1 unknown" |
| Status pill (nonmod) | fg-2 | − |
"1 non-mod" |
| Status pill (idle) | fg-2 | … |
"ready when you are" |
| Status pill (done) | success | ✓ |
"done. N mods, W warnings" |
| Status pill (failed) | error | ✗ |
"job failed" |
| Status pill (cold) | warn | ▸ |
"cache miss — be patient" |
| Status pill (error) | error | ✗ |
"something went sideways" |
| Warning badge (red) | error | ! |
"3" |
| Warning badge (amber) | warn | ⚠ |
"2" |
| Warning row (missing) | error | ! |
"MISSING" + msg |
| Warning row (cycle/conflict) | warn | ⚠ |
"CYCLE"/"CONFLICT" + msg |
| Err banner | error | ⚠ |
"err" + msg + retry button |
| Cold banner | warn | ❄ |
"cold" + msg |
| Cancel button | error (hover) | ✗ |
"cancel" |
| Copy button (default) | info | IconCopy | "copy" |
| Copy button (copied) | success | IconCheck | "copied" |
| Branch row (picked) | success | ★ (in label) |
mod_id text |
| Diff stat (add) | success | + |
count |
| Diff stat (rm) | error | − |
count |
| Diff stat (mv) | warn | ↕ |
count |
Acceptance check
- Every state signal is now (color × glyph × text); never color alone.
- All accent colors clear ≥4.5:1 against
--bg-1. - All text-bearing fg tokens (
--fg,--fg-1,--fg-2) clear ≥4.5:1. --fg-3reserved for non-text decoration, lifted to ~4.4:1 anyway as a hedge.- Focus rings present on every interactive element.
- Links remain underlined.
- Form-error pattern (red border + ⚠ + text) — N/A: no form validation surface in this app today; spec'd for future.
Implementation: see /opt/sortof/docs/a11y-changes-2026-05-01.md for the file-by-file diff summary.