# Lumotia v0.1 WCAG-AA Contrast Spot-Check

**Date:** 2026-05-14
**Auditor:** automated (Claude Code subagent)
**Scope:** WCAG 2.1 AA spot-check only — not a full audit. Normal text threshold: 4.5:1. Large text (≥ 18 pt / 14 pt bold) threshold: 3:1. Non-text UI components: 3:1 (WCAG 1.4.11).
**Theme coverage:** default dark (`:root`) + light (`[data-theme="light"]`) + three zone variants (cave / energy / reset).

---

## Tokens Audited

### Dark theme (`:root`)

| Token | Value |
|---|---|
| `--color-text` (text-primary) | `#f0ece4` |
| `--color-text-secondary` | `#9a9486` |
| `--color-text-tertiary` | `#8c8678` |
| `--color-bg` (bg-page) | `#0f0e0c` |
| `--color-bg-card` | `#1b1a17` |
| `--color-bg-elevated` | `#171614` |
| `--color-accent` | `#d68450` |
| `--color-danger` | `#e85f5f` |
| `--color-success` | `#5fc28a` |
| `--color-warning` | `#e8be4a` |

### Light theme (`[data-theme="light"]`)

| Token | Value |
|---|---|
| `--color-text` (text-primary) | `#1a1816` |
| `--color-text-secondary` | `#5c574d` |
| `--color-text-tertiary` | `#6b6557` |
| `--color-bg` (bg-page) | `#faf8f5` |
| `--color-bg-card` | `#ffffff` |
| `--color-bg-elevated` | `#f3f0eb` |
| `--color-accent` | `#a3683a` |
| `--color-danger` | `#b32626` |
| `--color-success` | `#1f7344` |
| `--color-warning` | `#a08a1f` |

---

## Pairs Tested and Results

All ratios computed via the WCAG relative-luminance formula (IEC 61966-2-1 sRGB linearisation + `(L1+0.05)/(L2+0.05)`).

### Body text and UI label text

| Pair | Dark ratio | Dark | Light ratio | Light |
|---|---|---|---|---|
| text-primary on bg-page | 16.38:1 | PASS | 16.70:1 | PASS |
| text-primary on bg-card | 14.77:1 | PASS | 17.70:1 | PASS |
| text-secondary on bg-page | 6.39:1 | PASS | 6.77:1 | PASS |
| text-secondary on bg-card | 5.76:1 | PASS | 7.18:1 | PASS |
| text-tertiary on bg-card | 4.80:1 | PASS | 5.79:1 | PASS |
| text-tertiary on bg-page | 5.33:1 | PASS | 5.47:1 | PASS |

All body/label text pairs clear AA with comfortable headroom. Phase 10a/10b token adjustments documented in `app.css` are confirmed effective.

### Filled buttons — white or dark text on semantic background

| Pair | Ratio | Result | Usage |
|---|---|---|---|
| **DARK** `#ffffff` on accent `#d68450` | 2.89:1 | **FAIL** | `bg-accent text-white` buttons throughout |
| **DARK** `#0f0e0c` on accent `#d68450` | 6.68:1 | PASS | `bg-accent text-bg` (EmptyState) |
| **DARK** `#ffffff` on danger `#e85f5f` | 3.37:1 | **FAIL** | DictationPage record button (recording state) |
| **DARK** `#ffffff` on success `#5fc28a` | 2.19:1 | **FAIL** | (no solid success button found in codebase — hypothetical pair) |
| **DARK** `#ffffff` on warning `#e8be4a` | 1.77:1 | **FAIL** | DictationPage record button (model-loading state, 60% opacity, `cursor-wait`) |
| **LIGHT** `#ffffff` on accent `#a3683a` | 4.57:1 | PASS | All `bg-accent text-white` buttons in light mode |
| **LIGHT** `#ffffff` on danger `#b32626` | 6.52:1 | PASS | FirstRunPage cancel button |
| **LIGHT** `#ffffff` on success `#1f7344` | 5.84:1 | PASS | (no solid success button) |
| **LIGHT** `#ffffff` on warning `#a08a1f` | 3.41:1 | **FAIL** | DictationPage record button loading state (light) |
| **LIGHT** `#faf8f5` (bg-page) on accent `#a3683a` | 4.31:1 | **FAIL** | EmptyState `text-bg` button — light mode only |

