Skip to content

[Frontend][A11y][Review] Queue live-region residuals: no repeat announcement on a same-scope reload, focus after the unavailable-pin return - #2712

Merged
Chris0Jeky merged 3 commits into
mainfrom
issue-2599/queue-region
Sep 5, 2026
Merged

[Frontend][A11y][Review] Queue live-region residuals: no repeat announcement on a same-scope reload, focus after the unavailable-pin return#2712
Chris0Jeky merged 3 commits into
mainfrom
issue-2599/queue-region

Conversation

@Chris0Jeky

@Chris0Jeky Chris0Jeky commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Summary

The two non-blocking residuals from PR #2593's review (#2599 items 1 and 2), fixed in both skins
together because a one-skin repair of the Review queue is the drift class itself (#1124 / ADR-0038).
Item 3 of the issue shipped in #2593 and is untouched.

Item 1: an explicit reload that changed nothing no longer repeats the count in the reviewer's ear.
Item 2: leaving the target-unavailable panel no longer drops focus to <body>.

No copy changed (the review locales are untouched), no test id changed, no docs changed.

Changes

Item 1 — the announcement gate asks whether a read has landed, not whether one is in flight

useReviewProposals exposes queueScopeLoaded. Its exact semantics: a queue read has landed for
the board scope currently on screen.

  • It settles at both landing sites — the explicit load and the background poll — and each stamps the
    scope it asked about, snapshotted before the await, so a late answer can never claim the board
    that happens to be current when it lands. The poll is a landing site in its own right: after a
    failed entry load it is what makes the count speakable again without the reviewer reloading.
  • It is false where the rendered count is not a count of the queue on screen: before the first read
    lands, after a board-filter change until the new scope's read lands, and for a scope no read has
    landed for — including one whose only read failed. A refusal (recordQueueAccessRevoked) clears
    the queue, so it unsettles there too; the revoked panel keeps its own separate gate beside it.
  • It stays true after a later read fails. landedQueueScope is written only where a read
    replaced the queue, and the catch arm leaves it alone, so a header Refresh that 500s keeps the last
    landed count announceable — correctly, since those rows are what is still on screen. Withholding
    there would restore the count → '' → count flicker for a read that changed nothing; a failing
    refresh is reported by the degraded/refused disclosures instead (Review queue refresh residuals: deep-link re-auth, poll restart after recovery, stale indicator (from PR #2208) #2214).
  • A same-scope reload keeps it settled, which is the whole point: loadProposals raises
    proposalsLoading without clearing proposals, so the count on screen is still the last
    landed read's count of that same board.
  • Scope comparison is lower-cased. The scope is the board, not the casing the query string carried —
    the same rule matchesActiveBoardFilter already applies.

Both gates now read it: LegacyReviewView.countIsAnnounceable directly, and
ReviewQueueRail.countIsAnnounceable through a new optional queueScopeLoaded prop that
PaperReviewView feeds from the same composable value. Result: a byte-identical same-scope reload
keeps the same announcement node (nothing added to the live region, nothing spoken); a changed
reload still re-keys once, exactly as #2710 shipped it; the skeleton, board-filter and revoked gates
all still withhold.

The rail's loading prop is removed rather than left in place. It was that gate's only consumer,
and a prop whose documentation describes a gate it no longer takes part in is how the next reader is
misled. The new prop is optional and defaults to withholding: a parent that cannot say a read has
landed cannot have its count spoken, because 0 from a never-read queue is the defect #2593 removed.
PaperReviewView is the rail's only mount site in the app.

Item 2 — focus after the unavailable-pin return control

Both skins now move focus after nextTick, following the settledElsewhereReturnRef pattern Paper
already uses one branch over.

The target: the queue the panel was standing in front of, at its first row; the empty state that
replaces the panel when there is no queue behind it.

  • Paper: the rail's first queue row (ReviewQueueItem renders a <button>, so it is already
    focusable and announces the row it lands on). The rail owns its rows, so it owns the handoff and
    exposes focusFirstQueueRow(), which reports whether there was a row; the view reaches for its
    empty column only when that answer is false. A parent querying the child's subtree for a row
    element would be the drift seam this repair exists to close.
  • Legacy: the queue list <section>. This skin's rows are ReviewProposalCards, which are not
    focusable and are not this claim's files; the list itself already is — tabindex="0", labelled
    "Proposals awaiting review", and carrying the Arrow cursor that starts on row one. So the same
    kind of target in both skins (the queue, at its first row), expressed through each skin's own
    queue widget.
  • Empty case: Legacy's ReviewEmptyState root and Paper's paper-review-deep__empty column, each
    given tabindex="-1" — programmatic focus targets only, neither enters the tab order.

Test plan

Every new spec was run against the unmodified source first. 16 tests failed red; recorded texts:

Spec Red failure
composable, all 5 scope-signal tests TypeError: Cannot read properties of undefined (reading 'value')
Legacy, same-scope reload Error: Unable to get [data-testid="review-queue-announcement"] within: <div class="td-review" ...>
Legacy, focus to queue AssertionError: expected <body>…(3)</body> to be <section data-v-c94e4f43 …(3)>…(1)</section>
Legacy, focus to empty state AssertionError: expected null to be '-1'
Paper, same-scope reload Error: Unable to get [data-testid="paper-review-queue-announcement"] within: <div class="paper-review-deep" ...>
Paper, focus to first row AssertionError: expected <body>…(6)</body> to be <button data-v-bc719c4d …(5)>…(3)</button>
Paper, focus to empty state AssertionError: expected null to be '-1'
Rail, withheld before a landed read AssertionError: expected '0 proposals awaiting review.' to be ''
Rail, withheld with no scope flag AssertionError: expected '2 proposals awaiting review.' to be ''
Rail, withheld when the scope changes AssertionError: expected true to be false
Rail, focusFirstQueueRow (2 tests) TypeError: wrapper.vm.focusFirstQueueRow is not a function

The Paper reload spec initially passed red for the wrong reason — handleDismissApplied patches the
queue locally when the server dismisses everything asked for, so no reload happened. It now drives
the partial-dismiss branch, which does re-read, and asserts getProposals was called a second time
so it can never go vacuous again.

Two of the new specs are guards rather than red tests and are called out as such: the board-filter
withholding cases pass under the old gate too (a filter change also raises proposalsLoading), and
they are here to keep that behaviour when the loading term is gone.

Commands, green at this head:

  • npx vitest --run --maxWorkers=2 src/tests/composables/useReviewProposals.spec.ts src/tests/views/ReviewView.spec.ts src/tests/views/paper/review/PaperReviewView.spec.ts src/tests/views/paper/review/ReviewQueueRail.spec.ts src/tests/views/paper/review/ReviewQueueRailMembershipFilter.spec.ts src/tests/views/paper/review/PaperReviewMembershipFilter.spec.tsTest Files 6 passed, Tests 508 passed.
  • npm run typecheck → clean.
  • npx eslint on the eight changed files → clean.
  • git diff --check → clean.

Not verified

  • No screen reader was used. The announcement behaviour is asserted structurally — element
    identity across a reload, node presence/absence, region never remounted — which is the mechanism
    aria-live's aria-relevant="additions text" acts on, not a recording of what NVDA or VoiceOver
    actually said.
  • No E2E run: no test id changed, and none of the Playwright specs exercise these seams.
  • No backend test run: nothing outside frontend/taskdeck-web/src changed.
  • Focus assertions are jsdom document.activeElement with attachTo: document.body, not a real
    browser's focus ring or its announcement of the focused element.
  • wrapper.vm.focusFirstQueueRow() reaching a <script setup> + defineExpose component through
    VTU, and InstanceType<typeof ReviewQueueRail> exposing that method under vue-tsc, are proven
    only by the spec run and the typecheck run in this PR — not by any other consumer.

Boundaries

Eight files, all inside the claim: useReviewProposals.ts (the scope signal only), both review
views, ReviewQueueRail.vue, and the four matching specs. Not touched: the review locales,
usePaperReviewSelectors.ts and the rail cards, the receipt recorders, the diff pane, the sticky
seam, src/api/**, docs, OUTSTANDING_TASKS.md.

Recorded divergence for the next ADR-0038 / #1124 drift audit: the two skins' focus targets are
deliberately not the same element. Paper focuses a queue row <button> — a screen reader announces
that row and Enter selects it. Legacy focuses the queue <section> — it announces the region label
and Enter does nothing — because Legacy's rows (ReviewProposalCard) expose no focusable control and
the section owns the roving Arrow cursor. What the skins share is the rule about where focus goes
(the queue that replaced the panel, else the empty state that did), not the element type, which each
skin's queue widget determines. Making them literally identical means making Legacy's rows focusable,
which is a different change in files this claim does not own.

Round 2 (head 80631e221)

Fresh-context review verdict was SHIP with no correctness defect; four LOWs, three of them comment
truth and one a vacuous spec. Fixed in one commit, 80631e221 — no mechanism change, so the
behaviour described above is unchanged.

  1. "Withholds after a failed read" was not true. landedQueueScope is written only where a read
    replaced the queue and cleared only by recordQueueAccessRevoked; the catch arm leaves it alone.
    A load that landed for board A followed by a Refresh that 500s keeps the count announceable, which
    is correct — the rows on screen are still that landed answer. All three places carrying the claim
    now say what is withheld (a scope no read has landed for, including one whose only read failed)
    and why a later failure deliberately is not, so nobody "restores" a withhold that would bring the
    flicker back. The Legacy gate repeated the same wording and was corrected with them; the rail's
    queueUnavailable docblock stopped pointing at the removed loading prop.
  2. Legacy's Arrow cursor does not start on row one. activeReviewIndex is a persistent ref and
    scrollVirtualizerToHashProposal rewrites it only when the hash target resolves — exactly what a
    dead pin fails to do — so a reviewer who arrowed to row 4 returns with the cursor at 4. The comment
    now states what is true: focus lands on the section, the cursor keeps its place, and it only drives
    scrollToIndex.
  3. The two skins' focus targets are not the same element. Recorded in both comments and in
    Boundaries above rather than papered over.
  4. The rail's same-scope-reload spec was vacuous — it re-set byte-identical props, which does not
    re-render, and passed on the pre-fix rail. It now mounts with the retired loading: true input
    (the pre-fix gate's first term; an inert fallthrough attribute here) and changes an unrelated prop
    to force a real re-render. Verified red on the unmodified rail source at 5487c263b:
    Unable to get [data-testid="paper-review-queue-announcement"]. Its comment points at the two
    view-level specs that carry the end-to-end reload evidence.

Re-proved at 80631e221: the same six specs → Test Files 6 passed, Tests 508 passed;
npm run typecheck clean; npx eslint on the four touched files clean; git diff --check clean.

Refs #2599, #2214

`!proposalsLoading` was the wrong question for both skins' announcement
gates. An explicit `loadProposals` raises that flag WITHOUT clearing
`proposals`, so the gate withheld the live region's content for the
length of every reload and restored it afterwards: the region wrote
count -> '' -> count, and a node addition inside a live region is spoken.
The reviewer heard the same figure read back after the header Refresh and
after filing a settled proposal away, about a queue that had not moved.

`queueScopeLoaded` asks the question the gate actually needs: has a read
landed for the board scope on screen? It settles at both landing sites --
the explicit load and the background poll, each stamped with the scope it
asked about rather than whichever board is current when it lands -- and
is false in exactly the three cases where the rendered count is not a
count of the queue on screen: before the first read, after a board-filter
change until the new scope's read lands, and after a failed read
(including a refusal, which clears the queue outright).

Scope comparison is lower-cased: the scope is the board, not the casing
the query string carried, which is how `matchesActiveBoardFilter` already
reads it.

The signal only lands here in this commit; the two skins still gate on
loading. Refs #2599, #2214
Two residuals from PR #2593's review, fixed in both skins together so the
Review queue cannot drift (#1124 / ADR-0038).

Item 1, no repeat announcement on a reload that changed nothing. Both
gates now read `queueScopeLoaded` instead of the loading term:
`LegacyReviewView.countIsAnnounceable` directly, and
`ReviewQueueRail.countIsAnnounceable` through a new `queueScopeLoaded`
prop that `PaperReviewView` feeds from the same composable value. A
byte-identical same-scope reload keeps the SAME announcement node, so
nothing is added to the live region and nothing is spoken; a reload that
changed the queue still re-keys once, as #2710 shipped it; the first
read, a board-filter change and a failed read still withhold, and the
revoked gate is untouched beside it.

The rail's `loading` prop is removed rather than left in place: it was
that gate's only consumer, and a prop documenting a gate it no longer
takes part in is how the next reader is misled. The replacement is
optional and defaults to WITHHOLDING -- a parent that cannot say a read
has landed cannot have its count spoken, because 0 from a never-read
queue is the defect #2593 removed. `PaperReviewView` is its only mount.

Item 2, focus after the unavailable-pin return control. Activating it
removed the element focus was in and moved focus nowhere, so it fell to
`<body>`: nothing announced, and the next keystroke acting on nothing.
Both skins now move focus after `nextTick`, following the
`settledElsewhereReturnRef` pattern Paper already uses one branch over.
The target is the queue the panel was standing in front of, at its first
row -- Paper's first rail row button (the rail owns its rows, so it owns
the handoff and exposes `focusFirstQueueRow`), and Legacy's queue list
section, which carries the "Proposals awaiting review" label and the
Arrow cursor that starts on row one. When the queue behind the panel is
empty, focus goes to the empty state that replaces it instead
(`tabindex="-1"`, programmatic only -- neither element enters the tab
order).

Refs #2599, #2214
@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.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review record (alpha product-trust lane; one fresh-context read-only reviewer subagent since Codex credits are exhausted, SC-9; round 1 at head e618311b9, inputs: the clean worktree at the head and the merge-base diff, 8 files).

Verdict at round 1: SHIP; no correctness defect the reviewer could construct. Triage:

  • LOW, fixed in round 2 as comments: the composable's and the rail's docblocks say the gate withholds "after a failed read"; landedQueueScope is written only on success and cleared only on revocation, so after a landed entry load a failed reload keeps the last landed count announceable (the right behaviour, since the rows are still that answer); the comments now say so.
  • LOW, fixed in round 2 as comments: Legacy's comment claimed the Arrow cursor starts on the first row after the focus move; activeReviewIndex is persistent and is rewritten only when the hash target is found.
  • LOW, fixed in round 2 as comments plus a PR-body line: the two skins' focus targets differ (Paper a row button, Legacy the queue section that owns the roving cursor, because Legacy's rows are not focusable); the comments called them the same, and the divergence is now recorded for the ADR-0038 drift audit.
  • LOW, fixed in round 2: the rail spec "keeps the announcement node across a reload of the same scope" re-set byte-identical props and would pass on the unmodified source; it is made to pin the fix or to say the view-level specs carry the evidence.
  • LOW, recorded on #2599 (pre-existing, contrived): Paper's fallback can still land focus on body when the rail is filtered to zero rows while a decision receipt holds the main column (emptyColRef exists only without an active proposal); a third fallback to the main column would close it. Legacy's mirror gap needs a concurrent explicit load.

Clean lenses corroborated by the reviewer against the tree at the head: the three writes to landedQueueScope (explicit success after the supersession guard, poll success behind isCurrentRead, revocation), none on a failure path or before proposals is assigned; the scope stamped before the await and compared against the live filter at render, so a late answer from an abandoned scope cannot read true; the poll blocked while an explicit load is in flight; case folding matches matchesActiveBoardFilter and distinct GUIDs cannot collide; revoke then recover with no frame showing settled over an empty queue; the first render is now withheld where the old loading term briefly spoke "0 proposals"; a board-filter change flips the gate false before the reload is issued; no consumer of the removed loading prop survives; the keyed span sits inside a permanently mounted status region in both skins and moves only with awaitingProposalIds; the post-decision double announcement becomes one; focusFirstQueueRow reaches the row button, the empty-state targets exist in both skins, no focus theft outside the control's own handler, no interaction with the settled-elsewhere focus; the specs mount attached to the document, assert node identity, and the Paper reload spec proves two list reads.

Unverified by the read-only reviewer, run by the worker at the head (six named specs 6 files / 508 tests; typecheck, eslint, diff-check) and by CI's frontend unit job; no screen reader was used, as the PR says. Round count: 2 (comments and one spec, no logic, so no further pass is owed). Merge gate remaining: the fix head's CI green and the three-minute age.

…il's reload node

Round 2 on PR #2712. Four LOWs from the fresh-context review: three are
comment truth, one is a vacuous spec. No mechanism changed.

1. The gate does not withhold "after a failed read". `landedQueueScope` is
   written only where a read replaced the queue and cleared only by
   `recordQueueAccessRevoked`; the catch arm leaves it alone. So an entry
   load that landed for board A followed by a header Refresh that 500s
   keeps the count announceable -- correctly, because the rows on screen
   are still that landed answer, and re-withholding there would put the
   count -> '' -> count flicker back for a read that changed nothing.
   Reworded in all three places that carried the claim (the composable,
   the rail prop, and the Legacy gate, which the review did not name but
   repeated it), each now saying what is withheld -- a scope no read has
   landed for, including one whose only read failed -- and why a later
   failure deliberately is not. The rail's `queueUnavailable` docblock
   stopped pointing at the removed `loading` prop while there.

2. Legacy's Arrow cursor does not start on the first row.
   `activeReviewIndex` is a persistent ref, and
   `scrollVirtualizerToHashProposal` rewrites it only when the hash target
   is found -- exactly what a dead pin fails to do -- so a reviewer who
   arrowed to row 4 returns with the cursor at 4. The comment now says
   what is true: focus lands on the section, the cursor keeps its place,
   and it only drives `scrollToIndex`.

3. The two skins' focus targets are not the same element, and calling
   them one was the drift claim to avoid. Paper lands on a row `<button>`
   (announces the row, Enter selects); Legacy lands on the queue
   `<section>` (announces the region label, Enter does nothing) because
   its rows are not focusable. Both comments now record the divergence and
   its cause; the shared rule is where focus goes, not the element type.

4. The rail's same-scope-reload spec re-set byte-identical props, which
   does not even re-render, and passed on the pre-fix rail. It now mounts
   with the RETIRED `loading: true` input -- the pre-fix gate's first
   term, an inert fallthrough attribute here -- and additionally changes
   an unrelated prop to force a real re-render. Verified RED on the
   unmodified rail source at 5487c26: `Unable to get
   [data-testid="paper-review-queue-announcement"]`. The spec comment
   says where the end-to-end reload evidence lives (the two view specs).

Refs #2599, #2214
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Checkpoint (alpha lane session ending 2026-09-05 ~21:50Z): round-1 review SHIP (record above); the round-2 commit answering its four comment-and-spec LOWs is pushed at 80631e221 (the worker's round-2 report was still pending when the session ended; the next session reads the PR body's Round 2 paragraph and confirms the commit touched only comments and the rail spec before merging). CI in progress at 80631e221. Remaining merge gate: that CI green plus the three-minute age; no further review pass is owed if the commit is comments and specs only.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Round 2 at 80631e221 (worker report received after the checkpoint): one commit, comments and the rail spec only, no mechanism change. The three docblocks (composable, rail, and Legacy's countIsAnnounceable, which repeated the same claim) now say the gate withholds for a scope no read has landed for, including one whose only read failed, and that the last landed count stays announceable after a later failed reload on purpose; the rail's queueUnavailable docblock no longer references the removed loading prop; Legacy's focus comment states the cursor keeps its position; both focus comments and the PR body's Boundaries record the Paper-row-button vs Legacy-section divergence for the ADR-0038 audit; the rail reload spec now mounts with the retired loading input and forces a genuine re-render, and was verified red on the pre-fix rail at 5487c263b (Unable to get [data-testid="paper-review-queue-announcement"]). Six named specs 6 files / 508 tests, typecheck, eslint and git diff --check clean. No further review pass is owed. Merge gate: CI green at 80631e221 plus the three-minute age.

@Chris0Jeky
Chris0Jeky merged commit 4ddb68c into main Sep 5, 2026
37 checks passed
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Sep 5, 2026
@Chris0Jeky
Chris0Jeky deleted the issue-2599/queue-region branch September 6, 2026 02:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant