Skip to content

♿ fix: Keep Names, Focus and Status Messages Right Across the Configuration Page and App Shell - #207

Merged
danny-avila merged 5 commits into
mainfrom
pr/fu-a11y-config-and-shell
Oct 29, 2026
Merged

danny-avila merged 5 commits into
mainfrom
pr/fu-a11y-config-and-shell

Conversation

@danny-avila

@danny-avila danny-avila commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Pull Request Template

⚠️ Before Submitting a PR, Please Review:

  • Please ensure that you have thoroughly read and understood the Contributing Docs before submitting your Pull Request.

⚠️ Documentation Updates Notice:

  • Kindly note that documentation updates are managed in this repository: librechat.ai

Summary

An accessibility review of the configuration page and the app shell (a live axe sweep plus a code read) found the problems below. This PR fixes each one in the panel. It doesn't disable any rule, exclude any element or loosen any tag.

On main, ten live axe scans in e2e/a11y/config.spec.ts fail because of them: mcp at 1440 and 390 px in both themes, features at 1440 px in both themes, and both change review dialog scans in both themes. With this PR they pass.

Label in name (WCAG 2.5.3)

axe 4.14's label-content-name-mismatch checks controls that have aria-label or aria-labelledby. The visible text, without parenthesized text and punctuation, must appear as one run of words in the accessible name.

  • MCP server card header. The header <button> was named Title (key) by an aria-label, but it also held the "New" badge, the source chips and the transport · summary line. axe flagged every card, including the librechat.yaml servers every stack has.
    • The button now holds only the title, a screen-reader-only "Name:", the key and the "New" badge, with no aria-label, so it's named by exactly what it shows: "Docs Search Name: sweep-docs".
    • The facts line (source chips, transport, summary) moved out of the button. It still describes it through aria-describedby.
    • This is the endpoints list's row-link pattern: a ::after on the button stretches over the whole header, so a click anywhere on it still opens and closes the card, and the ring and hover background still cover the header. The actions menu and the facts line sit above the stretched area, so the source chips' tooltips and the truncated summary's full text still show on hover. A click on the facts line is passed to the header button, so it opens and closes the card like the rest of the header. As in the endpoints list, a drag across the header no longer selects its text.
  • "Add tool" in an empty list. In a single-field section, SettingsRow points the field's control at the section's name with aria-labelledby. For the empty "Filtered tools" and "Included tools" lists, the control is the "Add tool" button, so it was named "Filtered tools Hidden tools".
    • A button that shows its own text (not a switch or a select trigger) now keeps it as its name, and gets the field's name as its description instead: "Add tool", described as "Filtered tools Hidden tools". Text fields, switches and selects are still named by the section.
    • The aria-describedby add-and-undo code that the error description already had is now one helper both effects use, and the control lookup moved to fieldControl in @/utils.
  • Change review "Go to field". It showed "Go to field" but was named "Go to Credits per refill". It's now named "Go to field: Credits per refill".
  • Revert and Remove override. Every revert action's name left out the words its button shows. For example, "Remove override (inherits 1,000,000)" was named "Remove the Role: editor override of Starting credits; it inherits 1,000,000 from Base".
    • Each name now starts with the button's words, then names the field and scope: "Remove override (inherits 1,000,000 from Base): Starting credits, Role: editor", "Revert to librechat.yaml (1,000,000): Starting credits", "Remove override: Starting credits".
    • The endpoint field chips show only "Revert", so getRevertLabels also returns a compact name for them that starts with "Revert", e.g. "Revert: remove the Role: editor override of Model; it inherits gpt-4o from Base". The old names that already started with "Revert" are kept as the compact ones.
    • A settings row without revert labels shows "Reset". On a previewed change its name was "Remove override for {{name}}"; it's now "Reset {{name}}: remove its override" ("Reset {{name}} to default" already started with "Reset"). The MCP server fields reuse the getRevertLabels names.
  • Langfuse keys. Found in the code; the LibreChat backend used for testing doesn't render this section.
    • A stored key's button showed the masked key but was named "Edit Public key". It's now named by its label and value, "Public key pk-lf-...cdef", with "Edit" as its title (its description).
    • The text field that replaces it had label="", so it was named only by its placeholder pk-lf-... and the "Public key" text above it wasn't tied to it. It's now labelled "Public key" or "Secret key".
    • Both states moved into one KeyField component. Its hover and focus colors used --cui-color-stroke-emphasis, which isn't defined anywhere; they use --cui-color-stroke-intense.