### Semantic text labels on card backgrounds

| Pair | Dark ratio | Dark | Light ratio | Light |
|---|---|---|---|---|
| success text on bg-card | 7.93:1 | PASS | 5.84:1 | PASS |
| danger text on bg-card | 5.17:1 | PASS | 6.52:1 | PASS |
| warning text on bg-card | 9.86:1 | PASS | 3.41:1 | **FAIL** |

### Semantic text on tinted backgrounds (10% alpha fills)

| Pair | Ratio | Result | Usage |
|---|---|---|---|
| **DARK** warning text on `bg-warning/10` over card | 8.07:1 | PASS | EnergyChip, MicroSteps |
| **DARK** danger text on `bg-danger/10` over card | 4.58:1 | PASS | Card danger variant |
| **DARK** success text on `bg-success/20` over card | 5.38:1 | PASS | MicroSteps completed row |
| **LIGHT** warning text on `bg-warning/10` over white | 3.08:1 | **FAIL** | EnergyChip light mode |
| **LIGHT** warning text on `bg-warning/10` over page bg | 2.92:1 | **FAIL** | EnergyChip on page bg |

### StatusPill label text on pill background

The StatusPill label uses `text-secondary` on a near-opaque pill bg (`bg-elevated` + ~6% white glow). The 6px coloured dots are decorative (always accompanied by visible text label); they are evaluated against the non-text 3:1 threshold (WCAG 1.4.11).

| Pair | Ratio | Result |
|---|---|---|
| **DARK** text-secondary on pill bg (~`#1d1c1b`) | 5.63:1 | PASS |
| **LIGHT** text-secondary on pill bg (`#f3f0eb`) | 6.32:1 | PASS |

**Non-text contrast — StatusPill/LlmStatusChip coloured dots (3:1 threshold):**

| Dot colour on pill bg | Dark | Light |
|---|---|---|
| danger dot | 5.05:1 PASS | 5.73:1 PASS |
| success dot | 7.75:1 PASS | 5.14:1 PASS |
| warning dot | 9.64:1 PASS | 3.00:1 PASS *(borderline)* |
| accent dot | 5.90:1 PASS | 4.02:1 PASS |

All decorative dots pass non-text contrast (3:1). Light warning dot is exactly 3.00:1 — right at the threshold; no action required.

### Zone variant bg-card surfaces (dark text on zone-coloured card)

| Pair | Ratio | Result |
|---|---|---|
| **DARK** text-primary on cave bg-card (`#14222b`) | 13.78:1 | PASS |
| **DARK** text-primary on energy bg-card (`#261913`) | 14.48:1 | PASS |
| **DARK** text-primary on reset bg-card (`#161f15`) | 14.36:1 | PASS |
| **LIGHT** text-primary on cave bg-card (`#f5fafc`) | 16.83:1 | PASS |
| **LIGHT** text-primary on energy bg-card (`#fff8ef`) | 16.80:1 | PASS |
| **LIGHT** text-primary on reset bg-card (`#f7fcf5`) | 17.03:1 | PASS |

All zone surfaces pass with substantial headroom.

---

## Summary

| Category | PASS | FAIL |
|---|---|---|
| Body / label text on surfaces | 12 | 0 |
| Filled buttons — text on semantic bg | 8 | 5 |
| Semantic text on card / tinted bg | 7 | 3 |
| StatusPill text labels | 2 | 0 |
| Non-text dots (3:1 threshold) | 8 | 0 |
| Zone variants | 6 | 0 |
| **Total** | **43** | **8** |

**8 pairs fail WCAG AA.**

---

## Fail Analysis and Recommendations

### FAIL-1 · DARK `white on accent (#d68450)` — 2.89:1

