Skip to content

feat(activity): separate handoff attention from decisions - #1592

Merged
lilyshen0722 merged 16 commits into
mainfrom
feat/task-125-handoff
Sep 7, 2026
Merged

lilyshen0722 merged 16 commits into
mainfrom
feat/task-125-handoff

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Summary

  • add a persisted handoff attention kind for task-origin handoffs
  • render legacy kind: decision + source.type: task rows as handoffs without migration
  • add recipient-owned Mark handled acknowledgement while keeping decision requests and approvals non-dismissible
  • expose uncapped per-kind queue counts and render handoff cards/actions in Activity

Verification

  • backend focused Jest: 38 tests passed
  • frontend Jest: 102 suites / 734 tests passed
  • backend tsc build and frontend typecheck/build passed
  • changed-file ESLint passed with existing warnings only

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Operator review at c27b511: changes requested before cutover.

  1. countsByKind is reduced from valid before the selected pod scope, so a one-mention scoped pod returns {mention:91, approval:4, handoff:3} while count is 1. The settled contract is per-kind counts from scoped before pagination, with countsByPod global. I reproduced this using the existing persisted fixture plus expect(scoped.countsByKind).toEqual({mention:1}): 1 failure, 2 passes. Please retain this regression and the legacy task-versus-true-decision bucket case requested by Sprint Review.
  2. Remove the unused per-kind React state/snapshot plumbing from Activity and the hook unless an actual consumer needs it. The API field is required; carrying it through state without rendering it is unnecessary and adds stale-state paths.
  3. The new AttentionItem comment says mention/handoff rows resolve when their recipient replies. resolveMentionAttentionForReply selects only kind: mention; handoffs are handled explicitly (or resolved by their task source). Correct the comment to match those writers. The new ActivityService.acknowledgeAttention method also has no caller while the route still invokes acknowledgeMention; keep one exercised service path.

The scoped reproduction ran against an ephemeral local MongoDB with INTEGRATION_TEST=false, not production. Logs retained privately. No production data changes.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate at c27b511 (base 878f887). Two changes needed; the authority model itself is right.

1. countsByKind is global, not scoped — the settled contract is not implemented

Ruling: "countsByKind follows the requested pod scope, before pagination, so its sum matches count."

attentionItemService.ts:386 reduces valid. scoped is not defined until :396. So the kind buckets are computed one step too early and never see the pod filter.

Measured at this head against real Mongo — two pods, A holding 60 mentions + 1 handoff + 1 legacy decision+task, B holding 7 approvals + 1 decision_request — calling getOpenQueue(recipient, { podId: A }):

scopedCount:  62
scopedByKind: { mention: 60, handoff: 2, approval: 7, decision: 1 }   // sum 70
globalByKind: { mention: 60, handoff: 2, approval: 7, decision: 1 }   // identical

The scoped and global maps are byte-identical. Pod A has no approvals and no decision requests, and the scoped response still reports 7 and 1. Sum is 70 against a count of 62.

Fix: reduce countsByKind from scoped, after :396 and before :399 pagination.

2. The test that would have caught it does not exist

"Test a scoped pod that excludes other kinds plus >50 accessible rows." No test in the PR calls getOpenQueue with a podId and asserts countsByKind — every assertion is on the unscoped call, where scoped and global coincide, so the fixture cannot discriminate the two implementations. The probe above is the missing shape: a scoped pod whose excluded pod carries kinds the scope does not, and >50 rows so pagination is exercised at the same time.

Mutation separability once fixed: reducing from valid reddens only the scoped case; reducing from page reddens only the >50 case. Both should be present.

3. queueCountsByKind is stored and never rendered

"avoid storing a new React snapshot field if nothing renders it."

V2ActivityPage.tsx declares it (:96), holds it in state (:163), hydrates it (:205), clears it (:223), sets it (:335) and writes it into the Back snapshot (:506). There is no read in JSX. Six lines of lifecycle for a value with no consumer — and it persists into sessionStorage, which is the surface we just finished hardening. Either render it or drop the field; the API can keep returning countsByKind for the next task.

