Skip to content

Harden frontend accessibility outside Settings#329

Merged
parsakhaz merged 3 commits into
mainfrom
contribution
Jul 13, 2026
Merged

Harden frontend accessibility outside Settings#329
parsakhaz merged 3 commits into
mainfrom
contribution

Conversation

@parsakhaz

@parsakhaz parsakhaz commented Jul 12, 2026

Copy link
Copy Markdown
Member

Summary

  • replace non-semantic desktop and Remote Pane interactions with keyboard-operable controls and complete composite-widget behavior
  • move dialogs, nested portals, tooltips, dropdowns, and selects onto shared accessible focus and portal contracts
  • add deliberate live-region announcements for meaningful progress, errors, and connection changes
  • enforce selected jsx-a11y rules and add maintained Playwright/axe journeys to minimal CI

Scope

Direct Settings dialog implementation work is intentionally excluded because it is being handled separately. This PR does not modify frontend/src/components/Settings.tsx; it only preserves Settings compatibility through shared Modal and Select contracts.

Refs #323

Testing

  • pnpm typecheck
  • pnpm lint (0 errors; 167 existing warnings)
  • pnpm --filter frontend test (83 passed)
  • pnpm run build:frontend
  • pnpm test:ci:minimal (26 passed, including 7 axe journeys)
  • pnpm test -- tests/settings.spec.ts (15 passed)
  • pnpm test -- tests/dropdown-keyboard-nav.spec.ts (3 passed)
  • rendered axe pass across all 12 themes, including seeded Night Owl recent-pane metadata
  • About dialog desktop and 200%-equivalent reflow/scroll verification
  • disconnected Remote Pane mobile verification at 390x844

The connected Remote Pane path was not exercised against a live host; automated QA covers it through a route-mocked host and covers the disconnected journey through the Electron API mock. No visual redesign is included.

Automated manual QA

Status: Passed and ready for review.

  • Tested commit 2efe26af36b35a175c7eef0fabf14ca556e6f74a with marker agent-e2e-pr-329-20260713-fixed.
  • All first-pass findings are resolved: branch-list Escape ownership, Advanced switch names, Remote button contrast, Night Owl metadata contrast, and malformed-code feedback.
  • Maintained accessibility journeys: 7/7 passed; minimal CI: 26/26; frontend unit tests: 83/83; typecheck, lint, and production frontend build passed.
  • GitHub Actions: 16/16 checks passed across smoke, main-process, and Runpane wrapper matrices.
  • Connected Remote Pane used a strict route-mocked host. A live-host smoke remains useful but is not blocking this accessibility QA result.
  • Detailed findings and screenshot evidence
Desktop Create Pane Connected Remote Pane
Desktop Create Pane after accessibility fixes Connected Remote Create Pane after accessibility fixes

Add accessible dialog, composite widget, live-region, remote UI, and semantic control contracts outside Settings. Enforce selected jsx-a11y rules and add maintained Playwright/axe coverage.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@parsakhaz

parsakhaz commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Automated manual QA: passed after fixes

Status: Ready for review. Every finding from the first QA pass is fixed and covered by maintained regression assertions.

  • Commit: 2efe26af36b35a175c7eef0fabf14ca556e6f74a
  • Marker: agent-e2e-pr-329-20260713-fixed
  • Environment: Chromium + repository Electron API mock. Connected Remote Pane used an isolated route-mocked host and fake event stream; no product or external data was touched.
  • Settings boundary: tested only shared Modal and Select compatibility; no direct Settings source was modified.

Resolved findings

  1. Desktop and Remote Create Pane branch comboboxes now consume the first Escape without dismissing their owning dialogs.
  2. Desktop Start pinned and Use worktree switches now have accessible names; the expanded Advanced state is axe-clean.
  3. Remote primary-button foreground/background pairs now pass axe contrast checks through semantic tokens.
  4. Night Owl recent-pane tertiary metadata now has an AA safety margin and is protected by a seeded axe journey.
  5. Malformed pane-remote:// codes now render and announce a specific error without unrelated Tailscale troubleshooting copy.

Verification

  • Maintained accessibility journeys: 7/7 passed, including both first-Escape paths, expanded switches, Night Owl seeded metadata, malformed-code error, and connected Remote Create Pane.
  • Minimal CI: 26/26 passed.
  • Frontend unit tests: 83/83 passed.
  • Workspace typecheck: passed.
  • Lint: passed with 0 errors and the same 167 existing warnings.
  • Production frontend build and xterm request-mode verification: passed.
  • GitHub Actions: 16/16 checks passed across smoke, main-process, and Runpane wrapper matrices.
Fixed-state evidence
State Preview
Night Owl seeded recent-pane metadata after the AA token correction. Night Owl Home screen with axe-clean recent pane metadata
Desktop Create Pane remains open after the branch-list Escape; Advanced switches are named and axe-clean. Desktop Create Pane advanced options after Escape handling fix
Malformed Remote code displays a specific alert and no unrelated host troubleshooting. Remote Pane invalid connection code error
Connected Remote Create Pane remains open after first Escape and its primary action is axe-clean. Connected Remote Create Pane after Escape and contrast fixes

