# Accessibility

> Priority ladder from ui-skills/fixing-accessibility, with the finance-specific rules that generic guidance doesn't cover.

Source: https://lopifi.com/docs/craft/02-accessibility
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

# Accessibility

Priority ladder from `ui-skills/fixing-accessibility`, with the finance-specific rules that generic guidance doesn't cover.

The framing that matters: in a financial product, an accessibility failure is frequently a **correctness** failure. A screen reader that drops the sign off `(1,234.00)` isn't giving a degraded experience — it's reading a different number.

---

## Priority ladder

| P | Category | Impact |
|---|---|---|
| 1 | Accessible names | critical |
| 2 | Keyboard access | critical |
| 3 | Focus & dialogs | critical |
| 4 | Semantics | high |
| 5 | Forms & errors | high |
| 6 | Announcements | medium-high |
| 7 | Contrast & states | medium |
| 8 | Media & motion | low-medium |

Fix in order. A perfectly contrasted button nobody can reach with a keyboard is worth nothing.

---

## 1. Accessible names

- Every interactive control has an accessible name.
- **Icon-only buttons require `aria-label`.** The most common failure in dense financial toolbars, where a row can have five icon buttons.
- Every `input` / `select` / `textarea` has a real `<label>`. A placeholder is not a label — it disappears the moment someone types, which in an amount field is exactly when they need it.
- Links have meaningful text. Never "click here", never "view".
- Decorative icons are `aria-hidden="true"`.

### Finance-specific
- **A table row that's clickable needs a name describing the row**, not "row". `aria-label="Transaction: Northwind Ltd, 1,240 dollars, 3 September"`.
- **A masked account is named by its meaning:** `aria-label="Checking account ending 4291"`, with the `••••` itself `aria-hidden`.
- **A status pill's name includes the entity:** `"Payment pending"` not `"Pending"`.

---

## 2. Keyboard access

- Never a `div` or `span` as a button without full keyboard support. Use `<button>`.
- Everything interactive is reachable by `Tab`.
- Focus is always visible.
- Never `tabindex > 0`.
- `Escape` closes dialogs, sheets, popovers, and cancels an in-progress inline edit.

### Data grid keyboard model
A financial table is a grid, and power users navigate it with arrow keys. This is non-optional for the primary transaction table.

| Key | Action |
|---|---|
| `↑ ↓ ← →` | Move cell focus |
| `Home` / `End` | First / last cell in row |
| `Cmd+↑` / `Cmd+↓` | First / last row |
| `Space` | Toggle row selection |
| `Shift+↑↓` | Extend selection |
| `Enter` | Open the row's detail |
| `Escape` | Clear selection, then close detail |
| `Tab` | Move **out** of the grid, not through every cell |

That last one is the rule people miss: a 12-column × 200-row grid has 2,400 tab stops if you get it wrong. The grid is **one** tab stop; arrows navigate within it. Use `roving tabindex` (or `aria-activedescendant`) — or better, use a primitive that implements it.

### Shortcuts
Every keyboard shortcut is discoverable (a `?` overlay), overridable, and never conflicts with a browser or assistive-tech shortcut. And per the [frequency law](../motion/01-principles.md), **every keyboard-initiated action is motionless.**

---

## 3. Focus & dialogs

- Dialogs trap focus while open.
- Focus returns to the trigger on close. Always. A user who opens a row detail from row 40 and closes it must land back on row 40, not at the top of the table.
- Initial focus is set inside the dialog — on the first interactive element, or on the dialog itself if the content is long. **Never on a destructive action.**
- Opening a dialog doesn't scroll the page underneath.
- **Scroll lock preserves scroll position** and compensates for scrollbar width, or the whole page shifts sideways when a dialog opens.

### Never hand-roll
Use Base UI (preferred) or Radix for dialogs, menus, comboboxes, tabs, popovers, tooltips. Focus trapping, `aria-activedescendant`, typeahead, and the twenty edge cases in a combobox are solved problems. Rebuilding them by hand is how products ship broken keyboard support.

**Never mix primitive systems in one interaction surface.** A Radix dialog containing a Base UI select will fight over focus.

---

## 4. Semantics

- Native elements before roles. `<button>`, `<a>`, `<input>`, `<table>`.
- If a role is used, its required ARIA attributes are present.
- Lists are `<ul>`/`<ol>` with `<li>`.
- Heading levels don't skip.
- **Tables use real table semantics** — `<table>`, `<thead>`, `<th scope="col">`, `<tbody>`. A `div` grid with `role="grid"` is a last resort for virtualization, and then every ARIA attribute must be right.

### Financial statement semantics
A P&L is a table with hierarchy, and getting this right is what makes it usable non-visually:

```html
<table>
  <caption>Profit and loss, 1 Jan – 30 Sep 2026</caption>
  <thead>
    <tr><th scope="col">Account</th>
        <th scope="col">Sep 2026</th>
        <th scope="col">Aug 2026</th>
        <th scope="col">Change</th></tr>
  </thead>
  <tbody>
    <tr><th scope="rowgroup" colspan="4">Revenue</th></tr>
    <tr><th scope="row">Product revenue</th><td>482,100</td>…</tr>
    <tr class="subtotal"><th scope="row">Total revenue</th><td>600,500</td>…</tr>
  </tbody>
</table>
```