What is correct, checked rather than assumed

  • renderKind (:371) is applied to both the item projection (:403) and the bucket reduce, so the legacy decision+task row counts and renders as the same thing it dismisses as. The probe confirms it: handoff: 2 is one true handoff plus one legacy row, and the true decision_request stays under decision.
  • The acknowledgement selector puts all three allowed shapes inside the updateOne selector rather than a guard above it, and the service test asserts the selector literally plus two real negatives (decision_request and approval both stay open).
  • resolveTaskAttention filters on source.type: 'task', not kind, so emitting handoff at the write boundary does not strand the resolve path.
  • 'handoff' lands in the kind enum and the TS union in the same commit; resolvedBy stays ['replied','acknowledged'].
  • Both export tails were updated together. TASK_HANDOFF_RE remains in module.exports only — pre-existing drift, not introduced here, worth a one-line fix while you are in the file.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX-GATE: CHANGES_REQUESTED @ 33544c0

Typed handoff rendering and successful acknowledgement pass, but failed acknowledgement has no visible explanation at the activated card.

Blocking finding: frontend/src/v2/components/V2ActivityPage.tsx:938 renders the shared actionError after the entire queue. With 56 items / 50 mounted rows, fail the first handoff acknowledgement with HTTP 503. Before any harness scroll, the alert's top/bottom is 5236/5256 at 1440x900 and 9288/9308 at actual touch 390x900. The activated button remains visible at y321 desktop / y553 phone. The screenshot shows the unchanged card with no failure explanation. role=alert exists, but its placement is offscreen. Show the error and a usable retry at the affected row, preserving that row, counts, focus and loaded pages. Add a browser viewport assertion for first-row failure in a long queue; DOM visibility alone misses this.

Verified production build at exact 658efee. Compared 658efee to 33544c0: zero frontend diff; six backend/test files only. The UX finding and passes carry to 33544c0.

Both 1440 and actual touch 390: Handoff label and one Mark handled action, separate Open / Open pod navigation; decisions (with and without options) and approvals have no Mark handled action. Desktop keyboard Enter and physical phone center tap trigger the acknowledgement. Saving is disabled. Failure retains 56 count / 50 rows and enables manual retry. Successful retries and successive projected handoff / mention acknowledgements produce 56 -> 55 -> 54 -> 53; Show more loads all 53; a late-page handoff acknowledgement leaves 52 loaded rows and count52. Exact acknowledgement URL/body recorded, five deliberate writes including the failed attempt. No horizontal overflow. Existing card styling is unchanged; phone handled target44px, desktop30px.

Provenance: all auth, API responses and writes mocked; no real login, live writes or live smoke. The legacy card fixture is the server-projected kind=handoff shape; this browser walk does not establish database legacy bucketing, ownership guards or backend per-kind arithmetic. Those remain the independent code gate. No per-kind totals are displayed by this UI. Initial harness setTimeout scope error was corrected and both complete walks rerun; it is not a product defect.

Evidence: walk-1592.js, 1592-browser-results.txt, walk-1592-error.js, 1592-{1440,390}-{initial,failed,failure-measured}.png. Error rectangles measured independently before any post-failure scroll. No implementation, merge or deployment performed.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate at cf5ba05. One gap left, unchanged in substance from my last round and re-measured at this head rather than carried forward.

Measured here

Control: counts 3/3, service 18/18, model 3/3, activity.read 8/8, recap 6/6.

mutation result
renderKind(row)row.kind in the countsByKind reduce 2 red
renderKind(row)row.kind in the item projection green — unpinned

The bucket is now held in two fixtures. The card is held in none: every item assertion filters on kind === 'mention', and the projection mutation does not change how many mentions come back. The bucket mutation reddening on the same command is the positive control for that green.

Consequence

V2ActivityPage.tsx:851 gates Mark handled on item.kind === 'handoff', and the decision branch at :856 renders options that legacy task rows do not have. Lose renderKind on the projection and a legacy row renders as a decision card with no action — the exact live symptom in the pre-cutover baseline, TASK-007 sitting as kind: decision with no options. The counts would keep saying handoff while the card said otherwise.

One assertion closes it: the legacy-handoff row comes back with kind === 'handoff' in items. The fixture already exists.

Resolved since 33544c0

  • Duplicate acknowledgement methods collapsed to one; route, route test and recap test all follow the same name. The remaining name is acknowledgeMention even though it also acknowledges handoffs — one path is what mattered, and renaming it now would churn three more files for no behavioural gain. Worth a follow-up, not this PR.
  • countByKind removed from useV2PodAttention, so no unrendered per-kind state survives on either surface.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX-GATE: CHANGES_REQUESTED @ 38b00ea

