Skip to content

a11y(admin/accounts): say what unlinking a member's Discord costs - #111

Merged
guarzo merged 1 commit into
mainfrom
worktree-admin-discord-unlink-adjacency
Aug 5, 2026
Merged

a11y(admin/accounts): say what unlinking a member's Discord costs#111
guarzo merged 1 commit into
mainfrom
worktree-admin-discord-unlink-adjacency

Conversation

@guarzo

@guarzo guarzo commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Executes the three scope items in docs/superpowers/plans/2026-08-05-admin-discord-unlink-adjacency.md. Rebased on #108 — one of the three turned out to be already done there.

What changed

The admin Discord cell's unlink sits beside an inert linked token and disconnects someone else's account, but announced only unlink Discord for <member>. Nothing about what unlinking does.

  • aria-describedby on the unlink ConfirmSubmit, pointing at a .visually-hidden sentence: "Unlinking queues removal of the Discord roles authGD manages for this member." Carried as a description, not folded into the accessible name — the name is spoken ahead of every press and has to keep matching the visible label (WCAG 2.5.3).
  • "queues removal", not "removes". unlinkDiscord ends in enqueueSync (services/discord-link.ts:135-138); the roles come off in the worker. Same verb a11y(account): separate the unlink control from the LINKED it sits beside #108 chose on the account page, for the same reason.
  • e2e asserts the description at rest and armed. ConfirmSubmit swaps aria-label on arm (confirm-submit.tsx:139) while leaving aria-describedby alone (:140) — the description has to survive that swap, and nothing else in the suite would notice if the id link broke.

Plan items that produced no code

.inline-pair gap (--s-1--s-3). Already landed in #108. It has exactly two call sites, so admin/accounts/page.tsx was covered for free. I had this change and dropped it rather than duplicate-and-conflict.

restName/confirmName convention across the row's destructive controls. Checked; already conforming. revoke and freeze both name their row the same way unlink does. No change.

Why hidden always, and not ConfirmCost

#108's ConfirmCost reveals the consequence when the action arms. It reads scope-level arm state, and this table has one tbody-wide ConfirmArmScope (page.tsx:282) over three confirms per row — unlink, revoke/promote, freeze/wake — across every row. Arming any one control would reveal every row's cost. ConfirmCost's own doc says it is correct only in a scope holding one ConfirmSubmit. When it takes a per-control id, this cell should switch to it; giving this one cell its own ConfirmArmScope to get there early would break the single-armed-control-per-table invariant the shared scope exists to hold.

Reveal-on-arm is also wrong for a table row on its own terms: the hint reflows the row, and ConfirmSubmit disarms on pointerleave for mouse users (confirm-submit.tsx:162-170) — the button sliding out from under a stationary pointer would disarm the control the admin just armed. Absolute positioning is no escape, since .scroller is a scroll container and the hint would extend the scrollable area rather than float clear of it.

.visually-hidden is itself position: absolute, so the sentence costs no row height — which is the point, given this renders once per account and scanning is the primary act (PRODUCT.md principle 3).

Verification

  • npm run format:checkAll matched files use Prettier code style!
  • npm run typecheck — clean
  • npm run lint — 0 errors, 1 pre-existing warning (ui.tsx:109, <img>, untouched)
  • npm test74 files, 957 passed
  • npm run test:e2e -- admin.spec.ts41 passed

The new e2e assertion was mutation-tested: renaming the span's id produces a genuine failure (Received: ""), not a silent pass.

Out of scope per the plan: making linked clickable, any modal/tooltip, and anything in src/app/account/.

The admin Discord cell's `unlink` sits beside an inert `linked` token and
disconnects someone else's account, but announced only "unlink Discord for
<member>" — nothing about what unlinking does.

- `aria-describedby` on the unlink ConfirmSubmit, pointing at a
  `.visually-hidden` sentence: "Unlinking queues removal of the Discord roles
  authGD manages for this member." Carried as a description, not folded into
  the accessible name, which has to keep matching the visible label (WCAG
  2.5.3) and is re-spoken on every press.
