Skip to content

refactor(a11y): scope ConfirmCost to its own control, not to the whole scope - #112

Merged
guarzo merged 1 commit into
mainfrom
worktree-confirm-cost-control-scoped
Aug 5, 2026
Merged

refactor(a11y): scope ConfirmCost to its own control, not to the whole scope#112
guarzo merged 1 commit into
mainfrom
worktree-confirm-cost-control-scoped

Conversation

@guarzo

@guarzo guarzo commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Scope reduced. This started as "control-scoped ConfirmCost + the admin consequence sentence". #111 landed the admin sentence first, and investigating the overlap showed the reveal half is unsafe. What's left is one file.

What lands

ConfirmCost read ctx.armedId !== null — "something in this scope is armed". Correct in a scope holding one control, silently wrong in every other one. #111 hit exactly this: the accounts table wraps every row in one ConfirmArmScope, so it shipped its sentence hidden always and asked for a per-control 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.

What this does NOT do

No behaviour change, and no test. Both call sites are scopes of one, so nothing observable differs today. The difference requires a click, and the repo has no jsdom or testing-library — renderToStaticMarkup (the pattern in tests/account-page.test.ts) cannot arm a control. Adding a test framework for a behaviour-neutral change wasn't worth it. This is the footgun removal #111 asked for, not a fix for a bug anyone can currently reach. Reviewers should weigh it on that basis, including "not worth landing".

What was reverted, and why it's worth knowing

I tried to use this in the admin Discord cell so sighted admins would see the sentence too. It does not work, and #111's comment predicted the reason:

the button sliding out from under a stationary pointer would disarm the control the admin just armed

Confirmed by measurement. Revealing inside a td widens the cell; the widening moves the armed button off the stationary mouse; pointerLeave fires; the control disarms. The reveal undoes the arm. Measured armedCount=0, control snapped back to its rest box.

The first version of that attempt appeared to pass — because of a bug in my own CSS. flex-basis: 100% resolved against .inline-pair, which is inline-flex and shrink-to-fit, so the note was squeezed to 77px wide and 161px tall instead of the 34ch I'd documented; max-width: 34ch never bound. My tests asserted only "width > 1" and geometric containment, so neither noticed. Correcting the width to the intended value is what exposed the disarm.

That finding is now in the ConfirmCost doc comment. It's the expensive half to rediscover, and the next person reaching for a reveal in a dense layout should get it for free.

The member page can reveal because its Discord row is a dd in a .facts grid that already reserves a wrapping line, so nothing moves.

Verification

  • npm test75 files, 983 tests passed (117s)
  • npm run test:e2e -- e2e/admin.spec.ts e2e/account.spec.ts61 passed (1.0m)
  • npm run typecheck clean; npm run format:check clean; npm run lint → 1 pre-existing <img> warning, untouched by this diff

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The confirmation system now tracks the armed control’s description ID. Discord unlink rows use individual cost notices with accessible associations and responsive styling. End-to-end tests cover visibility, isolation, disarming, and button position.

Changes

Discord unlink confirmation

Layer / File(s) Summary
Per-control confirmation state
src/app/_components/confirm-submit.tsx
ArmContext stores the armed control ID and description ID. ConfirmCost reveals only the matching description.
Admin unlink cost notice
src/app/admin/accounts/page.tsx, src/app/globals.css
Each Discord unlink control references a per-account notice. The notice wraps below the controls and has a 34ch maximum width.
Unlink interaction validation
e2e/admin.spec.ts
End-to-end tests verify row isolation, hidden and armed states, Escape disarming, and stable button geometry.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Admin as Admin account page
  participant Submit as ConfirmSubmit
  participant Context as ArmContext
  participant Cost as ConfirmCost
  Admin->>Submit: Click Discord unlink control
  Submit->>Context: Arm control ID and describedBy ID
  Context-->>Cost: Provide armed control state
  Cost->>Cost: Match description ID
  Cost-->>Admin: Reveal matching cost notice
Loading

Poem