Status messages and dialog descriptions

  • Unsaved changes count. The bar's "1 unsaved change · System" changed without being announced (WCAG 4.1.3). The bar only exists while there are changes, so a status role on it would arrive together with its first message, which screen readers mostly don't announce. Its text now also goes into a status region (role="status", aria-atomic: the shared ScreenReaderAnnouncer and useAnnouncement) that stays mounted while the bar comes and goes, so the first change is heard as well as later ones. The bar's buttons aren't in it.
  • No settings match. The tab's "No settings on this tab match "…"" and "Nothing is set on this tab" messages also mount with their text. ConfigTabContent now keeps a status region mounted, puts the message in it while the tab shows it, and empties it once the settings show again.
  • Dialog descriptions. The unsaved-changes prompt (NavigationGuardDialog), Revert all admin overrides (ResetBaseConfigDialog) and the confirm-a-choice dialog (ConfirmChoice) passed no description to click-ui's Dialog.Content. Their aria-describedby pointed at click-ui's own empty <p>, so the body text wasn't read when they opened. Each now points aria-describedby at its body text.

Focus that fell to <body> or stayed on the dialog

These controls take themselves away, or swap a view, and focus went with them (WCAG 2.4.3). Each now moves focus somewhere useful, and only when focus was actually lost, so focus the user put elsewhere stays there. The shared pieces are restoreFocus, restoreFocusLater and restoreFocusWhenShown in @/utils and the useRefocusWhenGone hook.

  • Discard in the unsaved-changes bar: focus goes to the open tab.
  • Remove override (or Revert) on a settings row, and an MCP server field's revert: focus goes to the field it reset, else the open tab.
  • Jump to… (at 1024 px): focus goes to the heading toggle of the section it scrolled to. revealConfigPath takes focus: 'heading' for this.
  • MCP server rename: Escape (or Enter on an unchanged name) puts focus back on the card's header. It checks a frame later, once focus has landed, so a click or Tab out of the rename (which also ends it) leaves focus where it went. A rename that changes the key already moved focus to the renamed card.
  • Retry after a load error: once the retry is over, focus goes to the open tab if the page loaded, or to Retry on the card shown again if it failed again (the route and the page both mount a new card). Nothing takes focus while the page loads again (a skeleton is shown), so neither the header's title nor the tab bar beside the page's own error card takes it before the outcome is known. If the user goes to another page before then, nothing takes focus there, or on the configuration page they come back to: Retry records the path it was pressed on, and restoreFocusWhenShown stops once the page leaves that path. The path is taken when Retry is pressed, not when the card unmounts, because the route keeps its card mounted (with Retry busy) while it loads again, and a navigation unmounts it only after the router has moved to the next page.
  • Removing the last allowed domain: the inline "Remove the restriction" question now takes focus on its Cancel button and is described by its text. Either answer returns focus to the domain input.
  • Create endpoint: Blank endpoint (or a template) focuses the Name field. Back focuses the first template.
  • Preview drawer: Change focuses the picker's search, and closing the picker puts focus back on Change.
  • Import YAML: Continue focuses the next step's first control once it renders (Continue is disabled while it checks the YAML).
  • Sidebar user menu: Escape or a click outside left focus on <body>. Radix returns focus to click-ui's trigger wrapper, a <div> that can't take it. The menu now focuses the avatar button itself, as KebabMenu does. After Settings or Sign out it leaves focus where that item put it.
  • Command menu navigation: a command that opens another page (e.g. "Access") left focus on <body>. Focus now goes to the new page's title (header h1, now tabIndex={-1}) once it renders, unless the page focused something itself (a setting the menu opens is still focused).

Create endpoint lost a typed name

Text fields commit on blur, and the "Discard this new endpoint?" check compared only the committed draft with its template. So typing a Name and pressing Escape, the close button or Cancel without leaving the field closed the drawer with no prompt and lost the draft. The check now commits the focused field first (blur, then focus again), so all three ask.