- Hidden always rather than revealed on arm. The account page's `ConfirmCost`
  (#108) reads scope-level arm state; this table has one tbody-wide
  ConfirmArmScope over three confirms per row, so arming one control would
  reveal every row's cost. `.visually-hidden` is `position: absolute`, so the
  sentence costs no row height.
- "queues removal", not "removes" — unlinkDiscord ends in enqueueSync and the
  roles come off in the worker.
- e2e asserts the description at rest and armed: ConfirmSubmit swaps the
  accessible name on arm while leaving aria-describedby alone, and nothing else
  in the suite would notice if the id link broke.

The plan's `.inline-pair` gap item is already covered by #108 (--s-1 to --s-3,
both call sites). The restName/confirmName convention check found the row's
revoke and freeze controls already conforming; no change needed.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2bd7f186-d727-4522-b768-249687c329fe

📥 Commits

Reviewing files that changed from the base of the PR and between fc699c0 and d700ba7.

📒 Files selected for processing (2)
  • e2e/admin.spec.ts
  • src/app/admin/accounts/page.tsx

Comment @coderabbitai help to get the list of available commands.

@guarzo
guarzo merged commit 81b20bc into main Aug 5, 2026
6 checks passed
guarzo added a commit that referenced this pull request Aug 5, 2026
…e scope

ConfirmCost read `armedId !== null` — "something in this scope is armed". That
is correct in a scope holding one control and silently wrong in every other one,
and #111 hit it: the admin accounts table wraps every row in one
ConfirmArmScope, so it had to ship its cost sentence hidden always and asked for
a per-control id. This is that id.

ArmContext now carries the armed control's `describedBy` beside its useId, in
one state object so a render can never see the new id with the previous
description. A scope may now hold any number of controls and any number of cost
sentences; controls sharing a sentence reveal it together.

No behaviour change today. Both current call sites are scopes of one, so
nothing observable differs, and there is no test: the difference needs a click,
and the repo has no jsdom/testing-library — renderToStaticMarkup cannot arm a
control. This lands as the footgun removal #111 asked for, not as a fix for a
bug anyone can currently reach.

The doc also records what #112 found while trying to use it in the admin table,
which is the more expensive half of this to rediscover: revealing on arm inside
a td widens the cell, the widening moves the armed button out from under a
stationary mouse, pointerLeave fires, and the control disarms itself. The admin
sentence stays visually-hidden always. Measured, not reasoned about — the first
attempt appeared to work only because flex-basis:100% resolved against an
inline-flex parent and squeezed the note to 77px instead of its intended 34ch.
guarzo added a commit that referenced this pull request Aug 5, 2026
…e scope (#112)

ConfirmCost read `armedId !== null` — "something in this scope is armed". That
is correct in a scope holding one control and silently wrong in every other one,
and #111 hit it: the admin accounts table wraps every row in one
ConfirmArmScope, so it had to ship its cost sentence hidden always and asked for
a per-control id. This is that id.

ArmContext now carries the armed control's `describedBy` beside its useId, in
one state object so a render can never see the new id with the previous
description. A scope may now hold any number of controls and any number of cost
sentences; controls sharing a sentence reveal it together.

No behaviour change today. Both current call sites are scopes of one, so
nothing observable differs, and there is no test: the difference needs a click,
and the repo has no jsdom/testing-library — renderToStaticMarkup cannot arm a
control. This lands as the footgun removal #111 asked for, not as a fix for a
bug anyone can currently reach.

The doc also records what #112 found while trying to use it in the admin table,
which is the more expensive half of this to rediscover: revealing on arm inside
a td widens the cell, the widening moves the armed button out from under a
stationary mouse, pointerLeave fires, and the control disarms itself. The admin
sentence stays visually-hidden always. Measured, not reasoned about — the first
attempt appeared to work only because flex-basis:100% resolved against an
inline-flex parent and squeezed the note to 77px instead of its intended 34ch.
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