**Affected components:** FirstRunPage (multiple CTA buttons), FilesPage (Browse button), ModelDownloader (Download button), ShutdownRitualPage (finish button), DictationPage (idle record button, badge, paste-confirm button), MorningTriageModal (selected chip and confirm button), ViewerPage (play button selected state).

**Root cause:** Dark accent was lightened in Phase 10b (chroma bump to `#d68450`) for brand warmth. That bump moved the token further from the luminance ceiling that allows white text to pass (`L ≤ 0.183`). The current luminance of `#d68450` is 0.314 — nearly twice the ceiling.

**Alternatives:**
- **Option A (recommended): swap these buttons to `text-bg` (dark-mode page background).** `#0f0e0c` on `#d68450` = **6.68:1 PASS**. Already used in EmptyState. Zero token change needed — just a class change per component (`text-white` → `text-bg`).
- **Option B:** darken dark-theme accent to approximately `#9a5c2e` to bring luminance under 0.183. Ratio would reach ~4.6:1. This is a brand token change; requires designer sign-off.
- **Option C (scope-limit):** accept the fail for v0.1, document it, and fix in v0.1.1. The dark-mode record button is large (80px), which might qualify as Large UI Component under some interpretations, but WCAG does not grant leniency for size on non-text contrast when text accompanies it.

**Nudge required to fix Option B:** darken `--color-accent` in dark theme from `#d68450` to approximately `#9b5e30` (–20% lightness). **This is a brand decision — do not apply without explicit approval.**

---

### FAIL-2 · DARK `white on danger (#e85f5f)` — 3.37:1

**Affected:** DictationPage record button in recording state (`bg-danger text-white`). FirstRunPage cancel-model button.

**Root cause:** `#e85f5f` is a medium-luminance red (L=0.262), too bright for white text at AA.

**Recommendation:**
- **Option A (recommended):** swap the icon/label in the record button to `text-bg` (`#0f0e0c` on `#e85f5f` = **5.73:1 PASS**). The record button currently holds a lucide icon — change `text-white` to `text-bg` for the dark-theme icon.
- **Option B:** darken `--color-danger` (dark) from `#e85f5f` to approximately `#c43838` to bring below the white-text ceiling. Ratio would reach ~4.6:1.

---

### FAIL-3 · DARK `white on warning (#e8be4a)` — 1.77:1 (record button loading state)

**Affected:** DictationPage record button when `modelLoading = true` — rendered as `bg-warning opacity-60 cursor-wait`. This is a transient disabled state, not an interactive element. The button shows a spinner and the `cursor-wait` cursor; the 80px yellow ring is the only affordance.

**Severity: lower.** This state is non-interactive (user cannot click) and lasts only during model warm-up (typically 2–10 seconds on first load). The `opacity-60` actually worsens the contrast ratio further against surrounding content.

**Recommendation:** Replace the loading state presentation with a neutral palette: `bg-bg-elevated text-text-tertiary` (already used for the `!tauriRuntimeAvailable` state). Eliminates the white-on-yellow problem entirely and is more semantically consistent (disabled = neutral, not warning). **Propose for v0.1.1 — low user-facing impact given the transience and non-interactivity of the state.**

---

### FAIL-4 · DARK `white on success (#5fc28a)` — 2.19:1

**Affected:** No solid `bg-success text-white` element found in the current codebase. All success uses are either 6px decorative dots or `text-success` labels on tinted backgrounds. This pair is included for completeness — **no action required for v0.1**.

---

### FAIL-5 · LIGHT `white on warning (#a08a1f)` — 3.41:1

**Affected:** DictationPage record button loading state in light mode (same as FAIL-3 above, light variant). Same recommendation applies — swap loading state to neutral palette.

---

### FAIL-6 · LIGHT `warning text (#a08a1f) on bg-card (#ffffff)` — 3.41:1

**Affected:** Any component that renders `text-warning` label text directly on a white card surface in light mode. Grep shows `text-warning` is used in EnergyChip (as `text-warning border-warning bg-warning/10` — the tinted case, not plain white). The plain-card case is the theoretical worst case.