A rabbit clicks once, then waits,
Each row reveals its proper notes.
No neighbor’s warning hops across,
Escape sends armed state at a loss.
The button stays beneath my paws.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary ConfirmCost scoping change.
Description check ✅ Passed The description explains the change, rationale, verification results, scope, and known behavior in sufficient detail.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch worktree-confirm-cost-control-scoped
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-confirm-cost-control-scoped
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch worktree-confirm-cost-control-scoped

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

…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
guarzo force-pushed the worktree-confirm-cost-control-scoped branch from dd63131 to 2149ed8 Compare August 5, 2026 15:48
@guarzo guarzo changed the title a11y(admin): tell an admin what unlinking someone else's Discord costs refactor(a11y): scope ConfirmCost to its own control, not to the whole scope Aug 5, 2026
@guarzo
guarzo enabled auto-merge (squash) August 5, 2026 15:49
@guarzo
guarzo merged commit 9e60eb1 into main Aug 5, 2026
7 checks passed
guarzo added a commit that referenced this pull request Aug 6, 2026
…ilent failures (#129)

* fix(payouts): make the create screen say less and collect more, and give finalize somewhere to land

Seven changes to the payouts surface, all UI-side.

The /payouts/new lede ran four lines to say what one says: creating an
operation pays nobody. The optional battle report input comes back to that
screen, validated http(s)-only before any appraisal call, because the value
renders as a plain <a href> on the operation's own page.

Notes stop hiding behind an edit toggle. They are a standing field on the
operation, so the textarea is always open with a Save beside it, controlled
because the form never unmounts and an uncontrolled one would snap back the
instant the first save settled.

Finalize and Unlock stop revealing their cost sentence on arm: revealing
inside the .btn-row shoves the neighbouring control out from under the mouse,
the same reflow #112 found in a table cell. The sentence stays in the DOM as
the aria-describedby target, permanently visually-hidden, via a new
alwaysHidden prop rather than a second hand-rolled span.

Both controls also delete themselves on success, which left focus on <body>
and nothing announced. Focus now goes to the operation heading and the outcome
is announced, both from the action's own continuation and a live region on a
wrapper that outlives the button, because an effect inside the button never
commits at all.

* fix(payouts): keep the finalize announcement alive for an operator who cannot unlock

Any operator can finalize any draft, but only the creator or an admin can
unlock it. For an operator who is neither, a successful finalize turns every
disjunct of showLifecycle false at once and the whole lifecycle block goes
away — taking the announcer with it. That operator, uniquely, heard nothing:
the live region was unmounted by the very response it existed to describe.

The announcer now sits outside the gate, so it outlives the block it
describes. Covered by an e2e that hands the draft to a second operator and
asserts the announcement lands while every lifecycle control is gone;
verified by reintroducing the bug and watching it fail.

Also corrects the comments this diff got wrong: the claim that the admin
table would leak every row's cost (it matches per-control, as the paragraph
above it says), the reflow rationale for alwaysHidden on Finalize/Unlock
(those two can never render together, and the row grows rightward from a
fixed left edge — the real reason is that prose appearing mid-press reads as
an error), the "a useEffect never commits at all" wording, the claim that
setNotes cannot reject (assertEditable throws PayoutLockedError on a stale
page), and three enumerations that predate the battle report field.

NotesForm's `value` prop is renamed `initialValue` to match what it does: it
seeds state once and never re-syncs, which is what lets a half-typed note
survive an unrelated revalidation.

* fix(payouts): tell an operator their notes hit a freeze, instead of apologizing

The notes textarea is the one editable field that sits open for as long as the
operation is editable, so it is the one an operator can be mid-paragraph in
when the operation freezes underneath them: a second tab, or another operator
finalizing first. canEdit narrows that window and cannot close it. Uncaught,
assertEditable's throw landed on error.tsx — "tell an admin and quote this
reference" — for a race that was nobody's fault and no fault of ours.

setNotesAction now catches PayoutLockedError and redirects as ?error=locked.

