Skip to content

fix(review): localize the degraded-queue warning, announce recovery, keep it visible - #2630

Merged
Chris0Jeky merged 5 commits into
mainfrom
issue-2214/degraded-disclosure
Sep 5, 2026
Merged

fix(review): localize the degraded-queue warning, announce recovery, keep it visible#2630
Chris0Jeky merged 5 commits into
mainfrom
issue-2214/degraded-disclosure

Conversation

@Chris0Jeky

@Chris0Jeky Chris0Jeky commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Summary

The review queue's degraded disclosure only ever ran one way. When three consecutive transient poll failures set queueRefreshStale, both skins rendered a warning that the retained queue may be out of date. When a later read succeeded, the flag cleared and the warning was simply unmounted. Nothing was said. A reviewer who was not watching that corner of the surface, and any screen-reader user, was never told the queue is trustworthy again.

The warning was also hardcoded English in all three render sites, so an Italian or Spanish reviewer saw English at exactly the moment the surface was admitting it might be wrong. And in the Paper skin the warning sat in the main column, which is the scroller, so it had scrolled out of view by the time the reviewer reached the decision rail and acted.

This change moves the sentence into the catalog, adds the missing recovery announcement in both skins through a single region that survives a branch flip, keeps the Paper warning on screen while the column scrolls without letting the sticky decision rail cover it, and covers the filtered-to-empty case that neither existing degraded spec reached.

Refs #2214. Residual items 1, 4, 5 and 6 of the 2026-09-03 reconciliation comment are closed here. Items 2 (persistent disclosure of repeated non-transient failures), 7 and 8 (the pin-leg 400) remain for the next slice; item 3 already shipped.

Changes

Five commits. The first four are round 1; the fifth is review round 2.

fix(review): localize the degraded-queue warning in both skins (item 5)

  • review.queue.degraded.body and review.queue.degraded.recovered added to the en, it and es review catalogs.
  • The three render sites (PaperReviewView.vue active-proposal column, PaperReviewView.vue empty column, LegacyReviewView.vue) render $t('review.queue.degraded.body'). The English wording is unchanged, verbatim.
  • Legacy's warning becomes one paragraph carrying the shared sentence instead of two hardcoded ones, so both skins render the same string and cannot drift (ADR-0038 / Core-loop correctness: false-green expiry regression test + #678 dismiss gap + #680 provenance 404 noise #1124).
  • The LegacyReviewView.vue comment claiming this skin's copy is entirely hardcoded English was already wrong for the unavailable panel below it and would have become wrong here. It now names the states that share the catalog.
  • PaperReviewView.spec.ts (two assertions) and ReviewView.spec.ts (one) assert the catalog value instead of the fragment 'may be out of date'.

fix(review): announce recovery from a degraded review queue in both skins (item 1)

  • useReviewProposals exposes queueRefreshRecovered. It turns true only when a visible degraded state is ended by a successful read, and falls back to false at the next degraded onset, because a live region only speaks when its text changes.
  • Both exits from the degraded state raise it: the explicit-load clear now runs the same recordQueueRefreshSuccess() accounting as a successful poll instead of duplicating its two assignments.
  • Success after success changes nothing, and a non-transient failure that resets the run without ever reaching the threshold recovers nothing.
  • Both skins render a mounted sr-only region (role="status", aria-live="polite", aria-atomic="true") that withholds its text rather than appearing with it, the same construction PR fix(review): name an unavailable pinned proposal in Legacy and hold the loading announcement #2593 established for the count regions. The regions are paper-review-queue-recovered and review-queue-recovered.
  • The rendered text is gated on !queueAccessRevoked, the same guard the warning already uses, so the composable's 403 branch keeps its single owner and is untouched. ReviewQueueRail's count region is untouched.

fix(review): pin the Paper degraded warning to the top of the scrolling column (item 4)

  • A dedicated paper-review-deep__queue-stale--pinned modifier on the active-proposal warning only: position: sticky; top: 0; z-index: 1; with background: var(--paper) and a bottom border. The two sibling notes that share the base class (decision-refresh lock, evidence-unavailable) stay in flow, because they describe the record on screen rather than the whole queue.

