← All posts

The keyboard hint that renamed every button

A decorative <kbd> showing a shortcut letter sat inside every decision button in the dashboard. Nothing marked it decorative, so it joined the accessible name: the Snooze button was called "Snooze s". A screen reader announced that, and a browser test clicking by exact name never matched — which is how a missing Snooze verb hid behind a failing test for a day. Release notes 2026-09-16 — vocion-core#367.

Vocion Teamvocion-v2.88.0

Status: shipped. Two changes, one story. The accessibility fix is commit 04796795, carried by v2.82.2; the restored verb is #367, commit fa58205b, cut as v2.82.4. No migrations, no flags. Part of Vocion 2.88.

Every decision surface in the dashboard shares one sticky action bar, and every button on it renders three things: an icon, a label, and — above the small breakpoint — a <kbd> showing the keyboard shortcut.

<button …>
  <Icon aria-hidden />
  <span>{a.label}</span>
  {a.shortcut && <kbd className="…">{a.shortcut}</kbd>}
</button>

The icon is marked aria-hidden, because of course it is; an icon that duplicates the label beside it is decoration. The <kbd> was not.

What the browser computes

The accessible name of a button with no aria-label and no aria-labelledby is built from its subtree: every descendant's text content, in document order, joined. Nodes hidden from the accessibility tree are skipped — which is exactly what aria-hidden on the icon is doing.

So the button's name was not "Snooze". It was:

"Snooze s"

And "Approve a". And "Reject d". Every decision verb in the product, on every surface that uses the shared bar, named after itself plus a letter.

That is what a screen reader announced. It is also, on a page where the shortcut row is hidden below sm, a name that changes with the viewport — which is its own small horror.

The same bug broke the test that would have caught it

The fix is one attribute:

// Decorative, like the icon above it: the letter is a hint at the
// keyboard shortcut, not part of what the button is called. Without
// this it joins the accessible name ("Snooze s"), which is what a
// screen reader announces and what `getByRole('button', {name})`
// matches on.
<kbd aria-hidden className="…">{a.shortcut}</kbd>

Not an aria-label on the button, and not moving the <kbd> outside it. The element is genuinely decorative and the correct thing to say is that it is decorative; an aria-label would have papered over the same problem while leaving the next <kbd> free to do it again.

The second half is the part that makes this a story rather than a one-line fix. The browser test suite had a line that looked like this:

await focus.getByRole('button', { name: 'Snooze', exact: true }).click();
await focus.getByRole('button', { name: 'Tomorrow' }).click();

That line is correct, it was never changed, and it could not pass. An exact-name query for Snooze cannot match a button whose computed name is Snooze s. The query was doing precisely its job — asserting the thing a screen-reader user would hear — and the failure it reported was real.

This is the argument for role-and-name queries over test ids, made at our own expense. A test id would have clicked that button happily for as long as the bug existed, and nobody would ever have known the button had the wrong name.

And then the verb was gone

Fixing the name did not make the test pass, because by then there was no Snooze button for it to find.

The review focus view has two branches. A proposal with a structured card renders an action card that owns its own verbs, including snooze. Anything else renders a generic branch with the shared sticky bar underneath — and that branch's secondary verbs were Decline, Save, Skip. No Snooze. The keydown handler had no case for it either, so the s shortcut, which the bar was still advertising in that very <kbd>, did nothing at all.

The view's own comment names the regression precisely: the bar dropped it when the sticky bar replaced the action row, the day before. A verb had been lost in a layout change, in the branch nobody had looked at, and the one test that would have shouted about it was already failing for a different reason.

Two bugs, stacked, each hiding the other. The failing test was assumed to be about the accessible name — which it was — and once that was fixed it kept failing, which is when the missing verb surfaced.

It is back, with the same three horizons the card offers — Tomorrow, 3 days, Next week — rendered into a slot the sticky bar already had, a keydown branch that toggles them, and a confirmation saying when the item comes back.

The rest of the sweep, honestly measured

The same pass took CI on main from red to green, and two of its items are worth recording because the interesting part in each is what turned out not to be true.

Five raw <a> tags, reported 33 times each. Three files still linked with plain anchors where the framework wants its own link component, and the rule reports once per matching route pattern rather than once per tag. The route patterns are derived by replacing bracket segments with a wildcard, and the replacement is greedy — so every route with two bracket segments collapses to the same catch-all pattern, and each surviving copy reports independently. Five tags × 33 patterns = 165 errors from five lines of JSX. The count was reproduced against the tree as it stood; it is 35 each today, because two more bracketed routes have landed since.

The correction: this was not what made CI red. The rule only finds the app directory when the linter runs from inside the package; run from the repository root, as CI does, it cannot locate the routes and reports nothing at all. The anchors were genuine hygiene, found by running the linter where it can see, and fixing them was right. Claiming they were the red build would not be.

Playwright artifacts that were gitignored but not lint-ignored. Traces and HTML reports are written beside the tests, and they are ignored by git, so nobody notices them — until a local lint run after a browser suite walks into a directory of generated JSON. The fix is two globs in the lint configuration, and the framing is the useful part: a .gitignore entry and a lint ignore are two different statements, and a generated directory needs both. The "about 1,500 errors" in the commit message is a local observation that cannot be reproduced from the repository, because the directories it counted are generated and were never committed. The asymmetry is real; the number is the author's, and it is quoted here as theirs.

A ref written during render. The conversation rail kept a ref mirroring its collapsed state so a once-bound DOM listener could read it, and wrote it in the component body. It moved into an effect — the same shape the neighbouring ref already used — with the reasoning that the only reader is a DOM event handler, which cannot fire before the commit that sets it.

What is still not green

The browser suite passes serially. Under parallel workers it still fails, for a pre-existing and unrelated reason: two specs assert organisation-wide approval counts while a sibling spec approves an action run, so they race each other. That is named in the commit rather than fixed, because the fix is a decision about test isolation rather than a patch.

Where this lives in the repo