Skip to content

fix(frontend): badge trigger is not exposed as a button (audit finding 3) - #62

Merged
Shaan-alpha merged 3 commits into
mainfrom
fix/badge-row-a11y-and-activity
Jul 31, 2026
Merged

fix(frontend): badge trigger is not exposed as a button (audit finding 3)#62
Shaan-alpha merged 3 commits into
mainfrom
fix/badge-row-a11y-and-activity

Conversation

@Shaan-alpha

@Shaan-alpha Shaan-alpha commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Scope narrowed after browser verification. This PR started as findings 2 and 3 from the audit in #59. Finding 2 turned out not to be a real bug — verified against Chrome and reverted in the second commit. What ships here is the a11y fix only. History kept rather than squashed, because the negative result is the useful part.

What ships: the badge trigger was not a button (finding 3)

The trigger renders through Badge, which is a <span>. Base UI defaults nativeButton to true, so it put type="button" on a span and left the element with no role at all.

Confirmed in real Chrome via the accessibility tree, not just the attribute:

role attr Chrome AX tree role
main null generic
this PR button button

generic means the evidence behind every badge was unreachable to a screen reader. The comment above the component claimed "Popover.Trigger renders a <button>" — a Base UI upgrade made that false at some point. Base UI has been warning about it at runtime the whole time (expected a native <button> because the nativeButton prop is true); the warning is even in the known-noise list in .claude/skills/verify, so it was being actively ignored rather than missed.

nativeButton={false} tells Base UI the truth, and useButton then applies role="button" plus the non-native keyboard handling. Confirmed against the Base UI docs rather than guessed.

What got reverted: the Activity popover leak (finding 2)

The audit's jsdom probe suggested that because Popover.Portal mounts into document.body — outside the <Activity> boundary — a popover pinned open when the user hits browser-back would stay painted over the next route. I wrote a fix for it. Real Chrome says there is no such bug.

The first probe run was itself wrong and worth flagging: it used Page.navigate to reach the report, which is a full document load, so "back" tore the document down and the popup was trivially gone on every branch. That proved nothing. Re-driven properly — land on /, navigate client-side through the app's own search bar so the App Router keeps / alive in an Activity boundary, pin a popover, then history.back():

main (unfixed), after browser-back:
  popup_after_back: { present: true, w: 0, h: 0,
                      hiddenAncestor: true, painted: false }

React's display: none does reach the portal in a real browser. The popup is already not painted without any fix. happy-dom's portal handling differs from Chrome's, and that difference is the whole of the original "evidence."

So the fix bought nothing and cost controlled Popover.Root state, a useLayoutEffect, direct DOM manipulation of the positioner, and an eslint-disable. Reverted.

The vitest suite now has no <Activity> test for this component, with a comment explaining why — a test that only fails under happy-dom is asserting the test environment, not the product.

Verification

vitest run 76 passed / 24 files, tsc --noEmit clean, eslint src clean, plus the CDP run above against both branches.

Console errors during the run were unchanged from the known-noise set (getServerSnapshot, uncached-promise suspension) with one difference in this PR's favour: the Base UI nativeButton warning is present on main and absent here.

Not drivable locally, so not claimed either way: signed-in and /share/{slug} variants (no DATABASE_URL/OAuth in backend/.env).

Merge order

CI here stays red on Backend until #60 merges — main is red from a fixture time bomb unrelated to this. No file overlap with #59, #60, #61 or #63. CHANGELOG/PROGRESS_LOG entries for the batch are consolidated in #59.

…idden

Two audit findings in one component.

a11y (finding 3): the trigger renders through Badge, which is a <span>,
while Base UI defaults nativeButton to true -- so it emitted type="button"
on a span and left the element with no role at all. Screen readers
announced an unlabelled span. The code comment claimed Popover.Trigger
renders a <button>; a Base UI upgrade had made that false, and Base UI was
warning about it at runtime. nativeButton={false} makes it apply
role="button" plus the non-native keyboard handling.

Activity (finding 2): Popover.Portal mounts the popup into document.body,
outside the <Activity> boundary, so display:none on the boundary never
reaches it and a popover pinned open when the user hits browser-back stays
painted over the next route.

Worth recording that the fix the Next guide prescribes -- setOpen(false)
in a useLayoutEffect cleanup -- is NOT sufficient for portaled content.
Verified directly: the cleanup runs, but the update is scheduled against a
subtree React has just hidden, hidden Activity content re-renders at low
priority, and data-open is still on the popup afterwards. Nothing hides
the portal in the meantime. So drop the positioner out of the layout
synchronously in the cleanup, then close the state for the re-show.
Copilot AI review requested due to automatic review settings July 31, 2026 02:54

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…rowser

Verified finding 2 against real Chrome over CDP and it is not a bug. The
jsdom probe that motivated it was an artifact of happy-dom's portal
handling.

Method: land on /, navigate client-side via the search bar so the App
Router keeps / in an Activity boundary (Page.navigate is a full document
load and tears the popup down regardless -- the first probe run made
exactly that mistake and proved nothing), pin a popover open, then
history.back(). On unmodified main the popup already reports
hiddenAncestor: true, painted: false, w=0, h=0. React's display:none does
reach the portal in a real browser.

So the fix bought nothing and cost controlled state, a layout effect,
direct DOM manipulation and an eslint-disable. Reverted. The a11y half of
the PR is unaffected and independently confirmed: Chrome's accessibility
tree reports the trigger as 'generic' on main and 'button' with the fix.
@Shaan-alpha Shaan-alpha changed the title fix(frontend): badge popover a11y role + Activity leak (audit findings 2 and 3) fix(frontend): badge trigger is not exposed as a button (audit finding 3) Jul 31, 2026
Shaan-alpha added a commit that referenced this pull request Jul 31, 2026
Browser verification showed the popover Activity leak is not a real bug --
main already reports painted:false after browser-back. The fix was
reverted in #62 and the user-facing changelog line is removed rather than
left claiming a fix for something that was never broken.

Also records both process lessons: the first CDP run used Page.navigate
(a full document load) and proved nothing, and a jsdom-only failure is not
evidence of a product bug when portals, layout or paint are involved.
@Shaan-alpha
Shaan-alpha merged commit 5cd73eb into main Jul 31, 2026
5 checks passed
@Shaan-alpha
Shaan-alpha deleted the fix/badge-row-a11y-and-activity branch July 31, 2026 03:39
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.

2 participants