Skip to content

1.4.3 — concierge a11y: the input's name, and a live region that exists before it speaks - #18

Merged
DelanoJoey merged 1 commit into
mainfrom
fix/concierge-a11y-names
Sep 20, 2026
Merged

DelanoJoey merged 1 commit into
mainfrom
fix/concierge-a11y-names

Conversation

@DelanoJoey

Copy link
Copy Markdown
Collaborator

The two a11y gaps tracked in gridiron's 9/20 handoff, both on a surface about to go public on every page of scrimmage.football.

1. The input had a name nobody could see

aria-label="Chat with concierge" — hardcoded, no prop to change it. The only visible label this control has is its placeholder, so WCAG 2.5.3 (Label in Name) requires the accessible name to contain it. On gridiron the placeholder reads "Ask how it works" — not one word in common with the name the machine reported.

before after
sees "Ask how it works" "Ask how it works"
hears / says "Chat with concierge" "Ask how it works"

The name now defaults to placeholder. New inputAriaLabel prop for hosts who want to say more; whatever they pass should still contain the placeholder text. A host rendering no placeholder gets the old literal back rather than an unnamed input — no name is worse than a generic one.

Fleet impact: every consumer improves, none needs a change. All are on ^1.2.x and none passes the new prop. marque ships "Ask The Marque Editors anything about luxury cars…", adellion "Ask about gear, codes or guides…", endsights "Ask a question…"; subdial and greece-blog take the default. Each of those is a better accessible name than the literal it replaces.

2. The band was not a live region — and aria-live alone would not have fixed it

role="note" is not a live region, so a disclaimer appearing mid-session was never announced. The sseDisclaimer path is exactly that case: it arrives on the done event of the first answer.

Adding aria-live to a conditionally-mounted element does not fix this. A live region has to be in the document before its content — a region inserted together with its text is a new node, not a change to an observed one, and screen readers routinely say nothing. So the band is now always mounted and only its content is conditional.

Empty, it takes position: absolute, which is load-bearing twice over:

  • it keeps the element in the accessibility tree, where display: none would not — an unrendered live region announces nothing, ever;
  • it takes the element out of flex layout, so the panel's gap: 12px does not reserve a slot. A merely zero-height child would still take its gap and push the thread down 12px.

Verification

55/55 tests, typecheck clean. The new guards were mutation-tested rather than trusted:

mutation tests that failed
restore the hardcoded aria-label 5
drop aria-live from the band 2
re-wrap the band in a conditional 4

The last of those includes the test that pins node identity across the text arriving — the property the whole fix rests on, and the one a presence-only assertion would have missed.

Notes

  • Version 1.4.3. Merging does not publish — a v* tag does.
  • PR perf(images): 1.5.0 — thumbnails stop downloading the original #17 is open at 1.5.0; whichever lands second takes a one-line package.json rebase.
  • beforeEach in the new block stubs scrollIntoView (jsdom ships none, and the panel scrolls to the newest message on mount). Scoped to the block rather than added to the shared harness.

🤖 Generated with Claude Code

…d exists before it speaks

Two a11y defects on the concierge bar, both on a surface that is about to
go public on every page of scrimmage.football.

THE INPUT HAD A NAME NOBODY COULD SEE

aria-label was the literal "Chat with concierge", hardcoded, with no prop to
change it. The only visible label this control has is its placeholder, so
WCAG 2.5.3 (Label in Name) requires the accessible name to contain it. On
gridiron the placeholder reads "Ask how it works" — not one word in common
with the name the machine reported. A speech-input user saying what they can
see addressed nothing; a screen-reader user never heard the host's wording
at all.

The name now defaults to `placeholder`, and `inputAriaLabel` is there for
hosts who want to say more. A host rendering no placeholder gets the old
literal back rather than an unnamed input — no name is worse than a generic
one. Every fleet consumer improves by default: marque, adellion and endsights
all ship placeholders more specific than "Chat with concierge", and none of
them passes the new prop.

THE BAND WAS NOT A LIVE REGION, AND aria-live ALONE WOULD NOT HAVE FIXED IT

role="note" is not a live region, so a disclaimer appearing mid-session was
never announced. The `sseDisclaimer` path is exactly that case: it arrives on
the done event of the first answer.

Adding aria-live to a conditionally-mounted element does not fix this. A live
region has to be in the document BEFORE its content — a region inserted
together with its text is a new node, not a change to an observed one, and
screen readers routinely say nothing. So the band is always mounted and only
its CONTENT is conditional.

Empty, it takes `position: absolute`, which is load-bearing twice: it keeps
the element in the accessibility tree, where `display: none` would not, and
it takes the element out of flex layout so the panel's `gap: 12px` does not
reserve a slot. A merely zero-height child would still take its gap and push
the thread down 12px.

VERIFICATION

55/55 tests, typecheck clean. The new guards were mutation-tested rather than
trusted: restoring the hardcoded aria-label fails 5, dropping aria-live fails
2, and re-wrapping the band in a conditional fails 4 — including the one that
pins NODE IDENTITY across the text arriving, which is the property the whole
fix rests on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DelanoJoey
DelanoJoey merged commit ce1f84f into main Sep 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant