diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index e4ef92f2..a4970074 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -953,6 +953,10 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | open-pr-babysit-continuation-20260725 | multipass | Babysit continuation after 14 merges: #1177 landed; #1174/#1178/#1153 in progress; drafts #1187/#1192 skipped; large cluster #1162/#1185/#1186/#1188/#1190 content-conflicted (skip). | merge-tree inventory; no provider-backed checks. | | 2026-07-25 | audit-remediation (PR #1153) | 5a731df5c25fed9b07fd2321a0ad4b6519471f4b | PR babysit: CodeRabbit thread fixes + merge | Before: MERGEABLE/BLOCKED on required_review_thread_resolution + pending CI; 6 CodeRabbit threads. After: fixed sync-skills pad/YAML escape, PDF temp cleanup, squash-aware rollback wording; dispositioned ledger mid-table + retained false-positive; approved CI; merged to main `191b17d2f` (merge commit); branch deleted; tip is ancestor of main. | Hosted CI green on tip; no provider-backed checks. | | 2026-07-25 | cursor/local-presence-054-7cf3 (PR #1178) | 9135891bfd194394549cb480a7ec86de12b23ee7 | PR babysit: local-presence + /tools + CI/UI fixes + squash merge | Before: flaky Safety audit on package.json scripts, Production UI Sources autofocus flake, CodeRabbit short-env duplicate thread. After: ci-change-scope lockfile-only; strip stale short env keys; sheet open-focus retries + skip focus=1 reclaim under modal; squash-merged `d08ec2e8e`; branch deleted; key-file content-diff empty. | Hosted PR required SUCCESS (Production UI green on tip); focused local-presence vitest; no provider-backed checks. | +| 2026-07-25 | cursor/fix-mobile-composer-edge-scroll-5b1d (PR #1192) | 3b5ef43f1825dd8cf11dd767069569ba1c701c45 | Bugbot branch review: mobile Answer edge dock, synchronized hide/reveal, focus safety, reserve-collapse and fractional-clamp safeguards | No bugs found. Highest residual risk is physical iOS Safari toolbar/visual-viewport behavior beyond Chromium emulation. | `npm run verify:cheap` (3,357 passed); `npm run verify:ui` (272 passed); focused clamp/reserve Vitest (28 passed); focused production Chromium regression passed; clean headed-phone video proof; no provider-backed checks run. | +| 2026-07-25 | cursor/fix-mobile-composer-edge-scroll-5b1d (PR #1192) | 69dc0dbfb46586f54f5934199d4a65b9f6a0aba8 | User-requested Bugbot review of current PR head after geometry-aware clamp handling and CI formatting fix | No bugs found. | Bugbot branch review; prior focused unit/Chromium/manual proofs retained; no provider-backed checks run. | +| 2026-07-25 | cursor/fix-mobile-composer-edge-scroll-5b1d (PR #1192) | c08ed37c5e8c7b62cf7d547089afff7a63c9122c | Live local document-detail scroll/ownership review at 390x844, 768x1024, and 1440x900 | FINDINGS: P2 canonical phone detail renders shared mobile header plus DocumentViewer header; P2 expanded desktop sticky rail scrolls its section navigation off-screen; P3 390px in-flow section nav fully hides Images with no overflow cue. Composer focus pinning, actions sheet, endpoint clearance, safe-area gap, and single composer/content reserve ownership otherwise held. | `npm run workflow:design-sweep -- --write-evidence`; `npm run ensure` + `/api/local-project-id` identity; live local Chromium natural down/up, anchors, focus, sheet, endpoint and geometry probes; focused document-viewer Playwright 3/3; reduced-motion + forced-colors visibility at 390/1440; no OpenAI/Supabase/GitHub/hosted CI/provider calls. | +| 2026-07-25 | PR #1192 / `cursor/fix-mobile-composer-edge-scroll-5b1d` | `5b5ecf4057b54f1b689935f8cb876a2ed1cbdb3a` | Bugbot triage after 0c2b60a REQUEST CHANGES: verify prior P1/P2 on 8b812b116 and fix remaining defects | P1 confirmed: `composerChromeFocused` still latched after phone dock teardown (`shouldAutoFocusComposer` only covers answer autofocus). Fixed by clearing focus pins when dock inactive / hide-on-scroll disabled. P2 confirmed: reserve-only hide gate still ignored offset (118/191 material-clamp frames); fixed with `offset <= postCollapseMaxOffset + tol`. Compact answer hide retained; material clamps ? 0. Prior autofocus/retainTarget mitigations kept. | Node stress before/after; vitest use-hide-on-scroll + mobile-composer-reserve 28/28; no provider/UI browser matrix. | | 2026-07-25 | cursor/search-correctness-030-075-6273 (PR #1177) | 96ba61520aeea59647dcaec6671ccb82618553ef | CORRECTION: real SHA for the 2026-07-24 post-sync #1177 row | That row recorded `96ba6152c1f8e5e0000000000000000000000000`, a zero-padded placeholder that resolves to no Git object. The real commit is `96ba61520aeea59647dcaec6671ccb82618553ef` ("ci: remove PR_POLICY_BODY.md after sync"); the reviewed outcome itself is unchanged. | `git rev-parse` verification; `npm run check:branch-review-ledger` pass; no provider-backed checks run. | | 2026-07-25 | cursor/eval-matching-distinct-identity-6273 | f5620e3a4a2d60fd8a67fd43d8046726f1761e5f | Self-review of the merged #030/#075 fixes against current main | Before: merged coverage matched expectations to distinct source *positions*, so one document repeated across `answer.citations` still filled both comparison slots, and first-come assignment made `allHit` depend on `expectedFiles` order; `PR_POLICY_BODY.md` was back on main (via #1153) ready to overwrite the next PR body; the #030/#075 queue row survived their archival. After: coverage dedupes by document text and assigns by maximum bipartite matching, label accumulation is linear, the stale template is removed, and #080 records the removed admission widening for approved eval re-test. Both new contracts were verified failing against main's matcher before the fix. RAG impact: no retrieval behaviour change — eval matching and label bookkeeping only. | Focused Vitest 39/39 (eval-document-matching, eval-utils, eval-search, search-scope, rag-imputation-contract); fail-closed proof 2 failed on the `origin/main` matcher; `npm run verify:cheap` pass (3379 passed, 5 skipped); `check:branch-review-ledger` and `check:pr-policy` pass; no OpenAI/Supabase/provider-backed checks run. | | 2026-07-25 | execute-audit-code-remediation (PR #1162) | d5455837231f5cb6a927e8c4752ef5aa9c72767c | Merge conflict + CI + Bugbot review | Merged origin/main (164 behind). Fixed conflicts in ClinicalDashboard/global-search-shell/mode-home/search-scope/tests/pdf extractor. Renamed duplicate migration 20260724120000→20260724130200. CI: skills openai.yaml, owner-scope setup-status exemption, sitemap, drift hash, answer-render duplicate key, setup-status mock `.eq`. Bugbot: fixed Codex P1 view-only indexing + P2 differential back; also fixed P2 viewer visibility for retained images and duplicate-upload cleanup ledger fail-closed. | Focused Vitest (back-href/worker/skills/setup-status/owner-scope/drift/sitemap/search-scope/favourites/forms/therapy/document-detail/upload-ledger) + typecheck + check:skills/migration-role/sitemap; no provider-backed checks. | @@ -961,6 +965,11 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | execute-audit-code-remediation (PR #1162) | b9b56c140eb14cbba5a2c2230e3fa28d3a791add | CI unblock after bot sync | Tip 96188eca had PR required SUCCESS (Static/Safety/Unit/Build/Migration/Production UI). Hosted pr-branch-sync then merged main (a420b86b/b9b56c14), leaving CI action_required for bot-authored runs. Pushing agent commit to re-trigger non-bot CI. | Prior tip 96188eca hosted CI green; local services referral Playwright PASS; no provider-backed checks. | | 2026-07-25 | PR supersede #1186 / `cursor/pr1186-audit-remediation-c94c` | `a38e83860510a4229d5658960657cd7448aff278` | Clean main-based port of intentional #1186 audit fixes | SUPERSEDE #1186 (do not merge old PR). Ported intentional 16-file delta onto current main; dropped conflicted checkpoint tree and placeholder skills. Fixed eval single results binding; async run-heavy so lock heartbeat fires; branch:cleanup dry-run default + argv-safe deletes; skill-create interface YAML. Close #1186. | Focused Vitest tooling+lock 6/6; check:skills 33; prettier on touched files; no provider/live eval runs. | | 2026-07-25 | `cursor/ledger-066-067-519b` | f04392a408eceee14215c15169cbd6b70ac2041c | Close stale ledger #066/#067 (+ drop resolved #030/#075 from queue) | READY. #066 proven on main via #1174; #067 already fixed in #1191 in-process preflight. Docs-only ledger sync; no code change. | Local proof: `git show`/`log` for #1174/#1191; preflight test already in-process on main; ledger integrity asserts. No providers. | +| 2026-07-25 | `cursor/fix-mobile-composer-edge-scroll-5b1d` (PR #1192) | 333e67b8 | pr-ci-fix: Static PR checks / Maintainability hotspot budgets | Main merge (e688c6e2) expanded a JSX comment from 2→3 lines while restructuring heroComposerBreakpoint/heroOwnsPhoneComposer declarations, netting +2 lines vs budget-fix commit (ae77f8c3). ClinicalDashboard.tsx hit 4141 vs 4140 budget. Fix: compressed 3-line comment back to 2 lines. Zero behaviour change. | `npm run check:maintainability-budgets` → PASS (4140/4140). No provider-backed checks. | +| 2026-07-25 | PR #1192 / `cursor/fix-mobile-composer-edge-scroll-5b1d` | `70abb74f7ceee5a50748c4c1e6baa730d7225cf4` | User-requested /review + Bugbot + /debug + /prlanded on current tip | APPROVE with notes. Prior P1 focus latch and P2 near-bottom reserve-only clamp confirmed fixed on `5b5ecf405` and retained through main merge. No new P0/P1. Residual P2s: PR body was wrong audit-remediation paste (fixing); non-answer `focus=1` autofocus still broad; earlier document-detail double-header findings unchanged/out of Answer-dock scope. /prlanded: still OPEN, not merged. GitHub CONFLICTING was staleness (merge-tree clean) � merged origin/main. | Bugbot; focused Vitest use-hide-on-scroll + mobile-composer-reserve 28/28 before and after main merge; merge-tree clean; no provider/UI browser matrix this pass. | +| 2026-07-25 | PR #1192 / `cursor/fix-mobile-composer-edge-scroll-5b1d` | `73e87da63f5a9eca4162534074755891f952a4ee` | CORRECTION/supersede: final head after Bugbot fixes + main sync + review ledger push | APPROVE with notes retained from prior row. Product fixes from `5b5ecf405` still present; branch now 0 behind / mergeable (BLOCKED on CI). Wrong audit-remediation PR body corrected. /prlanded: still OPEN � do not delete branch. | Focused Vitest 28/28 on pre-sync tip; merge-tree clean; CI re-queued on tip; no provider-backed checks. | +| 2026-07-25 | PR #1192 / `cursor/fix-mobile-composer-edge-scroll-5b1d` | `477ec929` | pr-ci-fix: Static PR checks / ESLint react-hooks/set-state-in-effect | Two `useEffect` blocks in `master-search-header.tsx` called setState synchronously (lines 383-392). Fix: moved `heroComposerOwnsPhones`, `phoneBottomSearchDockActive`, `hideOnScrollEnabled` before `sharedChromePinned`; gated focus pins at consumer; removed both effects. Net -12 lines, budget OK (4133/4140). | ESLint on file: 0 errors; `npm run typecheck`: clean; `prettier --check`: clean; `check:maintainability-budgets`: PASS. No provider-backed checks. | +| 2026-07-25 | PR #1192 / `cursor/fix-mobile-composer-edge-scroll-5b1d` | `b200d37af9bd6a93589e7984a7cb9c23164079f0` | Apply recommended review fixes after /review+/bugbot | Fixed Static CI eslint set-state-in-effect on latch clear (derive pins + queueMicrotask). Fixed residual P2: suppress `focus=1` autofocus after any `modeSearchSubmitted` and on `run=1` bootstrap. Prior P1/P2 chrome fixes retained. | eslint master-search-header+ClinicalDashboard; maintainability 4137/4140; vitest use-hide-on-scroll+mobile-composer-reserve 28/28; no provider-backed checks. | | 2026-07-25 | PR #1188 / `execute-audit-remediation-plan` | `8b8639113925601e1687bfe4f1f29c44a4308b61` | Explicit Bugbot + protocol review (+ /prlanded + /debug) | DO NOT MERGE tip. Not landed (state OPEN, mergeable CONFLICTING, 468 behind main). P0: ClinicalDashboard orphaned import body (parse break). P0: dangling `renderSystemNotice` after helper extraction. P0: `indexing-v3-agent/utils.ts` `async export function sha256Hex`. P0/P1: `CLINICAL_PHRASE_PATTERN` left in index.ts but used from utils. P0: ~12 files still contain conflict markers from archive base `faa50e6`. P1: PR policy missing Clinical Governance Preflight. P2: notice visibility dropped `answer` gate + `hidden sm:block`. IMP-04 prune unsafe vs current main (still-exported symbols in use). Clean rebuild of intentional remediation on main: `cursor/pr1188-fix-build-breakers-6ee0`. | Bugbot subagent; `git show`/marker scan; esbuild parse of tip utils; `gh pr view/checks`; typecheck + check:github-actions on fix branch. No provider calls. | @@ -971,8 +980,8 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | PR #1188 / `execute-audit-remediation-plan` | `8b8639113925601e1687bfe4f1f29c44a4308b61` | prlanded + close as superseded | CLOSED (not merged). Content never landed; tip remained CONFLICTING with P0 build breakers. Superseded by PR #1213 (`cursor/pr1188-fix-build-breakers-6ee0`). | Final P0 scan on #1213 tip clean; focused Vitest 16/16; node --check utils; check:github-actions. Closed via ManagePullRequest with supersession comment. | | 2026-07-25 | PR #1213 / `cursor/pr1188-fix-build-breakers-6ee0` | `64b13fba5d8c7c97dac553d02a8dd4c2b5522e1e` | Safe land handoff after #1188 close | #1188 CLOSED superseded. Tip was bot-merge-only so hosted CI sat in action_required; pushing agent commit to re-trigger non-bot CI before squash-merge to main. merge-tree clean vs main; intentional rebuild (notices/lazy/utils) intact. | gh run list action_required on bot tip; merge-tree clean; no provider calls. | -| 2026-07-25 | PR #1190 / `remediate-dark-mode-audit` | `00eca49b9b0d7e5fbfa5703a15e9e930963984a6` | Cursor review+Bugbot+prlanded+debug (fresh pass, same HEAD) | DO NOT MERGE; NOT LANDED (OPEN, mergeable=CONFLICTING/DIRTY, 468 behind / 2 ahead). Reconfirmed P0: conflict resolution deleted `trustGatedAnswerForClinicalNotes` (0 hits on head; main L584/632/659/1075) — Clinical Notes consumes ungated answer. P1: `src/app/api/answer/route.ts:6` imports nonexistent `@/lib/rag` (tsc TS2307; stream correctly uses `@/lib/rag/rag`). P1: merge-tree conflicts on answer/upload/evidence-panels + 10 paths; literal `<<<<<<<` in docs audit plan; migration timestamp collision risk vs main. Intentional dark-mode delta is only commit `363672602` (~10 files). Salvage: `cursor/pr1190-dark-mode-salvage-f453` cherry-picks that commit onto current main, restores unused-manifest-import cleanup, keeps clinical gate. Close #1190 after salvage lands. | Bugbot; `git grep` gate/import/markers; merge-tree; `tsc` TS2307 proof; gh pr view/checks (PR policy fail). Salvage: tsc clean; visual-evidence+overlay tests 13/13; eslint on changed files. No provider/UI matrix. | | 2026-07-25 | cursor/pr1197-fix-regressions-d06a (PR #1197 fix) | c1e9696de6000440ad5b44e4d2fb2738a858deae | Fix-forward after Bugbot do-not-merge review | FIXED for tip. Synced to origin/main; restored clinical-notes trust gate + boundary tests + summaryMode 400 contract by taking main; removed ISSUE-07 RAG pre-classifier (rag.ts matches main � RAG impact: no retrieval behaviour change). Kept only additive SQL remediation: migration 20260725000000 (real worker URL + ISSUE-05 revokes), schema.sql URL/[REDACTED] fix + ISSUE-05, regenerated drift-manifest. Dropped broken upload authority refs (undefined canonicalAuthority). | Vitest: summaryMode reject + ClinicalNotes boundary 4/4; rag-classifier-memo + rag-tail-latency 17/17; upload smart-title/cleanup 3/3; check:migration-role pass; drift:manifest regenerated via Docker. No provider-backed checks. | +| 2026-07-25 | PR #1190 / `remediate-dark-mode-audit` | `00eca49b9b0d7e5fbfa5703a15e9e930963984a6` | Cursor review+Bugbot+prlanded+debug (fresh pass, same HEAD) | DO NOT MERGE; NOT LANDED (OPEN, mergeable=CONFLICTING/DIRTY, 468 behind / 2 ahead). Reconfirmed P0: conflict resolution deleted `trustGatedAnswerForClinicalNotes` (0 hits on head; main L584/632/659/1075) — Clinical Notes consumes ungated answer. P1: `src/app/api/answer/route.ts:6` imports nonexistent `@/lib/rag` (tsc TS2307; stream correctly uses `@/lib/rag/rag`). P1: merge-tree conflicts on answer/upload/evidence-panels + 10 paths; literal `<<<<<<<` in docs audit plan; migration timestamp collision risk vs main. Intentional dark-mode delta is only commit `363672602` (~10 files). Salvage: `cursor/pr1190-dark-mode-salvage-f453` cherry-picks that commit onto current main, restores unused-manifest-import cleanup, keeps clinical gate. Close #1190 after salvage lands. | Bugbot; `git grep` gate/import/markers; merge-tree; `tsc` TS2307 proof; gh pr view/checks (PR policy fail). Salvage: tsc clean; visual-evidence+overlay tests 13/13; eslint on changed files. No provider/UI matrix. | | 2026-07-25 | execute-system-audit-remediation (PR #1197) | c7ae011683614c8de6027347b49e8b3fb79dfa34 | Merge-ready polish: grant reassert + CI green path | READY. Hardened migration/schema to reassert service_role EXECUTE after CREATE OR REPLACE / ISSUE-05 revokes; drift regenerated. Prior regressions remain fixed (trust gate, summaryMode, no RAG pre-classifier). Delta vs main: migration + schema + drift + ledger only. | check:migration-role; drift:manifest; supabase-schema + function-grants Vitest 83/83; awaiting hosted PR required on tip. | | 2026-07-25 | cursor/pr1190-dark-mode-salvage-f453 (PR #1214) | pending-ci-retrigger | CI unblock after bot sync | Hosted pr-branch-sync merged main onto tip (`68d0ceaab`), leaving CI `action_required` for bot-authored runs. Pushing agent commit to re-trigger non-bot CI before squash-merge; then close #1190. | Local pwa-manifest 8/8; trust gate present; prior unit failure fixed. No provider-backed checks. | @@ -982,9 +991,13 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-26 | cursor/global-header-scroll-hide-4fd7 (PR #1222) | `a7f6d81f8b1dd5613dda94a3ddef78d480de876e` | Cross-breakpoint header hide/reveal + tablet/desktop scroll coverage | Header now hides on scroll down and returns on scroll up at every breakpoint; bottom search dock stays phone-only. Two root causes fixed: GlobalSearchShell had no scroll source above phones (`#main-content` onScroll never fires there) and its sticky rule sat on `header#search`, which has zero travel inside two header-height parents; ClinicalDashboard's collapse row was `max-sm`-gated so it never hid. Red/green proof captured: with the four source files reverted to base `1aa64e94`, all 12 new Playwright tests and 8/10 static contract assertions fail. | `npm run verify:cheap` pass except pre-existing local `tests/pdf-extractor.test.ts` Python-OCR failure (reproduced identically at base `1aa64e94`); `npm run verify:ui` 284/284 Chromium on the main-synced tree; `check:migration-role`, `check:function-grants`, `check:branch-review-ledger` pass after the #1197 SQL sync; no provider-backed checks. | | 2026-07-25 | PR #1213 / `cursor/pr1188-fix-build-breakers-6ee0` | `c45189c76502050002e579cf2038157416a65f2d` | Deduplicate ledger rows before land | Removed 2 exact duplicate table row(s) introduced by union-merge churn (no unique review content lost). Production UI green after Sheet autofocus upgrade. | check:branch-review-ledger; hosted Production UI success on tip. | | 2026-07-25 | PR #1213 / `cursor/pr1188-fix-build-breakers-6ee0` (supersedes #1188) | merge `8e3a49d0449ec2c1b8e4e10f6cd500c0c8c9550b` | prlanded after safe close+#1213 land | LANDED. #1188 CLOSED (not merged). #1213 squash-merged to main after PR required green (Production UI/Static/Unit/Build/Migration). Content verified on main: dashboard-notices extract, indexing-v3 utils `export async function sha256Hex`, Sheet autofocus defense. Remote feature branch deleted by merge. | Hosted CI run 30165248633 success; gh pr merge squash; git cat-file content checks on origin/main. No provider calls. | +| 2026-07-26 | PR #1192 / cursor/fix-mobile-composer-edge-scroll-5b1d | `6721ca449` + short-runway determinism hunk | Production UI failure root-cause + focused fix | Failing check pair (Production UI + PR required aggregate) traced to ui-smoke short-runway test racing PageDown smooth-scroll against the near-bottom reserve guard (hide only fired when a frame sampled the 32-40px intent window). Replaced with deterministic scrollPrimarySurface path: bottom-jump refusal asserted, then floored post-collapse-offset hide. App code unchanged. | Focused Playwright chromium repeat-each=3 pass (2 runs, 6/6) on isolated prod build; no provider-backed checks. | +| 2026-07-25 | PR #1192 / cursor/fix-mobile-composer-edge-scroll-5b1d @ 2955bc5c | `df72c725` | CI / Production UI failure — non-answer phone header keeps the in-flow collapse hide | FIXED. Single failing Playwright test: `tests/ui-smoke.spec.ts:3780 — non-answer phone header keeps the in-flow collapse hide`. Root cause: RAF debouncing in useHideOnScroll.onScroll coalesces a layout-clamp scroll event with the test's deliberate -24px upward scroll into one RAF evaluation. The hook sees lastMaxOffsetRef stale (pre-collapse visibleMaxOffset V) while the net offset is already V-96 (= newMaxOffset-24). The clamp detection fires (`lastOffset V > newMaxOffset V-72`), incorrectly suppressing the genuine reveal. Fix: added `offset >= maxOffset - revealIntentDistance` guard to the clamp detection in `computeScrollHideUpdate`. Only treat movement as geometry feedback when the net offset is within revealIntentDistance (12 px) of the new bottom edge. Unit test added. PR-required failure is downstream of Production UI only (no independent breakage). | verify:cheap pass (exit 0); npm run test 20/20 on use-hide-on-scroll.test.ts; Playwright chromium 9/9 scroll-hide smoke tests; Playwright chromium 1/1 target test. No provider-backed checks. | +| 2026-07-26 | PR #1192 / cursor/fix-mobile-composer-edge-scroll-5b1d | `2955bc5ca` + in-flow reveal hardening | Production UI second failure root-cause + focused fix | Second Production UI failure (in-flow collapse test, reveal after bottom clamp) traced to rAF starvation coalescing the single upward scrollTop write into the trailing bottom-clamp evaluation, which the shrinking-range hold rebases away; real drags emit follow-up events so product behavior is unaffected. Replaced single -24 write with two separated upward steps yielding frames each. | Focused Playwright chromium repeat-each=3 pass for both touched tests (6/6) on isolated prod build; no provider-backed checks. | | 2026-07-25 | PR #1217 / `cursor/sheet-focus-hardening-c6d3` | `f7208d52eca6d9059f15908367cebada568b0124` | Sheet focus stress test + CI unblock before land | READY. Stress pass found and fixed two defects in this branch's own work before land: (1) the open-sheet stack guard skipped every close-restore while any sheet was open, so closing a stacked sheet dropped focus to body instead of the sheet below (now `canRestoreFocusTo`: restore proceeds when the target is inside the top-most sheet); (2) a background surface re-focusing itself from its own focus handler could trade `focus()` calls synchronously with the controller (reclaims that do not stick are now bounded; a reclaim that holds resets the count). Resolved the #1213 conflict in favour of the event-driven controller and widened the settle window to cover the lazily imported `data-sheet-autofocus` child main's 200-attempt poll was defending. Repeated `pr-branch-sync` bot merges left hosted CI `action_required`; applying `skip-branch-sync` + this agent commit to re-trigger non-bot CI before land. | `verify:pr-local` green (389 files / 3458 tests, build, client-bundle scan, RAG fixtures); `test:e2e:pr` 285/285 Chromium; `ui-smoke.spec.ts --repeat-each=3` 273/273 no flake; 120-cycle browser soak of the phone mode-menu sheet (zero inert/scroll-lock leaks, focus restored every cycle, median 0 ms / max 10 ms); new `tests/sheet-focus-stress.dom.test.tsx` 13 cases + real-browser inert guard in `ui-accessibility.spec.ts`. No provider-backed checks. | | 2026-07-25 | cursor/eval-matching-distinct-identity-6273 (PR #1204) | `c0db5f5810a37dbe7df9efa3d5479db134c98e10` | prlanded — #030 coverage hardening | LANDED. Squash `eb443dfda0fcfca8181e6839d156d9986dfc52db`. `expectedFileCoverage` now dedupes the citation window by document identity and assigns sources by maximum bipartite matching, closing two re-entry routes into #030 (repeat chunk citations of one document; order-dependent greedy assignment). Content verified by tree comparison against the squash commit (identical). Remote branch deleted at merge; local branch pruned 2026-07-25 after that verification. | `npm run verify:cheap` green pre-merge; hosted `PR required` green; tree-identity check `git diff eb443dfd c0db5f58` empty. No provider-backed checks. | | 2026-07-25 | cursor/alias-slot-disjointness-guard-6273 (PR #1215) | `406cf21eb730809fb06df00b1a9299e3462c728a` | prlanded — generalized #030 contracts + ledger #081 | LANDED. Squash `b2d794c532ea8b7e259751005165f69906fcd784`. Adds two table-independent guards to `tests/eval-document-matching.test.ts`: pairwise alias disjointness across every multi-slot eval case, and the structural rule that one document can never satisfy every slot of a multi-slot case. Also opened ledger item #081 for the then-open PR #1196 alias conflict. Content verified by tree comparison against the squash commit (identical); remote branch deleted at merge, local pruned. | `npm run verify:cheap` green; hosted `PR required` green; tree-identity check `git diff b2d794c5 406cf21e` empty. No provider-backed checks. | | 2026-07-25 | cursor/ledger-081-closeout-6273 (PR #1220) | `84e91194ecca7f74c0d70b9e30e1dbd05ab7853f` | prlanded — archive outstanding item #081 | LANDED. Squash `e7e60c6d02a37c1f5958cb97936bd3533c7a2f46`. #081 moved from Open items to Resolved/archive after PR #1196 was closed 2026-07-25 as superseded by #913 / current main; successor #1198 does not touch `src/lib/eval-document-matching.ts`, and the #1215 contracts fail closed on any re-added dual-listed alias. Merge friction worth recording: a `github-actions[bot]` branch-sync merge landed every 10-20 minutes and every bot-authored head produced `action_required` workflow runs, so the three required checks never reported and both normal and `--admin` merges were refused; runs on agent-pushed heads execute normally, so the resolution was to push an own-authored head and merge on green. Content verified by tree comparison against the squash commit (identical); remote branch deleted at merge, local pruned. | `npm run verify:cheap` on merged main: 387 files / 3431 tests pass; hosted CI, SAST, Secret Scan and PR Policy green on `84e91194`; `npm run docs:check-links` pass. No provider-backed checks. | | 2026-07-26 | cursor/global-header-scroll-hide-4fd7 (PR #1222) | `af235c399d8298fcbb28c6e7a990fafdf27d3531` / squash `0b82a826dd7953a14c56491ae9e52f3fae77ee5b` | prlanded after squash merge | MERGED. Cross-breakpoint header hide/reveal; two-dot content diff vs `origin/main` empty; remote branch deleted by `delete_branch_on_merge`. Required contexts (Gitleaks, PR required, PR policy) SUCCESS on the merged head, along with Build, Unit coverage, Static PR checks, Production UI and Advisory UI. `skip-branch-sync` was applied first because repeated pr-branch-sync bot merges left every new head `action_required` (same pattern as #1214). | `gh pr view` MERGED by BigSimmo; `git diff origin/main af235c39` empty; post-merge main is green except `worker-image`, which failed in Set up Docker Buildx on `registry-1.docker.io` context deadline exceeded - a Docker Hub flake unrelated to this UI-only diff, and not a required context. No provider-backed checks. | +| 2026-07-26 | PR #1192 / cursor/fix-mobile-composer-edge-scroll-5b1d | `771683af` + main `584b8045` | Reconcile against the #1222 cross-breakpoint header and run the gates the branch never re-ran | APPROVE. The bot's earlier sync of #1222 into this branch resolved correctly: `useScrollHideReporter(false, true[, searchMode])`, `useDocumentScrollHideReporter`, `wide: "collapse" \| "sticky"`, `sm:contents` and the hidden-only `sm:-translate-y-full` are all intact, every `readChromeCollapseBudget` caller migrated to `readChromeCollapseMetrics`, and the two models compose: `collapseKind` only refines the in-flow path, while the sticky path still reports a zero budget because `readChromeCollapseMetrics` keeps the `display === "grid"` test. One real blocker found and fixed: merging main let the union driver re-append two records both sides already held (940 rows / 938 unique), failing `check:branch-review-ledger`; the later copy of each was dropped after proving zero records lost and all non-record text byte-identical. | `npm run verify:cheap` pass except pre-existing `tests/pdf-extractor.test.ts` SIGKILL case, which needs local Python OCR prerequisites and whose subject is absent from this diff (3437/3439 otherwise). `npm run verify:ui` 285/285 Chromium on the production build. Focused: `ui-chrome-scroll` + `ui-phone-scroll` 30/30; `use-hide-on-scroll` + `header-scroll-hide-contract` + `mobile-composer-reserve` 39/39; `npm run typecheck` clean. No provider-backed checks. | | 2026-07-25 | PR #1217 / `cursor/sheet-focus-hardening-c6d3` | head `65f7580ce4f4821f8d1ad55aa2c84b7e11ca62fe` / squash `20300bb9f60dee09632fdce27d1191dde19e92b5` | prlanded after squash merge | LANDED. Two-dot content diff of `src`/`tests` vs `origin/main` empty; `sheet-focus.ts` on main carries `canRestoreFocusTo`, `MAX_CONSECUTIVE_RECLAIMS`, the 10s event-driven settle window and no `setInterval`; `sheet.tsx` restores through `canRestoreFocusTo` at both call sites. Hosted PR required SUCCESS (Static/Safety/Unit/Build/Production UI/Advisory UI); post-merge push CI on `20300bb9` SUCCESS (CI, Docker image build, SAST, Secret Scan). Remote feature branch deleted by merge. The one red rollup entry was a `pull_request_target` PR Policy run cancelled by its own successor, which reported SUCCESS. | `gh pr view` state=MERGED mergedBy=BigSimmo; `git diff origin/main HEAD -- src tests` empty; `git show origin/main:...` symbol checks; `gh run list --branch main` on the squash commit. No provider-backed checks. | diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 2af97b45..a9b086bb 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -22,6 +22,8 @@ This repo uses one shared search experience across the global shell, dashboard r 6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically: when hidden, underlying content must be visible to the viewport edge. Header hide/reveal is cross-breakpoint; the bottom search dock is phone-only. Read "Scroll hide/reveal" below before changing either. 7. Do not add page-local dock-sized `pb-[calc(...safe-area...)]` under a shell-owned dock. Put clearance in the shared reserve or the page-owned composer, never both. 8. `GlobalSearchShell` uses an inner `mobile-composer-reserve-pad` so phone padding contributes to scroll height; do not move phone shell clearance back to scrollport padding without a browser proof. +9. Keep collapse-budget policy geometry-aware: an in-flow collapsing header needs enough remaining runway to absorb header + dock clearance, while a fixed overlay that only releases bottom reserve may hide when its post-collapse range retains the top reveal band plus deliberate hide intent _and_ the current offset already fits that post-collapse range (no material near-bottom clamp). Do not use synthetic page padding to make the stricter gate pass. +10. Detect reserve-transition clamps from geometry, not a wider pixel tolerance: if the scroll range shrinks and the previous offset no longer fits inside the new maximum, rebase that frame as layout feedback. Once the range stabilizes, the same upward movement must reveal normally. ## Scroll hide/reveal diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2fafec63..1c7dfb65 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -85,7 +85,8 @@ import { import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { FavouritesGuestGate } from "@/components/clinical-dashboard/favourites-guest-gate"; import { useDashboardShellActions } from "@/components/clinical-dashboard/use-dashboard-shell-actions"; -import { readChromeCollapseBudget, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; +import { focusComposerInput as scheduleComposerFocus } from "@/components/clinical-dashboard/focus-composer-input"; +import { readChromeCollapseMetrics, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { answerReferencesDocument, @@ -354,6 +355,10 @@ export function ClinicalDashboard({ const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => Boolean(autoRunSearch && initialQuery.trim() && initialSearchMode !== "tools"), ); + // focus=1 means "focus on entry", not "keep the dock focused after results". + // Suppress autofocus once a mode search/answer has been submitted so hide-on- + // scroll can reclaim chrome on result views (Answer and other bottom docks). + const shouldAutoFocusComposer = focusSearch && !modeSearchSubmitted; const [answer, setAnswer] = useState(null); const [sources, setSources] = useState([]); // Answer-mode conversation thread. `priorAnswerTurns` holds completed @@ -1495,11 +1500,14 @@ export function ClinicalDashboard({ }, []); useEffect(() => { - if (!focusSearch) return undefined; - focusComposerInput(); - const timeout = window.setTimeout(focusComposerInput, 500); + if (!shouldAutoFocusComposer) { + if (document.activeElement === composerInputRef.current) composerInputRef.current?.blur(); + return undefined; + } + focusComposerInput(true); + const timeout = window.setTimeout(() => focusComposerInput(true), 500); return () => window.clearTimeout(timeout); - }, [focusSearch]); + }, [shouldAutoFocusComposer]); // Abort any in-flight answer/library search if the dashboard unmounts. useEffect(() => { @@ -1536,7 +1544,7 @@ export function ClinicalDashboard({ setLoading(false); setError(null); setAnswerProgress(null); - if (shouldFocusComposer) focusComposerInput(); + if (shouldFocusComposer) focusComposerInput(true); }); return () => window.cancelAnimationFrame(frame); }, [searchParams, clearDifferentialModeResultState]); @@ -1555,8 +1563,9 @@ export function ClinicalDashboard({ setSearchMode(targetMode); // run=1 URLs name the latest answered question; the composer stays empty // while an answer thread is active (including after localStorage restore). + // Do not reclaim focus on result deep-links — that pins phone chrome. if (searchText && params.get("run") !== "1") setQuery(searchText); - if (shouldFocusComposer) focusComposerInput(); + if (shouldFocusComposer && params.get("run") !== "1") focusComposerInput(true); }); return () => window.cancelAnimationFrame(frame); }, [clearDifferentialModeResultState]); @@ -2611,11 +2620,8 @@ export function ClinicalDashboard({ router.push(appModeHomeHref(mode, { queryMode, scopeFilters })); } - function focusComposerInput() { - window.requestAnimationFrame(() => { - composerInputRef.current?.focus({ preventScroll: true }); - window.setTimeout(() => composerInputRef.current?.focus({ preventScroll: true }), 150); - }); + function focusComposerInput(retainTarget = false) { + scheduleComposerFocus(composerInputRef, retainTarget); } function stageAnswerFollowUpDraft(draft: string) { @@ -2804,7 +2810,7 @@ export function ClinicalDashboard({ reportChromeScrollHideRef.current({ offset: main.scrollTop, maxOffset: Math.max(0, main.scrollHeight - main.clientHeight), - collapseBudget: readChromeCollapseBudget(main), + ...readChromeCollapseMetrics(main), source: main, }); }); @@ -3328,7 +3334,7 @@ export function ClinicalDashboard({ }} queryModeOptions={clinicalQueryModeOptions} queryInputRef={composerInputRef} - queryInputAutoFocus={focusSearch} + queryInputAutoFocus={shouldAutoFocusComposer} recentQueries={recentQueries} commandScopes={commandScopes} onCommandScopesChange={setCommandScopes} @@ -3350,8 +3356,7 @@ export function ClinicalDashboard({ } desktopHomeComposerSlotId={desktopHomeComposerSlotId} // Mode homes keep the composer in the centred hero slot at every - // breakpoint so documents, therapy, and the other homes share the - // same phone/tablet structure instead of switching to a bottom dock. + // breakpoint; documents, therapy, and other homes share the phone/tablet structure. heroComposerBreakpoint={heroComposerBreakpoint} // Answer view: the header overlays the scrolling
at every width // (main reserves matching top padding) so content frosts under the diff --git a/src/components/clinical-dashboard/focus-composer-input.ts b/src/components/clinical-dashboard/focus-composer-input.ts new file mode 100644 index 00000000..8ab6b83e --- /dev/null +++ b/src/components/clinical-dashboard/focus-composer-input.ts @@ -0,0 +1,16 @@ +import type { RefObject } from "react"; + +/** Focus the dashboard composer, optionally pinning the element across remounts. */ +export function focusComposerInput(composerInputRef: RefObject, retainTarget = false) { + const requestedInput = retainTarget ? composerInputRef.current : null; + const focusBoundInput = () => { + const input = retainTarget ? requestedInput : composerInputRef.current; + if (input?.isConnected && composerInputRef.current === input) { + input.focus({ preventScroll: true }); + } + }; + window.requestAnimationFrame(() => { + focusBoundInput(); + window.setTimeout(focusBoundInput, 150); + }); +} diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index d155aa6a..3cb19656 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -32,7 +32,7 @@ import { resolveShellVisibleMobileComposerReserve, } from "@/components/clinical-dashboard/mobile-composer-reserve"; import { - readChromeCollapseBudget, + readChromeCollapseMetrics, useDocumentScrollHideReporter, useScrollHideReporter, } from "@/components/clinical-dashboard/use-hide-on-scroll"; @@ -539,7 +539,7 @@ function GlobalStandaloneSearchShellClient({ chromeScrollHide.reportScroll({ offset: target.scrollTop, maxOffset: Math.max(0, target.scrollHeight - target.clientHeight), - collapseBudget: readChromeCollapseBudget(target), + ...readChromeCollapseMetrics(target), source: target, }); } @@ -564,7 +564,7 @@ function GlobalStandaloneSearchShellClient({ maxOffset: Math.max(0, target.scrollHeight - target.clientHeight), // Collapsing chrome releases layout into nested scrollers too (their // flex height cap grows with the shell), so the same budget applies. - collapseBudget: readChromeCollapseBudget(main), + ...readChromeCollapseMetrics(main), source: target, }); }; diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index bdbbc47c..3830aa9f 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -364,8 +364,6 @@ export function MasterSearchHeader({ disabled: !hideOnScroll || hideOnScroll.scrollHidden !== undefined, }); const scrollHidden = hideOnScroll?.scrollHidden !== undefined ? hideOnScroll.scrollHidden : internalScrollHidden; - const headerChromeHidden = - scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused; // Mode homes portal the composer into the hero slot. With "all" the hero owns // every width (the answer home keeps its in-flow pill on phones); "sm-up" // hero hosts hand phones the bottom dock instead. @@ -375,17 +373,38 @@ export function MasterSearchHeader({ searchComposerVisible && !heroComposerOwnsPhones && (isAnswerFooterComposer || mobileSearchPlacement === "bottom"); + const hideOnScrollEnabled = Boolean(hideOnScroll); + // Focus-capture pins can survive dock teardown when React skips blur (portal + // swap, hero reclaim, breakpoint change). Ignore latched focus unless that + // surface is still the active hide/reveal owner, then clear the latch async + // (repo pattern — avoids react-hooks/set-state-in-effect). + const composerFocusPinsChrome = composerChromeFocused && phoneBottomSearchDockActive && hideOnScrollEnabled; + const headerFocusPinsChrome = headerChromeFocused && hideOnScrollEnabled; + // Header and composer share one scroll signal, so any active surface or + // focus inside either edge pins both edges. This preserves keyboard focus + // safety without letting the unfocused header disappear above a still- + // focused composer (or vice versa). + const sharedChromePinned = + modeMenuOpen || + actionMenuOpen || + commandDropdownOpen || + scopeOpen || + scopeSheetOpen || + headerFocusPinsChrome || + composerFocusPinsChrome; + const headerChromeHidden = scrollHidden && !sharedChromePinned; // Compare addon chrome lives inside the phone dock; hide/reveal with it so // the search pill and Compare selected bar reclaim space together. const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && phoneBottomSearchDockActive); - const bottomComposerHidden = - bottomComposerScrollHiddenActive && - scrollHidden && - !actionMenuOpen && - !commandDropdownOpen && - !scopeOpen && - !scopeSheetOpen && - !composerChromeFocused; + const bottomComposerHidden = bottomComposerScrollHiddenActive && scrollHidden && !sharedChromePinned; + + useEffect(() => { + if (phoneBottomSearchDockActive && hideOnScrollEnabled) return; + queueMicrotask(() => { + if (!phoneBottomSearchDockActive || !hideOnScrollEnabled) setComposerChromeFocused(false); + if (!hideOnScrollEnabled) setHeaderChromeFocused(false); + }); + }, [phoneBottomSearchDockActive, hideOnScrollEnabled]); useEffect(() => { onBottomComposerHiddenChange?.(bottomComposerHidden); diff --git a/src/components/clinical-dashboard/mobile-composer-reserve.ts b/src/components/clinical-dashboard/mobile-composer-reserve.ts index d853669f..4e507d7d 100644 --- a/src/components/clinical-dashboard/mobile-composer-reserve.ts +++ b/src/components/clinical-dashboard/mobile-composer-reserve.ts @@ -14,7 +14,7 @@ * zero so content can paint all the way to the viewport edge once the dock is * invisible. The rem number is exported separately so the scroll-hide * collapse budget - * (use-hide-on-scroll's readChromeCollapseBudget) measures against the same + * (use-hide-on-scroll's readChromeCollapseMetrics) measures against the same * value; tests/mobile-composer-reserve.test.ts pins the pair together. */ export const mobileComposerHiddenReserveRem = 0; diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index a57e8bd5..36d3b274 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -26,9 +26,9 @@ const minimumDelta = 4; // chrome at a direction change. const hideIntentDistance = 24; const revealIntentDistance = 12; -// How close to the bottom edge (px) counts as "pinned to the bottom". When the -// offset is this near the maximum, an upward reading is the viewport growing -// under a collapsing header rather than a real scroll, so it must not reveal. +// How close to the stable bottom edge (px) counts as "pinned to the bottom". +// Keep this strict for iOS rubber-band readings; animated layout clamps are +// identified from the changing scroll range instead of pixel proximity. const bottomClampTolerance = 1; // Hiding the chrome releases its layout space back to the scroller (header // grid collapse + dock reserve-pad shrink), shrinking maxOffset by the same @@ -47,12 +47,18 @@ export interface ScrollMetrics { maxOffset?: number; /** * Layout px the chrome would release if it hid right now (see - * readChromeCollapseBudget). When provided together with maxOffset, hiding - * is refused unless enough runway remains below the offset to absorb the - * release. Omitted by consumers whose chrome does not change scroll - * geometry when hiding. + * readChromeCollapseMetrics). In-flow collapse requires enough runway below + * the current offset to absorb the release; reserve-only overlays require + * the resulting range to retain top reveal plus deliberate hide intent. + * Omitted by consumers whose chrome does not change scroll geometry. */ collapseBudget?: number; + /** + * A fixed-viewport overlay only removes tail clearance; it does not collapse + * an in-flow header or resize the scrollport. That path can safely hide when + * the post-collapse range retains the top reveal band plus deliberate intent. + */ + collapseKind?: "in-flow" | "reserve-only"; source?: EventTarget; } @@ -61,7 +67,9 @@ export function computeScrollHideUpdate(params: { offset: number; lastOffset: number; maxOffset?: number; + previousMaxOffset?: number; collapseBudget?: number; + collapseKind?: "in-flow" | "reserve-only"; sourceChanged?: boolean; currentlyHidden: boolean; direction?: ScrollDirection; @@ -76,7 +84,9 @@ export function computeScrollHideUpdate(params: { offset, lastOffset, maxOffset, + previousMaxOffset, collapseBudget, + collapseKind, sourceChanged = false, currentlyHidden, direction = null, @@ -93,20 +103,31 @@ export function computeScrollHideUpdate(params: { return { hidden: false, lastOffset: offset, direction: null, directionTravel: 0 }; } - // Collapsing in-flow chrome grows the scroll viewport: as the header hands its - // height back to the content, the browser clamps scrollTop to the new, smaller - // maximum and emits an apparent upward scroll even though the user is moving - // down or holding at the bottom. A collapse animates over several frames, so - // this clamp repeats frame after frame; if any frame's phantom "up" reveals - // the chrome, the viewport shrinks again and a hide/reveal scroll-bounce - // begins. While the chrome is hidden and the offset stays pinned to the bottom - // edge, treat every upward reading as layout feedback — hold the hidden state - // and rebase intent so only a genuine upward scroll (one that pulls the offset - // clear of the bottom) can reveal. This intentionally does not depend on the - // previous offset's relationship to the maximum, which the browser's per-frame - // clamping makes unreliable during the collapse. + // When hidden chrome releases layout, the scroll range shrinks and a previous + // offset beyond the new maximum becomes impossible. The browser clamps both + // values downward; that apparent upward movement is geometry feedback, not + // reveal intent. Hold hidden and rebase until the range stabilizes. // - // The bottom test is deliberately one-sided (`offset >= maxOffset - tol`, not + // Guard: only suppress when the net offset is within revealIntentDistance of + // the new bottom edge. RAF debouncing coalesces a layout-clamp event with any + // immediately-following user scroll into one evaluation. If the combined + // offset is more than revealIntentDistance below the new maximum the user has + // already supplied enough upward intent to reveal; treat it as user gesture, + // not geometry feedback. + if ( + currentlyHidden && + maxOffset !== undefined && + previousMaxOffset !== undefined && + maxOffset < previousMaxOffset && + offset < lastOffset && + lastOffset > maxOffset && + offset >= maxOffset - revealIntentDistance + ) { + return { hidden: true, lastOffset: offset, direction: null, directionTravel: 0 }; + } + + // The stable-bottom test is deliberately one-sided + // (`offset >= maxOffset - tol`, not // `|offset - maxOffset| <= tol`): iOS rubber-band overscroll at the bottom can // report a scrollTop *past* the maximum, and while the content springs back // the reading moves up. That is still the bottom edge, not a scroll away from @@ -126,20 +147,34 @@ export function computeScrollHideUpdate(params: { const nextDirectionTravel = nextDirection === direction ? directionTravel + Math.abs(delta) : Math.abs(delta); let hidden = currentlyHidden; - if (!currentlyHidden && nextDirection === "down" && offset > hideActivationOffset) { - // Only count travel beyond the activation band. This stops a single flick - // from the top hiding the chrome the instant it clears the header height. - const travelPastActivation = Math.min(nextDirectionTravel, offset - hideActivationOffset); - // Refuse to hide when the geometry the chrome would release exceeds the - // remaining runway (see collapseRunwaySlack above). Short pages then keep - // their chrome and scroll plainly; long pages simply never start a hide - // this close to the bottom edge. + const effectiveHideActivationOffset = collapseKind === "reserve-only" ? topRevealOffset : hideActivationOffset; + if (!currentlyHidden && nextDirection === "down" && offset > effectiveHideActivationOffset) { + // In-flow chrome waits beyond its header-height band; fixed overlays begin + // counting deliberate intent after the small top reveal band. + const travelPastActivation = Math.min(nextDirectionTravel, offset - effectiveHideActivationOffset); + // In-flow chrome must have enough remaining runway to absorb its release + // (see collapseRunwaySlack above). Reserve-only overlays use the separate + // post-collapse range test below. const runwayAfterCollapse = maxOffset === undefined || collapseBudget === undefined ? Number.POSITIVE_INFINITY : maxOffset - offset - collapseBudget; - hidden = - travelPastActivation >= hideIntentDistance && runwayAfterCollapse > revealIntentDistance + collapseRunwaySlack; + const postCollapseMaxOffset = + maxOffset === undefined || collapseBudget === undefined + ? Number.POSITIVE_INFINITY + : Math.max(0, maxOffset - collapseBudget); + // Reserve-only overlays keep the viewport geometry stable; requiring their + // resulting range to retain top-reveal + hide-intent distance prevents a + // material clamp while allowing genuinely compact results to hide. Also + // refuse when the current offset would not fit the post-collapse range — + // otherwise a near-bottom hide clamps the page under the finger even when + // the resulting range itself is long enough for deliberate intent. + const collapseHasSafeRunway = + collapseKind === "reserve-only" + ? postCollapseMaxOffset >= effectiveHideActivationOffset + hideIntentDistance && + offset <= postCollapseMaxOffset + bottomClampTolerance + : runwayAfterCollapse > revealIntentDistance + collapseRunwaySlack; + hidden = travelPastActivation >= hideIntentDistance && collapseHasSafeRunway; } else if (currentlyHidden && nextDirection === "up" && nextDirectionTravel >= revealIntentDistance) { hidden = false; } @@ -162,9 +197,12 @@ export function computeScrollHideUpdate(params: { * `document-viewer-content` for DocumentViewer's own clearance (its hidden * `pb-3` equals the shared 0.75rem hidden reserve), falling back to the * scroller's own padding exactly like tests/playwright-scroll.ts. Call from - * inside a scroll handler, where layout is already flushed. + * inside a scroll handler, where layout is already flushed. The returned kind + * distinguishes in-flow collapse from a fixed overlay that only sheds reserve. */ -export function readChromeCollapseBudget(scroller: HTMLElement): number { +export function readChromeCollapseMetrics( + scroller: HTMLElement, +): Pick { const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); // The 1fr -> 0fr grid IS the collapse mechanism, so the wrapper only hands // layout back while it is a grid at the current width. Where it sticks and @@ -185,7 +223,10 @@ export function readChromeCollapseBudget(scroller: HTMLElement): number { const viewerPad = scroller.querySelector('[data-testid="document-viewer-content"]'); const reserveRelease = reservePad || viewerPad ? padRelease(reservePad) + padRelease(viewerPad) : padRelease(scroller); - return headerRelease + reserveRelease; + return { + collapseBudget: headerRelease + reserveRelease, + collapseKind: collapse instanceof HTMLElement ? "in-flow" : reserveRelease > 0 ? "reserve-only" : undefined, + }; } function subscribeToPhoneMedia(onChange: () => void) { @@ -217,6 +258,7 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa const [hidden, setHidden] = useState(false); const hiddenRef = useRef(false); const lastOffsetRef = useRef(0); + const lastMaxOffsetRef = useRef(undefined); const directionRef = useRef(null); const directionTravelRef = useRef(0); const scrollSourceRef = useRef(null); @@ -225,24 +267,40 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa const reportScroll = useCallback( (report: number | ScrollMetrics) => { - const { offset, maxOffset, collapseBudget, source } = + const { offset, maxOffset, collapseBudget, collapseKind, source } = typeof report === "number" - ? { offset: report, maxOffset: undefined, collapseBudget: undefined, source: undefined } + ? { + offset: report, + maxOffset: undefined, + collapseBudget: undefined, + collapseKind: undefined, + source: undefined, + } : report; - if (!active || offset < 0) return; + if (!active) return; const lastOffset = lastOffsetRef.current; const delta = offset - lastOffset; const sourceChanged = source !== undefined && hasScrollSourceRef.current && scrollSourceRef.current !== source; + const previousMaxOffset = sourceChanged ? undefined : lastMaxOffsetRef.current; + const comparableRangeChanged = + previousMaxOffset !== undefined && maxOffset !== undefined && previousMaxOffset !== maxOffset; if (source !== undefined) { scrollSourceRef.current = source; hasScrollSourceRef.current = true; } - if (!sourceChanged && Math.abs(delta) < minimumDelta && offset > topRevealOffset) return; + // Baseline each metrics report, even when movement itself is too small to + // evaluate. Undefined explicitly clears stale geometry for numeric reports. + lastMaxOffsetRef.current = maxOffset; + if (offset < 0) return; + if (!sourceChanged && !comparableRangeChanged && Math.abs(delta) < minimumDelta && offset > topRevealOffset) + return; const update = computeScrollHideUpdate({ offset, lastOffset, maxOffset, + previousMaxOffset, collapseBudget, + collapseKind, sourceChanged, currentlyHidden: hiddenRef.current, direction: directionRef.current, @@ -261,6 +319,7 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa if (active) return undefined; hiddenRef.current = false; lastOffsetRef.current = 0; + lastMaxOffsetRef.current = undefined; directionRef.current = null; directionTravelRef.current = 0; scrollSourceRef.current = null; @@ -277,6 +336,7 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa useEffect(() => { hiddenRef.current = false; lastOffsetRef.current = 0; + lastMaxOffsetRef.current = undefined; directionRef.current = null; directionTravelRef.current = 0; scrollSourceRef.current = null; @@ -378,7 +438,7 @@ export function useHideOnScroll({ return { offset: container.scrollTop, maxOffset: Math.max(0, container.scrollHeight - container.clientHeight), - collapseBudget: readChromeCollapseBudget(container), + ...readChromeCollapseMetrics(container), source: container, }; } diff --git a/tests/mobile-composer-reserve.test.ts b/tests/mobile-composer-reserve.test.ts index 2fda80f8..4d1bddca 100644 --- a/tests/mobile-composer-reserve.test.ts +++ b/tests/mobile-composer-reserve.test.ts @@ -23,7 +23,7 @@ describe("mobile composer reserve contract", () => { it("collapses to zero hidden pad without Safari toolbar safe-area", () => { expect(mobileComposerHiddenReserve).toBe("0rem"); expect(mobileComposerHiddenReserveRem).toBe(0); - // The rem number feeds readChromeCollapseBudget's px math; it must stay + // The rem number feeds readChromeCollapseMetrics' px math; it must stay // equal to the CSS string above or the collapse budget silently drifts. expect(`${mobileComposerHiddenReserveRem}rem`).toBe(mobileComposerHiddenReserve); expect(resolveMobileComposerReserve(true, mobileComposerVisibleReserve.shellAnswer)).toBe( @@ -128,6 +128,12 @@ describe("mobile composer reserve contract", () => { expect(header).toContain( 'const heroComposerOwnsPhones = Boolean(desktopHomeComposerSlotId) && heroComposerBreakpoint === "all";', ); + // focus=1 is entry-only: after any mode submit (or run=1 bootstrap) autofocus + // must not re-pin the phone dock and block hide-on-scroll. + expect(dashboard).toContain("const shouldAutoFocusComposer = focusSearch && !modeSearchSubmitted;"); + expect(dashboard).toContain('if (shouldFocusComposer && params.get("run") !== "1") focusComposerInput(true);'); + expect(header).toContain("composerChromeFocused && phoneBottomSearchDockActive && hideOnScrollEnabled"); + expect(header).toContain("queueMicrotask(() => {"); expect( resolveDashboardVisibleMobileComposerReserve({ searchMode: "answer", diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 8d646803..46451c66 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2120,6 +2120,107 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); + test("phone answer result keeps the edge dock and shared chrome synchronized on a short runway", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize({ width: 390, height: 844 }); + await mockDemoApi(page); + await gotoApp(page, "/?mode=answer&focus=1"); + await waitForDemoDashboardReady(page); + + const input = await fillVisibleQuestionInput(page, "lithium dosing"); + await visibleAnswerSubmitButton(page).click(); + await expect(page.getByTestId("plain-answer-response")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("answer-streaming")).toHaveCount(0); + + const main = page.locator("main#main-content"); + const header = page.locator("header.universal-header"); + const dock = page.locator("form.answer-footer-search-dock"); + await expect(dock).toBeVisible(); + const edgeGeometry = await dock.evaluate((node) => { + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + return { + bottom: style.bottom, + left: style.left, + right: style.right, + width: rect.width, + viewportWidth: window.innerWidth, + rectBottom: rect.bottom, + viewportHeight: window.innerHeight, + }; + }); + expect(edgeGeometry.bottom).toBe("0px"); + expect(edgeGeometry.left).toBe("0px"); + expect(edgeGeometry.right).toBe("0px"); + expect(Math.abs(edgeGeometry.width - edgeGeometry.viewportWidth)).toBeLessThanOrEqual(1); + expect(Math.abs(edgeGeometry.rectBottom - edgeGeometry.viewportHeight)).toBeLessThanOrEqual(1); + + // Submitting from the auto-focused home composer must not carry stale focus + // into the newly docked follow-up input. A focused dock is intentionally + // pinned for keyboard safety, so retaining focus here permanently disables + // the ordinary touch-scroll hide path. + await expect(input).not.toBeFocused(); + const geometry = await main.evaluate((node) => { + const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); + const maxOffset = node.scrollHeight - node.clientHeight; + const collapseBudget = + (collapse?.getBoundingClientRect().height ?? 0) + + Number.parseFloat(window.getComputedStyle(node).paddingBottom); + return { maxOffset, collapseBudget, postCollapseMaxOffset: Math.max(0, maxOffset - collapseBudget) }; + }); + // Pin the unmodified short-result geometry. Its 39px post-collapse range + // clears top-reveal + hide-intent distance (32px), but not the 72px in-flow + // activation band; synthetic tail content would hide this distinction. + expect(geometry.maxOffset).toBeGreaterThan(140); + expect(geometry.maxOffset).toBeLessThan(180); + expect(geometry.collapseBudget).toBeGreaterThan(112); + expect(geometry.collapseBudget).toBeLessThan(128); + expect(geometry.postCollapseMaxOffset).toBeGreaterThanOrEqual(32); + expect(geometry.postCollapseMaxOffset).toBeLessThan(48); + // A jump straight onto the bottom edge (PageDown / full-page flick) lands + // past the post-collapse range; hiding there would clamp content under the + // finger, so the near-bottom guard keeps both chrome edges visible. + await scrollPrimarySurface(page, geometry.maxOffset); + await expect(header).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + await scrollPrimarySurface(page, 0); + // Deliberate downward travel that still fits the post-collapse range is + // the designed hide path: past the 8px top band plus 24px intent, at or + // below the ~39px post-collapse maximum (floored so fractional layout + // readings can never overshoot the hook's own near-bottom tolerance). + await scrollPrimarySurface(page, Math.floor(geometry.postCollapseMaxOffset)); + await expect(header).toHaveAttribute("data-scroll-hidden", "true"); + await expect(dock).toHaveAttribute("data-scroll-hidden", "true"); + // The reserve and both chrome edges animate for 240ms. The hidden state + // must survive the browser clamping scrollTop against the shrinking range, + // and the actual painted elements must finish outside the viewport. + await page.waitForTimeout(320); + await expect(header).toHaveAttribute("data-scroll-hidden", "true"); + await expect(dock).toHaveAttribute("data-scroll-hidden", "true"); + const settledHiddenGeometry = await page.evaluate(() => { + const headerNode = document.querySelector("header.universal-header"); + const dockNode = document.querySelector("form.answer-footer-search-dock"); + if (!headerNode || !dockNode) throw new Error("Expected shared phone chrome"); + const headerRect = headerNode.getBoundingClientRect(); + const dockRect = dockNode.getBoundingClientRect(); + return { + headerBottom: headerRect.bottom, + dockTop: dockRect.top, + viewportHeight: window.innerHeight, + }; + }); + expect(settledHiddenGeometry.headerBottom).toBeLessThanOrEqual(1); + expect(settledHiddenGeometry.dockTop).toBeGreaterThanOrEqual(settledHiddenGeometry.viewportHeight - 1); + await expect.poll(async () => readMobileComposerReservePx(main)).toBeLessThanOrEqual(1); + + await scrollPrimarySurface(page, 20); + await expect(header).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + + await input.click(); + await expect(input).toBeFocused(); + }); + test("recent searches appear on the answer home and re-run on tap", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); const answerRequests: string[] = []; @@ -3761,9 +3862,15 @@ test.describe("Clinical KB UI smoke coverage", () => { }), ) .toBe(0); - await main.evaluate((node) => { - node.scrollTop = Math.max(0, node.scrollTop - 24); - }); + // A deliberate upward gesture reveals the chrome again. Use two separated + // steps, each yielding frames: on a starved CI renderer a single upward + // write can coalesce into the trailing bottom-clamp evaluation and be + // rebased away as geometry feedback. A real drag always emits follow-up + // events, and the second step is a clean upward delta past reveal intent. + const settledBottomOffset = await main.evaluate((node) => node.scrollTop); + for (const rise of [24, 48]) { + await scrollPrimarySurface(page, Math.max(0, settledBottomOffset - rise)); + } await expect(collapseHost).not.toHaveAttribute("data-scroll-hidden", "true"); }); diff --git a/tests/use-hide-on-scroll.test.ts b/tests/use-hide-on-scroll.test.ts index c233bfe8..e279bae7 100644 --- a/tests/use-hide-on-scroll.test.ts +++ b/tests/use-hide-on-scroll.test.ts @@ -179,6 +179,39 @@ describe("computeScrollHideUpdate", () => { expect(revealed.direction).toBe("up"); }); + it("reveals when a genuine upward scroll is coalesced with a prior layout clamp", () => { + // RAF debouncing coalesces a layout-clamp scroll event with any + // immediately-following user scroll into a single RAF evaluation. The hook + // reads the FINAL offset (newMaxOffset - userScroll) but lastMaxOffsetRef + // still holds the pre-collapse maximum. The net offset is more than + // revealIntentDistance (12px) below the new bottom edge, so the clamp + // detection must yield to the upward-scroll reveal path. + const result = computeScrollHideUpdate({ + offset: 904, // newMaxOffset (928) - 24px deliberate scroll + lastOffset: 1000, // stale: pre-collapse jump-to-bottom offset + maxOffset: 928, // new max after 72px in-flow header collapse + previousMaxOffset: 1000, // stale: pre-collapse max (lastMaxOffsetRef) + currentlyHidden: true, + direction: null, + directionTravel: 0, + }); + expect(result.hidden).toBe(false); + expect(result.direction).toBe("up"); + + // A small simultaneous bounce (<= revealIntentDistance) must still be + // treated as geometry feedback, not user intent. + const stillHidden = computeScrollHideUpdate({ + offset: 918, // newMaxOffset (928) - 10px (below revealIntentDistance) + lastOffset: 1000, + maxOffset: 928, + previousMaxOffset: 1000, + currentlyHidden: true, + direction: null, + directionTravel: 0, + }); + expect(stillHidden.hidden).toBe(true); + }); + it("does not reveal on a small phantom clamp when the offset stays pinned to the bottom", () => { // Single frame: a 4px upward clamp while glued to the bottom edge. The old // guard required the previous offset to sit more than `minimumDelta` above @@ -293,6 +326,139 @@ describe("computeScrollHideUpdate", () => { }); }); + it("allows a reserve-only overlay to hide when its post-collapse range retains deliberate hide intent", () => { + // Measured compact Answer result at 390x844 without synthetic content: + // 159px visible range - 120px reserve = 39px after hiding. A fixed overlay + // can hide after the 8px top band + 24px intent without collapsing in-flow + // header geometry or clamping materially. + const hidden = computeScrollHideUpdate({ + offset: 40, + lastOffset: 0, + maxOffset: 159, + collapseBudget: 120, + collapseKind: "reserve-only", + currentlyHidden: false, + }); + expect(hidden.hidden).toBe(true); + + const clamped = computeScrollHideUpdate({ + offset: 39, + lastOffset: hidden.lastOffset, + maxOffset: 39, + currentlyHidden: hidden.hidden, + direction: hidden.direction, + directionTravel: hidden.directionTravel, + }); + expect(clamped.hidden).toBe(true); + expect( + computeScrollHideUpdate({ + offset: 20, + lastOffset: clamped.lastOffset, + maxOffset: 39, + currentlyHidden: clamped.hidden, + direction: clamped.direction, + directionTravel: clamped.directionTravel, + }).hidden, + ).toBe(false); + + // The same geometry remains protected when an in-flow header participates. + expect( + computeScrollHideUpdate({ + offset: 100, + lastOffset: 80, + maxOffset: 159, + collapseBudget: 120, + collapseKind: "in-flow", + currentlyHidden: false, + direction: "down", + directionTravel: 80, + }).hidden, + ).toBe(false); + + // A genuinely short reserve-only result would collapse below the deliberate + // hide threshold and still risks a top/bottom clamp cycle, so it stays visible. + expect( + computeScrollHideUpdate({ + offset: 40, + lastOffset: 0, + maxOffset: 145, + collapseBudget: 120, + collapseKind: "reserve-only", + currentlyHidden: false, + }).hidden, + ).toBe(false); + + // Near-bottom hides must stay refused even when the post-collapse range is + // long enough: offset 180 would clamp onto 80 and jump content under the finger. + expect( + computeScrollHideUpdate({ + offset: 180, + lastOffset: 160, + maxOffset: 200, + collapseBudget: 120, + collapseKind: "reserve-only", + currentlyHidden: false, + direction: "down", + directionTravel: 80, + }).hidden, + ).toBe(false); + }); + + it("uses a shrinking scroll range, not pixel tolerance, to identify a reserve-collapse clamp", () => { + // Real PageDown frame from the compact Answer result at 390x844. The + // animated reserve shrink moved the browser's maximum from 160px to 127px; + // compositor rounding left scrollTop 1.03px below that integer maximum. + // This is layout feedback near the new bottom, not upward user intent. + expect( + computeScrollHideUpdate({ + offset: 125.9683, + lastOffset: 160.5079, + maxOffset: 127, + previousMaxOffset: 160, + collapseBudget: 23.3347, + collapseKind: "reserve-only", + currentlyHidden: true, + direction: "down", + directionTravel: 160.5079, + }), + ).toEqual({ + hidden: true, + lastOffset: 125.9683, + direction: null, + directionTravel: 0, + }); + + // The same fractional positions are a genuine upward gesture once the + // scroll range is stable; no bottom-edge tolerance may suppress it. + expect( + computeScrollHideUpdate({ + offset: 125.9683, + lastOffset: 160.5079, + maxOffset: 127, + previousMaxOffset: 127, + collapseKind: "reserve-only", + currentlyHidden: true, + direction: "down", + directionTravel: 160.5079, + }).hidden, + ).toBe(false); + + // Range shrink alone is insufficient: if the old position still fits + // inside the new range, the browser did not have to clamp it. + expect( + computeScrollHideUpdate({ + offset: 100, + lastOffset: 120, + maxOffset: 127, + previousMaxOffset: 160, + collapseKind: "reserve-only", + currentlyHidden: true, + direction: "down", + directionTravel: 120, + }).hidden, + ).toBe(false); + }); + it("hides normally when ample runway remains below the collapse release", () => { expect( computeScrollHideUpdate({