<!-- https://getkonjo.com/design/foundations/accessibility · source: docs/design/foundations/accessibility.md -->

# Accessibility

Everything past contrast: screen readers, text scaling, reduced motion, and focus.

> Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine
> apply here unless this chapter contradicts them.

Contrast is settled in [L1](../konjo-design-language.md) and computed for you in the
[token tables](../../../.claude/skills/design/references/tokens.md). This chapter is the rest
of it — the parts that are invisible when they work and humiliating when they don't.

The person this chapter is for is the same white belt in week two. They are not a separate
audience. Half of what follows is also just *quality*: text that reflows, a control that
announces what it does, an animation that stops when someone asked for animations to stop.

## Where Konjo actually stands

Measured, not assumed, so the gap is known rather than guessed at:

| | Coverage |
|---|---|
| `accessibilityLabel` | 843 uses across 250 files, against 980 pressables — broadly good |
| `accessibilityRole` | 805 uses across 258 files — broadly good |
| `accessibilityState` | 131 uses across 89 files — partial |
| `accessibilityHint` | **8 uses across 5 files** — effectively absent |
| Reduced motion | **5 of the 19 files that animate** honour it |
| Text scaling | **no file** sets `maxFontSizeMultiplier`; nothing has been tested at 200% |

Labelling is in decent shape. The three rows below it are the work.

## Screen readers

**Every pressable needs a label, and the label is what it *does*, not what it says.** A
chevron row labelled "chevron" is useless; the same row labelled "Ana Torres, brown belt"
is the whole screen for someone who cannot see it. (Not "…, open profile" — the `button`
role already said that, and repeating it is the noise the hint rule below warns about.)

- **`accessibilityRole`** on everything interactive: `button`, `link`, `header`, `image`,
  `switch`, `checkbox`, `radio`, `tab`, `search`. The role is what tells the screen reader
  how to *announce* the element and what gestures apply to it.
- **`accessibilityState`** for anything with a state: `{ selected }` on a chip or tab,
  `{ checked }` on a toggle, `{ disabled }`, `{ expanded }` on an accordion, and
  **`{ busy: true }` on a control in flight** — a button relabelled "Submitting…" with a
  spinner has changed state in two visual channels and none an assistive technology reads. A selected chip
  that does not say it is selected is indistinguishable from an unselected one.
- **`accessibilityHint`** only when the outcome is not obvious from the label. "Double tap to
  open" is noise — the role already said that. "Removes this student from tonight's roster"
  is worth the extra sentence. Under-using hints is a much smaller sin than over-using them,
  which is why 8 uses is closer to right than 800 would be.
- **Group a row into one stop.** A list row with an avatar, a name, a belt dot, a status glyph
  and a chevron is *one* thing, not five. Put `accessible={true}` on the row and one
  `accessibilityLabel` that reads the whole thing in the order a person would say it. The
  [tile grid](../patterns/tile-grid.md) depends on this: one glyph shows, but the label
  enumerates every status the tile carries, so nothing is visible-only.
- **Hide decoration.** A glyph that repeats the adjacent text, a divider, a spacer image:
  `accessibilityElementsHidden`, `importantForAccessibility="no-hide-descendants"`, or simply
  no label. Announcing it twice is worse than not announcing it.
- **Announce what changed silently.** A toast, a validation failure, an "8 students checked
  in" counter that updates without navigation — a sighted user sees it and a screen-reader
  user gets nothing. Use `AccessibilityInfo.announceForAccessibility()` for the one-shot, or
  `accessibilityLiveRegion="polite"` on the region that mutates. Currently 3 uses in the whole
  app; nearly every async success and failure is silent.
- **Never type capitals to get uppercase.** `textTransform: 'uppercase'` renders the same and
  reads correctly; a literal `"ADD"` is announced "A. D. D.". `design-check` enforces this as
  `literal-caps`.

## Text scaling

React Native scales text by default — `allowFontScaling` is `true` unless you say otherwise —
and iOS accessibility sizes reach roughly **310%**, which turns 15pt body text into ~46pt.
**No file in Konjo sets `maxFontSizeMultiplier`, and no screen has been checked at large
sizes.** So this section is a specification, not a description.

The rules:

- **Never turn scaling off.** `allowFontScaling={false}` is not a fix; it is opting a person
  out of the accommodation they asked the OS for.
- **Cap it where the layout genuinely cannot give**, and only there. `maxFontSizeMultiplier`
  belongs on chrome with a hard geometric constraint — a tab bar label, a badge count inside a
  fixed dot, a chip that must stay on one line. Body copy, titles, form labels, empty states
  and error messages take whatever the person set.
- **Suggested caps**, so this is not decided per-screen: tab labels `1.3`, badge counts `1.4`,
  chips and pills `1.6`. Everything else uncapped.