This is a deliberate exception to the rule stated in this file, that input
rejections redirect and lifecycle errors belong on error.tsx. That rule holds
because no lifecycle error there has anything the operator typed at stake.
This one does. The code is worded generically rather than about notes, so the
other always-open editable fields can adopt it as they hit the same race.

The text is lost either way — that is what the freeze means. What changes is
that the operator learns the operation was finalized rather than being told we
broke. Covered by an e2e that freezes the row out from under an open textarea;
verified by disabling the catch and watching it fall back to the boundary.
guarzo added a commit that referenced this pull request Aug 6, 2026
* fix(a11y): give each Status tone its own shape, not just its own colour

`.st::before` set one filled dot for every tone; `.st--off::before` was the
only variant. For ok / warn / bad / neutral, colour was the only non-textual
signal, which WCAG 1.4.1 does not allow.

A member with red/green colour deficiency scanning a payouts list at 1am sees
an identical dot beside every operation and has to read each row's word to
tell a paid operation from an unpaid one; at payouts/page.tsx:167-190 three
branches render wording that differs only by tone, so there the distinction
was unrecoverable.

Neutral is now a thin bar, ok a circle, warn a triangle, bad a square, off the
hollow circle it already was. Width stays 0.5em in every rule — only height
and radius change — so the mono advance the comment at globals.css:1680-1687
depends on never moves.

The docblock at ui.tsx:223-225 already claimed the glyph carried the meaning.
This makes that true; it was a design intent the CSS never implemented.

* fix(a11y): make ConfirmSubmit's width reservation actually hold

The reservation was `Math.max(label.length, confirmLabel.length) + 4` in `ch`.
That cannot hold: `ch` is the advance of the "0" glyph alone, while the label
carries `letter-spacing` the browser inserts between every character pair, so
the shortfall scales with label length and a flat +4 cannot track it. It
overshot short labels and undershot long ones — /admin/accounts cleared by
~2px by accident, "Replace roster" fell ~23px short.

An operator who clicks the right-hand sixth of "Replace roster" arms it, the
button shrinks out from under their stationary pointer, pointerLeave fires,
the control disarms, and a destructive action reads as dead — so they press
it again. That is the trap #112 fixed elsewhere, reintroduced by arithmetic.

The wider label is now rendered as CSS generated content in flow but
`visibility: hidden`, sharing grid cell 1/1 with the real text, so the browser
measures it with the button's own font, weight, case transform and
letter-spacing. `content: attr()` is not a DOM node, so it cannot reach
`textContent` — several call sites assert exact button text with `toHaveText`,
which does read hidden real elements and would have picked up a duplicate.

The visible text carries its own `grid-area: 1 / 1`. As an anonymous grid item
it was auto-placed into row 2, which reserved the width correctly and doubled
the button's height; measured in a browser, not inferred.

* fix(a11y): give sign out the same hit target as the links beside it

A member ending their session on a phone at 1am aims at the smallest
control in the header — the only one in the bar that is not a link, on
all ten pages that render a header.

`.shell__signout`'s button is `.btn--quiet .btn--micro`, which carries
`min-height: 1.75rem`. DESIGN.md rations that 28px grade to `.btn--micro`
in admin table rows and nowhere else; every link beside it is already
`min-height: 2.25rem` with `padding: var(--s-2) var(--s-3)`, raised there
by an earlier sweep that pinned the geometry in e2e/shell.spec.ts and did
not carry the same fix across to the button.

Scoped override on `.shell__signout .btn` rather than dropping
`.btn--micro` from the markup, following `.inline-edit--standalone
.btn--quiet`: the micro grade also carries `font-size: var(--t-label)`,
which is what makes sign out read as one of the nav's own labels, so
removing the class to fix the box would have changed the type. The new
spec asserts both halves — 36px tall, and the same computed font size as
the links — and fails at 28px with the CSS reverted.

* refactor(ui): let ConfirmCost own its own register

`.confirm-cost` had exactly one rule in globals.css and it was layout
(`.facts__lead > .confirm-cost`, flex-basis). Everything about how the
cost sentence *looks* came from `className="dim"` passed by hand — by all
four call sites, identically, with no other value ever passed.