Remaining human check

The connected Remote journey was exercised against a strict route mock rather than a live Pane host. A live-host smoke is still useful but is not blocking this accessibility QA result. Screenshot evidence is temporary x0.at content with size-based 3-100 day retention.

Keep Create Pane dialogs open when branch popups consume Escape, name advanced switches, and correct affected contrast tokens. Surface malformed Remote connection codes and cover the repaired desktop and Remote states in maintained axe tests.

@parsakhaz parsakhaz left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

PR Review

Issue context: #323 — harden frontend accessibility beyond dropdown navigation: replace non-semantic clickables, put AboutDialog on the shared accessible dialog contract, add deliberate live-region announcements, complete custom ARIA widget keyboard behavior, and add lint + axe guardrails.

Quality Gates

  • Typecheck: PASS
  • Lint: PASS (0 errors; jsx-a11y subset enforced as errors)

Must-Fix (1)

  1. frontend/src/components/ProjectSessionList.tsx:750 — sidebar Archive/Pin buttons are unreachable by mouse. The SessionRow content wrapper is relative z-10 pointer-events-none contents. display: contents removes the element's box, so position/z-index never apply — the children are painted as plain in-flow content. Meanwhile the row-select overlay <button className="absolute inset-0 z-0"> is positioned, and positioned elements with z-index: 0 paint above in-flow siblings. Result: pointer clicks on Archive/Pin land on the overlay and select/open the pane instead. Verified in Chromium with a reduced repro: document.elementFromPoint over the Archive button returns the overlay, and a real mouse.click fires the overlay handler. (Keyboard still works because Tab reaches the real buttons — which is why the axe/keyboard journeys didn't catch it; the accessibility spec only asserts the buttons toBeAttached.) Fix: give the actions container its own stacking, e.g. relative z-10 pointer-events-auto on the actions div (the pattern ExecutionList and DiffViewer already use), or drop contents and restore a real positioned wrapper. Consider adding a Playwright assertion that clicking Archive fires the archive action, not row selection.

Should-Fix (3)

  1. frontend/src/components/panels/PanelTabStrip.tsx:333aria-owns references a nonexistent id during rename. The tablist's aria-owns always lists every panel's tab id, but while a tab is being renamed (isEditing) its tab button isn't rendered, so the reference dangles. That's an aria-valid-attr-value (WCAG A) violation — the very axe gate this PR adds would flag it if a scan ran mid-rename. Exclude the editing panel from the aria-owns list.
  2. frontend/src/components/panels/PanelTabStrip.tsx:210 — close-tab focus fallback can jump strips. document.querySelector('[role="tab"][tabindex="0"]') is a global query; with split panel groups there are multiple tablists, and closing the last tab in one group can move focus to a tab in a different strip (or the top bar). Scope the fallback to the current strip, e.g. query ids prefixed with panel-tab-${safeDomId(idNamespace)}-.
  3. frontend/src/components/PaneChatView.tsx:141 — focus is dropped mid-interaction while switching agents. Arrowing/clicking a radio sets switchingAgent, which immediately disableds both radios including the focused one — keyboard focus falls to <body> and is not restored when the switch completes. Prefer keeping the inputs enabled and guarding in onChange (the existing switchingAgent check already does this), or refocus the active radio after the switch resolves.

Suggestions (3)

  1. frontend/src/components/panels/PanelTabStrip.tsx:330 — the empty role="tablist" div with display: contents + aria-owns is a clever way to keep drag/drop DOM structure, but it's a fragile pattern (older non-Chromium engines dropped display: contents elements from the accessibility tree; fine in Electron's Chromium). Worth a short comment explaining why the tablist is detached from its tabs so a future refactor doesn't "simplify" it into a broken state.
  2. frontend/src/components/DetailPanel.tsx:575aria-label="Commit history" on a plain div (no role) is not reliably exposed; since this is a focusable scroll region, add role="region" so the label lands.
  3. frontend/src/remote/components/RemoteConnectionScreen.tsxshowConnectionTroubleshooting decides whether to show the Tailscale hint by regex-matching error copy (/^(Connection code|Invalid remote Pane connection code)/). This couples UI logic to message strings that live elsewhere; a structured error kind (or error code) would be sturdier.

Completeness