- `<th scope="row">` on account names — so a screen reader announces "Product revenue, September 2026, 482,100" when reading a cell, instead of a naked number.
- `<caption>` states the statement and the **resolved period**.
- Hierarchy depth via `data-level` on the row header, plus visual indentation — and the depth said in words where it matters ("Total current assets"). Not `aria-level`: on a plain table's cells and rows it is prohibited ARIA (it belongs to `treegrid`), and assistive tech ignores it there.
- Expandable rows: `aria-expanded` on the toggle, `aria-controls` pointing at the child rows.
- Subtotal and total rows are announced as such — put the word in the row header ("Total revenue"), don't rely on the rule line, which is invisible to a screen reader.

---

## 5. Forms & errors

- Errors linked to their field with `aria-describedby`.
- Invalid fields set `aria-invalid="true"`.
- Required fields announced (`required`, not just an asterisk).
- Helper text associated via `aria-describedby`.
- **A disabled submit explains why.** A greyed-out "Send payment" with no reason is a dead end — either enable it and show the error on submit, or state the blocker next to the button.
- **Never block paste** in any input. Users paste account numbers, amounts, and reference codes constantly, and blocking paste in an IBAN field is actively hostile.

### The amount input
The hardest input in fintech. Full spec in [payments](../catalog/03-payments.md#amountinput); the accessibility requirements:

- `inputMode="decimal"` for the right mobile keypad.
- `type="text"`, not `type="number"` — number inputs have scroll-wheel increment (which will change someone's payment amount by accident), inconsistent decimal handling across locales, and hidden spinners.
- The currency is part of the accessible name: `aria-label="Amount in US dollars"`.
- Announce the parsed value on blur so the user hears what was understood: `aria-describedby` → `"One thousand two hundred and thirty-four dollars and fifty-six cents"`.
- Errors are specific and actionable: `"Enter an amount of $0.01 or more"`, `"Amount exceeds available balance of $4,120.00"`.

---

## 6. Announcements

- Critical form errors use `aria-live="assertive"`; everything else `polite`.
- Loading uses `aria-busy` or explicit status text.
- **A toast is never the only channel for critical information.** Toasts are timed and dismissible; a failed payment must also persist inline.
- Expandable controls use `aria-expanded` + `aria-controls`.

### Live financial data
- The container is `aria-live="polite"`, not each cell.
- **Throttle announcements.** A live rate updating at 2Hz would announce 7,200 times an hour. Announce at most every 10 seconds, or on a meaningful threshold crossing.
- Offer a way to turn live announcements off entirely. Some users need it silent.
- The [value flash](../motion/03-catalog.md#value-flash) is a *visual* affordance; the live region is its non-visual peer. Both, always.

---

## 7. Contrast & states

Full table in [color](../foundations/03-color.md#contrast-requirements). The rules that get missed:

- **Money is 7:1** — our own stricter rule.
- Hover-only interactions have a keyboard equivalent. Row actions revealed on hover must be reachable by focusing the row.
- **Disabled state never relies on color alone.** Add `aria-disabled` and, where the reason isn't obvious, text.
- Focus outlines are never removed without a visible replacement.
- **Sign is never conveyed by color alone.** The greyscale test is the ship gate.

### Forced colors mode
Windows High Contrast overrides your palette entirely. Consequences:

- `border` survives; `box-shadow` does not. Since our surfaces are border-first, we're mostly fine — but any element relying only on a shadow for its edge disappears.
- Use `forced-colors: active` to restore what's lost:
  ```css
  @media (forced-colors: active) {
    .card    { border: 1px solid CanvasText; }
    .flash   { forced-color-adjust: none; }  /* keep the tick legible */
    .focused { outline: 2px solid Highlight; }
  }
  ```
- Semantic color is gone in forced colors — which is exactly why sign is never color-only.

---

## 8. Media & motion

- Images have correct `alt`: meaningful when informative, `alt=""` when decorative. Isometric empty-state illustrations are decorative — `alt=""` — because the adjacent text carries the message.
- `prefers-reduced-motion` respected for all non-essential motion. See [motion principles](../motion/01-principles.md#reduced-motion).
- No autoplaying media with sound. No sound at all in this system.

---

## Zoom and reflow

WCAG 1.4.10: content must reflow at **320px equivalent width** (400% zoom on a 1280px viewport) without two-dimensional scrolling.

A 12-column financial table cannot literally reflow into one column and remain a table. The compliant approach:

- **Data tables are the sanctioned exception** to the no-2D-scroll rule — but the *page around them* must reflow, and the table's own horizontal scroll must be keyboard-operable with a visible focus indicator on the scroll container.
- Below 640px, the table becomes a **card list** (see [responsive](04-responsive-and-input.md#table-collapse)) — which is a genuine reflow, not a compromise.
- **Text scales to 200% without loss.** Which means no fixed-height containers around text, `rem` for type, and no `overflow: hidden` on a text container that could grow.

---

## Testing

**Automated (CI, blocking):**
- `axe-core` on every component story
- `eslint-plugin-jsx-a11y`
- Contrast assertions computed from the token file — a token change that breaks contrast fails the build

**Manual (per component, before `stable`):**
- Full keyboard pass with no mouse
- VoiceOver (Safari) + NVDA (Firefox) on every interactive component
- 200% zoom
- Forced colors mode
- `prefers-reduced-motion: reduce`
- **The greyscale test** — screenshot in greyscale; every sign must still be readable

Automated tools catch ~30% of issues. The manual list is the real gate.