That made `className` a required argument dressed as an optional one: the
component decided when the sentence becomes visible but not what it
looked like once it got there. A fifth call site that forgot the prop
would have rendered the cost at full body size and full ink — reading as
primary copy at the exact moment the reveal puts it in front of a member
deciding whether to unlink their Discord or delete an operation.

`.confirm-cost` now carries `.dim`'s two declarations itself and the
passthrough is gone. No rendered text changes; the class list on each
span loses only `dim`.

* docs(ui): say which mechanism keeps the ghost label out of toHaveText

Review misread the ConfirmSubmit docblock as claiming `visibility: hidden`
is what stops Playwright picking up a duplicate. It does not, and the
comment did not quite say so — "would otherwise pick up a duplicate" had
no explicit antecedent, so the sentence read as being about the hidden
box rather than about the alternative that was rejected.

The real property is that `content: attr()` is never a DOM node, at any
visibility. What that rules out is a second real `<span>`, which
`toHaveText` would read (e2e/admin.spec.ts:569-571) and concatenate into
the button text several call sites assert exactly. Comment only.

* docs(css): correct why sign out uses a scoped override

The comment on `.shell__signout .btn` said dropping `.btn--micro` from the
markup would have changed the type. It would not: `.btn` sets
`font-size: var(--t-label)`, the identical value `.btn--micro` sets, so the
type register is the same either way.

The override is still the right shape, for a reason the comment did not give:
sign out is `.btn--quiet .btn--micro`, and `.btn--quiet` sets its own
`min-height: 1.75rem`. Removing the micro class would have left the box
exactly where it was — which is also why `.inline-edit--standalone
.btn--quiet` is written as an override rather than a class removal, while
`account/page.tsx:372-381` could just drop `--micro` and be done.

Comment-only. The rule and the spec pinning it are unchanged.
guarzo added a commit that referenced this pull request Aug 7, 2026
…169)

* perf(account): tighten the manifest row from 75px to 63px

The row's height floor was never the STATUS cell — it is the NAME cell's
two lines plus cell padding. Round 1 collapsed a cell that had 30px of
slack in a 50px block, so the pitch did not move.

Replaces #167's height-band assertion with a pitch ceiling. The band
required a located row to be 10-30px taller than a no-location row, which
is two lines by definition: it passed at 75px and would pass at 95px.

* feat(account): shorten the manifest's make-main control to 'main'

89px to 50px per character column, with the verb moved into a per-character
accessible name — the same trade the unlink beside it already makes.

* feat(account): render the manifest STATUS column only on exception

On the common account every row reads ok, costing a header, a column and
82px of a 320px viewport to report the absence of news. The column now
renders when at least one character is not ok, so its presence is the
signal.

`map on|off` varies per character while the chip reads ok either way, so
the cell's accessible name was its only home; it moves to a visually-hidden
span in the NAME cell at zero vertical cost. `aria-describedby` cannot
dangle: a contact remedy implies attention or stalled, never ok, so a
remedy existing implies the column renders.

* feat(account): fold STANDING into the page head

539px of chrome sat above the first manifest row at 1440x900 — 60% of the
fold — and 171px of it was a rule-head, a two-row definition grid and a
collapsed margin holding two facts. The verdict joins the h1's line at zero
vertical cost; tier and Discord become one meta line under the lede.