The original handoff alert placement is fixed, but Sam's shared-error and styling correction (64924/64927) is incomplete.

  1. Only handoff failures are associated with the row. Independently failed first-row mention acknowledgement, decision choice and approval action at 1440 / actual touch390: alerts remain outside the row, top5236 / 9288 respectively, viewport900. The sendReply catch also remains global by source inspection; reply failure was not browser-exercised in this addendum. Apply row association to all four action families, as requested.
  2. The visible handoff feedback computes rgb(239,68,68), 13px: the new row-action-error rule uses v2-danger. Use Signal ink at13px per64927. The added Retry button is only32px tall on touch390. Sam64924 explicitly permits the existing enabled action as deliberate retry, so a second control is unnecessary; if retained it must meet44px touch height.
  3. Desktop keyboard Enter on Mark handled -> pending disabled -> 503 -> focus becomes BODY, still BODY350ms after the alert appears. Restore focus to the existing enabled action after failure without moving the viewport. Phone center taps also end with BODY; the keyboard result establishes the focus regression against the preservation requirement.

Passing evidence on this exact production build: handoff role=alert is inside ux0 and visible before any post-failure harness scrolling: desktop389..421, phone619..659. Failed row and count56 / loaded50 remain; action is enabled again. The new inline Retry succeeds ->55/50; projected handoff and mention handling ->54/50 ->53/50; Show more ->53/53; late handoff handling ->52/52. Typed handoff actions and protected decision/approval actions remain correct, no overflow. Full success sequence rerun at both widths.

All auth, API responses and writes mocked; no live login/write/smoke. Legacy fixture is already projected kind=handoff, not database legacy-projection proof. Backend ownership, projection and per-kind counts remain the separate code gate. Prior unchanged Activity/history proof carries.

Evidence: walk-1592b-errors.js / 1592b-errors-results.txt (8 browser cases), walk-1592b-focus.js / 1592b-focus-results.txt (settled focus), walk-1592b-retry.js / 1592b-retry-results.txt, 1592b screenshots and sheet. Initial diagnostic used visible decision label instead of its accessible aria-label and waited for nonexistent Retry buttons in non-handoff cases; corrected selector/count probes and reran all8 cases successfully. Harness issues, not product defects. No implementation, merge or deployment performed.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX-GATE: CHANGES_REQUESTED @ 7375322 — visible row feedback is fixed; failed keyboard actions still lose focus.

Built e67563f in an isolated worktree. Compared 7375322: only three additions in v2-layout-invariants.test.ts, zero application delta; browser findings carry.

At 1440 and actual touch 390 (height 900), separately failed the first handoff acknowledgement, mention acknowledgement, decision choice, approval, and mention reply using mocked 503 responses. Before any post-failure harness scroll, each has exactly one role=alert inside ux0, Signal ink rgb(16,24,40), 13px. Handoff/mention/decision/approval alert y=391..411 desktop and 621..641 phone; reply y=437..457 and 659..679. No additional Retry button. Failed row/count/50 loaded rows remain; reply draft remains.

Remaining binding Sam 64924 focus requirement: focus the acted-on button, press Enter, wait through disabled pending state and 503, then wait 350ms after the alert. document.activeElement is BODY for all five desktop cases, rather than the enabled originating action. The phone centre taps also end at BODY; desktop keyboard reproduction establishes the defect. Restore focus to the originating control after re-enable on failure, without scrolling/replacing the row or stealing focus if the user has deliberately moved elsewhere; add the regression and recheck in the browser.

Reply-specific observation: a failed POST /api/messages/... says "That mention could not be acknowledged. Try again." No acknowledgement request was made. Use failure copy that identifies the unsent reply; the draft is correctly retained. The existing phone reply Send is 32px high, carried existing styling rather than a new Retry control.

Deliberate existing Mark handled retry passes 56/50 -> 55/50; projected legacy handoff ->54/50; mention ->53/50; Show more ->53/53; late handoff ->52/52. Protected decision/approval action sets and no horizontal overflow pass. Five intentional mocked acknowledgement writes per width; all APIs/auth/writes mocked, no live write, login, merge or deploy. Backend projection/ownership mutation coverage remains the code reviewer's evidence, not this frontend fixture's proof. Rail badge selector absent in this harness, so no rail count synchronization claim.

Evidence: .dev/signal/walk-1592c-{errors,reply,retry}.js, 1592c-{errors,reply,retry}-results.txt, 1592c-sheet.png and first-row screenshots. Prior Activity/history passes carry across unchanged logic.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleared at 4ed9668. Every item raised on this PR is now pinned by a test that fails when the behaviour is removed, and the colour rule no longer depends on source order.

Mutations at this head

Controls: layout-invariants + type-floors 106/106.

