Announcing an outcome: one polite region, and focus reserved for repair
Status: decided and built, by a 3-panelist design panel deciding 2-1. The convention is realized in apps/web/src/components/useDeferredAnnouncement.ts and its callers, including the SFTP authoring form's host-key probe (apps/web/src/console/SftpAuthoringForm.tsx). The structural properties that probe's failure surface has to hold are specified in SFTP_CONNECTION.md; this note records why the mechanism is the one it is. See docs/notes/README.md.
An outcome surface in the console -- a probe result, a listing settle, a verdict -- has to reach an operator who is not looking at it. Two mechanisms had grown up side by side: a visually-hidden polite live region owned by the component holding the outcome, and, on the SFTP authoring form's host-key probe, a role="alert" on the visible alert plus a programmatic focus move onto it. Both are defensible in isolation; having both meant the same class of event announced by different means on different screens, and neither reading of the DOM told a reviewer which one a new surface should adopt. This note records the settlement.
The convention
- One channel announces. A single always-mounted, visually-hidden polite live region (
role="status" aria-live="polite" aria-atomic="true"), owned by the component that holds the outcome state, rendered in every phase -- empty before there is an outcome -- and placed ahead of the outcome surface's subtree. Its text is written in after mount, which is whatuseDeferredAnnouncementexists to guarantee. - A live region is always mounted ahead of its content. A conditionally-mounted element never has a live role. A region that appears together with its text is a freshly inserted node rather than a change to something being observed, and what an assistive technology does with that is the browser's special-casing rather than the region's own semantics.
- The region's text must transit a distinct value on every phase change -- the in-flight sentence, or
"". Setting a region to the string it already holds is not a change, so without that transit a second identical outcome is silent. - Programmatic focus is reserved for focus repair. It moves only when the settle destroys the operator's focus anchor -- the anchor unmounted, or the control was disabled for the duration and the browser dropped focus to
<body>-- and never as the announcer. A move is guarded ondocument.activeElementstill being the anchor ordocument.body, so an operator who moved on during a long-running settle keeps their place.
Why the region rather than the focus move
The focus move announces as a side effect: moving focus onto a container makes an assistive technology read that container. It is appealing here, because the probe's failure needs a keyboard user moved to the outcome anyway, and one mechanism doing both jobs is one mechanism to keep correct.
The majority's answer was that the two jobs fail differently. A focus move is a runtime action that can be blocked (a browser refusing focus to an element it does not consider focusable, a competing focus call arriving after it) or quietly unwired (a component library that stops forwarding its ref to the focusable root -- exactly what the previous role="alert" alert's move rested on). When it does not happen, the outcome is not announced at all and nothing else is holding the message. A stable pre-mounted region has no such single point: the text is in the DOM whether or not focus moved, and it is the mechanism the rest of the app's outcome surfaces already use, so conforming the remaining one is cheaper than diverging.
Splitting the jobs also makes the focus rule statable. Once the region announces, a focus move has exactly one remaining justification -- repair -- which is a condition a reader can check against the code, where "focus moves to announce" was a rule with no boundary and no answer for the operator who had moved on.
The dissent
One panelist argued the opposite: that the focus move alone should announce, on the grounds that it both announces and moves the keyboard user in one action, and that a second channel is a second thing to keep in sync with the copy on screen. The majority did not dispute the coupling cost; it answered that a blocked or unwired focus call then leaves the outcome unannounced with no fallback, which is the worse of the two failures.
The acknowledged risk
role="alert" on insertion is the form browsers and screen readers most reliably special-case -- it is the oldest and most widely implemented of the live-region behaviors. Swapping it for a polite region that is already mounted trades that special-casing for the general mechanism. No assistive technology is driven anywhere in this repository (SFTP_CONNECTION.md states this), so the swap is asserted against the DOM -- the region is present, mounted ahead of its content, and holds the console's sentence; the discarded mechanism is gone -- and not measured against any screen reader. Two things bound the risk. The focus-repair path is retained, so a keyboard user still lands on a presented result. And assertiveness is a property of the stable region: a maintainer who wants interruption sets aria-live="assertive" on it, without reintroducing a conditionally-mounted live role.
Applied to the host-key probe
What the convention comes to on the SFTP authoring form's probe surface:
- The probe's own polite region is mounted in every phase, first in the result's DOM order, and holds a fixed console sentence per phase. It interpolates nothing, which is what makes the containment property SFTP_CONNECTION.md specifies structural rather than an escaping argument: no byte a peer chose can be in the announced run because no value reaches it at all.
- The visible failure alert has no live role. An
Alertdefaults to a live role (role="status", set for every Alert by the app theme inapps/web/src/theme.ts), so the surface passesrole="presentation"to displace that default rather than merely not passing it -- an attribute the convention makes inert is removed, never left in place asserting a behavior nothing holds. What holds the property is the ABSENCE of an alert or live role rather than the presentational role applying: ARIA's presentational-role-conflict resolution ignoresrole="presentation"on an element with globalaria-*attributes, which Mantine sets on the alert root, so the element is exposed as generic. The prop stays required all the same -- drop it and the live-role default returns -- and the browser test measures that outcome (no alert or live role anywhere in the probe result) together with the assumption this reasoning rests on (the alert root does have those global attributes), rather than the presentational role itself. - The failure settle restores focus to the probe trigger. That is repair, not announcement: the trigger is disabled while the probe runs, so the browser has already dropped focus to
<body>. - The presented-result panel has no live semantics either, but keeps its focus move, because that move is repair in the strict sense -- the trigger the operator pressed unmounts when the panel replaces it. The panel is named from its own visible lead line, so what focus lands on is not an anonymous
div.
The region holds a summary sentence, not the diagnosis. A screen-reader user hears that the probe settled and that pasting remains available, and reads the alert for what answered the port. That is the price of a region that interpolates nothing, and it is paid by design -- the alternative would put console-derived text into the announced run and reopen the containment question the spec settles structurally.
outline: "none" on a focus target is by design
The containers that take programmatic focus (the presented-result panel, and the secrets picker's stage, which established the pattern) set tabIndex={-1} and style={{ outline: "none" }}. tabIndex={-1} makes an element focusable by script only -- it is not in the tab order and not keyboard operable -- so WCAG 2.4.7 Focus Visible, which is about the keyboard focus indicator on the interactive controls a keyboard user tabs through, does not apply to it. Suppressing the outline is what keeps a scripted move from painting a focus ring around a whole panel that the user never navigated to; every control inside the panel keeps its own indicator.
The polite regions in the web app
A region mounts ahead of its content and takes its text after mount, through useDeferredAnnouncement where the content can already be there in the owner's own mount commit. These are the sites that had grown up around the other shape -- a live role on an element that appeared together with what it said -- and what each settled on; a test on each surface holds the choice.
| Site | Choice |
|---|---|
console/SftpCredentialWarnings.tsx | Mount ahead. The region renders whether or not the connection drew warnings and announces the alert's title alone; the visible Alert takes role="presentation", so the warnings themselves are read in reading order rather than announced twice. |
exchange/RunSurface.tsx, RunWarningsAlert | Mount ahead. The region stands through the whole run and announces the count ("The exchange reported 3 warnings"), which is what makes each further arrival a distinct value; the visible Alert takes role="presentation". |
exchange/RunSurface.tsx, the re-attachment notices | Mount ahead, in ReattachNotice: the region is rendered beside the two notices in every phase of a console run column, and each notice's own live role is gone. It announces the notice's lead line; the body stays in reading order. |
exchange/AcceptorScreen.tsx, "Reading your invitation..." | Role dropped. The decode runs once on mount, so the sentence is initial page content that no later change reaches, and the settle moves focus to the terms, the block, or the error alert. A live role there asserted an announcement nothing performs. |
components/AppShellStatus.tsx | Mount ahead. The region lives as long as the shell rather than being rebuilt on each online or update-ready flip, and holds the strip's title; each Alert takes role="presentation". |
An Alert's own live role is outside this: the app theme gives every Alert role="status", and an error Alert sets role="alert" itself. The convention displaces that role where a polite region takes the announcement over (above), and leaves the alerts that announce on their own -- for an error, the mechanism the acknowledged risk above names as the most reliably special-cased.
Considered and left alone
- The peer-bytes excerpt's control-like chrome. The field the peer's own first bytes render in is a read-only
Textarea, which looks like something to type into. That is a separate question about how the excerpt presents, not about how the outcome announces, and the properties that attribute those bytes are specified and checked independently. - A lint or check enforcing the convention -- for instance, one rejecting a live role on a conditionally-mounted element. Enforcement of a convention this new should be scoped on its own rather than built alongside the first surface that conforms to it.