The <dt>s were the only thing naming those values, so each fact keeps a
visually-hidden label. The confirm cost keeps its own line via
.page__meta-item > .confirm-cost: without it the revealed sentence
re-centres the button out from under a stationary pointer and the
pointerLeave disarms the control (#112), pinned by a boundingBox test
at 700px.

* test(account): gate the fold count at three viewports

The success criterion is characters above the fold, so assert that
directly — at 1440x900, 1280x800 and 390x844, not only the desktop case.

Re-baselines the 320px horizontal gate onto a ten-character seed. Its
previous 250px threshold came from a one-character account; a realistic
account measured 258px, so the number the gate protected had never been
taken at the size that matters.

* fix(account): post-review cleanup — dead CSS, a false caption, three stale comments

Whole-branch review of the manifest-density work (a4a292b..7c5654f) came back
SHIP with only Minors: Task 4 duplicated .facts__lead/.facts__lead >
.confirm-cost as .page__meta-item variants instead of renaming them, leaving
the originals with no consumer; the all-ok manifest caption asserted every
character is healthy even at zero characters, where showStatusColumn is
vacuously false; and three comments described a `.facts` grid or a "block
below" layout that Task 4's fold into the page head replaced.

- Remove the dead .facts__lead / .facts__lead > .confirm-cost rules and point
  every comment that cited them (confirm-submit.tsx, globals.css's discord-id
  comment) at the live .page__meta-item selectors instead.
- Give the zero-character case its own caption sentence instead of routing it
  through the all-healthy branch.
- Correct standing.tsx's `.facts` grid reference, globals.css's "block below"
  verdict comment, and the .verdict margin-top comment (dead in practice —
  every call site is inside .page__head-row, which zeroes it) to match the
  post-fold layout.
- Document that .page__meta-item > .confirm-cost's `>` combinator depends on
  ConfirmArmScope staying a pass-through with no wrapper element (the #112
  fix goes dark silently otherwise).

* test(account): pin the manifest caption's zero-character branch

The no-STATUS-column sentence is selected by `showStatusColumn`, a
`.some()` over the crew — false for an empty account as readily as for a
healthy one. The zero-character branch added in post-review cleanup was
the only user-visible copy on this branch with no test behind it;
removing it turns this test red.
guarzo added a commit that referenced this pull request Aug 8, 2026
)

A page-by-page walkthrough of every rendered surface with the project owner,
conducted against production screenshots. `docs/design-walkthrough.md` carries
the findings as seven self-contained sessions so each is startable from a cold
context; this commit is session 0, the cross-cutting part the other six depend
on.

Three rulings, recorded in DESIGN.md so a later sweep does not undo them:

R1, hit targets. The 28px in-row grade is now scoped by the density reason
rather than by the tag it lands in, so a `Disclosure as="row"` drawer takes the
36px standalone grade despite rendering a literal `<tr>`. This resolves a
contradiction rather than reversing a decision: e2e/sync.spec.ts:1095 has been
pinning the /admin/sync drawer to the standalone grade all along, and
payouts/[id]/notes-form.tsx:90-95 reasons the same way for a panel field. Only
/admin/accounts read the settled-decisions row as forbidding it. That row is
marked AMENDED rather than deleted, per that file's own contract.

