fix(sessions): make the capped project-notes list reachable by keyboard - #4831
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of The PR's actual diff (via Design-Verdict: PASS Real regression (keyboard trap from #4779's height cap), fixed at root with the standard focusable-scroll-region pattern; scope, semantics, and tests all proportionate. [DESIGN-REVIEWED] 2d74e54 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS A narrow, well-evidenced keyboard-access fix: the trapped notes list gains a tab stop, an accessible name reusing the visible heading, and a focus ring verified in both theme screenshots. [UX-REVIEWED] 2d74e54 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe change is a clean, consistent accessibility fix. The ref moves from No findings. [OPUS-REVIEWED] 2d74e54 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of Both data files are read; I verified the claims against the repository (existing suppressions, the First-Principles-Verdict: CONCERNS The keyboard fix earns every attribute it adds, but the "rail" de-jargoning rides along and is itself a point patch. What this change shipsIntent: let a keyboard-only user reach project notes below the fold that #4779's height cap made pointer-only — a FIX.
The fix duplicates nothing: grep for Watch
[FIRST-PRINCIPLES-REVIEWED] 2d74e54 |
Do not merge on the green label yet — this head carries a known a11y regression
Also outstanding on this head, both legitimate:
All three are fixed and verified locally: the tab stop and name move to the scroller They are not pushed because this branch's push authorization is per-push and the initial |
9cb2bc4 to
2d74e54
Compare
Disposition — Opus 4.8 Review (advisory FINDING): fixed in
|
Disposition — Design Review (✅ PASS, Suggestion): fixed in
|
Disposition — UX Review (✅ PASS, Suggestion): fixed in
|
Disposition — First Principles Review (🟡 CONCERNS): fixed in
|
bolichen97
left a comment
There was a problem hiding this comment.
Approved after a description-vs-diff consistency review: every claim in the PR description is backed by the diff, and the diff carries no material change the description leaves unmentioned.
Problem / Motivation
#4779 bounded the session-summary panel's "How this project works" footer at 33vh with
internal scrolling. That turned it into a scroll region whose only children are plain
<li>text — nothing focusable, and notabIndexon the container itself.So on a session carrying more notes than fit, everything below the fold is reachable by
pointer only. A keyboard-only user can open the section and then cannot move through it.
This is a regression that PR introduced rather than a pre-existing gap: before the cap the
footer had no ceiling, so it grew to fit every note and nothing was ever out of reach.
Adding the ceiling is what created the trap.
The open-items card above it is unaffected, and for a reason worth stating because it is
why the fix lands on only one of the two capped regions: its rows are
<button>s, so theyare already in the tab order and the browser scrolls each into view as focus moves.
Why it matters
WCAG 2.4.7 aside, the failure is total for the affected cohort rather than cosmetic: the
content is not merely awkward to reach, it is unreachable. It also fails silently — the
section opens, the first few notes render, and nothing signals that the rest exists but
cannot be got to without a mouse.
What changed (motivation → approach → change)
Give the scroll region a tab stop, a name, and a visible focus indicator — on the
SCROLLER, not on the list. The scroller is a wrapping
div; the<ul>inside it stays aplain list. That split is load-bearing rather than incidental: an explicit
roleREPLACESan element's implicit one, so
role="region"on the<ul>would stop its<li>s beingexposed as
listitems, costing "list, N items" and list navigation for exactly the cohortthis fix is for. It matches the existing precedent for a bounded scroll region in this
codebase (
SubagentCompletionCard's transcript body).tabIndex={0}so the region enters the tab order and arrow keys then scroll it natively.It has to be the scroller itself, not an outer wrapper: arrow keys scroll the focused
element's own scroll box, so a tab stop one level up would leave the list unscrollable.
role="region"plus an accessible name, so a keyboard user who lands there is told whatthey landed in rather than hearing an unlabelled group. The name reuses the existing
project_notescatalog key rather than adding a string — a new key would land in 13locale catalogs and interact with the dead-key ratchet for no benefit, since the visible
heading already says exactly this.
focus-visible:ring-2 ring-insetrather than an outline. An outline on a scrollcontainer is clipped to a hairline along one edge, which is not a visible focus
indicator; an inset ring is drawn inside the box and survives.
One browser behaviour is worth recording, because it changes what the evidence proves.
Chromium 145 puts a scroll container with no focusable children into the tab order by
itself (keyboard-focusable scrollers). Measured, not assumed: with
tabIndexremoved,Tab still reaches the region in the Playwright harness. Safari and Firefox implement no such
behaviour, and the dashboard supports both, so the attribute stays — but it means a
Chromium-based harness cannot be the thing that pins it. The jsdom unit test is, since
jsdom has no equivalent. The harness instead asserts the three things Chromium does not
supply: the accessible name, the surviving list semantics, and a visible focus indicator.
jsx-a11y/no-noninteractive-tabindexfires here and is a false positive. A boundedscroll region legitimately needs a tab stop — that is the requirement, not a violation of
it. Suppressed with a scoped
eslint-disableblock carrying the reason, matching the fourexisting suppressions in this repo (
CodeBlock,ActivityViewer,ui.tsx,SpecDetail).The block form is deliberate:
disable-next-linelands on the element's opening line whilethe rule reports against
tabIndexseveral lines below it, so the directive reads as unusedand the warning still fires.
Also in this diff: the "rail" vocabulary is removed wherever it appears. "Storage rail"
is language invented while writing #4779, not something a reader of the settings UI has any
reason to know. Both
session_summaryhelp strings now lead with the plain-languageconsequence — that what exceeds the ceiling is dropped from the record rather than hidden —
and the term is gone from the two
docs/system-specs/modules/session-summary.mdrowsdocumenting those same keys plus one code comment, so the settings UI and the module spec do
not disagree. Removing it from the help text alone would have left them diverged, which is
worse than not starting. Text only; no default, validator, or behaviour changes.
Tests
the bounded notes list is reachable by keyboard(vitest, new) — asserts the namedregion carries
tabindex="0"and takes focus, that it is the wrappingdivrather thanthe
<ul>, and that the list is still exposed as a list with its items intact. This isthe assertion that actually pins the tab stop, for the Chromium reason above.
Mutation-verified twice: removing
tabIndexfails exactly this test and no other, andputting
role="region"back on the<ul>fails it again on the list-semantics half.focus lands on the notes region and it is NAMED(Playwright harness, new) — tabsforward from the disclosure button and asserts the focused element is the region and
carries a non-empty accessible name.
the notes list inside it is still exposed as a list(Playwright harness, new) —reads the
<ul>'s role and requires it to remain implicit, which is what catches therole-clobbering regression in the browser as well as in jsdom.
the focused region shows a visible focus indicator(Playwright harness, new) —reads the focused element's computed
box-shadow, since Tailwind's ring utilities renderas one; an empty value means the reader cannot see where focus went.
The name and ring assertions were mutation-verified together — removing the
aria-labeland the ring classes fails both and nothing else, so neither is decorative.
They earned their keep here: moving the scroller to the
divbroke the notes measurementuntil it was retargeted, because the two-sided checks refuse a list that reports natural
content height instead of a bound one.
Manual verification
The focus ring is a rendered state that could not previously be reached at all, so it is
evidenced by a real frame rather than the no-visual-delta waiver. Captured with the repo's
own scripted Playwright harness (
website/scripts/capture-session-summary-height-caps.mjs)at a 1400x900 viewport, then read to confirm the ring renders rather than trusting the class
name — the computed value is a 2px inset ring in the accent colour.
At rest there is no visual delta:
role,aria-labelandtabIndexrender nothing, andthe ring paints only while the region holds keyboard focus.
Screenshots / video
The notes region holding keyboard focus, reached by tabbing from the "How this project
works" disclosure button — the 2px inset accent ring is the indicator that was missing:
Light theme, same focused state
Related Issues
no linked issue: this is a follow-up to #4779, raised by that PR's UX review after it had
already merged, so there is nothing filed for it to close.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)