1.4.3 — concierge a11y: the input's name, and a live region that exists before it speaks - #18
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.The name now defaults to
placeholder. NewinputAriaLabelprop 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.xand 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-livealone would not have fixed itrole="note"is not a live region, so a disclaimer appearing mid-session was never announced. ThesseDisclaimerpath is exactly that case: it arrives on the done event of the first answer.Adding
aria-liveto 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:display: nonewould not — an unrendered live region announces nothing, ever;gap: 12pxdoes 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:
aria-labelaria-livefrom the bandThe 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
1.4.3. Merging does not publish — av*tag does.1.5.0; whichever lands second takes a one-linepackage.jsonrebase.beforeEachin the new block stubsscrollIntoView(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