**EnergyChip specific:** `warning text on bg-warning/10 over white card` = 3.08:1 — also fails. The tint barely lightens white, so warning text effectively sits on near-white.

**Recommendation for light warning text:** Darken `--color-warning` in light theme from `#a08a1f` to approximately `#7d6b10` to reach 4.5:1 on white. Alternatively, for EnergyChip specifically, pair the warning label with `text-text` (dark primary) and rely on the warning colour only for the border/dot — this avoids the contrast problem without token surgery. **This is a design decision; propose for v0.1.1.**

---

### FAIL-7 · LIGHT `bg-page (#faf8f5) on accent (#a3683a)` — 4.31:1 (EmptyState button)

**Affected:** `EmptyState.svelte` uses `bg-accent text-bg`. In light mode, `text-bg` resolves to `--color-bg` = `#faf8f5`. Ratio = 4.31:1 — just 0.19 below AA.

**Fix options:**
- **Option A (minimal, recommended):** change `text-bg` to `text-white` on the EmptyState button. In light mode `white on accent-light` = **4.57:1 PASS**; in dark mode `white on accent-dark` = 2.89:1 (FAIL-1 above). So if this single button changes to `text-white`, it gains 0.26 headroom in light but joins the FAIL-1 pool in dark.
- **Option B:** Keep `text-bg` but fix FAIL-1 globally by swapping all accent buttons in dark mode to `text-bg`. That gives EmptyState 6.68:1 in dark and 4.31:1 in light — light still fails marginally.
- **Option C (cleanest):** Use a conditional: `text-bg` in dark (passing), `text-white` in light (passing). Requires a theme-conditional class.
- **Option D:** Accept the 4.31:1 fail for v0.1 (EmptyState appears only when no recordings exist — it is rarely seen by returning users). Residual for v0.1.1.

---

## Fixes Applied Inline

**None.** Per task constraints, no colour tokens were modified. All fails are reported for dispatcher review.

---

## Residual Items for v0.1.1

The following are documented as known contrast gaps. The app ships honest about them; none affect primary reading text.

| ID | Fail | Impact | Recommended action |
|---|---|---|---|
| CA-1 | DARK white on accent buttons | HIGH — multiple primary CTAs in dark mode | Swap buttons to `text-bg` (no token change) or await brand decision on accent darkening |
| CA-2 | DARK white on danger record button | MEDIUM — primary CTA in recording state | Swap icon/label to `text-bg` in dark mode |
| CA-3 | DARK/LIGHT white on warning loading state | LOW — transient, non-interactive, 2–10 s | Replace loading state with neutral palette |
| CA-4 | LIGHT warning text on white surfaces | MEDIUM — EnergyChip and any bare `text-warning` on white card | Darken light `--color-warning` or swap EnergyChip to `text-text` |
| CA-5 | LIGHT bg-page on accent (EmptyState) | LOW — empty state screen, rare | Use `text-white` conditionally or fix CA-1 globally first |

---

## What Passes (Confirmed)

- All body text, secondary text, and tertiary text on all surfaces (dark + light + all zones): **12/12 PASS**
- All StatusPill and LlmStatusChip label text: **PASS** in both themes
- All 6px/7px coloured decorative dots (non-text 3:1 threshold): **8/8 PASS**
- All zone variant bg-card surfaces: **6/6 PASS**
- Light-theme white on accent (all accent buttons in light mode): **PASS** at 4.57:1
- Light-theme white on danger: **PASS** at 6.52:1
- Light-theme white on success: **PASS** at 5.84:1
- All semantic text labels (`text-success`, `text-danger`, `text-warning`) on tinted (`/10`, `/20`) backgrounds in dark mode: **PASS**

---

## Methodology Note

Contrast ratios were computed in Python using the WCAG 2.1 relative luminance formula (sRGB linearisation at threshold 0.03928, gamma 2.4). Alpha-composited colours (e.g. `bg-warning/10` Tailwind utilities) were blended against the documented underlying surface before ratio computation. No browser rendering was used; values assume full opacity unless stated.