- **No fixed-height container around text, ever.** Height comes from the content. This is the
  single most common way scaling breaks a screen: a `height: 44` row clips its own label at
  150%. A **minimum** height is not a fixed height and is how you hit a target size or a
  density figure without breaking this law — `minHeight: 44` on a row, never `height: 44`.
  Studio's dense `46` table row is a minimum for exactly this reason; see
  [Studio metrics](../studio/metrics.md#density).
- **11 of 25 type tokens carry a `lineHeight`; 14 do not** — the generated tables name them. A fixed `lineHeight` scales with
  the font in RN, so it is safe — but an entry *without* one reflows unpredictably. Prefer a
  token that has one, and see the [type chapter](type.md) for which.
- **Test at three sizes, not one.** 100%, 135%, 200%. Two of those will find something.

**One exemption to the 11pt floor, stated so nobody "fixes" it:** `tabLabelActive` and
`tabLabelInactive` are 10pt. That matches the platform — iOS tab bar item titles are 10pt —
and the tab bar is chrome the OS itself sizes. It is the only place under 11pt, and it needs
the `1.3` cap above precisely because it starts small.

## Reduced motion

**14 of the 19 files that animate ignore the setting.** The hook is
`useReducedMotion()` from `react-native-reanimated`, already used correctly in `ListFadeIn`,
`ReflectPill`, `WizardProgressBar` and `WizardScaffold` — copy one of those.

- **Reduced motion means replace, not delete.** A slide becomes a cross-fade; a spring becomes
  an instant state change; a progress bar jumps to its value instead of counting up. The
  information still arrives. Removing the transition entirely often makes a screen *harder* to
  follow, not easier.
- **Anything that moves more than a few points, parallaxes, scales, or auto-plays is in
  scope.** A colour change is not.
- The one deliberate celebration — the [promotion certificate](../patterns/celebration.md) —
  is also the one animation someone is most likely to want, so it degrades to a static
  presentation of the same certificate rather than to nothing.
- Vestibular triggers are a real medical accommodation, not a preference. See
  [motion](motion.md) for what is allowed to move at all.

## Focus

RN-web is the surface CI actually walks, and it is the surface a keyboard reaches.

- **Focus is visible, always: 2px `border.focus`.** One of the three sanctioned uses of
  `borderWidth.emphasis` 2 — see [borders](borders.md). Without it the app fails SC 2.4.13
  outright, and forms become unusable by keyboard.
- **On a hero surface the ring flips to `border.focusOnHero`.** `border.focus` is near-black on
  light and near-white on dark, which is correct on a page or a card and catastrophic on
  `surface.hero`: **`border.focus on surface.hero` is 1.14:1** light and **1.00:1** dark — in
  dark it is the same hex as the surface. That covers every ink-filled control: Studio's
  navigation rail, a selected chip, a hero CTA, and any ring that lands *inside* an ink block
  rather than outside it. `border.focusOnHero on surface.hero` is 16.86:1 light and 14.72:1
  dark.
  **A ring with `outline-offset` ≥ 0 on a small ink control lands on the page behind it, and
  keeps `border.focus`.** The test is what the ring is drawn *on*, not what it surrounds.
  This pair is now in the generator's law table, so a palette that breaks it fails the build
  rather than being published with a ✗ nobody reads.
- Never remove the outline without replacing it with something at least as visible.
- **Focus order follows reading order.** If a visual reorder puts the primary action above the
  fields, the DOM order has to say so too.
- Opening a sheet moves focus into it; closing it returns focus to whatever opened it. A
  keyboard user who tabs into content behind a scrim is lost.

## Targets

- **44×44pt minimum** (SC 2.5.5), or `hitSlop` making up the difference. `touchTarget` is the
  token; a 24pt glyph in a 44pt target is the normal shape.
- Spacing between adjacent targets matters as much as their size — two 44pt buttons touching
  are one 88pt mistake.
- Studio is pointer input and drops to 32pt; see the [dialect](../studio/dialect.md).

## Before you claim a screen is done

- Every pressable has a role and a label that says what it does.
- Anything with state announces its state.
- Rows are one stop, not five, and decoration is hidden.
- Async success and failure are announced, not just drawn.
- Nothing is in a fixed-height box with text in it.
- It survives 200% text.
- Every animation checks `useReducedMotion()`.
- Focus is visible and lands in the right order.
- Contrast computed, both themes.


## The same contract on the web

Everything above is written as React Native props, because that is where most Konjo screens
live. **Konjo Studio is a web app**, and a completeness test on a Studio table found the system
contains exactly one ARIA attribute — which means the whole contract was being re-derived by
whoever built each screen. That is how a safety rule quietly stops being followed.

The rules are identical. Only the API changes:

| Mobile | Web |
|---|---|
| `accessibilityRole="button"` | a real `<button>`, or `role="button"` |
| `accessibilityLabel` | `aria-label`, or a visually-hidden label |
| `accessibilityHint` | `aria-describedby` pointing at the text |
| `accessibilityState={{ checked }}` | `aria-checked` |
| `accessibilityState={{ selected }}` | `aria-selected`, or `aria-pressed` on a toggle button |
| `accessibilityState={{ disabled }}` | `disabled`, or `aria-disabled` when it must stay focusable |
| `accessibilityState={{ expanded }}` | `aria-expanded` |
| `accessibilityState={{ busy }}` | `aria-busy` |
| `accessibilityElementsHidden` | `aria-hidden="true"` |
| `accessibilityLiveRegion="polite"` | `aria-live="polite"` |
| `AccessibilityInfo.announceForAccessibility()` | write into an `aria-live="polite"` region |
| `accessibilityRole="header"` | a real heading element at the right level |
| `testID` | **`data-testid`** — same kebab-case names, same rule: every interactive element and every screen root |

**Prefer the real element over the role.** A `<button>` is focusable, keyboard-activatable and
announced correctly with no attributes at all; `role="button"` on a `<div>` is three more
things to get right and one of them will be missed.

**One row of the table does not map, and inverts on the web.** *"Rows are one stop, not five"*
is a VoiceOver rotor idiom: on mobile a row is swiped to as a unit and its actions are rotor
actions. On the web, a row containing a link, a status and a second link is correctly **two**
tab stops — collapsing it into one hides the second action from every keyboard user. Group a
row on the web only when the whole row does exactly one thing.

**Cases the table above does not cover, and the web needs:**

- **A partially-selected select-all is `aria-checked="mixed"`.** The moment one row of a
  filtered page is unchecked, a plain checked/unchecked box is lying about the state.
- **A sortable column header carries `aria-sort` on the sorted column only** — `ascending`,
  `descending` — and the control inside it is a `<button>`, so Enter sorts.
- **A table is a real `<table>`** with `<thead>`, `<th scope="col">` and a `<caption>` that may
  be visually hidden. A grid of `<div>`s is unnavigable by screen reader, whatever ARIA is
  bolted to it.

### Composite widgets, and the drag criterion

The contract above is written for **controls on a page**. A board, a grid, a tree and a toolbar
are **widgets**, and two rules invert for them.

- **A composite widget is one tab stop**, not one per child — roving `tabindex`, arrows to move
  within it. This is the one sanctioned exception to "tab reaches every control": a 40-row grid
  with 40 tab stops recreates the problem a skip link exists to solve. It applies to a widget,
  never to a list of links.
- **SC 2.5.7 Dragging Movements (WCAG 2.2 AA): anything you can drag must also be doable with a
  single pointer, without a path.** Not a keyboard equivalent instead — as well. A "move to"
  control satisfies this, SC 2.1.1, and switch access in one control, and is usually a better
  interaction than the drag. See [direct manipulation](../patterns/direct-manipulation.md).
- **`assertive` is for the *outcome* a person is waiting on and cannot see** — a drop landing, a
  submit failing. **`polite` is for progress**, including a gesture's in-flight messages, which
  repeat several times a second and will sometimes be coalesced away.
  A gesture therefore needs **two** regions, not one: `polite` for "Contacted, 4" on every arrow
  press, `assertive` for "Ana Torres moved from New to Contacted." A single region cannot serve
  both — politeness is read when the region is created, not per message — and the terminal
  message is the one that must never be dropped. See
  [direct manipulation](../patterns/direct-manipulation.md).

### The page skeleton, which mobile does not have

Native apps have no landmarks and no bypass blocks; the web does, and Studio's shape makes both
mandatory rather than nice.

- **One `<main>`, one `<nav aria-label="…">`, and a skip link.** Studio's rail is ~25 links and
  precedes the content in DOM order on **every** screen, so without a bypass block a keyboard
  user tabs the entire navigation before reaching anything, on every page, forever (SC 2.4.1).
  The skip link is the first focusable element, visually hidden until focused, and it targets
  `<main>`'s id.
- **Headings are a real outline.** `<h1>` is the page title, once. `<h2>` is every panel or
  card title, however small the card looks. Never skip a level to reflect visual size — the
  heading list is how a screen-reader user gets the shape of the page, and Studio's pages are
  mostly panels.
- **`aria-current="page"` on the active nav item**, not just a colour and a fill.
- **A live region that is *present at load* is not `role="alert"`.** `role="alert"` interrupts,
  and it is for something that appeared **because of an action**. A standing banner that
  renders every morning with the page is a labelled `<section>`; announcing it as an alert on
  every page load trains people to dismiss the one time it matters.
- **One polite live region per page, not one per component.** Six cards finishing their loads
  should announce once, not six times.
- **Decorative repeats are `aria-hidden`.** A page eyebrow above an `<h1>` that says the same
  thing, and a rail item that is already `aria-current`, both announce twice otherwise.

### The armed destructive control, on the web

This is the passage that matters most, because it exists to stop a blind person firing a
destructive action they never confirmed — and it was written entirely as RN function calls.

On the web, arming must:

- **change the accessible name**, not only the visible label, so re-focusing reads the armed
  state — `aria-label="Really remove 3 students?"`;
- **carry `aria-pressed="true"`** on the control, which is the web's closest true statement:
  this control is currently engaged;
- **announce itself** by writing the consequence into an `aria-live="polite"` region — "Armed.
  Activate again to remove 3 students from the dojo";
- **announce the disarm** when a scroll, another click, Escape or navigation cancels it.

Never rely on colour to carry armed. It is a state change, and colour alone fails SC 1.4.1 on
every platform.
