RELEASE
planning/2.8.0/audits/ACCESSIBILITY_AUDIT.md
sha256 14cbc0631dfb0e09 · 35714 bytes ·
original held in the private archive
# ACCESSIBILITY_AUDIT.md — RobCo U.O.S. v2.6.0
> **FILED UNDER 2.8.0 — PRODUCED-BY, NOT MEASURED-VERSION.** This document *measures* the earlier v2.6.0/v2.7.0
> codebase, but it was *produced* as a stage of the PARKED-CHAIN audit sequence — the analysis phase that became
> the 2.8.0 overhaul. Planning artifacts are filed by the release whose work produced them, never by the version
> they discuss. The version in the title is the code it audited, not the folder it belongs in.
> **[ARCHIVE-class snapshot — not current truth.]** Per the 2.8.5 U-B1 doc-maintenance model (see `CLAUDE.md`'s
> Reference Pointer Index), this doc is frozen as of the date below and is never updated to track current code. It
> audited a codebase roughly 30% smaller than today's (pre-`test-console.js`, pre-OCR, pre-Diagnostic-Shell,
> pre-CHASSIS/Overseer). Reuse its structural reasoning as historical input only — re-verify every measurement and
> file-split proposal against current code before acting on it.
> Stage A2 of the PARKED-CHAIN audit sequence. PLAN-ONLY. Nothing here is implemented or committed.
> Companion docs: HOUSE_STANDARD.md (A0), DIEGETIC_AUDIT.md (A0), PERFORMANCE_AUDIT.md (A1).
> Target: bc8c2eb (v2.6.0-r1, 1078 tests). Standard: WCAG 2.1 AA.
> Guardrail: ARIA labels / alt text stay LITERAL & CLEAR — the in-world voice is visible text only.
---
## Coordination with Prior Stages
| Prior stage | Finding | This audit's treatment |
|---|---|---|
| A0 CR-1 | Zero `prefers-reduced-motion` gating across ~20 CRT animations + JS boot type-out | **Fully verified and resolved below** (§6). 22 keyframes confirmed unguarded. Adds: severity breakdown by animation type, JS typewriter, concrete fix scope. |
| A0 TN-13 | Dynamic JS-rendered rows have ~0 `aria-label` in JS | **Expanded** into the main finding §§3, 4, 5. Extends to span-onclick pattern, anonymous button labels, and the full scope of zero-ARIA renders. |
| A1 standby note | CRT/timer animations already paused in standby | **Not the same as prefers-reduced-motion.** `body.standby` fires on tab-hide (via blur + visibilitychange). `prefers-reduced-motion` is a user OS preference for vestibular / photosensitivity disability. They are **independent mechanisms** — standby does not satisfy reduced-motion, and CR-1 remains fully open. |
---
## Counts by Severity
| Severity | Count |
|---|---|
| CRITICAL | 5 |
| MAJOR | 7 |
| MINOR | 6 |
| Total | 18 |
---
## 1. Color Contrast — All Six Optics Themes
### Method
WCAG 2.1 AA requires 4.5:1 for normal text (< 18pt / < 14pt bold) and 3:1 for large text / UI components. Computed via the WCAG relative luminance formula (linearize sRGB → L = 0.2126R + 0.7152G + 0.0722B → ratio = (L_light + 0.05) / (L_dark + 0.05)).
Background is always `#010a07` (L = 0.002389). Each theme overrides only `--robco-green` and `--robco-dark`.
### Primary phosphor text — full opacity
| Theme | Hex | Luminance | Contrast vs bg | AA pass? |
|---|---|---|---|---|
| Green (default) | `#14fdce` | 0.7528 | **15.32:1** | ✅ |
| Amber | `#ffb642` | 0.5722 | **11.88:1** | ✅ |
| Blue | `#42cbf5` | 0.5258 | **10.99:1** | ✅ |
| Legion (red) | `#ff4040` | 0.2664 | **6.04:1** | ✅ |
| Ghoul | `#7dff5f` | 0.7734 | **15.71:1** | ✅ |
| Neon (purple) | `#c084fc` | 0.3695 | **8.01:1** | ✅ |
**All six themes pass AA (4.5:1) at full opacity.** Legion is tightest at 6.04:1.
### Secondary / accent colors — full opacity
| Token | Hex | Contrast vs `#010a07` | Use | Pass? |
|---|---|---|---|---|
| `--robco-alert` | `#f39c12` | **9.52:1** | Warnings, faction standing | ✅ |
| `--robco-danger` | `#e74c3c` | **5.60:1** | Faults, delete, negative rep | ✅ |
| `--robco-blue` | `#3498db` | **6.78:1** | COMM-LINK, cloud, account | ✅ |
### Inactive tab buttons at `opacity: 0.55` — **MAJOR FAILURES**
Tab buttons are styled at 55% opacity when inactive. The effective rendered contrast against `#010a07` drops significantly:
| Theme | Effective contrast | AA (4.5:1) | Large/UI (3:1) |
|---|---|---|---|
| Green | 5.47:1 | ✅ | ✅ |
| Amber | **4.38:1** | ❌ | ✅ |
| Blue | **4.20:1** | ❌ | ✅ |
| Legion | **2.63:1** | ❌ | ❌ |
| Ghoul | 5.61:1 | ✅ | ✅ |
| Neon | **3.31:1** | ❌ | ✅ |
Four of six themes fail AA for inactive tabs. Legion fails even the 3:1 large-text threshold.
**Fix**: Raise inactive tab `opacity` from `0.55` to a level that guarantees 4.5:1 across all themes. The limiting factor is Legion at 6.04:1 full opacity — its headroom is tight. To guarantee 4.5:1, the effective blended contrast must reach 4.5:1, which means a minimum raw opacity of ≈0.75 for Legion (yielding ≈4.56:1). Setting `opacity: 0.75` for all themes would make Green (≈11.7), Ghoul (≈12.0), and Neon (≈5.97) comfortable while Legion just barely passes. Alternatively, increase the `opacity` to 0.75 and call out that Legion is the binding constraint — or cap inactive opacity at 0.75 as a per-theme minimum. Alternatively, use an explicit `color:` fallback at reduced luminance rather than CSS `opacity` so the blending math is explicit.
**Verdict**: SPEC-FIRST · `[CACHE][TEST]` (CSS served; update Suite 50 or a new contrast-guard suite).
**Severity**: MAJOR (WCAG 1.4.3 — four themes fail on a primary navigation control).
**Files**: `css/terminal.css` (`.tab-btn` opacity rule).
### Opacity-reduced text — the Legion and Neon headroom problem
Both Legion (#ff4040, CR 6.04:1) and Neon (#c084fc, CR 8.01:1) have minimal contrast headroom. Any text rendered at partial opacity against the background may fail:
- **Neon at opacity 0.70**: effective contrast ≈ 4.49:1 — **fails AA by < 0.01:1** (marginal).
- **Legion at opacity 0.70**: effective contrast ≈ 3.52:1 — fails normal text, passes large text.
These themes trigger WCAG 1.4.3 failures on secondary labels (opacity 0.6–0.8 used throughout the app for faction counts, sub-panel labels, empty-state hints, type tags, and the "MINOR FACTIONS" details summary).
**Fix**: Document per-theme minimum opacity as a CSS variable or utility comment. For Legion/Neon, audit every `opacity < 0.9` text occurrence and either raise opacity, darken the affected element's text, or accept it as decorative (not conveying information).
**Verdict**: SPEC-FIRST (requires per-theme audit; can be BUILD-NOW for the easiest fixes like faction card sub-text).
**Severity**: MAJOR for Legion; MINOR for Neon.
---
## 2. Keyboard & Focus — CRITICAL FAILURES
### 2a. No focus indicator anywhere (WCAG 2.4.7) — CRITICAL
Four CSS rules suppress focus outlines with no replacement:
```css
input, input[type='number'], textarea, select {
outline: none; /* line 176 */
}
details.panel summary {
outline: none; /* line 518 */
}
details.sub-panel > summary {
outline: none; /* line 551 */
}
summary.config-summary {
outline: none; /* line 1938 */
}
```
There are **zero `:focus` or `:focus-visible` rules** anywhere in `css/terminal.css`. When a keyboard user tabs through the app — form inputs, panel toggles, buttons — no visual indication of focus exists. Any custom `:focus` styles that might be present on `button` are absent; the browser default is fully suppressed.
**Fix**: Replace each `outline: none` with a custom phosphor-glow focus ring that fits the CRT aesthetic:
```css
:focus-visible {
outline: 2px solid var(--robco-green);
outline-offset: 2px;
box-shadow: 0 0 4px rgba(var(--robco-green-rgb), 0.6);
}
```
Use `:focus-visible` (not `:focus`) to avoid the outline on mouse click (where it's visually distracting) while preserving it for keyboard. The `outline: none` rules on inputs and summaries should be replaced with `:focus { outline: none; }` + `:focus-visible { outline: ... }` pairs so mouse users keep the clean look and keyboard users get visibility.
**Verdict**: BUILD-NOW · `[CACHE][TEST]` (static guard that a `:focus-visible` rule exists — both runners).
**Severity**: CRITICAL (WCAG 2.4.7 Focus Visible — complete failure for all keyboard users).
**Files**: `css/terminal.css`.
### 2b. sysModal opens without focus management or ARIA (WCAG 4.1.2) — CRITICAL
The general-purpose system modal (`#sysModal`) is missing the required dialog semantics:
- No `role="dialog"`
- No `aria-modal="true"`
- No `aria-labelledby`
- No focus trap
- No focus restore on close
When `showErrorLog()`, `showChangelog()`, or `showVATSOverlay()` opens it, `modal.style.display = 'flex'` is set but focus stays on the triggering element. Screen reader users don't know a modal opened. Keyboard users can tab through the page behind it without restriction.
Compare with `#updateModal` which correctly implements `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, `btn.focus()` on open, and a keydown trap. `#sysModal` needs the same treatment.
`closeModal()` (line 1005) only hides the element — it does not restore focus to the element that opened the modal.
**Fix**:
1. Add `role="dialog" aria-modal="true" aria-labelledby="modalTitle"` to the `#sysModal` div (static HTML change).
2. In each `openModal`-style call, save the triggering element and call `.focus()` on the modal's first interactive element.
3. Add a keydown listener that traps Tab/Shift+Tab within the modal while open.
4. In `closeModal()`, restore focus to the saved triggering element.
**Verdict**: SPEC-FIRST (focus-trap implementation touches JS; modal-open functions in ui-core.js need coordination) · `[CACHE][TEST]`.
**Severity**: CRITICAL (WCAG 4.1.2 Name, Role, Value; also 2.1.2 No Keyboard Trap — inverse: users CAN escape, but focus goes nowhere useful).
**Files**: `index.html` (static ARIA), `js/ui-core.js` (focus save/restore, trap listener, `closeModal`).
### 2c. Tab widget missing ARIA state (WCAG 4.1.2) — MAJOR
The tab bar has `role="tablist"` (correct) and four buttons with `role="tab"` (correct). However:
- `switchTab()` only toggles the CSS `active` class — it never sets `aria-selected`.
- The tab panel content (`<details class="panel" data-tab="...">`) has no `role="tabpanel"`.
- No `aria-controls` connects each tab button to its panel.
- No `aria-labelledby` connects each panel back to its tab.
Screen reader users hear "STAT button", "INV button", etc. with no indication of which is selected or what it controls.
**Fix** (in `switchTab()`):
```js
const btn = document.getElementById('tab-btn-' + t);
btn.classList.toggle('active', t === tab);
btn.setAttribute('aria-selected', t === tab ? 'true' : 'false');
```
Each tab button also needs `aria-controls="tab-panel-stat"` (etc.) and each panel container needs `id="tab-panel-stat"` + `role="tabpanel"` + `tabindex="0"` + `aria-labelledby="tab-btn-stat"`.
Note: The panels use `<details>` which natively expands/collapses — the `tabpanel` role on the outer `data-tab` container group (the containing div) rather than on individual `<details>` is the cleanest approach, since individual `<details>` panels are independently toggleable within a tab's content.
**Verdict**: SPEC-FIRST (need to decide on the tabpanel container model given `<details>` nesting) · `[CACHE][TEST]`.
**Severity**: MAJOR (WCAG 4.1.2 — active tab state not programmatically determinable).
**Files**: `js/ui-core.js` (`switchTab`), `index.html` (tab panel IDs and roles).
### 2d. Limb buttons communicate state via text only (WCAG 1.3.3, 4.1.2) — MAJOR
The five limb buttons (`HEAD`, `ARM L/R`, `LEG L/R`) toggle between `[████] OK` and `[█ ] CRIP` states. They have no `aria-pressed` attribute. `toggleLimb()` updates button text and className but leaves `aria-pressed` unset.
A screen reader announces "bracket-fullblock-fullblock-fullblock-fullblock-bracket-space-O-K-space button" — neither the toggle state nor the body part it controls is meaningfully announced. The button's position in the grid provides only positional context, not semantic context.
**Fix**:
```js
// In toggleLimb() and in loadUI() DOM writes:
btn.setAttribute('aria-pressed', isCrippled ? 'true' : 'false');
btn.setAttribute('aria-label', `${limbName}: ${isCrippled ? 'Crippled' : 'OK'}`);
```
Where `limbName` maps `'hd'→'Head'`, `'la'→'Left Arm'`, `'ra'→'Right Arm'`, `'ll'→'Left Leg'`, `'rl'→'Right Leg'`.
**Note**: The visible text (`[████] OK`) stays unchanged — the literal ARIA label is parallel, not replacing it (HOUSE_STANDARD §3 / literal layer).
**Verdict**: BUILD-NOW · `[CACHE][TEST]`.
**Severity**: MAJOR (WCAG 4.1.2 — state not programmatically available; 1.3.3 — cannot determine state without seeing text symbols).
**Files**: `index.html` (add initial `aria-pressed` and `aria-label` to each `btn_l_*`), `js/ui-core.js` (`toggleLimb`, the `loadUI` DOM write section).
---
## 3. Screen Reader — Interactive Span Elements (WCAG 2.1.1, 4.1.2) — CRITICAL
**All tracker toggle controls are `<span onclick>` elements, not `<button>` elements.** They are not keyboard-focusable and not announced as interactive controls to screen readers.
Affected render functions and their toggle controls:
| Function | Toggle element | What it does | Announced as |
|---|---|---|---|
| `renderCollectibles()` | `<span onclick="toggleCollectible(...)">` | Mark acquired/missing | `[ACQUIRED]` or `[MISSING]` static text |
| `renderLincolnMemorabilia()` | `<span data-lname onclick="toggleLincolnItem(...)">` | Mark acquired/missing | Same |
| `renderTraits()` | `<span onclick="toggleTrait(...)">` | Select/deselect trait | `[SEL]` or `[---]` |
| `renderSkillBooks()` | `<span onclick="toggleSkillBook(...)">` | Mark read/unread | Similar |
| `renderMagazines()` | `<span onclick="toggleMagazine(...)">` | Mark read/unread | Similar |
None of these elements:
- Can receive keyboard focus (no `tabindex`)
- Are announced as interactive (`role="button"` absent)
- Have `aria-label` describing the action ("Mark Stealth Boy as acquired")
- Have `aria-pressed` describing the current state
A keyboard user cannot access any of these tracker toggles. A screen reader user hears static text with no affordance.
**Fix**: Replace each interactive `<span>` with a `<button>` in the HTML template strings. Pattern:
```js
// Before
`<span style="color:var(--robco-green);cursor:pointer;" onclick="toggleCollectible('${safeName}')" title="Click to mark MISSING">[ACQUIRED]</span>`
// After
`<button class="tracker-toggle tracker-toggle--acquired" onclick="toggleCollectible('${safeName}')" aria-label="Mark ${escapeHtml(d.name)} as missing" aria-pressed="true">[ACQUIRED]</button>`
```
CSS for `.tracker-toggle`: `background: transparent; border: none; color: inherit; padding: 0; cursor: pointer; font-family: inherit; font-size: inherit; min-height: 28px;` — preserves the inline look while making it keyboard-operable.
The ARIA label stays literal ("Mark as missing", not diegetic jargon).
**Verdict**: SPEC-FIRST (touches 5 render functions; needs CSS, regression tests for tracker Suite 66/67/85/87) · `[CACHE][TEST]`.
**Severity**: CRITICAL for keyboard-only users (WCAG 2.1.1 Keyboard — entire tracker system inaccessible).
**Files**: `js/ui-render.js` (renderCollectibles, renderLincolnMemorabilia, renderTraits, renderSkillBooks, renderMagazines), `css/terminal.css` (tracker-toggle style).
---
## 4. Screen Reader — Anonymous Button Labels (WCAG 4.1.2) — MAJOR
### 4a. Inventory USE and DELETE buttons
```js
// ui-render.js:104 (renderInventory)
`<button class="use-btn" data-use="${it._origIdx}" title="Quick-use: send [USE] ${escapeHtml(it.name)}">USE</button>`
`<button class="delete-btn" data-idx="${it._origIdx}">X</button>`
```
"USE" is announced without item context. "X" is announced as "X" or "Times" depending on the SR. Visually, the item name is adjacent — but screen readers linearize the DOM and the button label must be self-contained.
**Fix**: Add `aria-label` on each button:
```js
`<button class="use-btn" ... aria-label="Use item: ${escapeHtml(it.name)}">USE</button>`
`<button class="delete-btn" ... aria-label="Remove ${escapeHtml(it.name)} from inventory">X</button>`
```
**Verdict**: BUILD-NOW · `[CACHE][TEST]` · MAJOR.
### 4b. Faction adjustment buttons (F+, F-, I+, I-)
```js
// ui-render.js:1527–1530
`<button class="faction-btn faction-btn--fame" title="Fame +5" onclick="adjustFaction('${f.key}','fame',5)">F+</button>`
```
"F+" is announced as "F plus" with no faction context. `title` is a tooltip, not an accessible name (screen readers may or may not read it). A screen reader user hears four anonymous symbols per faction card, times 14 factions = 56 contextless buttons.
**Fix**:
```js
`<button class="faction-btn faction-btn--fame" aria-label="Fame +5 for ${escapeHtml(f.name)}" onclick="adjustFaction('${f.key}','fame',5)">F+</button>`
```
**Verdict**: BUILD-NOW · `[CACHE][TEST]` · MAJOR.
**Files for 4a+4b**: `js/ui-render.js`.
---
## 5. Screen Reader — Live Regions (WCAG 4.1.3) — CRITICAL
### 5a. COMM-LINK chatDisplay — no live region
`#chatDisplay` (ui-core.js:1780) receives dynamically injected message nodes — user messages, AI narrative responses, and system messages — via `appendToChat()`. No `aria-live` attribute is set on the container.
Screen reader users hear nothing when AI responses arrive. The core interaction of the app — the AI narrative — is completely silent to assistive technology.
**Fix**: Add `aria-live="polite" aria-atomic="false"` to `#chatDisplay` in index.html. Use `polite` (not `assertive`) so AI narrative doesn't interrupt the user mid-sentence when typing. `aria-atomic="false"` announces each appended child independently rather than re-reading the whole container.
```html
<div id="chatDisplay" aria-live="polite" aria-atomic="false" style="margin-top: 10px"></div>
```
**Note**: The typewriter animation (character-by-character reveal via `textContent`) will cause the live region to announce partial text as it builds. Best fix: skip the live-region announcement until the animation completes, then swap `aria-live` from `off` to `polite` for the final `innerHTML` assignment, then restore to `off`. Or: set `aria-live="polite"` only at animation completion.
**Verdict**: BUILD-NOW for the static attribute · SPEC-FIRST for typewriter coordination · `[CACHE][TEST]`.
**Severity**: CRITICAL (WCAG 4.1.3 — the primary app output is not announced).
**Files**: `index.html` (attribute), `js/ui-core.js` (`appendToChat` typewriter, typeWriter function).
### 5b. Status and standing changes — no live feedback
When `adjustFaction()` or `renderFactionRep()` runs, the standing label ("Neutral", "Vilified", etc.) updates silently. Same for karma changes, limb cripple/heal, and RAD level changes that trigger animation states. None of these have an `aria-live` region to announce the change.
**Fix**: A single `aria-live="polite"` status notification region (visually hidden, `position: absolute; clip: ...`) that the above functions append a brief readable summary to on state change:
```
"NCR Faction: Neutral (Fame 30, Infamy 10)"
"Head: Crippled"
"Karma: 55 — Neutral"
```
**Verdict**: SPEC-FIRST (design needed for what to announce and when) · `[CACHE][TEST]`.
**Severity**: MAJOR (WCAG 4.1.3).
**Files**: `index.html` (visually-hidden announcer div), `js/ui-render.js` (faction, karma), `js/ui-core.js` (limb toggle, rad level).
---
## 6. Reduced Motion — A0 CR-1 Resolution
### Confirmed: ZERO `prefers-reduced-motion` implementation
```bash
$ grep -c "prefers-reduced-motion" css/terminal.css
0
```
A0 CR-1 is **fully open**. There is no `@media (prefers-reduced-motion)` block anywhere in `terminal.css`. The PERFORMANCE_AUDIT.md (A1) reference to "existing prefers-reduced-motion at ~line 1879" was in error — that line is the `@keyframes update-banner-pulse` definition. Standby (`body.standby`) pauses 3 of the animations when the browser tab is hidden/blurred — an unrelated mechanism.
### Complete inventory of unguarded animations
| `@keyframes` name | Duration | Trigger | Risk level |
|---|---|---|---|
| `flicker` | 0.15s infinite | Always (`.crt-overlay`) | **HIGH** — 6.7Hz opacity oscillation, photosensitivity hazard |
| `flicker-medium` | 0.1s infinite !important | High rads | **HIGH** — 10Hz, stronger risk |
| `refresh-bar` | 7s infinite | Always (`.crt-overlay::after`) | LOW — slow scan, low risk |
| `header-pulse` | 3s infinite | Always (`.header h1`) | LOW |
| `rad-pulse` | — | Any rads | MEDIUM — pulsing UI element |
| `rad-shift` | — | High rads | MEDIUM — input animation |
| `rad-chaos` | — | Very high rads | **HIGH** — rapid input distortion |
| `vats-scan` | 1s infinite | VATS overlay open | MEDIUM |
| `phosphor-ghost` | — | Button interaction | LOW |
| `weight-sag` | — | Overencumbered | LOW |
| `weight-buckle` | — | Very overencumbered | LOW |
| `karma-good-flash` | — | Karma change | LOW |
| `karma-evil-flash` | — | Karma change | LOW |
| `crit-hp-flash` | — | HP critical | MEDIUM |
| `badge-pop` | — | Collectible badge | LOW |
| `skill-ping` | — | Skill change | LOW |
| `sync-flash` | — | Cloud sync | LOW |
| `map-blink` | 1s step-end infinite | World map markers | LOW |
| `update-banner-pulse` | 2s infinite | Update modal | LOW |
| `limb-glitch` | 4.5s infinite | Crippled limbs | LOW |
| `cursor-blink` | 0.6s step-end | Boot cursor | LOW (boot only) |
| `delta-rise` / `delta-fall` | — | Stat delta indicators | LOW |
**JS typewriter animation**: `appendToChat()` runs a `setTimeout` chain revealing characters at 4–20ms intervals. Under `prefers-reduced-motion`, this should complete instantly (skip to final innerHTML without the reveal loop).
### Fix — concrete reduced-motion block
```css
@media (prefers-reduced-motion: reduce) {
/* Freeze all decorative animations to their resting state */
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* Preserve the static CRT scanline look — only motion is calmed */
.crt-overlay { opacity: 1; }
}
```
The `*` rule is a broad catch that neutralizes all 22 keyframes at once. Using `0.01ms` rather than `0s` avoids some browser-specific bugs where `duration: 0` skips `animationend` events that code may depend on. The `opacity: 1` rule ensures the CRT overlay doesn't flicker off when the `flicker` animation is clamped to one iteration.
JS fix in `appendToChat()`:
```js
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (sender === 'ai' && !isHistoryLoad && !prefersReduced) {
// typewriter animation
} else {
msgDiv.innerHTML = fullHtml;
chatBox.scrollTop = chatBox.scrollHeight;
}
```
**Verdict**: SPEC-FIRST for the CSS block (broad rule needs regression-checking that no UI breaks visually) · BUILD-NOW for the JS typewriter guard. `[CACHE][TEST]` — add a static guard that a `prefers-reduced-motion` block exists (both runners, per A0 CR-1 planned test).
**Severity**: CRITICAL (WCAG 2.3.3 is AAA, but `flicker` at 6.7Hz and `flicker-medium` at 10Hz are photosensitivity hazards covered by WCAG 2.3.1 — Three Flashes which is Level A. The 3Hz threshold for 2.3.1 is exceeded by both animations).
**Files**: `css/terminal.css`, `js/ui-core.js` (`appendToChat`). `[TEST]` both runners.
---
## 7. Forms — Labels and Accessible Names
### 7a. Inputs labelled only by placeholder (WCAG 1.3.1, 3.3.2) — MAJOR
17 `placeholder` attributes serve as the only visible label for their input. Placeholder text disappears on focus and has lower screen reader support than proper labels. Affected inputs:
| Input ID / context | Placeholder | Has `<label for>` or `aria-label`? |
|---|---|---|
| `newAmmoCalib` | `Caliber (e.g. 5.56mm)` | ❌ |
| `newAmmoQty` | `Qty` | ❌ |
| `newItemName` | `Item Name` | ❌ |
| `newItemQty` | `Qty` | ❌ |
| `newItemWgt` | `lbs` | ❌ |
| `newItemVal` | `val` | ❌ |
| `newQuestName` | `Quest Name` | ❌ |
| `currentObjective` | `Current objective (optional)` | ❌ |
| `newPerkName` | `Perk Name` | ❌ |
| `perkRank` | `Rank` | ❌ |
| `campgNoteInput` | `Note` | ❌ |
| `traitFilter` | `Filter traits...` | ❌ |
| `chatInput` (textarea) | `Enter command or action...` | ❌ |
| `newCampaignNote` | `Campaign note` | ❌ |
SPECIAL stats have `aria-label="Strength"` etc. — these are **correct** and serve as the model.
**Fix**: Add `aria-label` to each input (the quickest fix — no DOM restructuring). The label text stays literal (not diegetic):
```html
<input id="chatInput" aria-label="COMM-LINK command input" placeholder="Enter command...">
<input id="newItemName" aria-label="New item name" placeholder="Item Name">
```
`<label for="">` is better practice but requires DOM restructuring around each input. `aria-label` is acceptable for cases where a visible label would clutter the compact UI.
**Verdict**: BUILD-NOW (aria-label additions are one-line-per-input) · `[CACHE][TEST]`.
**Severity**: MAJOR (WCAG 1.3.1, 3.3.2 — inputs without programmatic labels fail Level A and AA).
**Files**: `index.html`.
### 7b. `<label>` elements not associated with controls (WCAG 1.3.1) — MINOR
Several `<label>` elements have no `for` attribute and are not wrapping their input:
```html
<!-- index.html:267 -->
<label>HP:</label>
<input type="number" id="stat_hp_cur" ...>
```
No `for="stat_hp_cur"`. These labels are visually adjacent but not programmatically associated. Screen readers don't know they label those inputs.
Audit shows ~15 visual labels without `for=` associations (`HP:`, `LVL/XP:`, `CAPS:`, `KARMA:`, `LOCATION:`, `DATE:`, `TIME:`, `WEIGHT:`, `MAX AP:`, `EQUIPPED:`, `RADIATION:`, `HEAD:`, `ARMS:`, `LEGS:`, `GAME:`, `PLAYSTYLE:`).
**Fix**: Add `for="stat_hp_cur"` to the HP label (and equivalents). Where labels wrap inputs, that's sufficient. For the layout where label and input are siblings, `for=` is required. This is a global sweep of the BIO-METRICS, BIO-SCAN, and CAMPG panels.
**Verdict**: SPEC-FIRST (global sweep, affects many lines) · `[CACHE][TEST]`.
**Severity**: MINOR (many of these labels are short-text like "HP:" whose adjacent input is inferrable by sighted users — the issue is real but the impact is lower than fully unlabelled controls).
**Files**: `index.html`.
---
## 8. Document Structure and Landmarks (WCAG 1.3.6, best practice) — MINOR
### No semantic landmark elements
```html
<div class="container"> <!-- should be <main> or wrap in <main> -->
<div class="header"> <!-- should be <header> -->
<div class="col-left"> <!-- content column -->
<div class="col-right"> <!-- COMM-LINK column -->
```
The app has no `<main>`, `<nav>`, `<header>`, or `<aside>` elements. Screen reader users cannot use landmark navigation (F6 in NVDA, rotor in VoiceOver) to jump between major sections. The tab bar has `role="tablist"` which is correct for the tab widget, but no `<nav>` wraps it.
**Fix** (non-breaking): Replace `<div class="header">` with `<header>` and wrap the primary content in `<main id="main-content">`. The tab bar's `role="tablist"` already handles the tab-navigation semantics — no separate `<nav>` needed for that.
**Verdict**: BUILD-NOW · `[CACHE][TEST]`.
**Severity**: MINOR (not a hard WCAG failure under 2.1, but aids navigation for SR users; WCAG 1.3.6 is AAA).
**Files**: `index.html`.
### Heading hierarchy — `<h2>` inside collapsed `<details>`
All panel headings (`> BIO-METRICS`, `> BACKPACK INVENTORY`, etc.) are `<h2>` elements inside `<details><summary>` elements. Collapsed panels may cause the heading to be skipped in screen-reader heading navigation (JAWS `H` key, NVDA `H` key) depending on whether the SR traverses hidden-but-not-`display:none` content. In this implementation, closed `<details>` content is hidden by the browser's native mechanism — heading traversal behavior varies.
**Assessment**: Lower severity — this is inherent to the `<details>` pattern. The headings in `<summary>` (the toggle itself) are consistently accessible. The issue is limited to headings in collapsed panel body content, which are secondary section headings like `> AMMO RESERVES` inside BACKPACK INVENTORY. Not a hard failure.
**Verdict**: SKIP — inherent to the `<details>` pattern; not a practical blocker.
---
## 9. Touch Targets (Protocol 17 overlap) — MINOR
Per Protocol 17, interactive controls need ≥28px tap targets.
| Control | Explicit min-height | Verdict |
|---|---|---|
| `.faction-btn` (F+/F-/I+/I-) | `min-height: 28px` ✅ | Pass |
| `.delete-btn` (inventory delete) | `min-height: 28px` ✅ | Pass |
| `.limb-ok` / `.limb-crip` | Not checked in this grep | Needs verify |
| `.use-btn` (inventory USE) | No min-height; `padding: 1px 5px` | **Fails** — ≈12px actual |
| Tracker toggle spans | No min-height (spans) | **Fails** — 0px (not interactive element) |
The `.use-btn` has no `min-height` and its padding of `1px 5px` with a 10px font produces a tap target of approximately 12–14px — well below the 28px minimum. The global `button` rule sets `padding: 12px` but `.use-btn` explicitly overrides to `1px 5px`.
**Fix**: Add `min-height: 28px` to `.use-btn` and `min-width: 28px`. Adjust internal alignment to `display: flex; align-items: center; justify-content: center;`.
**Verdict**: BUILD-NOW · `[CACHE][TEST]`.
**Severity**: MINOR (same class as Protocol 17 — overlaps mobile audit).
**Files**: `css/terminal.css`.
---
## 10. imagePreview — Static Alt Text (WCAG 1.1.1) — MINOR
```html
<img id="imagePreview" class="image-preview" src="" alt="Preview" />
```
When a user attaches an image via `triggerImageUpload()`, the `src` is set to a data URL but `alt` remains "Preview". A screen reader announces "Preview image" — no description of the actual image content. Since AI vision processes the image, the user would benefit from knowing the app received their image (confirmation), not just the static word "Preview".
**Fix**: When `handleImageSelection()` fires, set `imagePreview.alt = 'Attached image ready for VISUAL UPLOAD'` (literal layer — not diegetic). Clear to `alt=""` (presentation role) when no image is attached (empty `src`).
**Verdict**: BUILD-NOW · `[CACHE][TEST]` (static Suite 59 or new guard).
**Severity**: MINOR.
**Files**: `js/ui-saves.js` or `js/ui-core.js` (wherever `handleImageSelection` is defined).
---
## 11. ARIA Guardrail Check — A0 Tension TN-13
A0 logged TN-13: "any copy reframe must keep ARIA literal and ideally add literal labels to rendered rows. Reframing must never move a screen-reader string into diegetic jargon."
**Assessment**: All proposed fixes above are literal. No finding here proposes diegetic jargon in ARIA.
Specific tensions:
| Potential conflict | Resolution |
|---|---|
| Faction button label: "F+ for NCR Faction" vs "Fame +5 for NCR" | Use literal: `aria-label="Fame +5 for NCR"` (not "Renown" or diegetic synonym) |
| Collectible toggle: "Mark as acquired" vs game-world action | Use literal: "Mark [item name] as acquired" — the in-world label `[ACQUIRED]` stays as visible text only |
| Limb button: "Crippled" vs "[█ ]" | ARIA uses "Crippled" (literal). Visible text keeps `[█ ]` glyph — two registers correctly separated |
| chatInput label: "COMM-LINK command input" | Acceptable — "COMM-LINK" is a proper noun in both layers; "command input" is literal |
No diegetic contamination introduced. All ARIA strings operate in the literal layer per HOUSE_STANDARD §5.
---
## 12. Summary Backlog
### BUILD-NOW — Safe isolated fixes
| ID | Finding | Severity | WCAG | Files |
|---|---|---|---|---|
| A-1 | No focus indicators (`outline: none`, zero `:focus-visible` rules) | **CRITICAL** | 2.4.7 | `css/terminal.css` · [CACHE][TEST] |
| A-2 | Limb buttons missing `aria-pressed` + `aria-label` | **MAJOR** | 4.1.2 | `index.html`, `js/ui-core.js` · [CACHE][TEST] |
| A-3 | Inventory USE button — anonymous label | **MAJOR** | 4.1.2 | `js/ui-render.js` · [CACHE][TEST] |
| A-4 | Inventory DELETE button — anonymous label | **MAJOR** | 4.1.2 | `js/ui-render.js` · [CACHE][TEST] |
| A-5 | Faction F+/F-/I+/I- buttons — no `aria-label` with faction context | **MAJOR** | 4.1.2 | `js/ui-render.js` · [CACHE][TEST] |
| A-6 | Form inputs — `aria-label` on 14 inputs (chatInput, item, quest, perk, etc.) | **MAJOR** | 1.3.1, 3.3.2 | `index.html` · [CACHE][TEST] |
| A-7 | `#chatDisplay` — add `aria-live="polite" aria-atomic="false"` | **CRITICAL** | 4.1.3 | `index.html` · [CACHE][TEST] |
| A-8 | `imagePreview` — dynamic `alt` on image attach | MINOR | 1.1.1 | `js/ui-saves.js` · [CACHE][TEST] |
| A-9 | `.use-btn` — add `min-height: 28px` | MINOR | 2.5.8 | `css/terminal.css` · [CACHE] |
| A-10 | `<main>` and `<header>` semantic elements | MINOR | 1.3.6 | `index.html` · [CACHE][TEST] |
| A-11 | JS typewriter: instant-complete under reduced-motion | CRITICAL (2.3.1) | 2.3.1 | `js/ui-core.js` · [CACHE][TEST] |
### SPEC-FIRST — Require design before implementation
| ID | Finding | Severity | WCAG | Files |
|---|---|---|---|---|
| A-S1 | `sysModal` — `role="dialog"`, `aria-modal`, focus trap + restore | **CRITICAL** | 4.1.2 | `index.html`, `js/ui-core.js` |
| A-S2 | Tracker toggle `<span>` → `<button>` (5 render functions) | **CRITICAL** | 2.1.1 | `js/ui-render.js`, `css/terminal.css` |
| A-S3 | Tab widget — `aria-selected` in `switchTab()`, tabpanel roles | **MAJOR** | 4.1.2 | `js/ui-core.js`, `index.html` |
| A-S4 | CSS `prefers-reduced-motion` block — full 22-animation coverage | **CRITICAL** | 2.3.1 | `css/terminal.css` |
| A-S5 | Inactive tab opacity — raise from 0.55 to 0.75 (4 failing themes) | **MAJOR** | 1.4.3 | `css/terminal.css` |
| A-S6 | Status live region — announcer div for faction/karma/limb state changes | MAJOR | 4.1.3 | `index.html`, `js/ui-render.js`, `js/ui-core.js` |
| A-S7 | `<label for="">` associations — global sweep of BIO-METRICS panel | MINOR | 1.3.1 | `index.html` |
### SKIP
| ID | Finding | Rationale |
|---|---|---|
| SK-1 | h2 in collapsed `<details>` heading traversal | Inherent to `<details>` pattern; not a practical blocker |
| SK-2 | Dynamic row full ARIA (inventory list `<li>` structure) | The item text already carries the name + qty. Focus on button labels (A-3/A-4) first |
---
## 13. A11Y Baseline (Tests/a11y-check.mjs)
Suite 60 confirms `@axe-core/playwright` is in devDependencies and `tests/a11y-check.mjs` + `tests/a11y-baseline.json` exist. The axe check runs in the non-fast gate block.
After implementing the BUILD-NOW fixes above, run `npm run a11y` to capture a new baseline. Expect axe to flag:
- Missing role/ARIA (A-S1, A-S3): `aria-required-attr`, `aria-required-children`
- Color contrast (A-S5): `color-contrast` for inactive tabs on certain themes (axe uses computed colors; opacity may not be detected — manual verification required)
- Missing labels (A-6): `label` rule
The a11y-baseline.json must be regenerated after each fix batch; Suite 60 is the regression fence.
---
## 14. Constraint Checklist
- **[GATE]** 1078 tests must pass · never `--no-verify`.
- **[CACHE]** Any change to `index.html`, `css/`, or `js/` → bump `CACHE_NAME` in sw.js.
- **[TEST]** New a11y behavior → add static guard to BOTH runners at parity (Protocol 2a, Protocol 15).
- **[BEHAVIOR]** A11y fixes must be behavior-neutral — no visible UX change except focus indicators.
- **[GUARDRAIL]** ARIA labels and alt text stay LITERAL — never diegetic-ized (HOUSE_STANDARD §5).
- **[VERSION]** Do not bump APP_VERSION for a11y-only changes.
---
*End of ACCESSIBILITY_AUDIT.md*
STAMP · generated for RELEASE v2.8.5 commit 06e5180 (06e51801b38a) · archive input-tree hash c07fbfbdd2e1ddeb · 754 files · no wall-clock timestamp (regenerates identically when nothing changed).