# ADR 0212: Keyboard layers are a headless React subpath

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0212-keyboard-layers-are-a-headless-react-subpath.md

---

# ADR 0212 — Keyboard layers are a headless React subpath

## Context

A consuming project with a keyboard-driven workspace — sections switched with
← and →, lists walked with the arrows, Escape closing one level at a time — had
built that behaviour itself, and measured what such code tends to become:

- one "highlighted" id shared by every list on the page, and highlight instead
  of focus: no roles, no `aria-activedescendant`, no scrolling to the item in
  most lists, nothing a screen reader could follow;
- Escape caught by two independent `document` listeners whose order was the
  order they mounted in, neither preventing the default of what it handled;
- seventeen more hand-written `keydown` listeners beside the system, each with
  its own guard for text fields.

A second project carried the same arrows-and-guard code in its feed list, and a
third its own grid navigation. The logic is small and the defects are the same
everywhere: the question "who gets this key" has no owner.

stitchkit does not own frontend routing or rendering (ADR 0060), and the terminal
UI moved out of core because it brings a renderer (ADR 0133). The question here
is neither: it is not where the user is, and it draws nothing.

## Decision

**Keys go to layers on one stack, asked in a fixed order.** One listener per
window, in the bubbling phase, asks the live layers `overlay` → `local` →
`route` → `zone` → `global` until one takes the key; a live overlay is a barrier
for everything below it. Within a kind the innermost layer wins — by element
containment when both are scoped, else by activation order — because React runs
a child's effects before its parent's and activation order alone would let a
page shadow the panel inside it. Being in the bubbling phase is what makes this
safe beside other components: a widget, a field or a popover hears the key first
and keeps it with `preventDefault` or `stopPropagation`. Keys typed in fields
pass layers by, except Escape; a held Escape closes one level.

**A list is a zone with real focus.** Roving `tabIndex`, arrows moving DOM focus,
`listbox`/`tablist` roles or the items' own semantics with `aria-current`, each
zone with its own pointed item. Modes `select` and `highlight`, the cross axis
as `onEnter`/`onExit`, the ends as `onBoundary` or a loop. A key the zone does
not take bubbles on to the layers.

**It is headless and owns no location.** Three hooks — `useKeyLayer`,
`useEscapeLayer`, `useNavZone` — and no component, no provider, no styles. A
layer calls the application's functions; nothing reads or writes the URL, so
ADR 0060's boundary holds: the router, sections and rendering stay in the
application.

**It is an isolated subpath of core, not a package (I7, I8).** `react` is already
an optional peer of core and `stitchkit/react` already a subpath; the entrypoint
registry, the optional-peer matrix on the packed artifact and the surface gates
already prove a leaf like this one. A separate package would add a release
target to every piece of the release machinery for the same result, and the
reason ADR 0133 moved the TUI out — a renderer in core — does not apply to
hooks that render nothing. No list library is taken on either: roving focus is
a few dozen lines, and a new peer for them would cost every consumer more than it
saves.

**The logic has no framework in it.** The stack and the zone are plain objects
driven by tests without a DOM; the hooks bind them to React and are tested in a
rendered DOM.

## Consequences

- Applications drop their own `keydown` listeners for layers and zones; a
  component library can register its dialogs as `overlay` layers so that Escape
  is right without the application doing anything.
- A screen gets accessibility it did not have — focus, roles, names — as a side
  effect of using the zone, not as separate work.
- Global shortcut registries, key-combination parsing and grid (two-axis)
  navigation are out of scope; a layer's `onKey` receives the event and decides.
- The entrypoint starts evolving (ADR 0103, ADR 0198).
