fix(review): localize the degraded-queue warning, announce recovery, keep it visible - #2630
Conversation
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
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
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:
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
|
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. |
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.bodyandreview.queue.degraded.recoveredadded to theen,itandesreview catalogs.PaperReviewView.vueactive-proposal column,PaperReviewView.vueempty column,LegacyReviewView.vue) render$t('review.queue.degraded.body'). The English wording is unchanged, verbatim.LegacyReviewView.vuecomment 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) andReviewView.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)useReviewProposalsexposesqueueRefreshRecovered. 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.recordQueueRefreshSuccess()accounting as a successful poll instead of duplicating its two assignments.sr-onlyregion (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 arepaper-review-queue-recoveredandreview-queue-recovered.!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)paper-review-deep__queue-stale--pinnedmodifier on the active-proposal warning only:position: sticky; top: 0; z-index: 1;withbackground: 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)PaperReviewView.spec.tscase 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-renderedNo matches in Mine.title, and thatpaper-review-queue-livestill carries the real awaiting count.PaperReviewView.language.spec.tscase 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)v-if="activeProposal"andv-elsearms, so a branch flip remounted it. The recovering poll assignsproposals.valueand callsrecordQueueRefreshSuccess()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.ReviewDecisionRailis alsoposition: sticky; top: 0in the same scroller, opaque throughcard-liftand 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.PaperReviewViewnow measures the warning with aResizeObserverand writes--paper-review-sticky-offsetonto the scrolling column while the warning is on screen; the rail's single sticky rule becametop: 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. WhereResizeObserveris unavailable the offset stays 0, which is the previous behaviour rather than a broken one. The warning stays atz-index: 1, below the rail's2, so an opaque paragraph can never cover the Approve and Reject controls or swallow clicks aimed at them.ReviewDecisionRail.vuewas touched for that one rule and its comment only.?rawdirectly, the formReviewDecisionRail.spec.tsalready uses under this tsconfig. The previous comment implied?rawwas unavailable, which was wrong.Test plan
All commands run from
frontend/taskdeck-webin the worktree.Red first, round 1. The recovery specs were written before the implementation and recorded failing.
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.
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 wholev-ifchain, so that spec is a regression pin on working behaviour, not a fix.Verified (at the round-2 head).
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/guards—25 passed / 639 tests.Both new CSS rules were confirmed present in the production build output, not only in source:
dist/assets/ReviewView-*.csscontainstop:var(--paper-review-sticky-offset,0)for the rail and the fullqueue-stale--pinnedrule.NOT verified.
<style scoped>and happy-dom lays nothing out, so neither sticky rule is reachable throughgetComputedStyleand 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-offsetwhile 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.it/esreview catalogs carry aGH-1307 machine-translated, pending native reviewmarker for the same reason.dotnet testwas run.Boundaries and risks
!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..paper-review-deep, which is a three-column grid..sr-onlymakes 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.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.vuewas edited for one CSS declaration and its explanatory comment. Nothing else in that component changed, and its own spec suite is green.ReviewQueueRail's count region;LegacyReviewView's unavailable panel; every Inbox and board file.docs/STATUS.mdmay want a line; that call is left to the coordinator who holds the write lease.