Skip to content

fix(ui-web): state the model picker's name and checked row to a screen reader - #718

Merged
LivXue merged 3 commits into
mainfrom
fix/model_picker_aria_gaps
Sep 23, 2026
Merged

LivXue merged 3 commits into
mainfrom
fix/model_picker_aria_gaps

Conversation

@LivXue

@LivXue LivXue commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Summary

Three accessibility gaps in the model picker, all in what the markup
says rather than in what it draws.

.mpick carried role="dialog" with no accessible name, so a reader
was told something had opened without being told what. Model rows
marked the current model with a tick glyph alone, so which one is
chosen was invisible to a screen reader. #modelChip advertised
aria-haspopup="true" with no aria-expanded, while its three sibling
chips carried both.

The popover is named after the slot that opened it, or after the word
for a model when nothing did. Rows are radios carrying aria-checked,
grouped one radiogroup per account plus one for Recent. Per account
rather than one set for the whole list: the same model is listed under
Recent and under its account, both are marked, and one set may hold one
checked member. The tick is hidden from the accessibility tree now that
the state is said in the markup, and the show-all row stays out of the
set it sits in, since it reveals more radios rather than being one.

One visual consequence, deliberate and worth knowing before reading a
diff that looks attribute-only. src/styles/page.css selects on
.chip[aria-expanded="true"] and on .under .chip[aria-expanded], so
giving #modelChip the attribute repaints it while its popover is up.
Measured in Chrome on the built page:

#modelChip closed  background rgba(0, 0, 0, 0)  border rgb(26, 26, 26)   radius 12px
#modelChip open    background rgb(247, 247, 247) border rgb(157, 157, 157) radius 100px
#permChip  open    background rgb(247, 247, 247) border rgb(157, 157, 157) radius 100px

The chip now looks engaged exactly as #permChip and #wdChip already
did, which is the wanted outcome; color is unchanged, pinned by
.under .chip.model. No rule matches the added role, aria-checked
or aria-hidden: .mpick is not inside a .menu and the rows are not
inside .model-seg, verified in the same browser run by reading
::before on a checked row as content: none.

A third commit answers the review on the first head. aria-expanded
was derived from isOpen(), which answers "a model picker is up
somewhere" -- and one store serves every opener: this chip with no
anchor, and each settings role pill against its own button
(features/settings/providers/Roles.tsx). Opening a role such as
Planner model from the settings dialog therefore lit up the composer
chip as well, handing a screen reader a control and a state that do not
belong together. The store already keeps the opener as at.host, so it
answers the narrower question itself through openedFrom rather than
the chip filtering a global, and the chip asks about its own node.

The second commit fixes a test this branch had written. Reverting the
source left seven of the eight new assertions red and one green: the
show-all case asserted only that the row carries no role and no
aria-checked, which is equally true of a picker that marks no row at
all. It is asserted against its siblings now, and the expected set is
read off the source's own FOLD, so the negative half rests on a
positive one.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

Run from ui-web/ unless noted. This box runs node 26, whose
experimental globalThis.localStorage shadows happy-dom's and kills
every suite that clears it, so the suite runs need
NODE_OPTIONS=--localstorage-file=<path> and --no-file-parallelism.
Neither is a repo change; CI runs node 22 and needs neither.

vitest run ModelChip + ModelPicker + Dock + settings Roles
           + scripts/gates/model-pill-clear-css.test.mjs
  Test Files 5 passed (5)   Tests 119 passed (119)

the chip and picker files, with only the two source files reverted
  Tests 8 failed | 62 passed (70)   -- the 8 are the 8 added here

the same five files with the review fix mutated back to isOpen()
  Tests 1 failed | 114 passed (115)   -- the one case added for it

vitest run (whole ui-web suite, serial, --testTimeout=30000)
  747 suites, 2889 tests, 2887 passed, 2 failed, 0 unhandled errors

npm run type-check    clean
npm run gen:check     generated.ts matches the contract (202 methods)
npm run lint          4 problems (0 errors, 4 warnings), the pre-existing four
npm run lint:i18n     (in ui-tui) generated catalogue is up to date

The 2 failures are features/desk/store.test.ts and
features/desk/palette.test.ts, both when storage refuses, and
neither is this branch. They are unevaluable on a node 26 box either
way: with the flag, storage works when the test needs it to throw;
without it, the file dies in beforeEach. The review of the first head
ran the same suite on another machine and reported 2870 of 2870 passed,
which settles them as this box's artifact rather than a repository-wide
failure.

