IInbox

Accessibility

The two contracts — colour, which is tested, and interaction, which is centralised.

Two things in this system are easy to get wrong and impossible to see: what colour a token resolves to in the other mode, and where focus goes. Both now have a single place where they are decided.

Colour is tested

Every threshold is enforced by npm test, in both modes, before CI builds anything. The full table lives in Tokens.

Writing those rules down found seven defects that had already shipped and been reviewed by eye — text/error at 4.44:1 on its own tint, dark bg/brand-hover putting white text at 3.9:1, dark text/placeholder at 2.86:1 on a card.

Two of the rules are not accessibility rules at all. They exist because the two worst colour bugs this system shipped were invisibility, not contrast:

  • Adjacent layers are never equal. An active tab whose pill and track were both bg/raised had 1.00:1 against what it sat on. It was simply not there.
  • A tint is ≤ 2:1 against the canvas. Dark tints once aliased saturated 900 primitives, so every recommendation block was a solid slab.

Focus is centralised

aria-modal="true" is a promise that the rest of the page is inert. Nothing in the DOM enforces it. Without a Tab handler the third press walks focus out through the scrim and into the page behind, where the user is typing into a form they cannot see.

Modal, Drawer and the command palette all made that promise and none of them kept it. They now share one hook — useDialog — which owns four things:

Focus inlands on the first control, not the container, so the dialog announces something
Focus trappedTab and Shift+Tab cycle inside the panel; focus that escapes is pulled back
Escape closesevery dismissable surface, always
Focus restoredback to whatever opened it

It also locks the page behind, holding the scrollbar's width as padding — the Drawer was locking the body without that, so opening one shifted the whole page sideways.

const panel = useDialog<HTMLDivElement>({ open, onClose });

return (
  <Scrim onClick={onClose}>
    <div ref={panel} role="dialog" aria-modal="true" aria-labelledby={id} tabIndex={-1}>

    </div>
  </Scrim>
);

Anything new that covers the page uses this hook. Writing the four behaviours by hand is how three surfaces ended up with three different subsets of them.

lockScroll: false is available for a drawer that deliberately sits beside a still-scrollable page.

Naming the thing that opened

A role="dialog" with no accessible name is announced as "dialog" and nothing else. Modal derives its name from its title via aria-labelledby; Drawer and the palette use aria-label; Popover takes an aria-label prop and defaults to something rather than nothing.

State belongs on the control, not its wrapper

Popover used to put aria-expanded and aria-controls on the wrapper div that caught the click, while the render prop returned the actual <Button>. Mouse and keyboard both worked — the click bubbled — but a screen reader landed on the button and heard an ordinary button, because the state was on an element it never visited.

The wrapper still catches the click; the ARIA is now cloned onto the returned element. If you build a trigger-plus-panel component, put the state on whatever the user actually focuses.

Focus is tested too

tests/dom/dialog.test.tsx asserts the contract against the real Modal and Drawer rather than against the hook in isolation — the hook was never the problem, three hand-written subsets of it were.

Focus lands on the first control, not the panelModal
Tab wraps last → firstModal, Drawer
Shift+Tab wraps first → lastModal
Focus that escapes is pulled backModal
Escape closesModal
Focus returns to the openerModal
Page locked while open, released afterModal
Scrollbar compensated, so the page does not jumpDrawer
A panel with nothing focusable holds focususeDialog
lockScroll: false leaves the page scrollableuseDialog

These were checked against the pre-fix implementation before being kept: with the trap removed, the first-control focus reverted and the scrollbar padding dropped, six of the eleven fail. A test that passes against the broken code is not a test.

Tab is dispatched as a keydown on document, which is exactly what the hook listens for. happy-dom has no native tab order, so driving it through user-event would be testing the simulation rather than the trap.

The colour contract stays on node's built-in runner — it reads JSON and does arithmetic, so it needs no transform, no DOM and no dependencies. npm test runs both.

What is not done

  • Tooltip opens on hover and focus but its content is not reachable by keyboard, which is correct for a tooltip and wrong if anyone puts a link in one. Use Popover for interactive content.
  • Reduced-motion preferences are not yet honoured by the transition utilities.