diff --git a/PR_POLICY_BODY.md b/PR_POLICY_BODY.md new file mode 100644 index 000000000..55f30f8bc --- /dev/null +++ b/PR_POLICY_BODY.md @@ -0,0 +1,33 @@ +## Summary + +- On phone viewports (`max-width: 639px`), the header Mode picker opens as the shared bottom `Sheet` so the full mode list is scrollable with backdrop dismiss, Escape, focus trap, and return-focus to the Mode button. +- Desktop (`sm:`+) keeps the existing anchored absolute dropdown, keyboard navigation, blur-leave dismiss, and outside-click dismiss via `useDismissableLayer`. +- Hardens shared `Sheet` backdrop dismiss (gesture must start on the dimmed area; no click-through) and Tab-trap handling for roving `tabindex="-1"` menus. +- Keeps PDF extractor process-tree termination reliable under suite load; Python binary resolution uses shared `python-bin` from main. +- Fixes phone hydration: Mode layout gate initializes SSR-safe and syncs from `matchMedia` after mount / on open (avoids React #418 on phone route coverage). + +## Verification + +- [x] Focused Vitest — header/sheet/audit-navigation contracts green after hydration fix +- [x] Mode Playwright proofs — phone sheet open/scroll/select, backdrop dismiss + focus restore, desktop outside-click (no sheet), keyboard nav (5/5) +- [x] Previously failing Production UI route-coverage journeys — DSM/specifier/differential phone hydration (3/3) +- [x] `npm run check:production-readiness:ci` — READY (no blocking failures) +- UI verification not run: full `verify:ui` / `verify:release` not required beyond the focused Mode + route-coverage proofs above +- Provider-backed live evals not run: no answer-generation or retrieval scoring changes + +## Risk and rollout + +- Risk: low — phone-only Mode open path plus shared Sheet dismiss/focus hardening; desktop Mode contracts preserved. Extractor change is fail-closed process cleanup only. +- Rollback: revert this PR; Mode menu returns to the previous floating phone panel and prior Sheet/extractor behaviour. +- Provider or production effects: None + +## Clinical Governance Preflight + +Touched extractors/document.ts (fail-closed process-tree cleanup only). No answer, citation, source-governance, privacy, or document-access behaviour change. + + + +## Notes + +- Phone gate reuses `phoneSearchLayoutMediaQuery` (`max-width: 639px`). +- `id="app-mode-menu"` is preserved inside the sheet for existing restore-focus guards. diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index f80044a4d..2a3b5b961 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -621,6 +621,11 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-18 | claude/clinical-kb-pwa-review-asi3wb (PR #896, plan Phase 5; content commit + this ledger follow-up) | a6c2b4e92374e9002fb00c547eb5677d01ce538c | Design-polish sweep: audit-then-fix (plan Phase 5, final phase) | Audit on post-#890 main: three strict design guards clean; full re-run of the 07-token-adoption-audit grep method shows all July 3 debt resolved (M1–M3 done, L4 reduced to the deliberate theme-aware `ring-white/N dark:ring-white/10` glass idiom, L5/L7 gone; production hex all legitimate print/brand/console/comment classes); 43-capture live sweep across 15 routes × desktop/phone + 320px spots + dark/reduced-motion/forced-colors spots found 0 overflow and 0 console errors. Three defects found and fixed: (1) forced-colors solid-button labels rendered as blank Canvas-on-Canvas backplate boxes (axe-invisible) — command controls flattened to the native HCM ButtonFace/ButtonText pairing and accent glyph tokens flipped to ButtonText inside the existing forced-colors block, regression-locked by a new ui-accessibility test; (2) tools desktop 6-up quick-action rail truncated card titles at 1440×1000 — card metrics tightened, all six titles verified unclipped; (3) privacy page rendered "systemand" from a JSX newline-adjacent-to-tag drop — explicit space, locked by a privacy-ui assertion. Dated July 18 run appended to docs/redesign/07-token-adoption-audit.md (archived design-qa.md not resurrected). | Guards + focused vitest 14/14; `verify:cheap` chain green to the known container-only pdf-extraction-budget artifact (2806/2809); `verify:ui` 220 passed/2 failed (the two long-baselined container artifacts, hosted-CI-green through #826/#835/#872/#890); `test:e2e:accessibility` 8/8 incl. the new forced-colors token test; production build + client-bundle secret scan passed; `check:bundle-budget` within tolerance vs the Phase 4 ratchet (1290.6 vs 1278.6 KiB baseline); `verify:pr-local` runtime/format/lint/typecheck/build/rag-fixtures green with the same sole unit-suite artifact. `verify:release` not run (provider-backed; awaits explicit confirmation). No provider-backed checks run. | | 2026-07-19 | all remote feature branches and registered worktrees against `origin/main` through PR #899 | 8242fa63d5f5b79fc770c9ae4f633e3a784b80e1 | branch/worktree cleanup, useful-work recovery, and protected-main merge closure | Deleted 122 stale or closed remote feature refs with exact SHA leases; four additional merged PR branches were removed by the protected-main PR workflow. Removed 32 obsolete, superseded, or merge-proven worktree registrations. Recovered useful dirty RAG work into PR #901 (deterministic and opt-in semantic reranking) and PR #902 (retrieval phase latency telemetry), preserved follow-up decisions in `docs/process-hardening.md`, and recovered four missing historical review rows. PRs #897, #899, #901, and #902 are merged with green exact-head checks and zero unresolved review threads. A detached full-repo-review worktree is deliberately retained because its ownership/activity could not be safely disproved; one unregistered `node_modules` junction residue is also retained because deletion was denied by local safety policy. | Fresh fetch/prune; full GitHub PR/check/thread inventory; `git worktree list --porcelain`; cherry-pick-aware right-only logs; exact leased remote deletes; exact-old-value local ref deletes; clean-worktree, path, and merged-PR proof before every removal. PR #899 local proof: focused Vitest 31/31, changed-file ESLint, `verify:cheap` 317 files / 2,879 tests, and `verify:ui` 239/239; exact-head hosted checks all passed. PR #901 local proof: `verify:cheap` 316 files / 2,870 tests; PR #902 focused Vitest 8/8 plus ESLint and typecheck. No OpenAI, Supabase, live clinical, deployment, or production-data workflow ran; provider-backed semantic canary evaluation remains approval-gated. | | 2026-07-19 | main / `codex/supabase-database-review` | 4034d2e60ebb6616130ff17bf3cb69368f36f8f6 + reviewed working diff | live `Clinical KB Database` security, migration, schema-drift, integrity, and performance review against current repo | Confirmed and remediated a P1 privacy defect: 601 private-document title-vocabulary rows were reachable by the service-role query corrector; the live public-only sync/backfill now reports zero private or out-of-scope rows. Applied the committed retrieval-count bound, audit-metadata minimization, registry cleanup/index, public-title corrector, and atomic summary-rate-limit migrations. The missing FK and registry indexes are present and no invalid indexes remain. A second P1 was found in the untracked live `ingestion-worker`: gateway JWT verification accepted any project JWT before privileged direct-Postgres job processing. Recovered the deployed source into the repo, restricted it to POST plus a gateway-verified `service_role` claim, expanded the Deno checker to every tracked Edge Function, and deployed exact-matching v13 with JWT verification enabled. Review also exposed a repo mirror/test gap: the count-clamp migration was not reflected in `schema.sql`; the branch now mirrors it and locks both sources in the focused test. Remaining hosted blocker: `postgres` cannot assume managed `supabase_admin`, so the fail-closed default-ACL migrations and final title-word constraint/trigger migration remain unapplied; the intentional service-role-only table still produces one INFO no-policy advisor. | Supabase connector project identity, migration and Edge Function inventory, full drift snapshot comparison, security/performance advisors, catalog integrity/ACL/index queries, Vault JWT-role compatibility check, post-apply invariants, exact deployed-source hashes, and unauthenticated live rejection (401); focused retrieval/schema/drift Vitest 82/83 with only manifest freshness failing; Edge/retrieval auth 9/9; Deno check for both functions; offline RAG 36 cases / 294 tests; function-grant guard; scoped ESLint, Prettier, and `git diff --check`. `check:supabase-project` was attempted but stopped before provider contact because local project env vars are unset. `drift:manifest` was blocked because Docker Desktop could not start and was cleaned up. `verify:cheap`, `verify:pr-local`, production-readiness, OpenAI, hosted CI, broader deployment, and commit/push were not run. | +| 2026-07-19 | PR #935 / `cursor/mobile-mode-menu-sheet-efee` | 87d4a479cd320220c91eba5c91e253e843dcc98f | final Mode phone-sheet review + merge-readiness | No remaining high-confidence P0/P1. Fixed residual P2 Sheet backdrop drag-dismiss (gesture must start on dimmed area). Phone ≤639px Mode menu uses bottom Sheet; desktop absolute dropdown/keyboard/blur contracts preserved. Python PDF extractor resolves python/python3 and process-group kills reliably. Clinical governance: UI + fail-closed extractor binary resolution only; no answer/source/privacy surface change. Safe to merge after hosted required checks green on this HEAD. | `verify:cheap` 2954 passed; Mode Playwright 5/5 (phone sheet/backdrop/desktop/keyboard/a11y); `check:production-readiness:ci` READY; prettier format check fixed for CI Static; no OpenAI/live Supabase writes; full `verify:ui`/`verify:release` not required beyond Mode proofs. | +| 2026-07-20 | PR #935 / `cursor/mobile-mode-menu-sheet-efee` | f6522603eedabb88c311a6f7df172505673d087e | main sync + merge-readiness revalidation | Re-merged moving `origin/main` (kept `PR_POLICY_BODY.md` for CI body sync; adopted main `python-bin` helper; retained PDF process-tree kill hardening). Mode phone Sheet path still present. PR Policy had a race with Sync PR policy body on prior tip; body now contains checked governance items matching main policy. No new P0/P1 in product scope. Merge-ready once exact-head required checks are green. | Local after merge: focused Vitest for extractor/sheet 1127 passed; Mode sheet symbols intact in `master-search-header.tsx`. Hosted: awaiting Static/Unit/Build/Production UI/PR policy on this HEAD. No OpenAI/live Supabase writes. | +| 2026-07-20 | PR #935 / `cursor/mobile-mode-menu-sheet-efee` | ddbc549a6af469b61c112373d6a5099cb04810d0 + ledger | hydration fix + merge-readiness | Fixed P1 UI regression: phone `matchMedia` in `useState` caused React #418 hydration pageerrors on DSM/specifier/differential phone journeys in Production UI. Gate now SSR-safe (`useState(false)`), syncs after mount, and refreshes from live matchMedia on Mode open. Merged `#940` header inset. | Local: route-coverage 3/3 previously failing; Mode smoke 3/3; audit-navigation + focused header/sheet Vitest green. Hosted: awaiting exact-head Production UI/PR required. No OpenAI/live Supabase writes. | +| 2026-07-20 | PR #935 / `cursor/mobile-mode-menu-sheet-efee` | 792142c88191e201311238984b1530f784430f0e | exact-head merge-ready CI | No remaining high-confidence product defect. Hosted required checks green on tip after Prettier format fix. Residual: branch protection still needs a human approving review (`mergeStateStatus=BLOCKED`, empty `reviewDecision`). | Hosted exact-head: PR policy, Sync PR policy body, Static, Safety, Unit, Build, Production UI, Advisory UI, PR required all SUCCESS. Local Mode Playwright 5/5 + route-coverage hydration 3/3. No OpenAI/live Supabase writes. | +| 2026-07-20 | PR #935 / `cursor/mobile-mode-menu-sheet-efee` | e05cd0b8ac1ef4dbc9b30307e3715fc437b1540e + ledger | CodeRabbit findings + merge push | Closed unresolved review threads: removed PR-head `GOVERNANCE_ALL_CHECKED` self-attest (aligned sync with main + workflow guard), restored strict checklist regex, fixed unset `PYTHON_BIN` test path. Hydration Mode-sheet thread already resolved. Local: full unit 2971 passed; Mode Playwright 5/5; route-coverage 3/3; pr-policy self-test + workflow guard green. | Awaiting exact-head hosted CI; then human approving review remains the only merge gate. No OpenAI/live Supabase writes. | | 2026-07-19 | cursor/safari-edge-to-edge-f46b (PR #933) | 15061964dd2fdf9665f72b7282f5cc81c736e57f | final Safari edge-to-edge / phone dock reserve review + merge readiness | No high-confidence P0-P1. Confirmed implementation: shared reserve module collapses to 0.75rem when dock hides; shell uses block scrollport + inner mobile-composer-reserve-pad so clearance contributes to scrollHeight; child dock-sized env(safe-area) pads removed; DocumentViewer owns its dock pad. Review polish: formulation/specifier max-sm:min-h-0 alignment, document-route ownership simplification, hidden-pad CSS token guard. Residual P2/P3 only: differentials compare zero-inset backdrop margin, idle 2rem vs max(2rem,safe-area) ~2px, unused-looking #main-content padding transition still needed by ClinicalDashboard. Merge-ready. | Local: format/lint/typecheck/knip/budgets pass; unit 2952 passed with only pre-existing pdf-extraction-budget (python ENOENT, also fails on clean main); production build + client-bundle secret scan pass; focused Chromium composer suite 6/6 (forms hide, tablet/desktop clearance, differentials compare, service-detail endpoint, document-viewer hide, long-answer dock). Hosted CI on prior head fully green including Production UI; polish head re-checked before merge. No OpenAI/live Supabase/provider calls. | | 2026-07-19 | cursor/hide-favourites-guest-mode-a26d (PR #934) | 23d05539320427ea3f994eaec3c2e95daa481492 | Favourites guest mode-switcher/sidebar auth gate review | No P0/P1. Primary request correctly wired: MasterSearchHeader fails closed; ClinicalDashboard and global-search-shell both pass session favouritesAccessible to mode menu + desktop/mobile showAccountLibrary; selectSearchMode/changeMode guarded; /favourites stays on-page with signup for guests. Residual P2: guest discovery still possible via composer cross-mode Favourites chips and Tools catalog Saved workflows (content remains gated on /favourites). P2 test gap: wiring tests are source-string, not MasterSearchHeader DOM. P3: header passes precomputed access as authenticated plus demoMode (redundant OR, intentional demo reopen). | Focused Vitest favourites-auth-gate + app-modes 26/26; prior verify:cheap green on branch; browser guest check: sidebar omits Your library/Favourites, /favourites shows signup gate; mode-menu automation inconclusive. No OpenAI/Supabase/hosted CI. | | 2026-07-19 | cursor/hide-favourites-guest-mode-a26d (PR #934) | 04192653 | Final Favourites total guest-blackout review + merge readiness | No P0/P1. Fixed residual P2s: rankToolRecords now fails closed by default; mode/chip filters stop double-ORing demoMode; gated deep-link mode chrome falls back to Answer; MasterSearchHeader mode-menu DOM tests added. Guest discovery closed for mode menu, sidebar, cross-mode chips, Tools Saved, universal tools ranking, also-matches, prefetch. Intentional residual: direct /favourites signup gate; mockup routes. PR body appears overwritten by unrelated Sync PR policy content — agent posted corrective comment (403 on body edit). | Focused Vitest 37/37; verify:cheap green; verify:pr-local green (build + client-secret scan + RAG fixtures); browser guest Tools/sidebar/favourites proof earlier. No OpenAI/Supabase writes. | diff --git a/scripts/check-pr-policy-workflow.mjs b/scripts/check-pr-policy-workflow.mjs index 153715fe4..c57d8fd0a 100644 --- a/scripts/check-pr-policy-workflow.mjs +++ b/scripts/check-pr-policy-workflow.mjs @@ -108,6 +108,11 @@ if (!syncJob) { if (/map\(\(item\) => `\s*-\s*\[x\]/i.test(applyStep)) { failures.push("sync-pr-policy-body must not synthesize completed Clinical Governance Preflight items."); } + if (/forceChecked|GOVERNANCE_ALL_CHECKED/.test(applyStep)) { + failures.push( + "sync-pr-policy-body must not let PR-head markers force-check Clinical Governance Preflight items.", + ); + } } } diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 423fc5790..6c5e19318 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -316,7 +316,14 @@ export function MasterSearchHeader({ const [commandListboxId, setCommandListboxId] = useState(); const [commandActiveItemId, setCommandActiveItemId] = useState(null); const [modeMenuOpen, setModeMenuOpen] = useState(false); + // Which menuitemradio should receive initial focus when the mode menu opens + // (keyboard ArrowOpen or the active mode on tap). Shared by the desktop + // popover and the phone bottom sheet. + const [modeMenuFocusIndex, setModeMenuFocusIndex] = useState(0); const [usesScopeSheet, setUsesScopeSheet] = useState(false); + // SSR and the first client hydration paint must agree (phone matchMedia is + // unavailable on the server). Sync from matchMedia after mount; Mode open + // paths also refresh from the live query so the first tap still picks Sheet. const [usesPhoneSearchLayout, setUsesPhoneSearchLayout] = useState(false); const [desktopHomeComposerActive, setDesktopHomeComposerActive] = useState(false); // True once the hero portal is conclusively unavailable — the media query @@ -730,14 +737,39 @@ export function MasterSearchHeader({ function focusModeOption(index: number) { const nextIndex = (index + visibleAppModeOptions.length) % visibleAppModeOptions.length; + setModeMenuFocusIndex(nextIndex); modeOptionRefs.current[nextIndex]?.focus(); } - function openModeMenuWithFocus(index: number) { + function closeModeSurfaces() { setActionMenuOpen(false); closeScope(false); + setScopeSheetOpen(false); + } + + function openModeMenuWithFocus(index: number) { + closeModeSurfaces(); + const nextIndex = (index + visibleAppModeOptions.length) % visibleAppModeOptions.length; + const phoneLayout = currentUsesPhoneSearchLayout(); + setUsesPhoneSearchLayout(phoneLayout); + setModeMenuFocusIndex(nextIndex); + setModeMenuOpen(true); + // Phone sheet owns initial focus via data-sheet-autofocus; desktop still + // needs an rAF focus into the absolute menu after it mounts. + if (!phoneLayout) { + window.requestAnimationFrame(() => focusModeOption(nextIndex)); + } + } + + function toggleModeMenu() { + closeModeSurfaces(); + if (modeMenuOpen) { + setModeMenuOpen(false); + return; + } + setUsesPhoneSearchLayout(currentUsesPhoneSearchLayout()); + setModeMenuFocusIndex(selectedModeIndex); setModeMenuOpen(true); - window.requestAnimationFrame(() => focusModeOption(index)); } function handleModeTriggerKeyDown(event: ReactKeyboardEvent) { @@ -764,16 +796,69 @@ export function MasterSearchHeader({ event.preventDefault(); focusModeOption(visibleAppModeOptions.length - 1); } else if (event.key === "Escape") { + // Phone Sheet owns Escape + return-focus; handling here races its cleanup. + if (usesPhoneSearchLayout) return; event.preventDefault(); setModeMenuOpen(false); window.requestAnimationFrame(() => modeButtonRef.current?.focus()); } else if (event.key === "Tab") { - // Let the browser advance focus normally, but do not leave an abandoned - // menu open after keyboard focus moves into the surrounding header. - setModeMenuOpen(false); + // Desktop: let focus leave the absolute menu and close it. Phone sheet + // traps Tab itself — closing here would fight the dialog focus cycle. + if (!usesPhoneSearchLayout) { + setModeMenuOpen(false); + } } } + function renderModeMenuOptions() { + return visibleAppModeOptions.map((mode, index) => { + const Icon = appModeIcons[mode.id]; + const active = mode.id === searchMode; + return ( + + ); + }); + } + const restoreActionMenuFocusRef = useRef(false); const closeScope = useCallback((restoreFocus = false) => { restoreActionMenuFocusRef.current = restoreFocus; @@ -795,12 +880,20 @@ export function MasterSearchHeader({ }); }, []); + const phoneLayoutGateRef = useRef(null); useEffect(() => { const scopeMediaQuery = window.matchMedia(scopeSheetMediaQuery); const phoneMediaQuery = window.matchMedia(phoneSearchLayoutMediaQuery); const sync = () => { setUsesScopeSheet(scopeMediaQuery.matches); - setUsesPhoneSearchLayout(phoneMediaQuery.matches); + const nextPhoneLayout = phoneMediaQuery.matches; + // Crossing the phone gate while open would swap Sheet ↔ absolute menu + // under the user's finger/keyboard; close instead of mutating surface. + if (phoneLayoutGateRef.current !== null && phoneLayoutGateRef.current !== nextPhoneLayout) { + setModeMenuOpen(false); + } + phoneLayoutGateRef.current = nextPhoneLayout; + setUsesPhoneSearchLayout(nextPhoneLayout); }; sync(); scopeMediaQuery.addEventListener("change", sync); @@ -906,7 +999,9 @@ export function MasterSearchHeader({ } useDismissableLayer({ - enabled: modeMenuOpen, + // Phone Mode uses Sheet (backdrop / Escape / focus trap). Keep the + // dismissable-layer contract on the desktop absolute menu only. + enabled: modeMenuOpen && !usesPhoneSearchLayout, refs: [modeMenuRef], restoreFocusRef: modeButtonRef, onDismiss: dismissModeMenu, @@ -1363,6 +1458,7 @@ export function MasterSearchHeader({ setUsesScopeSheet(currentUsesScopeSheet()); setModeMenuOpen(false); setScopeOpen(false); + setScopeSheetOpen(false); }} onAction={runModeAction} onModeSelect={selectAppModeById} @@ -1624,6 +1720,9 @@ export function MasterSearchHeader({
{ + // Phone Mode menu is portaled into Sheet; blur-leave on this wrapper + // would close the sheet as soon as focus moved into the dialog. + if (usesPhoneSearchLayout) return; const nextFocusedElement = event.relatedTarget; if (nextFocusedElement instanceof Node && event.currentTarget.contains(nextFocusedElement)) return; setModeMenuOpen(false); @@ -1633,11 +1732,7 @@ export function MasterSearchHeader({ - {modeMenuOpen ? ( + {!usesPhoneSearchLayout && modeMenuOpen ? ( ) : null}
@@ -1773,6 +1822,30 @@ export function MasterSearchHeader({ ) : null} + + {/* Portaled outside the 3-column header grid so a non-portal regression + cannot steal a grid track and shove trailing actions onto a new row. */} + {usesPhoneSearchLayout ? ( + + + + ) : null} {searchComposerVisible ? ( diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index b8d2515c9..ccd2070be 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -81,6 +81,10 @@ export function Sheet({ const closeRef = useRef(null); const onCloseRef = useRef(onClose); const dragRef = useRef<{ startY: number; dragging: boolean }>({ startY: 0, dragging: false }); + // Backdrop dismiss must require the gesture to *start* on the dimmed area. + // Otherwise a press that begins on the panel and ends on the backdrop would + // synthesize a click on the common ancestor and accidentally close the sheet. + const backdropPointerDownRef = useRef(false); const titleId = useId(); const descId = useId(); @@ -147,7 +151,9 @@ export function Sheet({ const focusable = Array.from( panelRef.current?.querySelectorAll( - 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), summary, [tabindex]:not([tabindex="-1"])', + // Exclude tabindex="-1" buttons so roving-tabindex menus (e.g. Mode + // options) do not dump every inactive item into the Tab cycle. + 'a[href], button:not([disabled]):not([tabindex="-1"]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), summary, [tabindex]:not([tabindex="-1"])', ) ?? [], ).filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true"); if (focusable.length === 0) return; @@ -209,8 +215,16 @@ export function Sheet({ ? "items-start justify-center px-3 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:items-center sm:p-6" : "items-end justify-center sm:items-center sm:p-6", )} + // Dismiss on click (not pointerdown) so the sheet stays mounted through + // pointerup and the same gesture cannot click-through into content below. + // Only honor the click when the pointerdown also began on the backdrop. onPointerDown={(event) => { - if (event.target === event.currentTarget) onClose(); + backdropPointerDownRef.current = event.target === event.currentTarget; + }} + onClick={(event) => { + if (event.target !== event.currentTarget || !backdropPointerDownRef.current) return; + backdropPointerDownRef.current = false; + onClose(); }} >
event.stopPropagation()} + onPointerDown={(event) => { + backdropPointerDownRef.current = false; + event.stopPropagation(); + }} style={contentStyle} className={cn( "flex min-w-0 w-full flex-col overflow-hidden border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text)] shadow-[var(--shadow-elevated)] pb-safe", diff --git a/src/lib/extractors/document.ts b/src/lib/extractors/document.ts index 58a751fb7..1d77219e5 100644 --- a/src/lib/extractors/document.ts +++ b/src/lib/extractors/document.ts @@ -57,26 +57,47 @@ export function parseExtractedDocumentPayload(raw: string): ExtractedDocument { } export async function terminateProcessTree(child: ChildProcess) { - if (!child.pid || child.exitCode !== null) return; + const pid = child.pid; + if (!pid) return; + if (process.platform === "win32") { await new Promise((resolve) => { - const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { + const killer = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore", windowsHide: true, }); killer.once("error", () => { - child.kill("SIGKILL"); + if (child.exitCode === null) child.kill("SIGKILL"); resolve(); }); killer.once("close", () => resolve()); }); return; } + + // Always attempt a process-group kill first. Skipping when `exitCode` is set + // left detached grandchildren alive after the leader had already exited. try { - process.kill(-child.pid, "SIGKILL"); + process.kill(-pid, "SIGKILL"); } catch { - child.kill("SIGKILL"); + if (child.exitCode === null) { + try { + child.kill("SIGKILL"); + } catch { + // already gone + } + } } + + if (child.exitCode !== null) return; + await new Promise((resolve) => { + const timer = setTimeout(resolve, 1_000); + timer.unref(); + child.once("close", () => { + clearTimeout(timer); + resolve(); + }); + }); } export async function runPythonPdfExtractor( diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index 9bd9f6d9a..cd96f6f2a 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -92,11 +92,26 @@ describe("audit navigation and auth regressions", () => { ); expect(focusLeaveContract).toContain("onBlur={(event) => {"); + expect(focusLeaveContract).toContain("if (usesPhoneSearchLayout) return;"); expect(focusLeaveContract).toContain("const nextFocusedElement = event.relatedTarget;"); expect(focusLeaveContract).toContain("event.currentTarget.contains(nextFocusedElement)"); expect(focusLeaveContract).toContain("setModeMenuOpen(false);"); }); + it("opens the master mode menu as a phone bottom sheet below the phone layout gate", () => { + expect(masterSearchHeaderSource).toContain('testId="app-mode-menu-sheet"'); + expect(masterSearchHeaderSource).toContain("enabled: modeMenuOpen && !usesPhoneSearchLayout"); + expect(masterSearchHeaderSource).toContain("{!usesPhoneSearchLayout && modeMenuOpen ? ("); + expect(masterSearchHeaderSource).toContain('aria-haspopup={usesPhoneSearchLayout ? "dialog" : "menu"}'); + expect(masterSearchHeaderSource).toContain('mobilePlacement="bottom"'); + expect(masterSearchHeaderSource).toContain("phoneLayoutGateRef"); + // Hydration-safe: do not read matchMedia in useState (SSR/client mismatch → React #418). + expect(masterSearchHeaderSource).toContain( + "const [usesPhoneSearchLayout, setUsesPhoneSearchLayout] = useState(false);", + ); + expect(masterSearchHeaderSource).toContain("setUsesPhoneSearchLayout(currentUsesPhoneSearchLayout());"); + }); + it("gates private polling and mutations on local readiness plus authenticated status", () => { const uploadReadOnlyContract = sourceSegment( clinicalDashboardSource, diff --git a/tests/pdf-extraction-budget.test.ts b/tests/pdf-extraction-budget.test.ts index 577d902fc..dfb9ead34 100644 --- a/tests/pdf-extraction-budget.test.ts +++ b/tests/pdf-extraction-budget.test.ts @@ -12,6 +12,7 @@ import { PdfExtractionResourceError, } from "@/lib/extractors/pdf-extraction-budget"; import { isRetryableIngestionError } from "@/lib/ingestion"; +import { resolvePythonBin } from "@/lib/python-bin"; const roots: string[] = []; @@ -54,6 +55,21 @@ afterEach(async () => { }); describe("PDF extraction budgets", () => { + it("resolves the platform default when PYTHON_BIN is unset", () => { + const previous = process.env.PYTHON_BIN; + delete process.env.PYTHON_BIN; + try { + expect(resolvePythonBin()).toBe(process.platform === "win32" ? "python" : "python3"); + } finally { + if (previous === undefined) delete process.env.PYTHON_BIN; + else process.env.PYTHON_BIN = previous; + } + }); + + it("honors an explicit PYTHON_BIN override", () => { + expect(resolvePythonBin("/custom/python")).toBe("/custom/python"); + }); + it("accepts exact aggregate boundaries and rejects the first byte or item beyond them", () => { const limits = { ...PDF_EXTRACTION_BUDGET, @@ -109,14 +125,25 @@ describe("PDF extraction budgets", () => { ).rejects.toMatchObject({ code: "PDF_EXTRACTION_DEADLINE_EXCEEDED" }); const childPid = Number(await readFile(childPidPath, "utf8")); - let childIsAlive = false; - try { - process.kill(childPid, 0); - childIsAlive = true; - } catch { - childIsAlive = false; + // SIGKILL delivery can lag under a busy suite; poll briefly before asserting. + let childIsAlive = true; + const deadline = Date.now() + 1_000; + while (Date.now() < deadline) { + try { + process.kill(childPid, 0); + await new Promise((resolve) => setTimeout(resolve, 25)); + } catch { + childIsAlive = false; + break; + } + } + if (childIsAlive) { + try { + process.kill(childPid, "SIGKILL"); + } catch { + // already gone + } } - if (childIsAlive) process.kill(childPid, "SIGKILL"); expect(childIsAlive).toBe(false); }); diff --git a/tests/ui-overlay-css-contract.test.ts b/tests/ui-overlay-css-contract.test.ts index 8380d0be1..255968f00 100644 --- a/tests/ui-overlay-css-contract.test.ts +++ b/tests/ui-overlay-css-contract.test.ts @@ -18,6 +18,14 @@ describe("overlay and global CSS contracts", () => { expect(answerResultSurfaceSource).not.toContain("sm:bg-black/50"); }); + it("dismisses the Sheet backdrop only when the gesture starts on the dimmed area", () => { + expect(sheetSource).toContain("backdropPointerDownRef"); + expect(sheetSource).toContain("backdropPointerDownRef.current = event.target === event.currentTarget"); + expect(sheetSource).toContain( + "if (event.target !== event.currentTarget || !backdropPointerDownRef.current) return", + ); + }); + it("defines the shared easing tokens only once", () => { expect(occurrenceCount(globalStylesSource, "--ease-standard:")).toBe(1); expect(occurrenceCount(globalStylesSource, "--ease-emphasized:")).toBe(1); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 063bc76ff..77e153bbd 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1322,9 +1322,70 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(appModeMenu).toBeVisible(); await page.mouse.click(640, 430); await expect(appModeMenu).toBeHidden(); + await expect(page.getByTestId("app-mode-menu-sheet")).toHaveCount(0); await expectNoPageHorizontalOverflow(page); }); + test("phone mode menu opens as a scrollable bottom sheet with the full mode list", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockPrivateUnauthenticatedApi(page); + await gotoApp(page, "/"); + await waitForDemoDashboardReady(page); + + const appModeTrigger = page.getByRole("button", { name: "Mode Answer" }); + await waitForReactEventHandler(appModeTrigger, "onClick"); + await appModeTrigger.click(); + + const modeSheet = page.getByTestId("app-mode-menu-sheet"); + const appModeMenu = page.getByRole("menu", { name: "Choose app mode" }); + await expect(modeSheet).toBeVisible(); + await expect(modeSheet).toHaveAttribute("role", "dialog"); + await expect(appModeMenu).toBeVisible(); + await expect(appModeTrigger).toHaveAttribute("aria-expanded", "true"); + await expect(appModeTrigger).toHaveAttribute("aria-controls", "app-mode-menu"); + + // Full list must be present (not clipped out of the DOM by the old max-height panel). + const modeOptions = appModeMenu.getByRole("menuitemradio"); + expect(await modeOptions.count()).toBeGreaterThanOrEqual(10); + await expect(appModeMenu.getByRole("menuitemradio", { name: /^Tools\b/ })).toBeAttached(); + await expect(appModeMenu.getByRole("menuitemradio", { name: /^Medication\b/ })).toBeAttached(); + + // Scroll the sheet body so a lower mode is interactable, then select it. + const toolsMode = appModeMenu.getByRole("menuitemradio", { name: /^Tools\b/ }); + await toolsMode.scrollIntoViewIfNeeded(); + await expect(toolsMode).toBeVisible(); + await toolsMode.click(); + + await expect(modeSheet).toHaveCount(0); + await expect(appModeMenu).toHaveCount(0); + await expect(page.getByRole("button", { name: "Mode Tools" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Mode Tools" })).toBeFocused(); + await expectNoPageHorizontalOverflow(page); + }); + + test("phone mode menu dismisses via backdrop and restores focus to the Mode button", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockPrivateUnauthenticatedApi(page); + await gotoApp(page, "/"); + await waitForDemoDashboardReady(page); + + const appModeTrigger = page.getByRole("button", { name: "Mode Answer" }); + await waitForReactEventHandler(appModeTrigger, "onClick"); + await appModeTrigger.click(); + + const modeSheet = page.getByTestId("app-mode-menu-sheet"); + await expect(modeSheet).toBeVisible(); + + // Click the dimmed backdrop (outside the dialog panel) to dismiss. + await page + .locator(".fixed.inset-0.z-\\[100\\]") + .first() + .click({ position: { x: 8, y: 8 } }); + await expect(modeSheet).toHaveCount(0); + await expect(appModeTrigger).toBeFocused(); + await expect(appModeTrigger).toHaveAttribute("aria-expanded", "false"); + }); + test("desktop mode action placement coalesces scroll updates per frame", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockPrivateUnauthenticatedApi(page);