# ADR: Keyboard and screen reader

Status: accepted, 2026-09-03, shipped as 1.0.0.36. The README listed this as
open work: "focus management on view switches (the detail page should receive
keyboard focus), plus a fuller accessibility audit."

## In plain words

This page makes the site work for people using a keyboard or a screen reader instead of a mouse. A hidden link on the first Tab jumps straight past the sidebar (a skip link). Focus now moves to each new view as it opens, and a hidden line tells a screen reader what changed (a live region).

What that is worth: a developer has browser tests that fail if a change breaks the keyboard path, and the organization's site can be used end to end by a visitor who never touches a mouse.

## Context

The app was already better than its starting point in the parts a component
library gives you for free. The palette clears WCAG AA on both grounds and a
test asserts every pair (ADR: The palette). The sidebar's drawer is a native
`<dialog>`, so focus trapping, the backdrop, and Escape are the browser's job
rather than a hand-rolled approximation. Icons are `aria-hidden`, icon-only
buttons carry labels, and the document rows mark the open one.

Three things were still wrong, and all three are the same kind of wrong: they
are invisible to a mouse.

**Nothing moves focus when the view changes.** Clicking a tile replaces the
grid with the detail page. The tile that had focus no longer exists, so focus
falls to `<body>`: the next Tab starts over at the top of the document, and a
screen reader announces nothing, because from its point of view nothing
happened. Back to the list, and into the Admin tab, behave the same way.

**The rail is thirty buttons deep.** Every document, every record, the Admin
link. A keyboard user reaches the vehicle grid by tabbing past all of it, and
does it again after every view change, because of the paragraph above.

**Filtering is silent.** Narrowing a hundred thousand vehicles to eleven
rerenders the grid and says nothing. A sighted user reads the count in the
filter bar; nobody else is told it changed.

## Decision

**A skip link, first in the tab order.** Visible only while focused, which is
the whole convention: a mouse user never sees it, a keyboard user finds it
first. It is positioned off-screen rather than `display: none`, because a
`display: none` element cannot take focus and the link would do nothing.

**Focus follows the view.** `<main>` carries `tabIndex={-1}`, which makes it
focusable by script and never by Tab, and an effect moves focus there when the
view identity changes: list, a specific vehicle, or the Admin tab. It fires on
change only, never on first paint, because the browser's own initial focus is
correct. `preventScroll` is passed because the scroll-restore effect already
decides where the page sits, and the two otherwise fight.

**A polite live region says what changed.** One visually hidden `role="status"`
paragraph, whose text is derived from the same state the view is: the vehicle's
name on the detail page, the match count on the list, the loading and error
states in between. Polite by definition, so it waits for a pause rather than
interrupting.

## In the code

The focus effect, keyed on view identity rather than on any one piece of state
(`src/app/hooks/useNavigation.ts` and `src/app/Shell.tsx`):

