Repository navigation
Description
The gateway header has two controls, the theme toggle and the language toggle, and neither has an accessible name. Each is a <button> whose only content is a lucide icon rendered with aria-hidden="true", and neither carries an aria-label. A screen reader therefore announces both as "button", and a patient who relies on one cannot tell which control switches the page language. For a patient-facing page that is often opened in a language the reader did not choose (the first active language, unless the link carries ?lang=), the language switcher is the control they most need to find. Both components come from shared code, so apps/web (Navbar, Sidebar, login, setup, reset-password) and apps/playground render the same unnamed buttons. #1701 covers other icon-only buttons but not these two.
Where
apps/gateway/src/Root.tsx:110-113:
<div className="flex gap-3">
<ThemeToggle className="h-9 w-9" />
<LanguageToggle activeLanguages={props.activeLanguages} triggerClassName="h-9 w-9" />
</div>The server-rendered markup of / (from render in src/entry-server.tsx) contains, for these two:
<button class="... h-9 w-9"><svg ... class="lucide lucide-sun ..." aria-hidden="true">...</svg><svg ... class="lucide lucide-moon ..." aria-hidden="true">...</svg></button>
<button class="... h-9 w-9" type="button" id="radix-«R57»" aria-haspopup="menu" aria-expanded="false" data-state="closed"><svg ... class="lucide lucide-languages" aria-hidden="true">...</svg></button>libui's ThemeToggle spreads its remaining props onto the Button, so it accepts an aria-label. libui's LanguageToggle (wrapped by packages/react-core/src/components/LanguageToggle/LanguageToggle.tsx) builds its trigger <Button> from a fixed prop list, so a caller has no way to name it today.
Reproduce
- Open
/or any assignment link on the gateway with VoiceOver or NVDA running. - Tab to the two buttons at the top right.
Actual: both are announced as "button" with no name. The language button adds "menu pop-up" but still has no name.
Expected: they are announced in the page language as, for example, "Toggle dark mode" and "Change language".
Tests
apps/gateway/src/__tests__/Root.test.tsx:it('should name the theme and language controls, so screen-reader users can find the language switch'), assertingscreen.getByRole('button', { name: ... })for each.packages/react-core/src/components/LanguageToggle/__tests__/LanguageToggle.test.tsx:it('should give the trigger an accessible name in the current language').- e2e,
testing/src/specs/gateway-landing.spec.ts: locate both header controls withgetByRole('button', { name })instead of by test id.
Suggested fix
Pass a translated aria-label to ThemeToggle in Root.tsx. For the language toggle, give react-core's LanguageToggle a default translated trigger label. That needs libui's LanguageToggle to forward an aria-label (or extra trigger props) to its Button, which is a small change in @douglasneuroinformatics/libui. Alternatively, react-core can render its own DropdownMenu trigger. Apply the same labels at the apps/web and apps/playground call sites.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Bumping libui does not fix it. Both toggles are unchanged in 6.17.0 and 6.18.0, the latest release, and libui'smainhas not touched either file since January.Call sites
Each toggle is rendered in nine places:
Root.tsx:111-112Navbar.tsx:129-130,Sidebar.tsx:170-177,routes/auth/login.tsx:107-113and:145-151,routes/auth/reset-password.tsx:158-164,routes/setup.tsx:168-174components/Header/Header.tsx:51-61,pages/IndexPage.tsx:89-97. Both importLanguageTogglestraight from@douglasneuroinformatics/libui/components, so a default label in react-core's wrapper does not reach them. Playground icon-only buttons have no accessible names, and editor tabs cannot be closed from the keyboard #1763 leaves these two to this issue.Where the label goes
The suggested fix passes the theme label at nine call sites across three workspaces, and the next call site can forget it. The default belongs in the component.
Recommended: fix it in libui. Give
ThemeToggleandLanguageTogglea defaultaria-labelfrom a new key in libui'ssrc/i18n/translations/libui.json, where every key already hasen,esandfr. LetLanguageToggletake an override for its trigger, eitheraria-labelor atriggerPropsobject. libui's own namespace resolves in the gateway and the playground even though both calli18n.init({ translations: {} })(seepackages/react-core/AGENTS.md). react-core's wrapper derives its props from libui's and spreads them (LanguageToggle.tsx:7,:25), so a new trigger prop passes through unchanged. For wording, reuse the outreach site's existing toggle labels (apps/outreach/src/i18n/translations/common.json:6-13) and add Spanish.Then raise the catalog floor to that release in a commit of its own.
@douglasneuroinformatics/*is inminimumReleaseAgeExclude, so the release can be installed the day it ships. The bump also crosses 6.18.0, which dropsforwardReffrom libui's components (Buttonnow takesrefas a prop).If a libui release is not wanted now, do it in react-core instead. Add a react-core
ThemeTogglethat wraps libui's with an inlinet({ en, es, fr })label. Have react-core'sLanguageTogglerender its ownDropdownMenutrigger, the wayInteractiveContent.tsx:149-154does. Then move the playground onto both. WithactiveLanguages={['en', 'fr']}it gets the sameEnglish/Françaisoptions it hard-codes now. No new dependency is needed.Tests
In addition to the two unit tests in the issue:
apps/gateway/src/__tests__/entry-server.test.ts:render({ activeLanguages: ['en', 'fr'], kind: 'landing', language: 'fr' })should contain both French labels, alongside the existingBienvenuecase. React does not guarantee that hydration patches a mismatched attribute, so the server HTML has to carry the right label.gateway-landing.spec.ts:testing/AGENTS.mduses roles only where nodata-testidexists. So keepgetByTestId('language-toggle')and asserttoHaveAccessibleName(...)on its button, once in English and again after the existing test picksFrançais.ThemeTogglehas no test id, sopage.getByRole('banner').getByRole('button', { name: ... })is fine for it.Related
InteractiveContentlanguage trigger needs the same name. Under the libui option it can use the newlibui.*key once the bump lands. Under the react-core option, define the string once in react-core.SearchBarfix in Pressing Enter in the bulk-assignment or delete-assignment table search reloads the app and signs the user out #1679 are also libui changes. One libui release and one catalog bump can carry all three.Navbar.tsx,Sidebar.tsxandlogin.tsx. The react-core option changes theThemeToggleimport in all three, so land it after fix: resolve 26 low-difficulty issues, one commit each #1825. The libui option touches none of them.Triage note written with Claude Code. Code references point at
main@d86740e.