test(review): cover the filtered-empty degraded case and the Italian disclosure (item 6)

  • A PaperReviewView.spec.ts case with a foreign-authored row, the Mine filter and three consecutive 500s, asserting the warning's role, aria-live and aria-atomic, its parent being the empty element, the co-rendered No matches in Mine. title, and that paper-review-queue-live still carries the real awaiting count.
  • A PaperReviewView.language.spec.ts case that flips to Italian with the queue degraded and then recovers it, proving both new keys reach the DOM.

fix(review): hoist one recovery region, offset the rail, retire the sentence (review round 2)

  • MEDIUM 1. The recovery region was duplicated in the v-if="activeProposal" and v-else arms, so a branch flip remounted it. The recovering poll assigns proposals.value and calls recordQueueRefreshSuccess() in one synchronous block, so a recovery that puts a proposal back into an empty or filtered-empty queue flipped the branch in the same render the sentence appeared, and the region mounted already carrying its text. That is precisely the insert-with-text case the construction exists to avoid, and after any recovery a later flip remounted a region with content. One node now sits above the branch pair. It is absolutely positioned by .sr-only, so it is not a grid item and takes no column of .paper-review-deep's three-column track list. The two root-children pins now name it.
  • MEDIUM 2. ReviewDecisionRail is also position: sticky; top: 0 in the same scroller, opaque through card-lift and taller, so past the card header it covered the pinned warning completely and the pin bought nothing in exactly the scrolled state item 4 exists for. PaperReviewView now measures the warning with a ResizeObserver and writes --paper-review-sticky-offset onto the scrolling column while the warning is on screen; the rail's single sticky rule became top: var(--paper-review-sticky-offset, 0), so the rail sits below the warning and both stay visible. With no warning the property is absent and the fallback restores the rail's original position. Where ResizeObserver is unavailable the offset stays 0, which is the previous behaviour rather than a broken one. The warning stays at z-index: 1, below the rail's 2, so an opaque paragraph can never cover the Approve and Reject controls or swallow clicks aimed at them. ReviewDecisionRail.vue was touched for that one rule and its comment only.
  • LOW 3. The recovered sentence lived for the whole session. The following healthy success now retires it, so it lives for about one poll interval, with no timer to tear down. The clear is silent: a live region going empty announces nothing.
  • LOW 5. Both stylesheet source reads use Vite's ?raw directly, the form ReviewDecisionRail.spec.ts already uses under this tsconfig. The previous comment implied ?raw was unavailable, which was wrong.

Test plan

All commands run from frontend/taskdeck-web in the worktree.

Red first, round 1. The recovery specs were written before the implementation and recorded failing.

npx vitest --run --maxWorkers=2 src/tests/composables/useReviewProposals.spec.ts -t 'degraded-queue recovery signal'
  Test Files  1 failed (1)      Tests  5 failed | 122 skipped (127)
  all five: TypeError: Cannot read properties of undefined (reading 'value')  [queueRefreshRecovered absent]

npx vitest --run --maxWorkers=2 src/tests/views/paper/review/PaperReviewView.spec.ts -t '#2214'
  Test Files  1 failed (1)      Tests  2 failed | 2 passed | 168 skipped (172)

npx vitest --run --maxWorkers=2 src/tests/views/ReviewView.spec.ts -t '#2214'
  Test Files  1 failed (1)      Tests  2 failed | 6 passed | 44 skipped (52)

Red first, round 2. The round-2 specs were run against the round-1 sources (the three source files checked out at the round-1 head, specs left in place) before the fix landed.