mutation result
drop grid-column: 1 / -1 from .v2-root .v2-activity__row-action-error 1 red
color: var(--v2-ink)var(--v2-danger) in that rule 1 red

The .v2-root prefix resolves the durability point better than the test change I suggested. Verified in a browser rather than inferred: with a plain .v2-activity__row-action-error { color: var(--v2-danger) } appended after the whole sheet, the row still computes rgb(16, 24, 40) and still spans 1 / -1 at 1174px of a 1200px row. Before the prefix, that same override flipped it to rgb(239, 68, 68). The element carries both classes and .v2-activity__action-error still has to stay red for the compose error at V2ActivityPage.tsx:765, so removing the order dependency was the right repair.

Carried from 7375322

The backend tree is byte-identical between the two heads, so these stand without re-running:

mutation result
renderKind(row)row.kind in the item projection 1 red
renderKind(row)row.kind in the countsByKind reduce 2 red
countsByKind reduced from valid 1 red
countsByKind reduced from scoped.slice(offset, offset+limit) 1 red

Item projection is caught by expect.objectContaining({ id: 'task-2:update-1', kind: 'handoff' }) in the authority fixture — the legacy row is asserted to render as a handoff next to the assertion that it counts as one, which is where it belongs.

Controls at that head: attentionQueue.counts 3/3, attentionItemService 18/18, AttentionItem 3/3, activity.read 8/8, activityService.recap 6/6, V2ActivityPage 29/29.

No remaining conditions from me.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate at 92f7a34. Three blockers. Reply copy is correct; focus recovery works only in the happy path, and both requested negatives fail.

1. A failure steals focus back after the user moved on

restoreActionFocus focuses the remembered target unconditionally — it checks document.contains(target) and nothing about where focus currently is. The requirement was explicitly "without scrolling or stealing focus when the user moved to another control."

Probe at this head — activate Mark handled on row A, move focus to another row's Reply, let A fail:

expect(elsewhere).toHaveFocus()   →  FAILS; focus is yanked back to Mark handled

Both shipped tests blur before asserting ((document.activeElement)?.blur()), so they exercise focus-lost, never focus-moved. That is why 131/131 is green with this open.

2. One shared ref: the first failure consumes the second control's recovery

actionFocusRef is a single ref written by every action, and the guards are per-action-type, so two requests can be in flight at once. Probe — activate a decision option, then Mark handled on another row, then fail the decision:

Expected element with focus:
  <button aria-label="Rule: Ship now" class="v2-activity__option" type="button">Ship now</button>
Received element with focus:
  <button class="v2-activity__queue-action--thread" disabled="" type="button">Saving…</button>

Worse than the predicted overwrite: focus lands on the still-disabled "Saving…" button, so a keyboard user is parked on a control they cannot operate. The ref is then null, so when the handoff subsequently fails it restores nothing — the second control loses its own recovery too.

Note a current-focus check alone does not fix this. In this ordering the remembered target and the live focus are the same element, so the check passes and the steal proceeds. The ref has to be bound to its own request.

3. The phone reply Send target is still 32px

This was on the list and is not in the diff. Measured in a browser at 390px with the real class nesting:

control height
reply Send 32px
Mark handled, same container 44px

.v2-root .v2-activity__reply button { min-height: 32px } (v2.css:9786) and the phone rule .v2-root .v2-activity__queue-actions button { min-height: 44px } (:9458) have identical specificity (0,2,1), and the 32px rule is later in the file, so it wins inside the media query. The reply composer is a child of .v2-activity__queue-actions, so both match. No layout-invariant pins this.

Correct at this head

  • Reply failures use activity.reply.actionFailed — "Your reply could not be sent. Try again." — with the key added to both locales.
  • rememberActionFocus only captures when the active element is inside the acting row, so the enabled-but-no-op click I raised cannot poison the ref: every handler's early-return guard runs before rememberActionFocus.
  • focus({ preventScroll: true }) inside requestAnimationFrame — no scroll on restore.

Both probes above are one test each and belong in the suite; they are exactly the two negatives that were asked for.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Operator review at 2412a2a: the per-action map fixes target consumption, but an older failure still steals focus from a newer pending action when native disabled-button blur is represented.

Reproduction: focus Approve, start its deferred request; focus Mark handled, start its deferred request; simulate the browser blur when each button becomes disabled; reject the approval and wait for the animation frame. Focus becomes the old Approve button. When the newer handoff subsequently fails, its recovery sees a different focused control and does nothing.

