Open the sidebar with a screen reader and tab through a stopped project's row. The Claude terminal button is not there. Not "there but greyed" — absent. Native disabled takes an element out of the tab order and out of the accessibility tree, so there is nothing to focus and nothing to announce. The reason it cannot be used ("the container is not running") is announced nowhere, because there is no longer an element to announce it on. A sighted mouse user gets a title tooltip on hover; everyone else gets silence and a control that seems not to exist.
Add Project is the same defect with a sharper edge. Submit the dialog and the button goes disabled while the add is in flight. A keyboard user who was standing on that button has focus yanked out from under them to document.body, and the one thing that would have explained it — the label swapping to "Adding…" — is now on an element assistive technology cannot reach.
The fix
aria-disabled="true" plus aria-describedby pointing at a real sr-only element, instead of the native disabled attribute. The control stays focusable, stays in the accessibility tree, announces its state, and announces the reason.
The hazard is that aria-disabled is advisory: unlike disabled it blocks nothing, so a converted control will happily fire on click and on Enter/Space. Forgetting the guard turns a cosmetic accessibility fix into a functional regression (here: opening a terminal against a stopped container, and double-submitting a project add).
So the guard is not left to the call site. app/src/components/ui/unavailable.tsx returns the attributes and the guarded handlers together, as one object you spread — you cannot take the announcement without taking the guard.
Shared component, not per-site.ui/Button.tsx gets the pattern as an opt-in unavailable / unavailableReason pair. Opt-in is what makes this safe: 37 files render Button, and every one of them is byte-identical in behaviour after this change — nothing happens unless a call site passes unavailable. The underlying useUnavailable hook is exported separately because one of the two call sites (ProjectRow) uses a raw <button>, not Button; a hook serves both without forcing a rewrite of the sidebar's icon buttons.
Two details worth a reviewer's eye:
Tailwind's disabled: variant only matches the native attribute, which this pattern deliberately does not set, so the "unavailable" look needed aria-disabled: mirrors alongside the existing disabled: classes in Button's variant strings. Confirmed present in the built CSS as [aria-disabled=true] rules, not just in source. The two lists must be kept in step; there is a comment saying so.
The sr-only reason renders as a sibling of the button, not inside it. Inside, it would join the accessible name rather than the description. Button.test.tsx asserts the name stays "Save".
Survey
grep -rn "disabled" app/src --include=*.tsx → 291 lines; 254 outside tests; ~208 real disabled props/identifiers after excluding Tailwind disabled: variants and --text-disabled token references. 82 title= lines in non-test TSX.
Elements with bothdisabled and a title-like prop on the same tag: 6 distinct, plus ~18 bulk aria-label list-row cases.
Elements where that title-like prop actually explains the disabled state: 0. The literal "disabled + explanatory title" pattern does not exist in this codebase — worth stating plainly, since it is what the fix was originally scoped around.
Converted: 2.
Site
Why
ProjectRow.tsx — Claude terminal button
disabled={!isRunning}. Precondition the user must act on; explanation existed nowhere in the accessibility tree.
AddProjectDialog.tsx — submit button
disabled={loading}. Explanation existed only as a label swap on an unreachable element.
Rejected, by category:
Title is a name, not a reason (~24 sites).ProjectRow's start/stop button (title is Start/Stop/Force stop {name} — conditional on isRunning, not on the busy that disables it), MicrophoneSettings.tsx:91 ("Refresh microphone list"), FilesTab.tsx:538, ClaudeAuthModal.tsx:291, ClaudeInstructionsEditor.tsx:39, and the aria-label list rows in PortMappingsEditor, EnvVarsEditor, WorkspaceSection, ClaudeCodeSettingsEditor, Toggle, SegmentedControl, OverflowMenu. Converting these would announce a state without adding any information, which is churn.
No explanatory text anywhere (~50 sites).DockerSettings.tsx:151,159 is the tempting one — disabled={working || !dockerAvailable}, and "Docker is not running" is exactly the missing sentence — but it is missing, so fixing it means inventing copy, not preserving it. Explicitly out of scope. Same for OpenPageDialog, ExportSettingsModal, ImportSettingsModal, GatewaySettings, CaCertPathInput, SttSettings, and the 21 sites in ModelSection.tsx.
Deferred, and the largest finding: 17 components carry a disabledReason string rendered as an unassociated sibling <p>.ClaudeInstructionsEditor, EnvVarsEditor, PortMappingsEditor, ClaudeCodeSettingsEditor, PermissionModeControl, RuntimeSection, AccessSection, ConfigTab, ContainerMigrationBanner, SharedAuthSettings, AutomationTab, CapabilityTiles, BrowserTab, MigrateContainerModal and the three config modals. These pass the "explains why it is unavailable" test — PermissionModeControl's prop doc literally says so — but they are a different, weaker defect: the reason is visible text in the same region, so it is discoverable, just not programmatically associated with the control (no id, no aria-describedby). Nothing is lost, only unlinked. That is a real WCAG 1.3.1/4.1.2 gap and it should be fixed, but it is a 17-component change with its own design question (does Field/ConfigGroup grow the association, or each editor?) and folding it in here would make this diff unreviewable. Filed as follow-up, not silently skipped.
The line this PR draws: fix where the reason is absent from the accessibility tree entirely; defer where it is present but unlinked; reject where there is no reason to preserve.
Testing
Vitest + React Testing Library, matching the house idiom (component hooks mocked at the hook boundary; no @tauri-apps/api/core mocking anywhere).
New coverage, written before the fix and watched fail:
ui/Button.test.tsx (5) — unavailable button is queryable by role and not toBeDisabled(); accessible description carries the reason; accessible name stays clean; click and Enter/Space are ignored; non-activation keys still reach onKeyDown; available button clicks normally; native disabled still works, which is the regression guard for the other 36 call sites.
projects/ProjectRow.test.tsx (4 new/rewritten) — the pre-existing toBeDisabled() assertion failed on this change and was rewritten, which is the honest signal that behaviour moved.
projects/AddProjectDialog.test.tsx (5, new file) — including fireEvent.submit(form) while loading, which exercises the second guard specifically: Enter inside a text field submits a form without ever touching the submit button, so handleSubmit has to guard itself. The hook cannot cover that path.
The click guard was mutation-tested, not assumed. Neutering both guards in useUnavailable (passing the raw handlers through) fails exactly the two guard tests and nothing else:
× guards clicks and Enter/Space while unavailable
× ignores clicks and Enter/Space on the terminal button while stopped
Tests 2 failed | 13 passed (15)
Guards restored, and the numbers:
$ npm test -- --run
Test Files 52 passed (52)
Tests 656 passed (656)
$ npx tsc --noEmit
(exit 0, no output)
$ npm run build
✓ built in 1.04s
Baseline on main was 50 files / 643 tests. Nothing under src-tauri was touched, so cargo test was not run.
Deliberately not done
The 17-component disabledReason association gap described above.
Tooltip.tsx has no role="tooltip" and no aria-describedby wiring at any of its 27 call sites, so it is visual-only. Out of scope here, but it is adjacent and real.
Nothing from feat/project-notes; this branch is cut from origin/main.
## What a screen-reader user hits today
Open the sidebar with a screen reader and tab through a stopped project's row. The Claude terminal button is not there. Not "there but greyed" — absent. Native `disabled` takes an element out of the tab order *and* out of the accessibility tree, so there is nothing to focus and nothing to announce. The reason it cannot be used ("the container is not running") is announced nowhere, because there is no longer an element to announce it on. A sighted mouse user gets a `title` tooltip on hover; everyone else gets silence and a control that seems not to exist.
Add Project is the same defect with a sharper edge. Submit the dialog and the button goes `disabled` while the add is in flight. A keyboard user who was standing on that button has focus yanked out from under them to `document.body`, and the one thing that would have explained it — the label swapping to "Adding…" — is now on an element assistive technology cannot reach.
## The fix
`aria-disabled="true"` plus `aria-describedby` pointing at a real `sr-only` element, instead of the native `disabled` attribute. The control stays focusable, stays in the accessibility tree, announces its state, and announces the reason.
The hazard is that `aria-disabled` is advisory: unlike `disabled` it blocks nothing, so a converted control will happily fire on click and on Enter/Space. Forgetting the guard turns a cosmetic accessibility fix into a functional regression (here: opening a terminal against a stopped container, and double-submitting a project add).
So the guard is not left to the call site. `app/src/components/ui/unavailable.tsx` returns the attributes and the guarded handlers together, as one object you spread — you cannot take the announcement without taking the guard.
**Shared component, not per-site.** `ui/Button.tsx` gets the pattern as an opt-in `unavailable` / `unavailableReason` pair. Opt-in is what makes this safe: 37 files render `Button`, and every one of them is byte-identical in behaviour after this change — nothing happens unless a call site passes `unavailable`. The underlying `useUnavailable` hook is exported separately because one of the two call sites (`ProjectRow`) uses a raw `<button>`, not `Button`; a hook serves both without forcing a rewrite of the sidebar's icon buttons.
Two details worth a reviewer's eye:
- **Tailwind's `disabled:` variant only matches the native attribute**, which this pattern deliberately does not set, so the "unavailable" look needed `aria-disabled:` mirrors alongside the existing `disabled:` classes in `Button`'s variant strings. Confirmed present in the built CSS as `[aria-disabled=true]` rules, not just in source. The two lists must be kept in step; there is a comment saying so.
- **The `sr-only` reason renders as a sibling of the button, not inside it.** Inside, it would join the accessible *name* rather than the description. `Button.test.tsx` asserts the name stays `"Save"`.
## Survey
`grep -rn "disabled" app/src --include=*.tsx` → 291 lines; 254 outside tests; ~208 real `disabled` props/identifiers after excluding Tailwind `disabled:` variants and `--text-disabled` token references. 82 `title=` lines in non-test TSX.
Elements with **both** `disabled` and a title-like prop on the same tag: **6** distinct, plus ~18 bulk `aria-label` list-row cases.
Elements where that title-like prop actually **explains the disabled state**: **0**. The literal "disabled + explanatory `title`" pattern does not exist in this codebase — worth stating plainly, since it is what the fix was originally scoped around.
**Converted: 2.**
| Site | Why |
| --- | --- |
| `ProjectRow.tsx` — Claude terminal button | `disabled={!isRunning}`. Precondition the user must act on; explanation existed nowhere in the accessibility tree. |
| `AddProjectDialog.tsx` — submit button | `disabled={loading}`. Explanation existed only as a label swap on an unreachable element. |
**Rejected, by category:**
- **Title is a name, not a reason (~24 sites).** `ProjectRow`'s start/stop button (`title` is `Start/Stop/Force stop {name}` — conditional on `isRunning`, not on the `busy` that disables it), `MicrophoneSettings.tsx:91` (`"Refresh microphone list"`), `FilesTab.tsx:538`, `ClaudeAuthModal.tsx:291`, `ClaudeInstructionsEditor.tsx:39`, and the `aria-label` list rows in `PortMappingsEditor`, `EnvVarsEditor`, `WorkspaceSection`, `ClaudeCodeSettingsEditor`, `Toggle`, `SegmentedControl`, `OverflowMenu`. Converting these would announce a state without adding any information, which is churn.
- **No explanatory text anywhere (~50 sites).** `DockerSettings.tsx:151,159` is the tempting one — `disabled={working || !dockerAvailable}`, and "Docker is not running" is exactly the missing sentence — but it is missing, so fixing it means inventing copy, not preserving it. Explicitly out of scope. Same for `OpenPageDialog`, `ExportSettingsModal`, `ImportSettingsModal`, `GatewaySettings`, `CaCertPathInput`, `SttSettings`, and the 21 sites in `ModelSection.tsx`.
- **Deferred, and the largest finding: 17 components carry a `disabledReason` string rendered as an unassociated sibling `<p>`.** `ClaudeInstructionsEditor`, `EnvVarsEditor`, `PortMappingsEditor`, `ClaudeCodeSettingsEditor`, `PermissionModeControl`, `RuntimeSection`, `AccessSection`, `ConfigTab`, `ContainerMigrationBanner`, `SharedAuthSettings`, `AutomationTab`, `CapabilityTiles`, `BrowserTab`, `MigrateContainerModal` and the three config modals. These pass the "explains why it is unavailable" test — `PermissionModeControl`'s prop doc literally says so — but they are a different, weaker defect: the reason is *visible text in the same region*, so it is discoverable, just not programmatically associated with the control (no `id`, no `aria-describedby`). Nothing is lost, only unlinked. That is a real WCAG 1.3.1/4.1.2 gap and it should be fixed, but it is a 17-component change with its own design question (does `Field`/`ConfigGroup` grow the association, or each editor?) and folding it in here would make this diff unreviewable. Filed as follow-up, not silently skipped.
The line this PR draws: **fix where the reason is absent from the accessibility tree entirely; defer where it is present but unlinked; reject where there is no reason to preserve.**
## Testing
Vitest + React Testing Library, matching the house idiom (component hooks mocked at the hook boundary; no `@tauri-apps/api/core` mocking anywhere).
New coverage, written before the fix and watched fail:
- `ui/Button.test.tsx` (5) — unavailable button is queryable by role and not `toBeDisabled()`; accessible description carries the reason; accessible name stays clean; click and Enter/Space are ignored; non-activation keys still reach `onKeyDown`; available button clicks normally; **native `disabled` still works**, which is the regression guard for the other 36 call sites.
- `projects/ProjectRow.test.tsx` (4 new/rewritten) — the pre-existing `toBeDisabled()` assertion failed on this change and was rewritten, which is the honest signal that behaviour moved.
- `projects/AddProjectDialog.test.tsx` (5, new file) — including `fireEvent.submit(form)` while loading, which exercises the second guard specifically: Enter inside a text field submits a form without ever touching the submit button, so `handleSubmit` has to guard itself. The hook cannot cover that path.
**The click guard was mutation-tested, not assumed.** Neutering both guards in `useUnavailable` (passing the raw handlers through) fails exactly the two guard tests and nothing else:
```
× guards clicks and Enter/Space while unavailable
× ignores clicks and Enter/Space on the terminal button while stopped
Tests 2 failed | 13 passed (15)
```
Guards restored, and the numbers:
```
$ npm test -- --run
Test Files 52 passed (52)
Tests 656 passed (656)
$ npx tsc --noEmit
(exit 0, no output)
$ npm run build
✓ built in 1.04s
```
Baseline on `main` was 50 files / 643 tests. Nothing under `src-tauri` was touched, so `cargo test` was not run.
## Deliberately not done
- The 17-component `disabledReason` association gap described above.
- `Tooltip.tsx` has no `role="tooltip"` and no `aria-describedby` wiring at any of its 27 call sites, so it is visual-only. Out of scope here, but it is adjacent and real.
- Nothing from `feat/project-notes`; this branch is cut from `origin/main`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
Native `disabled` removes an element from the tab order and from the
accessibility tree, so any explanation of why a control cannot be used is
delivered only to a sighted user with a mouse. `useUnavailable` is the way
out: `aria-disabled` keeps the control focusable and announced,
`aria-describedby` carries the reason, and — because `aria-disabled` is
advisory and blocks nothing — the hook hands back the click and Enter/Space
guards along with the attributes, so a call site cannot take the
announcement without the guard.
`Button` gets it as an opt-in `unavailable` / `unavailableReason` pair.
Opt-in matters: 37 files render this button and none of them change. The
`aria-disabled:` class mirrors exist because Tailwind's `disabled:` variant
only matches the native attribute, which this pattern deliberately omits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
Both were the defect the new hook exists for. The sidebar's Claude terminal
button is disabled whenever the container is not running and never said so —
its `title` names the action, so the precondition appeared nowhere in the
accessibility tree at all. Add Project's submit button is disabled while an
add is in flight, and its only signal is the label swapping to "Adding…" on
an element a screen reader can no longer reach.
The submit button needs a second guard the hook cannot supply: Enter inside
a text field submits a form without touching the submit button, so
`handleSubmit` now returns early while loading. Without it, swapping
`disabled` for `aria-disabled` would have turned an accessibility fix into a
double-submit bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What a screen-reader user hits today
Open the sidebar with a screen reader and tab through a stopped project's row. The Claude terminal button is not there. Not "there but greyed" — absent. Native
disabledtakes an element out of the tab order and out of the accessibility tree, so there is nothing to focus and nothing to announce. The reason it cannot be used ("the container is not running") is announced nowhere, because there is no longer an element to announce it on. A sighted mouse user gets atitletooltip on hover; everyone else gets silence and a control that seems not to exist.Add Project is the same defect with a sharper edge. Submit the dialog and the button goes
disabledwhile the add is in flight. A keyboard user who was standing on that button has focus yanked out from under them todocument.body, and the one thing that would have explained it — the label swapping to "Adding…" — is now on an element assistive technology cannot reach.The fix
aria-disabled="true"plusaria-describedbypointing at a realsr-onlyelement, instead of the nativedisabledattribute. The control stays focusable, stays in the accessibility tree, announces its state, and announces the reason.The hazard is that
aria-disabledis advisory: unlikedisabledit blocks nothing, so a converted control will happily fire on click and on Enter/Space. Forgetting the guard turns a cosmetic accessibility fix into a functional regression (here: opening a terminal against a stopped container, and double-submitting a project add).So the guard is not left to the call site.
app/src/components/ui/unavailable.tsxreturns the attributes and the guarded handlers together, as one object you spread — you cannot take the announcement without taking the guard.Shared component, not per-site.
ui/Button.tsxgets the pattern as an opt-inunavailable/unavailableReasonpair. Opt-in is what makes this safe: 37 files renderButton, and every one of them is byte-identical in behaviour after this change — nothing happens unless a call site passesunavailable. The underlyinguseUnavailablehook is exported separately because one of the two call sites (ProjectRow) uses a raw<button>, notButton; a hook serves both without forcing a rewrite of the sidebar's icon buttons.Two details worth a reviewer's eye:
disabled:variant only matches the native attribute, which this pattern deliberately does not set, so the "unavailable" look neededaria-disabled:mirrors alongside the existingdisabled:classes inButton's variant strings. Confirmed present in the built CSS as[aria-disabled=true]rules, not just in source. The two lists must be kept in step; there is a comment saying so.sr-onlyreason renders as a sibling of the button, not inside it. Inside, it would join the accessible name rather than the description.Button.test.tsxasserts the name stays"Save".Survey
grep -rn "disabled" app/src --include=*.tsx→ 291 lines; 254 outside tests; ~208 realdisabledprops/identifiers after excluding Tailwinddisabled:variants and--text-disabledtoken references. 82title=lines in non-test TSX.Elements with both
disabledand a title-like prop on the same tag: 6 distinct, plus ~18 bulkaria-labellist-row cases.Elements where that title-like prop actually explains the disabled state: 0. The literal "disabled + explanatory
title" pattern does not exist in this codebase — worth stating plainly, since it is what the fix was originally scoped around.Converted: 2.
ProjectRow.tsx— Claude terminal buttondisabled={!isRunning}. Precondition the user must act on; explanation existed nowhere in the accessibility tree.AddProjectDialog.tsx— submit buttondisabled={loading}. Explanation existed only as a label swap on an unreachable element.Rejected, by category:
ProjectRow's start/stop button (titleisStart/Stop/Force stop {name}— conditional onisRunning, not on thebusythat disables it),MicrophoneSettings.tsx:91("Refresh microphone list"),FilesTab.tsx:538,ClaudeAuthModal.tsx:291,ClaudeInstructionsEditor.tsx:39, and thearia-labellist rows inPortMappingsEditor,EnvVarsEditor,WorkspaceSection,ClaudeCodeSettingsEditor,Toggle,SegmentedControl,OverflowMenu. Converting these would announce a state without adding any information, which is churn.DockerSettings.tsx:151,159is the tempting one —disabled={working || !dockerAvailable}, and "Docker is not running" is exactly the missing sentence — but it is missing, so fixing it means inventing copy, not preserving it. Explicitly out of scope. Same forOpenPageDialog,ExportSettingsModal,ImportSettingsModal,GatewaySettings,CaCertPathInput,SttSettings, and the 21 sites inModelSection.tsx.disabledReasonstring rendered as an unassociated sibling<p>.ClaudeInstructionsEditor,EnvVarsEditor,PortMappingsEditor,ClaudeCodeSettingsEditor,PermissionModeControl,RuntimeSection,AccessSection,ConfigTab,ContainerMigrationBanner,SharedAuthSettings,AutomationTab,CapabilityTiles,BrowserTab,MigrateContainerModaland the three config modals. These pass the "explains why it is unavailable" test —PermissionModeControl's prop doc literally says so — but they are a different, weaker defect: the reason is visible text in the same region, so it is discoverable, just not programmatically associated with the control (noid, noaria-describedby). Nothing is lost, only unlinked. That is a real WCAG 1.3.1/4.1.2 gap and it should be fixed, but it is a 17-component change with its own design question (doesField/ConfigGroupgrow the association, or each editor?) and folding it in here would make this diff unreviewable. Filed as follow-up, not silently skipped.The line this PR draws: fix where the reason is absent from the accessibility tree entirely; defer where it is present but unlinked; reject where there is no reason to preserve.
Testing
Vitest + React Testing Library, matching the house idiom (component hooks mocked at the hook boundary; no
@tauri-apps/api/coremocking anywhere).New coverage, written before the fix and watched fail:
ui/Button.test.tsx(5) — unavailable button is queryable by role and nottoBeDisabled(); accessible description carries the reason; accessible name stays clean; click and Enter/Space are ignored; non-activation keys still reachonKeyDown; available button clicks normally; nativedisabledstill works, which is the regression guard for the other 36 call sites.projects/ProjectRow.test.tsx(4 new/rewritten) — the pre-existingtoBeDisabled()assertion failed on this change and was rewritten, which is the honest signal that behaviour moved.projects/AddProjectDialog.test.tsx(5, new file) — includingfireEvent.submit(form)while loading, which exercises the second guard specifically: Enter inside a text field submits a form without ever touching the submit button, sohandleSubmithas to guard itself. The hook cannot cover that path.The click guard was mutation-tested, not assumed. Neutering both guards in
useUnavailable(passing the raw handlers through) fails exactly the two guard tests and nothing else:Guards restored, and the numbers:
Baseline on
mainwas 50 files / 643 tests. Nothing undersrc-tauriwas touched, socargo testwas not run.Deliberately not done
disabledReasonassociation gap described above.Tooltip.tsxhas norole="tooltip"and noaria-describedbywiring at any of its 27 call sites, so it is visual-only. Out of scope here, but it is adjacent and real.feat/project-notes; this branch is cut fromorigin/main.🤖 Generated with Claude Code
https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
disabledaa0a574091