npx vitest --run --maxWorkers=2 src/tests/composables/useReviewProposals.spec.ts src/tests/views/paper/review/PaperReviewView.spec.ts
  Test Files  2 failed (2)      Tests  5 failed | 298 passed (303)

  x retires the recovered sentence on the following success and does not re-fire
      AssertionError: expected true to be false
  x keeps one recovery region across an empty-to-active branch flip (#2214)
      AssertionError: expected <p …> to be <p …>   [two different nodes: the per-arm duplication]
  x offsets the decision rail below the pinned degraded warning (#2214)
      AssertionError: the given combination of arguments (undefined and string) is invalid
      [no --paper-review-sticky-offset written; the rail rule was still top: 0]
  x renders and politely announces a degraded retained queue after repeated poll failures
  x renders the degraded warning when repeated poll failures leave Paper empty
      both: root children did not contain the hoisted recovery node

Honest notes on which round-1 specs were and were not red. The three literal toContain('may be out of date') assertions did not go red on the catalog swap, because the English wording moved into the catalog verbatim; they were repointed at the catalog so a future copy edit cannot pass silently, not because they were failing. The filtered-to-empty co-render spec was green the moment it was written: the empty branch already renders the warning above the whole v-if chain, so that spec is a regression pin on working behaviour, not a fix.

Verified (at the round-2 head).

npx vitest --run --maxWorkers=2 src/tests/composables/useReviewProposals.spec.ts src/tests/views/paper/review/PaperReviewView.spec.ts src/tests/views/paper/review/PaperReviewView.language.spec.ts src/tests/views/ReviewView.spec.ts src/tests/i18n/catalogs.spec.ts
  Test Files  5 passed (5)      Tests  381 passed (381)

npx vitest --run --maxWorkers=2 src/tests/views/paper/review/ReviewDecisionRail.spec.ts src/tests/views/paper/review/ReviewMain.spec.ts
  Test Files  2 passed (2)      Tests  45 passed (45)

npm run typecheck        clean (vue-tsc -b)
npm run build            clean, PWA precache 142 entries
npx eslint <changed files>   exit 0, no output
git diff --check         exit 0

Round 1 also ran the broader Review set at its head: src/tests/views/paper/review, ReviewView.spec.ts, ReviewView.coverage.spec.ts, useReviewProposals.spec.ts, useReviewActions.spec.ts, src/tests/i18n, src/tests/guards25 passed / 639 tests.

Both new CSS rules were confirmed present in the production build output, not only in source: dist/assets/ReviewView-*.css contains top:var(--paper-review-sticky-offset,0) for the rail and the full queue-stale--pinned rule.

NOT verified.

  • No Playwright run. This slice touches no flow the E2E suite exercises and a running stack was not started.
  • The layout claim remains browser-unverified. vitest does not process an SFC's <style scoped> and happy-dom lays nothing out, so neither sticky rule is reachable through getComputedStyle and the warning measures 0. The specs assert the modifier class on the element, the two declared rules read from source through ?raw, and that the view writes --paper-review-sticky-offset while degraded and stops writing it after recovery. The property's numeric value is asserted only as present. That the rail visibly clears the warning, and that both stay readable while the column scrolls, is a browser fact this suite cannot reach.
  • Screen-reader behaviour itself. The specs prove the region is a single node that survives a branch flip, is mounted before its text appears, and changes text; no assistive technology was driven.
  • The Italian and Spanish wording is my own translation in the register of the surrounding catalog entries. It is not native-reviewed. The existing it/es review catalogs carry a GH-1307 machine-translated, pending native review marker for the same reason.
  • No backend surface was touched, so no dotnet test was run.

Boundaries and risks

  • Named assumption: the Legacy skin stays a shipped v0.3 surface until the maintainer rules otherwise, so it gets the same disclosure rather than being left behind. Reversible by deleting the Legacy region and its two specs. The Legacy half is a catalog swap plus one region; all the behaviour lives in the shared composable.
  • Accepted, LOW 4. A 403 that revokes access and is later restored can re-speak a still-fresh recovery: the sentence's text is gated on !queueAccessRevoked, so it is withheld while access is gone and returns when access comes back, if the following healthy success has not yet retired it. Accepted rather than fixed, because clearing it would mean writing to the recovery signal from the composable's 403 branch, which this slice deliberately does not touch.
  • Also accepted. A recovery landing in the same render as an unavailable-pin empty state still announces "Showing current proposals." That is true at that instant — the queue was successfully re-read — even though the pinned deep link the reviewer followed is separately unavailable. The two states describe different things and both are disclosed.
  • The recovered sentence lives for about one poll interval: it is retired by the next healthy success, and re-armed by a fresh degraded onset. No timer, so the composable gains no teardown state and none of the many fake-timer polling specs are perturbed.
  • The recovery region is now a root child of .paper-review-deep, which is a three-column grid. .sr-only makes it absolutely positioned, so it is out of flow and not a grid item; the three columns keep their tracks. Two specs pin the root's children and name it explicitly.
  • The explicit-load path calls recordQueueRefreshSuccess() instead of repeating its two assignments. That is why a board-filter change or a post-action reload also announces recovery, and why such a reload can retire the sentence early. It changes no load semantics; the existing explicit-load and stale specs are unchanged and green.
  • ReviewDecisionRail.vue was edited for one CSS declaration and its explanatory comment. Nothing else in that component changed, and its own spec suite is green.
  • Not touched, by scope: the 403, teardown and supersession guards; the pin re-authorization read; the transient-failure threshold; item 2 and item 8; ReviewQueueRail's count region; LegacyReviewView's unavailable panel; every Inbox and board file.
  • No docs were edited. The behaviour change is user-visible, so docs/STATUS.md may want a line; that call is left to the coordinator who holds the write lease.

The warning was hardcoded English in all three render sites (Paper's
active-proposal column, Paper's empty column, Legacy), so an it/es reviewer
saw English at exactly the moment the surface was admitting it might be
wrong. Move the sentence to review.queue.degraded.body with faithful Italian
and Spanish, and add the matching review.queue.degraded.recovered key the
recovery announcement will render.

Legacy's warning becomes one paragraph carrying the shared sentence instead
of two hardcoded ones, so both skins render the same string and cannot drift
(ADR-0038 / #1124). The Legacy comment claiming this skin's copy is entirely
hardcoded English was already wrong for the unavailable panel below it and
would have become wrong here; it now names the states that share the catalog.

The two view specs assert the catalog value rather than a copy fragment, so
an edit to the sentence cannot pass silently.

Refs #2214
…kins

Clearing the degraded state only unmounted the warning. A reviewer who was
not watching that corner of the surface, and any screen-reader user, was
never told the queue is trustworthy again, so the disclosure only ever ran
one way.

useReviewProposals now exposes queueRefreshRecovered: it turns true only when
a VISIBLE degraded state is ended by a successful read, and falls back to
false at the next degraded onset so a second recovery is announced too (a
live region only speaks when its text changes). Both exits from the degraded
state raise it, because the explicit-load clear now runs the same
recordQueueRefreshSuccess accounting as a successful poll. An ordinary
success after a success changes nothing, and a non-transient failure that
resets the run without ever reaching the threshold recovers nothing.

Both skins render a mounted sr-only region that withholds its text rather
than appearing with it (#2593), gated on !queueAccessRevoked exactly like the
warning, so the composable's 403 path keeps its single owner. The rail's
count region is untouched.

The recovered sentence stays until the next degraded onset or until the
surface unmounts. No timer: a timed clear would add teardown state to a
composable whose polling seams are already fake-timer sensitive, and the
sentence remains true for as long as it is shown.

Refs #2214
…ng column

The main column is the scroller and a reviewer acts from a scrolled position,
so the warning that the queue may be out of date had scrolled away by the
time the decision was made. A dedicated pinned modifier keeps it on screen for
as long as it is true; the two sibling notes that share the base class stay in
flow, because they describe the record on screen rather than the whole queue.

The pinned band sits below ReviewDecisionRail's own sticky z-index on purpose.
Winning that band would put an opaque paragraph over the Approve and Reject
controls and swallow clicks aimed at them.

The spec pins the modifier on the element and the declared rule in the
stylesheet, and says plainly that neither happy-dom nor vitest can prove the
element actually stays on screen: vitest does not process SFC styles and
happy-dom lays nothing out.

Refs #2214
…disclosure

The two existing degraded cases cover an active proposal and an unfiltered
empty queue. Neither reaches the branch a reviewer is most likely to be on
when polls start failing: a filter that currently matches nothing, where the
surface is already saying no matches and could be read as an authoritative
answer about a queue it can no longer refresh. The new case combines a
foreign-authored row, the Mine filter and three consecutive 500s, and asserts
the warning and the filter answer co-render while the rail keeps speaking the
real awaiting count.

The language case flips to Italian with the queue degraded and then recovers
it, so both new catalog keys are proven to reach the DOM. The parity guard
only proves they exist.

Refs #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, review-and-ship round 1 at head 950a054).

Reviewer: one fresh-context independent reviewer subagent (read-only), input = merge-base..head diff plus the worktree at the head. Verdict: SHIP, no CRITICAL or HIGH; two MEDIUMs that affect whether #2214 items 1 and 4 are fully delivered, taken in round 2.

Confirmed clean: the recovery flag is set only when a visible degraded state ends (never on success after success; a non-transient reset does not touch it; both refs are written in one synchronous block so the warning and the recovered sentence can never co-render); the signal call sits after the request-id, abort and isCurrentRead guards and replaces exactly the two statements it supersedes, so the 403, supersession, deadline and pin re-authorization paths are untouched; every exit from the stale state goes through the signal; the Legacy region is unconditionally rendered at template root; no count region changed; the sticky modifier is on exactly one element with an opaque background in both themes and the node stays a direct child of the scroller; catalog parity holds in en, it and es with faithful copy; the repointed assertions compare rendered text to the catalog value, not to a key.

Triage:

  • MEDIUM 1, taken in round 2: the Paper recovery region was duplicated across the v-if/v-else arms, so a recovery that also flips the branch (a poll returning a proposal into an empty queue) mounted the region already carrying its text, the exact case the construction exists to avoid; one region is hoisted above the pair and a spec covers the empty-to-populated recovery.
  • MEDIUM 2, taken in round 2: the pinned warning (z-index 1) sits under the decision rail's opaque sticky card (z-index 2) in the same scroller, so once scrolled past the header the warning was hidden for the rest of the range; the rail now sticks below the pinned warning through a CSS custom property the view sets from the warning's rendered height (ResizeObserver, offset 0 where unavailable), both visible, controls never covered. Layout remains browser-unverified and the PR says so.
  • LOW 3, taken in round 2: the recovered sentence persisted for the session; it now clears on the next successful poll after recovery (about one interval), no timers.
  • LOW 4, accepted and recorded: a 403-then-restored cycle can re-speak a still-fresh recovery, and a recovery landing with an unavailable-pin empty state announces "Showing current proposals"; both true at that instant.
  • LOW 5, taken in round 2: the import.meta.glob rationale comment.
  • LOW 6: docs/STATUS.md line is the coordinator's (thirteenth block).

Merge gate: ci-required green at the round-2 head, aged three minutes, one verification pass scoped to the fix diff (the fixes touch template structure, a rail CSS rule and one composable branch), then merge commit.

…entence

Review round 2 on #2630.

MEDIUM 1. The recovery region was duplicated in the active-proposal and empty
arms, so a branch flip remounted it. The recovering poll assigns the queue and
records the success in one synchronous block, so a recovery that puts a
proposal back into an empty or filtered-empty queue flipped the branch in the
same render the sentence appeared, and the region mounted already carrying its
text: the exact insert-with-text case the construction exists to avoid. One
node now sits above the branch pair and survives every flip. It is
absolutely positioned by sr-only, so it is not a grid item and takes no column
of the three-column track list. The two root-children pins name it.

MEDIUM 2. ReviewDecisionRail is sticky at top 0 in the same scroller, opaque
via card-lift and taller, so past the card header it covered the pinned
warning completely and the pin bought nothing in the state it exists for. The
view now measures the warning with a ResizeObserver and writes
--paper-review-sticky-offset onto the scrolling column; the rail's one sticky
rule reads it with a 0 fallback, so the rail sits below the warning and both
stay visible. Without ResizeObserver the offset stays 0, which is the previous
behaviour rather than a broken one. The warning stays at z-index 1 so an
opaque paragraph can never cover the decision controls or swallow their
clicks.

LOW 3. The recovered sentence lived for the whole session. The following
healthy success now retires it, so it lives for about one poll interval, with
no timer to tear down. A live region going empty announces nothing.

LOW 5. The two stylesheet source reads use Vite's ?raw directly, the form
ReviewDecisionRail.spec.ts already uses under this tsconfig, and the comment
no longer claims a constraint that does not exist.

Refs #2214
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Round-2 verification record (scoped to the fix diff 950a054..18cc35b; read-only pass).

Verdict: SHIP for the fix diff, no CRITICAL or HIGH. Confirmed: the hoisted recovery region is the first element child of the unconditional root, out of flow (sr-only is absolute) so the three-column track list and the narrow two-column variant are unaffected; no spec or selector indexes the root's first child except the two updated pins, which match template order; the custom property is written through the column's single :style binding and inherits down to the rail; the observer is created only for a live element, disconnected on every ref change and on unmount, and guarded where ResizeObserver is absent; the empty branch renders no rail and carries no property; the retire cannot fire in the same call that sets the flag, the explicit-load recovery path still fires when stale was true, and no path sets the flag outside the one site; the source-read pins are not tautological; the ?raw imports are type-checked under the vitest tsconfig with the existing precedent.

Two MEDIUMs and one LOW, tracked as #2638 rather than a third round (the two-round ceiling is reached and neither is a merge blocker):

Merge gate unchanged: ci-required green at 18cc35b, aged, merge commit. The #2214 residual items 1, 4, 5 and 6 are recorded in STATUS as delivered with #2638's two caveats stated.

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