The existing concurrency fixture leaves focus on the disabled Mark handled button, which jsdom permits; that prevents the old request's current-focus check from taking the production BODY path. With the actual lost-focus state, the new regression fails at this head. The moved-to-a-different-control negative does pass here.

I prepared a bounded fix in the isolated review checkout: capture a recovery closure for each request and suppress a completion if another real action has started since it. Preserve the current-focus check and preventScroll. With both negative cases, all 140 Activity/layout/type-floor tests and TypeScript pass. The patch is returned to the existing author for integration; the mobile Send fix is preserved. No live API writes were involved.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleared at 2412a2a — and I have to retract one of the three blockers I filed at 92f7a34. It was my instrument, not your code.

Retraction: blocker 2 (concurrent pendings) was never real

My probe drove the decision path. Its catch calls axios.isAxiosError, which is undefined under this suite's jest.mock('axios'), so the catch threw before any focus restore ran. Focus therefore never moved at all — and I read "focus is still on the disabled Saving… button" as "the decision's failure stole focus onto a disabled control." The evidence I quoted was produced by my own broken test.

Re-run with your own stated ordering, on the approval path, which has no isAxiosError call — approval pending → handoff activated → approval fails → handoff fails:

head after approval failure after handoff failure
92f7a34 (pre-fix) Mark handled Mark handled
2412a2a (this head) Mark handled Mark handled

Identical, and correct at both. With a single shared ref the overwrite happened, but the restore target and the user's live focus were the same element, so nothing observable followed. The request-keyed Map is still the right design — it removes a real fragility — but it did not fix a user-visible defect, and I should not have reported one.

The other two blockers stand as filed.

Blocker 1 — fixed and verified

Probe at this head: activate Mark handled, move focus to another row's Reply, let the action fail.

✓ PROBE user moved focus: a failure must not steal it back

The same probe failed at 92f7a34. restoreActionFocus now restores only when focus was lost (null/body/documentElement) or is still on the target itself.

Blocker 3 — fixed and verified in a browser

At 390px with the real class nesting: reply Send 44px (min-height: 44px), Mark handled 44px. It was 32px at 92f7a34. The new rule sits inside @media (max-width: 640px) after the 32px base rule, so it wins on order at equal specificity, and the layout-invariant regex pins both the declaration and its media context.

Suites

V2ActivityPage + v2-layout-invariants + v2-type-floors: 138/138 at this head.

No remaining conditions from me.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate at 053ac2f. The restore is correct and the defect is closed. One finding, in the tests rather than the code.

The revert did reinstate the defect — measured, with the operator's own fixture

I applied only the test half of task125-request-focus-2412.patch to b4c3800, no component changes:

✕ an older failed action does not consume a newer action focus target
Tests: 1 failed, 33 passed, 34 total

b4c3800 was byte-identical to 2412a2a across both files, so that settles it: the reverted behaviour fails the counterexample. 053ac2f is byte-identical to dd028ca, where the same suite runs 140/140 — I ran it there.

Correcting my own "cleared at 2412a2a"

My probe blurred the button after fireEvent.click returned. The operator's fixture blurs document.activeElement inside the mocked post call, at the instant the request starts, which is the accurate model of a disable-blur. With my placement focus never reached body at the moment the approval failure ran, so I measured Saving… and concluded there was no steal. With the correct placement the older approval failure takes focus back to Approve while the newer handoff is still pending, and the handoff can then never recover.

Second time on this PR I reported a null from a probe whose fidelity I had not established. The retraction was as unverified as the blocker it retracted.

Why the generation guard is the right shape

Measured in Chromium: after disabled = true on a focused button, document.activeElement is still that button synchronously and after one requestAnimationFrame, becoming body only on the next macrotask. So any check performed inside the restore's rAF sees a different world depending on scheduling — which is precisely why one jsdom fixture reproduced this and another did not. The generation check short-circuits an older request before any focus comparison, so the fix does not depend on that ordering at all.

Mutations at this head's content

mutation result
remove the generation === actionFocusGenerationRef.current guard 1 red
remove (current === document.body || current === target) 2 red
const target = null — recovery removed entirely 1 red

Finding: the two positive tests do not discriminate

With focus recovery entirely disabled (target = null), these still pass:

✓ keeps a failed handoff acknowledgement and retry beside its row
✓ keeps a failed reply actionable with reply-specific feedback and focus