App shell

  • Command menu "No results". It was rendered inside the role="listbox", which may hold only options (axe aria-required-children, critical). It's now a status message beside the listbox, and the list is hidden while it's empty. It stays in the DOM because the input's aria-controls points at it.
  • Page titles (WCAG 2.4.2). Every page titled the browser tab "LibreChat Admin Panel". Each page now sets " · LibreChat Admin Panel" from the same localized title the header shows: Dashboard (the home page's header says "Welcome!"), Configuration, Access management, System grants, Help, Sign in, Authentication failed (an SSO sign-in's error page), Page not found and Something went wrong. The app name is a new key, com_app_name.
  • Toast close buttons. click-ui names a toast's close button after its icon, "cross". A useToastCloseLabels hook, next to useDialogCloseLabels, names them "Close".
  • Search field clear buttons. click-ui names the clear button "clear input" in every language. The configuration filter and the shared SearchInput now name it with the localized "Clear".

Change Type

  • Bug fix (non-breaking change which fixes an issue)

Testing

Unit tests. Each change has a new test that fails without it:

  • src/test/a11y.ts adds isLabelInName, a port of axe 4.14's label-in-name check (lower case, parenthesized text dropped, punctuation as spaces, the visible words as one run of the name's words), and visibleText. values/names.test.tsx runs it over every getRevertLabels pair (all of them failed before), the compact chip names, the settings row's "Reset" fallbacks and the endpoint chip. ChangeReview.test.tsx checks "Go to field".
  • Card.test.tsx: the MCP header is named by every word it shows and described by the facts line, the header and the facts line both open and close the card, Escape and Enter on a rename that keeps the name give focus back to the header, and focus that a click or Tab moved to another card's header or to a text field outside the card stays there. Those last two move focus as a browser does: React commits what the blur started, effects included, before focus lands, and the move is dropped if that took focus.
  • SettingsRow.test.tsx: "Add tool" keeps its name and is described by the field's name.
  • LangfuseRenderer.test.tsx: the stored key's button is named "Public key pk-lf-...cdef" and described as "Edit", and after a click the text field is named "Public key".
  • ChangeReview.test.tsx (ChangesBar) and ConfigTabContent.test.tsx: the status region is there, empty, before the first unsaved change or empty-tab message, and that same element then holds the message (without the bar's buttons); Discard puts focus on the open tab.
  • descriptions.test.tsx: the three dialogs pass toHaveAccessibleDescription with their body text.
  • Focus: ConfigRow.test.tsx (Remove override), ConfigLoadError.test.tsx (Retry: the open tab once the page has loaded, the new card's Retry when it fails again, never the header's title, not the tab bar while the page's own error is retried, nothing on a page the user goes to while it loads, including while the route still shows its card with Retry busy, or on the configuration page they come back to, and still the tab when only the query changes), DomainListField.test.tsx (Cancel takes focus, both answers return it to the input), reveal.test.ts (focus: 'heading' on a section and on an entry card), CustomEndpointsRenderer.test.tsx (Blank endpoint focuses Name, Back focuses a template, Escape with an uncommitted name asks first), Drawer.test.tsx (Change and Cancel), ImportYamlDialog.test.tsx (Continue), Sidebar.test.tsx (Escape on the user menu) and CommandMenu.test.tsx (the new page's title).
  • CommandMenu.test.tsx: "No results" is a status message outside the listbox, and no empty list shows.
  • hooks/title.test.tsx, SsoError.test.tsx and hooks/labels.test.tsx: the document title (also on the SSO error page), and the toast close and clear button names.

E2E. New tests (each fails on main, except one guard noted under Runs):

  • e2e/a11y/config.spec.ts, live: WCAG-tag scans (withTags(WCAG_TAGS), as e2e/grants.spec.ts does) of the blank endpoint form, the Preview drawer after Change and Import YAML's second step, in light and dark, each also checking where focus landed. A test opens the USER role's profile (creating it if there is none) and, unless the profile already has a Starting credits of its own, saves one. It scans the System tab with every section open in light and dark, then presses Remove override, checks focus is on the field and discards the removal. Afterwards, also after a failure, it deletes the profile if it created it, or removes the Starting credits it saved; a Starting credits the profile already had is never written. Focus checks after Discard, Jump to, and Escape or Enter on an MCP rename; a click on the filter, a click on another card's header and Tab out of a rename leave focus there (text typed next lands in the filter). The unsaved-changes count lands in a status region that was on the page before the edit. On the first MCP card, the source chip and the summary are what the pointer hits (so their tooltips show), and a click on the chip opens and closes the card. And the Escape prompt for a typed endpoint name.
  • e2e/config-shell.spec.ts: the command menu's empty state (no listbox, no WCAG violations in the dialog) and navigation focus, a page title for each route, /login, a failed SSO callback and a not-found page, a toast's close button named "Close", and, live, the filter's clear button and its no-match message (in a status region that was there before), the Discard toast, and Retry on a failing schema and on a failing base config: focus goes back to Retry when it fails again, then to the open tab once it loads. The file's blanket live-only skip moved onto its configuration describes so the app-shell tests run against the mock backend too.
  • e2e/sidebar.spec.ts: Enter then Escape on the user menu leaves focus on the avatar.
  • WCAG_TAGS and the USER role profile helpers (selectUserRoleProfile, deleteUserRoleProfile, openScopeSelector) moved into e2e/live.ts; access, grants, login and config-shell import them instead of their own copies. openConfigTab takes an optional profile scope. e2e/features.spec.ts uses the new revert names.

Runs:

  • npx tsc --noEmit: exit 0.
  • npx eslint src/ --max-warnings 0: exit 0.
  • npx prettier --check on every changed file: exit 0.
  • bun run test:lint-rules: exit 0 (19 passed).
  • npx vitest run --maxWorkers=3: exit 0, 181 files, 3641 tests passed.
  • bun run build: exit 0.
  • Mock suite (npx playwright test --workers=2): exit 0, 127 passed, 164 skipped (live-only). On main, all 13 new mock tests fail.
  • Live (E2E_LIVE=1, a LibreChat dev backend): e2e/a11y/config.spec.ts, e2e/config-shell.spec.ts and e2e/sidebar.spec.ts: 121 passed, 1 skipped (the mock-only toast test), 2 failed. The two failures are the visual baseline: light-390 screenshots of the System and Features tabs. They fail on main too, on the same stack and by the same number of pixels (System 12,294, Features 10,320). The differences are in rows this PR doesn't touch (where the source chip sits at 390 px, the read-only note's heading, field labels), so the baselines are out of date rather than broken by this PR.
  • On main, with this branch's e2e/ copied in, the ten scans above, the new scans and focus tests, the status region checks, the Retry focus tests, the page title, command menu, toast and clear-button tests and the user-menu test all fail. The MCP facts-line test passes there: it guards this PR's own header change (on main the facts line sat inside the button). With this branch's unit test files copied onto main, 57 tests in 21 files fail: every new test, and the existing tests updated for the new names (hooks/title.test.tsx can't load there, as the hook is new). The ones that still pass there are existing tests and regression guards: for example, "Reset {{name}} to default" already started with "Reset", and the MCP card's facts-line, click and Tab tests guard this PR's own header and rename changes.

Screenshots, light and dark, 1440 and 390 px. The MCP cards with a header focused (the ring covers the whole header card, the facts line sits under the name and the actions menu on the right) and with the pointer on a facts line (the pointer hits the chip and the summary, not the header button, and a click on the chip opens the card without a focus ring), the change review with "Go to field", the System tab after Discard (focus ring on the tab, the Undo toast), the command menu's "No results", the blank endpoint form with Name focused, and its "Discard this new endpoint?" prompt after Escape with a typed name. In the live DOM, each MCP header has no aria-label, is named by its text (e.g. "everything") and is described by its facts line, and after Discard focus is on the System tab.

Test Configuration:

  • Chromium (Playwright 1.64), @axe-core/playwright 4.14, click-ui 0.15, LibreChat dev backend, MongoDB replica set.

Not covered live. The Langfuse section isn't rendered by the backend used here, so its fix is covered by unit tests only.

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • I have commented in any complex areas of my code
  • My changes do not introduce new warnings
  • I have written tests demonstrating that my changes are effective or that my feature works
  • Local unit tests pass with my changes

…ration Page and App Shell

Fixes the screen reader, keyboard and naming problems an accessibility
review of the configuration page and the app shell found. Ten live axe
scans that failed on main now pass; no rule, tag or element is excluded.

Label in name (WCAG 2.5.3).
- MCP server card header: its aria-label ("Title (key)") left out the
  New badge, source chips and transport line the button also held. The
  button now holds only the title, "Server key" (screen-reader only),
  the key and the New badge, and is named by them. The facts line moved
  out of the button and still describes it. A ::after stretched over
  the header keeps the whole header clickable (the endpoints list's
  row-link pattern), with the actions menu above it.