```ts
// A single-page app swaps the view without changing the document, so the
// browser has nothing to move focus to. Open a vehicle from a tile and the
// tile is gone: focus falls to <body>, the next Tab starts from the top of
// the page, and a screen reader announces nothing at all. Moving focus to
// the region that just changed is the standard repair, and it is why <main>
// carries tabIndex={-1}: focusable by script, never by Tab. It is found by
// its id (MAIN_ID, which Shell.tsx gives it), the same id the skip link names.
const viewKey = adminOpen
  ? 'admin'
  : accountOpen
    ? 'account'
    : selectedVehicle
      ? `vehicle:${selectedVehicle.id}`
      : 'list';
const lastView = useRef(viewKey);
useEffect(() => {
  if (lastView.current === viewKey) return;
  const arrivedByItself = address.deepLinkPending.current;
  lastView.current = viewKey;
  // A deep-linked vehicle resolving is data arriving, not a view the visitor
  // chose. Moving focus for it yanks a screen reader's cursor mid-sentence
  // seconds into the page, and pulls a keyboard user off the skip link.
  if (arrivedByItself) return;
  // preventScroll because the effect below already decides where the page
  // sits; without it the two fight and the detail opens part-scrolled.
  document.getElementById(MAIN_ID)?.focus({ preventScroll: true });
}, [viewKey, address.deepLinkPending]);
```
*Live from [`src/app/hooks/useNavigation.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/app/hooks/useNavigation.ts#L44-L70), region `focus`, read from this build at 1d6b50b.*

The announcement, derived rather than pushed, so it cannot go stale:

```ts
// Focus says where you are; this says what you arrived at. Moving focus to a
// container is announced inconsistently across screen readers, so the view's
// name is stated outright. The match count is deliberately not repeated here:
// the filter bar's own status line already owns that sentence, and two live
// regions saying the same thing is worse than one saying it once.
const announcement = adminOpen
  ? 'Admin panel'
  : accountOpen
    ? 'Account'
    : selectedVehicle
      ? `${selectedVehicle.year} ${selectedVehicle.make} ${selectedVehicle.model}, vehicle detail`
      : !inventoryOpen
        ? 'The Yard, home'
        : loadState === 'loading'
          ? 'Loading inventory'
          : loadState === 'error'
            ? 'The inventory API could not be reached'
            : 'Vehicle inventory';
```
*Live from [`src/app/hooks/useNavigation.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/app/hooks/useNavigation.ts#L244-L261), region `announcement`, read from this build at 1d6b50b.*

The link itself, and the element it targets:

```tsx
{/* First in the tab order on purpose. The docked rail is around thirty
    buttons, and without this a keyboard user tabs every one of them to
    reach the grid, on every page view. Hidden until it has focus. */}
<a className={styles.skipLink} href={`#${MAIN_ID}`}>
  Skip to content
</a>
```
*Live from [`src/app/Shell.tsx`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/app/Shell.tsx#L58-L63), region `skip-link`, read from this build at 1d6b50b.*

The two rules that make a hidden thing audible and a focused thing visible
(`src/app/App.module.css`):

```css
/* The skip link is present in the tab order at all times and visible only
   while it holds focus, which is the whole convention: a sighted mouse user
   never sees it, a keyboard user finds it first. It is positioned rather than
   hidden with display:none, because a display:none element cannot be focused
   and the link would do nothing. */
.skipLink {
  position: absolute;
  left: var(--space-3);
  top: -3rem;
  z-index: var(--layer-skip);
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-md);
  background: var(--color-accent);
  color: var(--color-on-accent);
  font-weight: var(--weight-semibold);
  text-decoration: none;
  box-shadow: var(--shadow-md);
  transition: top var(--transition-fast);
}

.skipLink:focus-visible {
  top: var(--space-3);
  outline: var(--focus-ring-on-dark);
  outline-offset: var(--focus-ring-offset);
}

/* Visually hidden, still announced. Not display:none, which removes it from
   the accessibility tree along with the layout. */
.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Script moves focus here on every view change, and a focus ring drawn around
   the whole page would be noise. The ring stays on the controls that earn it. */
.main:focus {
  outline: none;
}
```
*Live from [`src/app/App.module.css`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/app/App.module.css#L185-L229), region `a11y`, read from this build at 1d6b50b.*

## Consequences

- Tab from a cold load reaches "Skip to content" first, then the rail. The
  order is deliberate: the link is the escape hatch, so it comes before the
  thing it escapes.
- Opening a vehicle, going back, and opening Admin each move focus to the new
  view and announce it. The Playwright suite asserts all three, because focus
  is exactly the kind of behaviour that a refactor breaks silently.
- The live region adds a paragraph to the DOM that no sighted user will ever
  see. That is the cost of the feature and it is the right cost.
- Still open: a full audit with an actual screen reader, which is a person's
  job and not a checklist's. The keyboard path is now walkable end to end,
  which is the part a test can hold.

## Where it sits

Everything here is front end: the skip link and the live region live in src/app/Shell.tsx, and the focus effect and the announcement text live in src/app/hooks/useNavigation.ts. Single responsibility fits at the hook level: useNavigation owns both where focus goes and what a screen reader hears, and because the announcement is derived from view state there is no second copy to go stale. The price is a hidden paragraph in the DOM that no sighted visitor sees, plus browser tests to keep the path walkable. An audit with a real screen reader is still open, and what it finds could push this work into components the hook does not reach.

## Files

- [`src/app/Shell.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/app/Shell.tsx): the skip link and the live region.
- [`src/app/hooks/useNavigation.ts`](https://github.com/SteveStout/TheYard/blob/main/src/app/hooks/useNavigation.ts): the focus effect and what the live region says.
- [`src/app/App.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/app/App.module.css): the two rules that make them work.
- [`src/components/layout/SideNav/SideNav.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/layout/SideNav/SideNav.tsx): the drawer's native dialog, and `aria-current="page"` on the open document.
- [`tests/e2e/a11y.spec.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/a11y.spec.ts): the keyboard path, walked.
- [`docs/decisions/ADR-016-palette.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-016-palette.md): the contrast half of this, decided earlier and asserted in `src/styles/tokens.test.ts`.