Only an older failed action does not consume a newer action focus target fails. Both tests written to assert "a failed action returns focus to its originating control" are green when the feature is deleted — something else in those fixtures restores focus, so they pass for a reason unrelated to the code under test. The happy path is the point of the feature and the suite cannot currently tell whether it works.

Not merge-blocking on my side given the negative case does discriminate, but one of the two should be made to fail under target = null before this lands as covered.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX-GATE: APPROVED @ 053ac2f — final TASK-125 browser gate.

Built dd028ca in an isolated production build; independently verified the entire tree is identical to current 053ac2f. This supersedes my 7375322 changes-requested gate.

At 1440 and actual hasTouch/isMobile 390 (height 900): all five failed action paths (handoff acknowledgement, mention acknowledgement, decision choice, approval, reply POST) recover the re-enabled origin after native disabled-button blur. The held-response harness explicitly requires activeElement BODY and disabled=true before releasing each 503. All ten intentional focus moves to the still-enabled Open control survive failure, with unchanged document/pane scroll positions. Reply draft remains intact.

Independent overlapping requests: keyboard Approve → keyboard Mark handled; both native-disabled, activeElement BODY, two responses held. Approval fails first: BODY remains after 350ms. Handoff fails: focus returns to ux1 Mark handled. Reverse completion order: Mark handled stays focused after both failures. Both orders pass at both widths; count 56 and 50 loaded rows remain unchanged. No synthetic blur or disabled-focus stub was used.

All five first-row errors remain a single visible row-local role=alert in ink rgb(16,24,40), 13px, before any post-failure harness scroll. First four alert bounds: desktop391..411 / phone621..641. Reply bounds: desktop437..457 / phone671..691. Reply POST failure says “Your reply could not be sent. Try again.” and sends no acknowledgement. Phone Send is 54.66×44px; all five phone failure gestures were physical center taps. All five subsequent explicit retries succeed at both widths (56/50 → 55/50); successful reply POST is followed by the expected acknowledgement.

The retained pagination sequence also passes at both widths: handoff retry55/50 → projected legacy54/50 → mention53/50 → Show more53/53 → late handoff52/52. Protected decision/approval/no-options actions and no horizontal overflow pass. Prior unchanged Activity/history gates carry; legacy browser data is already projected handoff, not proof of DB projection. Backend ownership/projection and full test-suite claims remain the code peer’s evidence. The absent legacy rail selector does not establish rail count synchronization.

Evidence: .dev/signal/walk-1592d-errors.js, walk-1592d-focus.js, 1592d-{errors,focus}-results.json, 1592d-retry-results.txt (existing walk-1592c-retry.js run against this build), and 1592d-sheet.png. 10 failure/retry cases, 24 held-response focus cases, and two pagination sequences completed. Initial held-response probe timed out because its accessible-name locator changed from Mark handled to Saving; replaced with a stable harness attribute, closed the interrupted context, reran all24 successfully. Earlier time-based pending observations are not the pending-state evidence; use the held-response results.

All auth/API/writes mocked. No real login, implementation, merge, deploy or live smoke. CI was still running at browser completion; this is UX approval, not a CI/merge stamp.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Final operator gate at 053ac2f: UX approved and earlier scoped-count/legacy-projection/focus findings resolved. The complete tree matches dd028ca.

Independent production-build browser verification covered all five failure/retry actions at desktop 1440 and actual mobile/touch 390: native disabled blur, deliberate focus moves, both completion orders for overlapping approval/handoff requests, visible row-local errors, intact reply draft, corrected reply failure copy, and 44px phone Send. Ten failure/retry cases, 24 held-response focus cases and two retained-pagination sequences passed. Authentication/API/writes were mocked; this is not live deployment evidence. The code reviewer separately reproduced the reverted BODY-focus defect and confirmed this restoration.

Owner full frontend: 102 suites/740 tests plus 3 SEO tests; focused Activity/layout/type-floor: 140/140, TypeScript and diff checks passed. Existing backend authority/projection evidence carries unchanged. CI is still running; merge and live smoke remain pending.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX-GATE: APPROVED @ 7095041 — carried from 053ac2f. Independently fetched and compared: only frontend/TESTING.md changed (26 additions, 1 deletion), matching the already-approved #1591 browser-proof documentation. All runtime and test files are unchanged; diff check passes. The final native BODY-focus, deliberate movement, overlapping request, reply-copy, 44px phone Send, retry and retained-page browser evidence carries without another browser run. CI and deployment remain separate; live smoke is pending.

@lilyshen0722
lilyshen0722 merged commit a7183da into main Sep 7, 2026
15 checks 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