- Single-field sections renamed an empty list's "Add tool" button after
  the section ("Filtered tools Hidden tools"). A button that shows its
  own text now keeps it as its name and is described by the field's
  name; switches, selects and text fields are still named by it.
- Change review: "Go to field" is named "Go to field: <label>".
- Revert and Remove override: every revert name now starts with the
  words its button shows, e.g. "Remove override (inherits 1,000,000 from
  Base): Starting credits, Role: editor". getRevertLabels also returns a
  compact name starting with "Revert" for the endpoint field chips,
  which show only "Revert". The settings row's fallback "Reset" is named
  "Reset <field>: remove its override".
- Langfuse keys: a stored key's button is named by its label and masked
  value ("Public key pk-lf-...cdef") with "Edit" as its title, and the
  text field that replaces it is labelled "Public key" or "Secret key"
  instead of only by its placeholder. Its hover and focus colors used
  --cui-color-stroke-emphasis, which is not defined; they use
  --cui-color-stroke-intense.

Status messages and descriptions.
- The unsaved-changes bar's count and the tab's "No settings on this tab
  match" message are status messages, so screen readers hear them
  change.
- The unsaved-changes prompt, Revert all admin overrides and the
  confirm-a-choice dialog are described by their body text; click-ui's
  own empty description was read instead.

Focus that fell to <body> or stayed on the dialog.
- Discard in the unsaved-changes bar puts focus on the open tab.
- Remove override (and an MCP field's revert) puts focus on the field it
  reset.
- Jump to puts focus on the heading toggle of the section it scrolls to.
- Escape on an MCP server rename puts focus back on its header.
- Retry after a load error puts focus on the open tab once the page
  loads, or back on Retry if it fails again.
- Removing the last allowed domain moves focus to the question's Cancel,
  and either answer returns it to the input; the question is described
  by its text.
- Blank endpoint focuses the Name field, Back focuses the first
  template, the Preview drawer's Change focuses the picker's search (and
  closing the picker returns to Change), and Import YAML's Continue
  focuses the next step's first control.
- The sidebar user menu gives focus back to the avatar on Escape or a
  click outside; Radix returned it to click-ui's trigger wrapper, which
  can't take it.
- A command menu command that opens a page puts focus on that page's
  title once it renders, unless the page focused something itself.

Create endpoint. Escape, the close button and Cancel decided whether to
ask before discarding from the committed draft only, and text fields
commit on blur, so a Name typed and not yet left was dropped without a
prompt. The focused field is now committed before that check.

App shell.
- Command menu: "No results" was rendered inside the listbox
  (aria-required-children). It is now a status message beside it, and
  the empty listbox is hidden.
- Every page titled the browser tab "LibreChat Admin Panel". Each page
  now sets "<page> · LibreChat Admin Panel" (Dashboard, Configuration,
  Access management, System grants, Help, Sign in, Page not found,
  Something went wrong).
- Toast close buttons were named "cross" and click-ui's search field
  clear buttons "clear input". They are named "Close" and "Clear" in the
  panel's language.

Tests. New unit tests cover each change and fail without it, including
a port of axe 4.14's label-in-name check run over every revert name,
the go-to name, the row fallbacks and the endpoint chips. New live e2e
tests scan the role-profile override rows, the blank endpoint form, the
Preview picker and Import YAML's second step with the WCAG tags in
light and dark, and check focus after Discard, Remove override, Jump to
and an MCP rename. Mock e2e tests cover the user menu, the command
menu's empty state and navigation focus, page titles and toast close
names. WCAG_TAGS and the USER role profile helpers moved to e2e/live.ts
so the specs share them.
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 1. Head: 784d13f

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-29T11:04:10.428551Z 584524f Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 784d13ff00

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# Conflicts:
#	e2e/access.spec.ts
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 2. Head: 700ee53

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 700ee53b63

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread e2e/a11y/config.spec.ts Resolved
…ound USER Profile's Starting Credits Untouched
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 3. Head: acd8bfd

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: acd8bfdf41

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/utils/focus.ts Resolved
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 4. Head: 584524f

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 584524f740

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danny-avila
danny-avila merged commit ba88991 into main Oct 29, 2026
3 checks 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