Against #323's acceptance criteria:

  • ✅ Non-semantic clickables replaced with native buttons across Sidebar, ExecutionList, DiffViewer headers, ProjectSessionList, ProjectDashboard rows, FileEditor search results, HomePage, GitStatusIndicator, GitHistoryGraph, SessionView pills.
  • ✅ AboutDialog consumes the shared Modal (now Radix Dialog) with title, focus containment, and opener restore — including the update-handoff case that intentionally skips restore.
  • ✅ Live regions: shared LiveRegion component, deliberate announcements for archive progress, dashboard loads, updates, copy actions, session status, remote connection state; terminal output correctly not announced. Nice touch keeping regions mounted in null branches (ArchiveProgress) so announcements actually fire.
  • ✅ Composite widgets: roving-tabindex tabs (desktop + remote), comboboxes with aria-activedescendant (CommandPalette, branch pickers, FilePathAutocomplete), menu keyboard nav, native radio group for Pane Chat, and the joystick as a real input[type=range] with aria-valuetext.
  • ✅ Lint guardrails: jsx-a11y rules as errors, CI-enforced, no ignore-wall.
  • ⚠️ Axe journeys cover Home, session creation, active-session workflow, Pane Chat, themes, and both Remote paths — but not Settings, which #323's criteria list. The PR explicitly defers Settings implementation work (#328-adjacent scope note), which is reasonable, but the Settings axe journey should land with that follow-up so the criterion isn't silently dropped.
  • ✅ Manual pass documented (12 themes, 200% reflow, mobile viewport); --owl-300 and interactive-primary contrast fixes came out of it.

Summary

This is a thorough, well-architected accessibility pass — the Radix Dialog migration with the portal-container context (so nested tooltips/selects/dropdowns stay inside the dialog's focus boundary) is the standout piece, and the test infrastructure (axe helper, seedable Electron API mock) is a durable investment. One real regression must be fixed before merge: the sidebar Archive/Pin buttons lose mouse clicks to the row overlay (finding 1). The remaining items are small. After fixing #1 (and ideally #2–4), this is ready to merge.

className="absolute inset-0 z-0 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-interactive"
/>
</Tooltip>
<div className="relative z-10 pointer-events-none contents">

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Must-Fix: display: contents removes this element's box, so the relative z-10 here never applies — children become plain in-flow content. The sibling overlay button (absolute inset-0 z-0) is positioned, and positioned elements with z-index: 0 paint above in-flow siblings, so pointer clicks on the Archive/Pin buttons hit the overlay and select the pane instead of archiving/pinning. Verified in Chromium: document.elementFromPoint over the Archive button returns the overlay, and a real click fires the overlay's handler.

Fix: move the stacking onto the actions container itself, e.g. change the actions div to relative z-10 pointer-events-auto ... (the pattern ExecutionList/DiffViewer use), or drop contents in favor of a real positioned wrapper.

<div
role="tablist"
aria-label="Panel tabs"
aria-owns={panels.map((panel) => getPanelTabId(idNamespace, panel.id)).join(' ')}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Should-Fix: while a tab is being renamed (isEditing), its tabButton isn't rendered, so this aria-owns references a nonexistent id — an aria-valid-attr-value (WCAG A) violation that the axe gate added in this PR would flag if scanned mid-rename. Exclude the editing panel:

aria-owns={panels
  .filter((panel) => panel.id !== editingPanelId)
  .map((panel) => getPanelTabId(idNamespace, panel.id))
  .join(' ')}

const preferredTarget = focusTarget
? document.getElementById(getPanelTabId(idNamespace, focusTarget.id))
: null;
const fallbackTarget = document.querySelector<HTMLElement>('[role="tab"][tabindex="0"]');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Should-Fix: this fallback is a global query. With split panel groups there are multiple strips, so closing the last tab of one group can move focus to a tab in a different strip. Scope it to this strip, e.g. document.querySelector(\[id^="panel-tab-${safeDomId(idNamespace)}-"][tabindex="0"]`)`.

name="pane-chat-agent"
value={option.id}
checked={selected}
disabled={switchingAgent !== null}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Should-Fix: disabling the focused radio the moment the user activates it drops keyboard focus to <body>, and it isn't restored when the switch completes. Since handleAgentChange already early-returns while switchingAgent !== null, consider leaving the inputs enabled (or using aria-disabled), or refocusing the selected radio in the finally block.

<div className="flex-1 min-h-0 overflow-y-auto px-2 pb-2">
<div
tabIndex={0}
aria-label="Commit history"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Suggestion: aria-label on a role-less div isn't reliably exposed to assistive tech. Since this is a focusable scroll region, add role="region" so the label takes effect.

Restore pointer access to sidebar actions, keep tab and radio focus semantics valid, and use structured Remote connection errors. Add Playwright coverage for the reviewed interaction states.
@parsakhaz

Copy link
Copy Markdown
Member Author

Addressed the linked review in 7867d1b:

  • restored mouse access to sidebar Archive/Pin actions and added real-click coverage
  • kept aria-owns valid during rename, scoped close focus fallback to the current strip, and labeled the rename input
  • preserved Pane Chat radio focus while agent switching is pending
  • exposed Commit history as a named region and expanded close-tab targets to 24x24
  • replaced Remote error-copy matching with a structured connection error kind

Validation: typecheck; lint (0 errors); frontend unit tests (83/83); frontend production build; minimal CI Playwright suite (26/26). Settings implementation remains untouched as requested.

@parsakhaz
parsakhaz merged commit 22d61d8 into main Jul 13, 2026
16 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