R2, rare destructive controls do not hold permanent width in a scanning table.
Records the existing reveal-on-arm constraint (#112) as a reason to move such
controls rather than to progressively disclose them where they sit.

R4, information may not live only in the assistive-tech channel. Two instances
found, both the inverse of the usual defect: a working role="status" save
confirmation marked .visually-hidden, and an affordance named in aria-label and
absent from the visible summary.

Copy: removes the four em dashes from rendered strings (the glyph stays as this
app's null-value marker, per error.tsx:239), and drops the /payouts lede's first
sentence, which restated its own table columns. The flat-pool note label changed
shape, so its 12 e2e assertions move with it.

The equivalent /account lede is deliberately left to session 3.
e2e/account.spec.ts:1583 uses it as the narrow-capped sibling proving the
manifest opts out of the cap, and finding 3.1 changes what that anchor should
be; deleting it here would mean editing that test twice.

Gates: typecheck, lint, format:check, 1290 unit tests across 83 files, 297 e2e,
production build, and check-node-version all pass.
guarzo added a commit that referenced this pull request Aug 9, 2026
…t status line

Follow-up to #193, from the same critique.

- admin/accounts: the token badge said "0/8 ok" in the exact case the badge
  turns red. A red marker and the word OK argued with each other in the one
  cell built to be glanced at; "healthy" is a word the numerator can be zero
  of, where "ok" is a verdict and reads as one no matter what precedes it.
- admin/accounts: REVOKE drops to .btn--danger-quiet at rest and keeps full
  .btn--danger on arm, matching FREEZE and UNLINK. globals.css already
  records why: full --danger on a per-row control "made it the most
  saturated thing on the account page, permanently, which reads as a warning
  against an ordinary choice". Every row of an admin table can be an admin,
  and resting red as furniture spends the alarm channel the token badge two
  cells left needs when an account actually goes dark.
- account: the per-row status summary renders only where a character
  deviates from nominal. For a managed, contacts-ok, on-ACL character the
  sentence can only be "token ok, standings ok, map on" — the same string on
  every row, under a heading that has already said all of them are healthy.
  The R4 parity fix it was added for is kept exactly where it was earned: a
  row whose map or standings state differs still says so, and the gate is on
  the element, so both channels drop it together. The <caption> states the
  rule so an unannotated row is readable as nominal rather than uncomputed.

Two new e2e cases cover the gate, which no existing test reached:
seedNominalCrew never seeds wandererAclObservation, so all ten of its
characters are `map off` and none is nominal. The existing counts are
unchanged for that reason, and their regexes are tightened from
`map (on|off)` to `map off` to pin the precondition they now depend on.

Not done, and why: putting the object in the Discord unlink's visible label
("unlink Discord") was tried and reverted. It widens the button ~64px, which
at ~700px pushes the arming live region off the line; the line box grows on
arm, align-items: center re-centres the button out from under a stationary
pointer, and the pointerLeave disarms it. That is the #112 mechanism, caught
by "arming the Discord unlink does not move it out from under the pointer".
The twin-UNLINK finding was also weaker than it read: the two controls
already differ in grade, size and accessible name, and only share the
visible word. Reasoning left in a comment at the call site.

Verification: typecheck, lint, format:check clean; 83 files / 1324 unit
tests pass; e2e/account.spec.ts 71 passed.
guarzo added a commit that referenced this pull request Aug 9, 2026
…t status line (#194)

Follow-up to #193, from the same critique.

- admin/accounts: the token badge said "0/8 ok" in the exact case the badge
  turns red. A red marker and the word OK argued with each other in the one
  cell built to be glanced at; "healthy" is a word the numerator can be zero
  of, where "ok" is a verdict and reads as one no matter what precedes it.
- admin/accounts: REVOKE drops to .btn--danger-quiet at rest and keeps full
  .btn--danger on arm, matching FREEZE and UNLINK. globals.css already
  records why: full --danger on a per-row control "made it the most
  saturated thing on the account page, permanently, which reads as a warning
  against an ordinary choice". Every row of an admin table can be an admin,
  and resting red as furniture spends the alarm channel the token badge two
  cells left needs when an account actually goes dark.
- account: the per-row status summary renders only where a character
  deviates from nominal. For a managed, contacts-ok, on-ACL character the
  sentence can only be "token ok, standings ok, map on" — the same string on
  every row, under a heading that has already said all of them are healthy.
  The R4 parity fix it was added for is kept exactly where it was earned: a
  row whose map or standings state differs still says so, and the gate is on
  the element, so both channels drop it together. The <caption> states the
  rule so an unannotated row is readable as nominal rather than uncomputed.

Two new e2e cases cover the gate, which no existing test reached:
seedNominalCrew never seeds wandererAclObservation, so all ten of its
characters are `map off` and none is nominal. The existing counts are
unchanged for that reason, and their regexes are tightened from
`map (on|off)` to `map off` to pin the precondition they now depend on.

Not done, and why: putting the object in the Discord unlink's visible label
("unlink Discord") was tried and reverted. It widens the button ~64px, which
at ~700px pushes the arming live region off the line; the line box grows on
arm, align-items: center re-centres the button out from under a stationary
pointer, and the pointerLeave disarms it. That is the #112 mechanism, caught
by "arming the Discord unlink does not move it out from under the pointer".
The twin-UNLINK finding was also weaker than it read: the two controls
already differ in grade, size and accessible name, and only share the
visible word. Reasoning left in a comment at the call site.

Verification: typecheck, lint, format:check clean; 83 files / 1324 unit
tests pass; e2e/account.spec.ts 71 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