Everything above was run with the branch rebased onto main as it
stood a few commits ago; main has moved since, in commits that touch
neither file this branch changes. Left un-rebased on purpose, since the
merge ref is what CI measures and the branch would be stale again
before it was reviewed.

Repository gates, from the repo root:

scripts/check_source_language.py origin/main...HEAD   clean
scripts/check_large_files.py origin/main...HEAD       clean
scripts/check_commit_messages.py origin/main..HEAD    clean
npx commitlint --from origin/main --to HEAD           clean
  • Relevant tests pass locally
  • Relevant lint / type checks pass locally
  • User-facing docs or screenshots are updated when needed

Risk

User-visible change: #modelChip gains the open-state paint described
above. It is one chip matching three siblings, and it is the only pixel
difference in the branch. Everything else is markup a screen reader
reads and a sighted reader does not.

No behaviour change to what the picker offers or what choosing does.
Rollback is reverting the commits; nothing persists and no stored shape
changes.

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

Related Issues

N/A

Handsome-wzw
Handsome-wzw previously approved these changes Sep 23, 2026

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: the model chip must only report expanded for the picker it opened.

I found one introduced ARIA state error, marked inline. I reviewed the full diff, the model store and both composer/settings opener paths, relevant history, backward-compatibility behavior, test changes, and the repository's AGENTS.md, CLAUDE.md, CONTEXT-MAP.md, and Web UI architecture terms. I found no weakened tests or other merge-blocking issue.

Verification: npm test (207 files, 2870 tests passed); focused ModelChip/ModelPicker run (2 files, 70 tests passed); npm run type-check; npm run gen:check; npm run --prefix ui-tui lint:i18n; and the source-language target's underlying uv command all passed. The make wrapper itself was unavailable in this environment.

Comment thread ui-web/src/chrome/ModelChip.tsx Outdated
LivXue and others added 3 commits September 23, 2026 09:48
…n reader

The picker carried role="dialog" with no accessible name, its rows marked
the current model with a tick glyph alone, and #modelChip advertised
aria-haspopup without aria-expanded -- so a reader using the markup rather
than the pixels was told a dialog had opened, could not hear which of it
was chosen, and could not tell whether the chip's popover was up.

The popover is named after the slot that opened it, or after the word for
a model. Rows become radios carrying aria-checked, one radiogroup per
account plus one for Recent, because the same model is listed under both
and one set may hold one checked member. The tick is hidden from the
accessibility tree now that the state is said, and the show-all row stays
out of the set it sits in.

Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
Reverting the source left this one green while the other seven went red:
it asserted only that the show-all row has no role and no aria-checked,
which is equally true of a picker that marks no row at all. It shares the
`row` class with the radios, so the negative half means something only
next to the positive one.

Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
Review finding on the first head. `isOpen()` answers "a model picker is
up somewhere", and one store serves every opener: the composer chip
with no anchor, and each settings role pill against its own button. So
opening a role such as Planner model from the settings dialog lit up
the composer chip too, handing a screen reader a control and a state
that do not belong together.

The store already knows which control it was opened from -- it keeps
the opener as `at.host` -- so it answers the narrower question itself
through `openedFrom`, rather than the chip filtering a global. The chip
asks about its own node. Reverting the predicate to `isOpen()` reddens
the added case and nothing else.

Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

The prior blocker is fixed: the store now answers whether the picker belongs to a specific opener, the chip asks with its own node, and the regression test covers a settings pill opening the shared picker. I reviewed the full PR diff and fix delta, relevant composer/settings callers, store lifecycle, rebase history and backward compatibility, test changes, and the repository and Web UI architecture rules. The two original commits are patch-identical after the rebase, tests were not weakened, and I found no new issue worth raising.

Verification: the focused ModelChip/ModelPicker/Dock/Roles run passed 4 files and 115 tests; npm run type-check, npm run gen:check, and npm run --prefix ui-tui lint:i18n passed. The full UI run passed 2,888 of 2,889 tests but timed out in the unrelated state/session/registry case with a late no gateway installed rejection; rerunning that file alone passed all 28 tests.

@LivXue
LivXue merged commit a9aaf3d into main Sep 23, 2026
27 checks passed
@LivXue
LivXue deleted the fix/model_picker_aria_gaps branch September 23, 2026 10:54
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.

3 participants