diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index f75bd714a1..2658f33611 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -700,14 +700,17 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-07 | cursor/viewer-phase0-gesture-a11y-1db8 (PR #1660) | 097dfd245f798f8105eeb6c1cf4fc077f969496f | prlanded | MERGED; squash tip empty vs branch tip 810cfc9b4a1c476a0dcc995bffb00d7329a85686; Phase 0 gesture INP, preview a11y, image decode | content tree empty vs squash; no provider-backed checks run | | 2026-08-07 | claude/pr-handoff-loop-prevention-54y5zr (PR #1670) | dfe2946110e0ff93bd4acc571ae79c26b79a7a85 | PR #1670 heavy review-and-fix | synced origin/main (behind-but-clean DIRTY cleared); fixed CodeRabbit checks-cell to name exact #1649 gates + incomplete verify:pr-local/ui + no provider checks; Bugbot none; no P0/P1; #258/#ledger delta accurate; merge-tree clean; threads cleared | verify:cheap 519 files/5493 passed; verify:pr-local docs scope (format+docs+ledger+outstanding-issues); check:branch-review-ledger; check:outstanding-issues; no provider gates | | 2026-08-07 | claude/handover-review-nlhuln | 978623337c12dc1721fe5236eadbf9a5ad929f03 | mode nav remaining modes: factsheets adoption (PR #1674) | Adopted the shared ModeNav for factsheets (Topics + Search); replaced the action-only entry, added the activeId branch, q/category/run carry, BookOpenText icon; three pinned adopted-mode lists updated together; record-route protection pinned at render now the item-count protection has expired | lint clean; typecheck clean; test 518/519 files (pr-handoff-stop failure confirmed pre-existing via stashed re-run); focused 5 files 95 tests; ui-mode-nav-density 55 passed incl 7 new factsheets rows; two mutation checks confirmed red; format committed; verify:pr-local blocked at check:installed-lock-parity (playwright 1.62.0 vs 1.62.1) | -| 2026-08-07 | cursor/privacy-live-signal-variants-bc81 (PR #1676) | f63eba10f5e9b6db047302c34412ea5261cd410b | PR #1676 unblock | before: PR policy fail (missing Clinical Governance Preflight; privacy* mockup paths trip clinicalRisk), behind-but-clean then main advanced; after: PR body preflight completed (policy green), tip synced to main via merge f63eba10 (duplicate local merge discarded), merge-tree clean, 0 unresolved threads, required CI in progress on synced tip; no code fix needed | local evaluatePullRequestPolicy ok after body; PR policy run 31172248093/31172327100 success; merge-tree clean; no provider-backed checks run | | 2026-08-07 | claude/handover-review-nlhuln | 4ff613c10fbf734b1e740a31611296c17c791ec7 | mode nav remaining modes: vestigial strip removal (PR #1679) | Removed the single-button action strip from answer/documents/services/forms/favourites/prescribing/tools; deleted the registry index-0 fallback (TS2493-forced) and the dead documents clause; stripped modeItems/onSearch/modeAriaLabel/stickyTop from PageSecondaryNavigation, keeping the empty-registry return below the information-section branch; kept the action kind with a no-live-consumer note. Completes the 13-mode navigation rollout. | lint exit 0; typecheck clean; focused 5 files 97 tests; test 518/519 files (pr-handoff-stop re-confirmed pre-existing on this base via stashed re-run); ui-mode-nav-density + ui-accessibility 71 passed (landmark scan green); branch-order guard mutation-checked (hoisting it fails 2 tests); format committed; verify:pr-local blocked at check:installed-lock-parity (playwright 1.62.0 vs 1.62.1) | -| 2026-08-07 | cursor/pr-1676-unblock-ledger-ef51 (PR #1677) | dec8f7489b4e5492e924af30dec85859932af3bb | review-and-fix | synced origin/main (behind-but-clean DIRTY; merge-tree clean); Bugbot mid-table finding dispositioned (row was tip-append before #1679; post-merge order correct; ledger guard passed); no P0/P1; 0 threads; no code fix; merge left to user | check:branch-review-ledger pass; ledger:dedupe none; merge-tree clean vs origin/main; format unchanged; no provider gates | -| 2026-08-07 | cursor/pr-1676-unblock-ledger-ef51 (PR #1677) | 574702a681cbb4d455151da023428d16c22fb460 | review-and-fix | late-synced origin/main after CI green (brought #1678 cn/tailwind-merge; remote merge 574702a6); prior sync cleared DIRTY; Bugbot mid-table finding dispositioned (tip-append before #1679; post-merge order correct); no P0/P1; 0 threads; no code fix; merge left to user | check:branch-review-ledger pass; ledger:dedupe none; merge-tree clean; prior tip required CI green; format unchanged; no provider gates | | 2026-08-05 | HEAD | 2a8881afad230880166de60a533e17588d9920ec | seven-report repo-wide audit | P2 confirmed: live drift and live-only migration; mobile CLS; ACL runner; OOXML declared-size robustness; assertion false positives. Numerous report claims stale, narrowed, or unsafe. | verify:cheap (5088 pass, 3 skip, 1 timeout); focused retry 21/21 pass; build pass; bundle budget pass; offline RAG 574/574; assertions 98%; live drift 34; browser CLS 0.228/0.218 | | 2026-08-07 | codex/consolidated-ledger-updates (PR #1683) | 413e679bb92cb19717d6d8301764df44694eb73e | review-and-fix PR #1683 | synced origin/main (behind-but-clean DIRTY cleared); restored main ledger order + sole seven-report row; Bugbot none; no P0/P1; merge-tree clean | verify:pr-local docs scope PASS (format:changed Prettier; check:branch-review-ledger 648; docs links 1650; outstanding-issues 258); merge-tree clean | -| 2026-08-07 | cursor/pr-1676-unblock-ledger-ef51 (PR #1677) | fddf495b5176f570a5238b5c17326f64f333ecff | review-and-fix | synced origin/main (behind-but-clean DIRTY; merge-tree clean; main advanced with #1684/#1666); 0 review threads; no P0/P1; no code fix; merge left to user | merge-tree clean vs origin/main; ledger:dedupe none; prior tip required CI green except stale PR mergeability; format unchanged; no provider gates | +| 2026-08-07 | cursor/privacy-live-signal-variants-bc81 | 9508c3e34325ae2c0f814b8c01360cb6a719898b | PR 1676 review | clean; behind-not-conflicted — merged origin/main (merge-tree clean); mockup-only Live Signal variants; no P0/P1 | merge-tree clean; gh mergeable=MERGEABLE/BEHIND then local merge; policy ok; code review of 5 files | +| 2026-08-07 | cursor/privacy-live-signal-variants-bc81 | 68f1c98d9eb281f90f36c8b803f4abde72cdb862 | pr-1676 babysit: sync behind-clean main + CI triage | MERGEABLE behind->synced; merge-tree clean; required CI green on prior tip; advisory lighthouse fail only; no review threads; FixDocker.bat left untracked | merge-tree clean; prior tip PR-required SUCCESS; lighthouse advisory FAILURE ignored | | 2026-08-07 | cursor/inpage-nav-default-235a | c6d72e406c03e205bac86d23e84813c41332c205 | docs: default in-page nav DocumentViewer chrome + PhoneHeaderCollapsePortal | docs-only; verify:pr-local passed (low-risk docs scope) | verify:pr-local --files AGENTS.md,docs/search-chrome-behaviour.md | | 2026-08-07 | claude/issues-256-section-nav-clean | 169323053db5c572d183d59c113ecd0c76e7aca5 | issues #256: forms section anchors + differentials presentation set (PR #1697) | Wired all six formSections anchors in form-detail-page.tsx (four direct ids, two breakpoint pairs via existing mobile wrappers and single-child desktop wrappers, no component signature change); deleted differentialPresentationSections and declared /differentials/presentations/ locally-owned instead, since three of its six sections declared a -mobile targetId ReviewPanels can never satisfy and the page owns MobileTabs below xl plus the xl review sidebar. Added a registered browser spec because source-text and jsdom guards both structurally cannot see breakpoint-variant resolution. | lint exit 0; typecheck clean; test 519/520 files (pr-handoff-stop confirmed pre-existing via stashed re-run); check:gate-manifest and check:ci-scope pass with the new spec in both playwright allowlists; ui-forms-section-nav + ui-accessibility 18 passed incl real-record nav with 6 links and exactly one variant per pair visible at 390px and 1280px; binding guard mutation-checked red on one removed id; browser spec observed failing when nav genuinely absent; format clean. Environment: npm ci blocked (main lockfile needs Node >=24.15, container has 24.13), tailwind-merge@3.6.0 materialised from tarball only | | 2026-08-07 | claude/handover-review-nlhuln | de8b74e2fb94d1ec9b1982c15a2dea43421c3eee | outstanding-issues ledger capture after the mode-nav rollout (PR #1685) | Confirmed #256's two remaining suspected section sets are dead (/forms/ and /differentials/presentations/ draw no section nav; form-decision-context-mobile is a testId not an id; ruled out sectionId indirection in both files). Added #261 (delete-or-keep the consumer-less action kind) and #262 (addon-slot single-owner rule held by two lists agreeing by coincidence). #207/#226/#231 reviewed and deliberately left untouched as existing P1 rows. | check:outstanding-issues passed (260 rows, 119 open, unique ids, no ids deleted from base 1ff9ed206456); prettier --check clean on the changed file; rows written via scripts/outstanding-issues.mjs, never hand-edited; no code gates run - docs-only diff | | 2026-08-07 | claude/handover-review-nlhuln | de8b74e2fb94d1ec9b1982c15a2dea43421c3eee | outstanding-issues ledger capture after the mode-nav rollout (PR #1685) (supersedes 2026-08-07) | Confirmed #256's two remaining suspected section sets are dead (/forms/ and /differentials/presentations/ draw no section nav; form-decision-context-mobile is a testId not an id; ruled out sectionId indirection in both files). Added #271 (delete-or-keep the consumer-less action kind) and #272 (addon-slot single-owner rule held by two lists agreeing by coincidence) — renumbered from this PR's original #261/#262 because main claimed #261-#270 via PR #1678 design-system tracks in the interim. #207/#226/#231 reviewed and deliberately left untouched as existing P1 rows. | check:outstanding-issues passed (270 rows, 129 open, 141 archived, unique ids, next-id=273 above the highest, no ids deleted from base d32dd549a3dd); prettier --check clean on the changed file; rows written via scripts/outstanding-issues.mjs, never hand-edited; no code gates run - docs-only diff | +| 2026-08-07 | cursor/privacy-live-signal-variants-bc81 (PR #1676) | f63eba10f5e9b6db047302c34412ea5261cd410b | PR #1676 unblock | before: PR policy fail (missing Clinical Governance Preflight; privacy* mockup paths trip clinicalRisk), behind-but-clean then main advanced; after: PR body preflight completed (policy green), tip synced to main via merge f63eba10 (duplicate local merge discarded), merge-tree clean, 0 unresolved threads, required CI in progress on synced tip; no code fix needed | local evaluatePullRequestPolicy ok after body; PR policy run 31172248093/31172327100 success; merge-tree clean; no provider-backed checks run | +| 2026-08-07 | cursor/pr-1676-unblock-ledger-ef51 (PR #1677) | dec8f7489b4e5492e924af30dec85859932af3bb | review-and-fix | synced origin/main (behind-but-clean DIRTY; merge-tree clean); Bugbot mid-table finding dispositioned (row was tip-append before #1679; post-merge order correct; ledger guard passed); no P0/P1; 0 threads; no code fix; merge left to user | check:branch-review-ledger pass; ledger:dedupe none; merge-tree clean vs origin/main; format unchanged; no provider gates | +| 2026-08-07 | cursor/pr-1676-unblock-ledger-ef51 (PR #1677) | 574702a681cbb4d455151da023428d16c22fb460 | review-and-fix | late-synced origin/main after CI green (brought #1678 cn/tailwind-merge; remote merge 574702a6); prior sync cleared DIRTY; Bugbot mid-table finding dispositioned (tip-append before #1679; post-merge order correct); no P0/P1; 0 threads; no code fix; merge left to user | check:branch-review-ledger pass; ledger:dedupe none; merge-tree clean; prior tip required CI green; format unchanged; no provider gates | +| 2026-08-07 | cursor/pr-1676-unblock-ledger-ef51 (PR #1677) | fddf495b5176f570a5238b5c17326f64f333ecff | review-and-fix | synced origin/main (behind-but-clean DIRTY; merge-tree clean; main advanced with #1684/#1666); 0 review threads; no P0/P1; no code fix; merge left to user | merge-tree clean vs origin/main; ledger:dedupe none; prior tip required CI green except stale PR mergeability; format unchanged; no provider gates | +| 2026-08-07 | claude/search-bar-mobile-layout-buu0io | 9d64388c0ce530d0c20bb7efe8ffb32cd928319c | phone results-filter idiom: 7 modes off MobileResultFilterControl onto ResultFilterTrigger + ResultFilterSheet; band, docs, tests | changes-shipped | typecheck; lint; test 5538 passed (1 pre-existing pr-handoff-stop failure, baselined on unmodified tree); build; check:rag:fixtures; check:bundle-budget +6.3% within tolerance; targeted Playwright: ui-accessibility 16, ui-specifiers+ui-formulation 12, ui-tools 5, ui-smoke 2, ui-stress 3 | diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index f4e7b92860..6d4896a0b0 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -980,7 +980,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SearchField` | controls | yes | yes | no | yes | no | 0 | | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 2 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 19 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 20 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 1 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index a133bf4e86..9da8275ccc 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1415,6 +1415,7 @@ "src/components/clinical-dashboard/image-lightbox.tsx", "src/components/clinical-dashboard/master-search-header.tsx", "src/components/clinical-dashboard/mode-action-popup.tsx", + "src/components/clinical-dashboard/result-filter-control.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", "src/components/differentials/diagnosis-map-panel.tsx", "src/components/document-viewer/document-clinical-summary.tsx", @@ -1437,6 +1438,7 @@ "src/components/clinical-dashboard/image-lightbox.tsx", "src/components/clinical-dashboard/master-search-header.tsx", "src/components/clinical-dashboard/mode-action-popup.tsx", + "src/components/clinical-dashboard/result-filter-control.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", "src/components/differentials/diagnosis-map-panel.tsx", "src/components/document-viewer/document-clinical-summary.tsx", diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index d809e66e17..5c66819f0b 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -153,20 +153,19 @@ removed after current-main verification; it is not missing recommended work. | 100 | `#242` | A2 | High — design-system baselines | After human review of Linux baselines | 1–2 hours | Commit approved Linux visual baselines and promote adoption not-committed → committed. **Stop:** never commit baselines from an unreviewed machine run. | | 101 | `#244` | A3 | High — design tokens / forced-colors | With any ckb-v2 forced-colours edit | 15–30 min | Keep grouped dark selectors in the forced-colours media block so specificity matches dark rules. **Stop:** do not trim to a single `.ckb-v2.ckb-v2` selector. | | 102 | `#245` | A3 | High — cross-mode links | Next CrossModeLinks / analytics pass | 30–60 min | responsive-compact CrossModeLinks keeps duplicate rails in the DOM; prefer one mount or accept test double-counts. **Stop:** do not break phone-only rail contract. | -| 103 | `#247` | A3 | High — search results UI | After one-line results bar proves stable on Documents/Therapy | 2–4 hours | Widen the one-line results bar to the six modes that pass a full-width phone select. **Gate:** `verify:phone-chrome` / focused results-band tests. **Stop:** do not force modes that fail the select width contract. | -| 104 | `#248` | A2 | Operator — Supabase + Specialist | After PR #1614 symptom repair; approved live/history window | 1–2 hours | Investigate why 20260705180000 search-health indexes were missing on live despite applied history; decide if drift checks should catch this class. **Stop:** no hosted mutation without approval. | -| 105 | `#249` | A3 | High — agent process | Next issues-skill / plan touch | 1–2 hours | Extend issues/plan with an agent-safe wins classifier (optional filter; no new skill unless reused thrice). **Stop:** do not outrank A1 operator work. | -| 106 | `#250` | A2 | High — multi-agent execution | After Wave 0 queue repair on main (done in this capture); run remaining Wave 0/#202 process gates next on the engineering track | multi-wave | Execute the fastest-wins multi-wave plan (Waves 0–4 + operator track) with parallel agents and per-PR gates. Waves do not outrank A1 acuity. **Stop:** provider/RAG approvals still required where flagged. | -| 107 | `#251` | Optional | High — agent process | Next handoff/gates doc touch | 15–30 min | Handoff checklist pairs gates skill with verification-router; paste decisive proof line. **Stop:** do not stack broad gates by default. | -| 108 | `#252` | A2 | High — bundling/gates | Next bundle-budget decision | 1–2 hours | Decide whether check:bundle-budget should exclude mockup chunks or keep counting them as hygiene; do not raise tolerance to clear #1580. **Stop:** do not --update without deciding. | -| 109 | `#253` | A2 | High — phone results UI | Next #1606 merge pass | 1–2 hours | Hand-merge #1606 portal menu against #1615 iOS 16px select fix; re-verify keyboard + lint. **Stop:** do not close #1606 to dodge the conflict. | -| 110 | `#254` | A2 | Operator — Codex Cloud | Before #1617 leaves draft | 1–2 hours | Re-run Codex Cloud acceptance at the exact current head or mark head-independent evidence. **Stop:** do not treat stale pins as coverage. | -| 111 | `#255` | A2 | High — Cloud/browser gates | Next environment image update | 2–4 hours | Align Cloud Playwright browser builds with lockfile pin; document CI delegation until then. **Stop:** do not force mismatched Chromium revisions. | -| 112 | `#256` | A2 | High — mode section nav | Next information-page / mode-nav pass | 2–4 hours | Declared information-page section sets whose target ids nothing renders — verify each set against the rendered DOM per route; render anchors or delete the set. **Stop:** do not audit by grepping for `id=` alone (sectionId props exist). | -| 113 | `#257` | Optional | High — formulation/specifiers flake | Standing until second reproduction | 15–30 min | Single unreproduced ui-formulation flake when run with ui-specifiers — record a second sighting only; do not quarantine until three on the same SHA. **Stop:** do not weaken assertions. | +| 103 | `#248` | A2 | Operator — Supabase + Specialist | After PR #1614 symptom repair; approved live/history window | 1–2 hours | Investigate why 20260705180000 search-health indexes were missing on live despite applied history; decide if drift checks should catch this class. **Stop:** no hosted mutation without approval. | +| 104 | `#249` | A3 | High — agent process | Next issues-skill / plan touch | 1–2 hours | Extend issues/plan with an agent-safe wins classifier (optional filter; no new skill unless reused thrice). **Stop:** do not outrank A1 operator work. | +| 105 | `#250` | A2 | High — multi-agent execution | After Wave 0 queue repair on main (done in this capture); run remaining Wave 0/#202 process gates next on the engineering track | multi-wave | Execute the fastest-wins multi-wave plan (Waves 0–4 + operator track) with parallel agents and per-PR gates. Waves do not outrank A1 acuity. **Stop:** provider/RAG approvals still required where flagged. | +| 106 | `#251` | Optional | High — agent process | Next handoff/gates doc touch | 15–30 min | Handoff checklist pairs gates skill with verification-router; paste decisive proof line. **Stop:** do not stack broad gates by default. | +| 107 | `#252` | A2 | High — bundling/gates | Next bundle-budget decision | 1–2 hours | Decide whether check:bundle-budget should exclude mockup chunks or keep counting them as hygiene; do not raise tolerance to clear #1580. **Stop:** do not --update without deciding. | +| 108 | `#253` | A2 | High — phone results UI | Next open-PR sweep | 15–30 min | Decide #1606's fate: the `MobileResultFilterControl` it rewrites was deleted by #247, so there is nothing left to hand-merge. Verify keyboard parity of the replacement sheet on a real device, then close #1606 as superseded. **Stop:** the decision is a human's; do not close #1606 automatically. | +| 109 | `#254` | A2 | Operator — Codex Cloud | Before #1617 leaves draft | 1–2 hours | Re-run Codex Cloud acceptance at the exact current head or mark head-independent evidence. **Stop:** do not treat stale pins as coverage. | +| 110 | `#255` | A2 | High — Cloud/browser gates | Next environment image update | 2–4 hours | Align Cloud Playwright browser builds with lockfile pin; document CI delegation until then. **Stop:** do not force mismatched Chromium revisions. | +| 111 | `#256` | A2 | High — mode section nav | Next information-page / mode-nav pass | 2–4 hours | Declared information-page section sets whose target ids nothing renders — verify each set against the rendered DOM per route; render anchors or delete the set. **Stop:** do not audit by grepping for `id=` alone (sectionId props exist). | +| 112 | `#257` | Optional | High — formulation/specifiers flake | Standing until second reproduction | 15–30 min | Single unreproduced ui-formulation flake when run with ui-specifiers — record a second sighting only; do not quarantine until three on the same SHA. **Stop:** do not weaken assertions. | - + ## Open items > **Merged-main canary update (2026-07-23, run `30018289898`):** the new structured report correctly recorded evaluated tree `c24f2e8f2d30d0c59fc1eba025d3dcd63478137e`, run/attempt identity and `cross-region-runner` latency context. Golden retrieval remained 36/36 with document/content recall 1.0 and no failed cases. The 44-case answer gate had grounded-supported and unsupported-correct rates of 1.0, but failed because `neuroleptic-side-effect-escalation` again returned one citation where two are required (citation-failure rate 0.0227). `admission-discharge-comparison` again omitted the specific AKG admission document after `comparison_source_extractive_fallback`; `admission-discharge-coverage-paraphrase` was advisory-only at 24,870 ms. Answer cost was reported as `$0.234736`. Do not retry immediately: retain this as the first structured datapoint, compare it with the scheduled 2026-07-26 report, and keep retrieval/ranking unchanged. @@ -184,7 +183,6 @@ removed after current-main verification; it is not missing recommended work. | ID | Pri | Type | Summary | Detail / next action | Source | Added | | ---- | --- | ----- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -| #247 | P3 | task | Widen the one-line results bar to the six modes that pass a full-width phone select | **Outcome:** more than two modes get the 58px bar instead of keeping a second row. **Detail:** the redesign collapses the band to one line, but `mobileControlsPlacement` defaults to `row` whenever a page passes `mobileControls`, because six modes pass `MobileResultFilterControl` — a `w-full` native select — and formulation and specifiers pass *two* in a two-column grid. Pinned into a 58px line at 320px those are unreadable, verified in a real browser. Documents and therapy-compass opt in to `inline` because they pass a compact badged trigger. The default is deliberately the safe one, so this is a widening opportunity and not a defect. **Next:** per mode, decide whether the select can become a compact trigger opening a sheet (as documents did) or can be width-capped and truncated; differentials, services, factsheets and prescribing pass a single select and are the cheapest candidates. **Stop:** do not flip the default to `inline` — a new mode that forgets the prop would then degrade to an unusable layout rather than to today's. | claude/top-search-design-mockups-fbbfuf; 320px sweep 2026-08-04 | 2026-08-04 | | #059 | P1 | task | Verify containment of every credential reported exposed in chat | **Outcome:** every reported exposed credential is rejected or retired. **Next:** in approved security windows, verify and revoke or rotate the GitHub token, OpenAI key, Supabase service-role JWT, database password, and E2E credential; create replacements only when required and update only intended secret stores. **Success:** provider evidence confirms the old credentials cannot authenticate, replacements are distinct and minimally scoped, presence/readiness checks pass, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never print or paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation; AI Agent Target Manifest | 2026-07-24 | | #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 | | #053 | P1 | task | Execute cross-border privacy/legal package | Execute OpenAI and Railway DPAs; decide ZDR and Australian data residency; obtain prompt-cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not represent the release as privacy-approved or alter final public privacy wording before sign-off. | `docs/openai-cross-border-basis.md`; `docs/privacy-impact-assessment.md` | 2026-07-24 | @@ -294,7 +292,7 @@ removed after current-main verification; it is not missing recommended work. | #250 | P2 | task | Execute the fastest-wins multi-wave plan (Wave 0–4) | **Outcome:** the 2026-08-04/05 fastest-wins plan is executed with parallel agents, correct gates, and no regression. **Acuity first:** the recommended queue remains acuity-ordered — A1 rows (#059/#053/#231/#207/#226) are not demoted by wave numbers. Operator-track items (#022/#183) retain their A2 priority and approval gates. **A1 track (parallel, not a wave):** run approved A1 work (#059/#053/#231/#207/#226) whenever capability/approvals allow — do not wait for Waves 0–3. **Engineering waves only:** 0 = ledger/process gates (#201 resolved 2026-08-06; continue with remaining #202 work); 1A = #147+#176 phone CLS; 1B = #149+#167+#204+#210 gate integrity; 1C = hygiene/docs #232–#236+#223+#157+#151/#154/#187; 2 = #117 then #118; 3 = #098 then #189 (defer #099 body); 4 = remaining non-A1 clinical/UI packaging once approvals exist (never a holding pen for A1 items). **Next:** continue Wave 0/#202 on a fresh branch off origin/main in parallel with any approved A1 work; use gates + verification-router per PR; respect #155 concurrency. **Stop:** no RAG behaviour without flag+canary; no provider gates without approval; do not mix operationalRisk with clinical/UI in one squash. | session 2026-08-05 fastest-wins plan | 2026-08-05 | | #251 | P3 | rec | Handoff checklist should pair gates skill with verification-router | **Outcome:** every PR handoff picks the smallest correct gate and pastes the decisive proof line, using verification-router when scope is unclear. **Next:** add one line to handoff/gates productivity defaults: after flightplan, run verification-router (or gates) before claiming green; never report exit 0 alone. **Stop:** do not stack verify:cheap + verify:ui + verify:release by default. | session 2026-08-05 fastest-wins plan | 2026-08-05 | | #252 | P2 | issue | check:bundle-budget counts mockup chunks, contradicting #013's initial-bundle position | The budget's totalGzipBytes comes from measureChunkPaths(walkJsFiles(CHUNKS_DIR)) — EVERY built client chunk, including routes that 404 in production. The manifest-scoped initialDashboardChunks set is used only for the fixture-payload assertion, not the budget. So two repo positions disagree about mockups and nothing says so: #013 records that mockup chunks 'are not an initial production bundle' and must not be restructured without deploy-artifact evidence, while the gate charges them against a repo-wide ceiling. PR #1580 is the live cost — a mockups-only PR blocked on 'FAIL +10.1% vs baseline (tolerance 10%)' for chunks no user can load; it has sat red and unmerged since 2026-08-02. Docs now state the mechanism (AGENTS.md gate bullet, CLAUDE.md mockups bullet) so it stops being a surprise, but the metric decision is unmade. Next action: pick one and make the script say so — (a) exclude mockup-only chunks from totalGzipBytes so the number means production weight, which matches #013 but removes all back-pressure on mockup growth (59 routes on main today, 4 more in open PRs); or (b) keep counting them, rename the reported metric so it does not read as production bundle weight, and treat the tolerance as a deliberate hygiene ceiling. Option (b) additionally wants the mockup share reported separately, which is the measurement #013 asks for before any prune. Stop: do not raise the tolerance or run --update to clear #1580 — that discards the only back-pressure without deciding anything. Renumbered from this PR's original #249 → #252 because main claimed #249–#251 via PR #1624. | session 2026-08-05 open-PR review; PR #1580 Build log; scripts/check-bundle-budget.mjs; ledger #013 | 2026-08-05 | -| #253 | P3 | task | #1606 needs a hand-merge against merged PR #1615, not a rebase | MobileResultFilterControl's native — its change is the iOS 16px anti-zoom rule — so #1606's blue-highlight fix does not exist on main today. #1606 is still open (verified 2026-08-05, not closed) and reports mergeable_state 'dirty' against main because both PRs rewrote the same function with different designs; resolving it needs a hand-merge of the two implementations, not a rebase and not a close-and-redo. Two things to re-verify on the current head before merging: (1) the P2 from Codex review — ArrowDown/ArrowUp previously focused the current placeholder option even when disabled (Services' 'current', Formulation's 'Current search'), stranding keyboard users since every option was tabIndex=-1; the branch is reported to have since fixed this, but confirm on the exact head being merged. (2) its prior lint error, react-hooks/set-state-in-effect at search-results-header-band.tsx:670 (setMenuBox(null) synchronously inside useLayoutEffect), which PR #1620's new pre-push guard now catches before push. Also re-check the 3 Playwright failures previously seen on ui-stress and ui-tools single-line badge assertions against the current implementation. Stop: do not close #1606 to route around the conflict — it is the only open PR carrying this accessibility fix. Renumbered from this PR's original #250 → #253 because main claimed #249–#251 via PR #1624. | session 2026-08-05 open-PR review sweep; PR #1606 (open, dirty, verified live); Codex review thread on search-results-header-band.tsx:696 | 2026-08-05 | +| #253 | P3 | task | #1606 needs a hand-merge against merged PR #1615, not a rebase | SUPERSEDED IN PART 2026-08-07: the component both PRs rewrite no longer exists. `MobileResultFilterControl` — the native `` on + * phones. That control cost a mode its whole second band line, could not show + * more than one filter dimension without a two-column grid of selects, could not + * report how many filters were active without spending label width on it, and — + * because `globals.css` pins every native select to 16px below `sm` to stop iOS + * zooming on focus — rendered its value at the same size as the query heading it + * sat under. Documents replaced it with a badged trigger that opens a sheet, the + * band collapsed to one line, and that is the design every mode now uses. + * + * Two pieces: + * - `ResultFilterTrigger` — the compact badged control that goes in the ribbon's + * `mobileControls` slot (with `mobileControlsPlacement="inline"`, which is what + * makes the one-line band legal — see `search-results-header-band.tsx`). + * - `ResultFilterSheet` — a single-choice sheet for the modes whose filters are + * one-of-N per dimension. Documents keeps its own panel: its filters are + * multi-select facet groups with counts, a find-a-filter field and + * collapse-by-default, none of which a radio sheet can express. + * + * Desktop is untouched. The ribbon renders `filterControls` from `sm` up and + * `mobileControls` below it, never both, so each mode keeps the chip row or tab + * strip it already had on a wide screen. + */ + +export type ResultFilterOption = { + value: Value; + label: string; + /** Trailing detail — a count, a qualifier. Never the only thing distinguishing two options. */ + hint?: string; + /** Renders as a dead end: focusable and explained, but not selectable. */ + disabled?: boolean; +}; + +export type ResultFilterGroup = { + /** Stable within one sheet; used for the group's own labelling ids. */ + id: string; + label: string; + value: string; + options: ReadonlyArray>; + onChange: (value: string) => void; +}; + +/** + * Builds a type-checked group for `ResultFilterSheet`. + * + * The sheet holds groups of different value unions in one array, which no single + * generic parameter can express. This narrows at the call site — `value`, + * `options[].value` and `onChange` are checked against one `Value` — and erases + * once. The erasure is sound because the sheet only ever invokes `onChange` with + * a value taken from that same group's `options`, so nothing outside `Value` can + * reach the callback. + */ +export function resultFilterGroup(group: { + id: string; + label: string; + value: Value; + options: ReadonlyArray>; + onChange: (value: Value) => void; +}): ResultFilterGroup { + return { + id: group.id, + label: group.label, + value: group.value, + options: group.options, + // The one narrowing, isolated here rather than repeated at seven call sites. + onChange: (value) => group.onChange(value as Value), + }; +} + +/** + * Opens a filter panel and reports how many filters are active. + * + * Faithfully the control documents shipped, lifted here so seven modes cannot + * drift apart. Written out rather than composed from `floatingControl`: this is + * the band's own control recipe — the same one `Save search` and `Retry` use — so + * a trigger sitting flush against the sort group is structurally the same + * component rather than a near-match. + */ +export function ResultFilterTrigger({ + panelId, + testId, + open, + activeCount, + onToggle, + title, + label = "Filter", +}: { + panelId: string; + /** Distinct per slot when a page renders the trigger more than once: both + copies are in the DOM, so a shared id makes every `getByTestId` lookup + ambiguous under Playwright strict mode even though only one is displayed. */ + testId: string; + open: boolean; + activeCount: number; + onToggle: () => void; + /** Pointer tooltip, e.g. "Filter services". The accessible name comes from the + visible label plus the state note below, so this is decoration. */ + title: string; + label?: string; +}) { + return ( + + ); +} + +/** + * A single radio group inside the filter sheet. + * + * Implements the roving-tabIndex pattern (Arrow keys + Home/End, single tab + * stop) so the group behaves like a real radio group for keyboard users — + * consistent with `SegmentedControl` and matching the `role="radiogroup"` it + * exposes to AT. Announcing "radio, 1 of 4" and then not answering an arrow key + * would be worse than exposing no role at all, because the role is what promises + * the interaction. + * + * Arrow keys select as they move, which is the ARIA default and also what the + * native ` onChange(event.target.value as Value)} - aria-label={ariaLabel} - className="h-tap min-w-0 flex-1 cursor-pointer appearance-none truncate bg-transparent text-xs font-semibold text-[color:var(--text)] outline-none [-webkit-appearance:none]" - > - {options.map((option) => ( - - ))} - - - - ); -} - /** * The recovery actions on the no-results panel. * diff --git a/src/components/document-viewer/non-pdf-source-preview.tsx b/src/components/document-viewer/non-pdf-source-preview.tsx index 738e8dd95f..28aafcb9ec 100644 --- a/src/components/document-viewer/non-pdf-source-preview.tsx +++ b/src/components/document-viewer/non-pdf-source-preview.tsx @@ -3,8 +3,9 @@ /* eslint-disable @next/next/no-img-element */ import { memo, useEffect, useId, useRef, useState } from "react"; -import { CircleAlert, Download, ExternalLink, FileText, RefreshCw } from "lucide-react"; +import { CircleAlert, Download, ExternalLink, FileText, Maximize2, RefreshCw } from "lucide-react"; +import { ImageLightbox } from "@/components/clinical-dashboard/image-lightbox"; import { cn, floatingControl } from "@/components/ui-primitives"; import { announce } from "@/components/ui/live-announcer"; @@ -17,7 +18,7 @@ const placeholderSurface = * Inline preview for non-PDF source documents. * * PDFs render in PdfCanvasViewer/NativePdfEmbed; everything else lands here: - * - image/* → the source image inline (native browser view for full-size/zoom), + * - image/* → inline stage + shared ImageLightbox (same gestures as rail crops), * - text/* → a pointer to the already-extracted indexed text below, * - other (DOCX/XLSX/…) → an honest "download to view" affordance, * - no signed URL yet → the original placeholder. @@ -92,9 +93,8 @@ export const NonPdfSourcePreview = memo(function NonPdfSourcePreview({ /** * Inline image with a failure fallback. The source is a direct signed URL owned - * by the parent (not a re-fetchable endpoint), so on an expired/broken URL it - * surfaces the same Open/Download recovery affordance rather than a silently - * broken . + * by the parent (not a re-fetchable endpoint). Primary viewing opens the shared + * ImageLightbox in URL mode; Open/Download remain secondary recovery affordances. */ function InlineImagePreview({ signedUrl, @@ -106,8 +106,10 @@ function InlineImagePreview({ title: string; }) { const [failed, setFailed] = useState(false); + const [lightboxOpen, setLightboxOpen] = useState(false); const announcementSourceId = useId(); const failureTransition = useRef(0); + const triggerRef = useRef(null); useEffect(() => { if (!failed) return; @@ -153,21 +155,60 @@ function InlineImagePreview({ } return ( -
-
+
+
{title} setFailed(true)} - className="absolute inset-0 h-full w-full object-contain" + className="mx-auto max-h-[min(70vh,36rem)] w-full object-contain" /> +
- - +
+ + + + {downloadSignedUrl ? ( + + + ) : null} +
+ setLightboxOpen(false)} + url={signedUrl} + alt={title} + returnFocusRef={triggerRef} + />
); } diff --git a/src/components/factsheets/factsheets-search-page.tsx b/src/components/factsheets/factsheets-search-page.tsx index b8252f4190..2af3427e86 100644 --- a/src/components/factsheets/factsheets-search-page.tsx +++ b/src/components/factsheets/factsheets-search-page.tsx @@ -3,12 +3,14 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { ChevronRight, Info, LayoutGrid, List, SearchX } from "lucide-react"; -import { useState } from "react"; +import { useId, useState } from "react"; +import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { - MobileResultFilterControl, - SearchResultsHeaderBand, -} from "@/components/clinical-dashboard/search-results-header-band"; + ResultFilterSheet, + ResultFilterTrigger, + resultFilterGroup, +} from "@/components/clinical-dashboard/result-filter-control"; import { categoryTheme, factsheetCategories, @@ -45,6 +47,8 @@ export function FactsheetsSearchPage({ const router = useRouter(); const [view, setView] = useState("list"); const activeCategory = factsheetCategories.find((entry) => entry === category); + const filterPanelId = useId(); + const [filterOpen, setFilterOpen] = useState(false); return (
} filterLabel="Filter factsheets by category" + // A compact badged trigger, so it shares the count line. + mobileControlsPlacement="inline" mobileControls={ - ({ value: chip.key ?? "all", label: chip.label }))} - onChange={(value) => router.push(searchHref(query, value === "all" ? undefined : value))} + setFilterOpen((current) => !current)} /> } filterControls={ @@ -132,6 +138,39 @@ export function FactsheetsSearchPage({ } /> + {/* Phone-only by construction: the trigger that opens it lives in the + ribbon's `mobileControls` slot, which the band hides from `sm` up. + Selecting a category is a navigation here, so the sheet closes with the + push — leaving it open would float over a page it no longer describes. */} + setFilterOpen(false)} + panelId={filterPanelId} + testId="factsheet-filter-panel" + title="Filter factsheets" + groups={[ + resultFilterGroup({ + id: "category", + label: "Category", + value: activeCategory ?? "all", + options: filterChips.map((chip) => ({ value: chip.key ?? "all", label: chip.label })), + onChange: (value) => { + setFilterOpen(false); + router.push(searchHref(query, value === "all" ? undefined : value)); + }, + }), + ]} + onClearAll={ + activeCategory + ? () => { + setFilterOpen(false); + router.push(searchHref(query)); + } + : undefined + } + footerNote={`${results.length} showing`} + /> + {results.length === 0 ? (
diff --git a/src/components/formulation/formulation-home-page.tsx b/src/components/formulation/formulation-home-page.tsx index 4206f7090c..adc780d96e 100644 --- a/src/components/formulation/formulation-home-page.tsx +++ b/src/components/formulation/formulation-home-page.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useMemo, useState, useDeferredValue } from "react"; +import { useId, useMemo, useState, useDeferredValue } from "react"; import { ArrowRight, CheckCircle2, ChevronRight, GitCompareArrows, ListChecks, Network, Search } from "lucide-react"; import { @@ -14,10 +14,12 @@ import { } from "@/components/formulation/formulation-ui"; import { ClinicalPathwayStrip } from "@/components/clinical-record-panels"; import { ModeHomeMain, ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; +import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { - MobileResultFilterControl, - SearchResultsHeaderBand, -} from "@/components/clinical-dashboard/search-results-header-band"; + ResultFilterSheet, + ResultFilterTrigger, + resultFilterGroup, +} from "@/components/clinical-dashboard/result-filter-control"; import { cn, eyebrowText } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; import { @@ -139,6 +141,8 @@ function EmptySearchResults({ query }: { query: string }) { function FormulationResults({ query }: { query: string }) { const router = useRouter(); const [domain, setDomain] = useState("all"); + const filterPanelId = useId(); + const [filterOpen, setFilterOpen] = useState(false); const deferredQuery = useDeferredValue(query); const rankingReady = deferredQuery === query; const results = useMemo(() => { @@ -165,36 +169,18 @@ function FormulationResults({ query }: { query: string }) { status={rankingReady ? "ready" : "refetching"} headingLevel={1} filterLabel="Filter formulation mechanisms" + // One compact badged trigger replaces the two-column grid of selects, so + // the band collapses to one line here too. + mobileControlsPlacement="inline" mobileControls={ -
- ({ - value: preset.query, - label: preset.label, - })), - ]} - onChange={(value) => { - if (value !== "current") router.push(presetHref(value)); - }} - /> - ({ value: item, label: item })), - ]} - onChange={setDomain} - /> -
+ setFilterOpen((current) => !current)} + /> } filterControls={
@@ -228,6 +214,51 @@ function FormulationResults({ query }: { query: string }) { } /> + {/* Phone-only by construction: the trigger that opens it lives in the + ribbon's `mobileControls` slot, which the band hides from `sm` up. Two + groups here, which is the whole reason this stopped being a select — + two dimensions used to mean two side-by-side controls in a 320px line. */} + setFilterOpen(false)} + panelId={filterPanelId} + testId="formulation-filter-panel" + title="Filter formulation mechanisms" + groups={[ + resultFilterGroup({ + id: "pattern", + label: "Pattern", + // A pattern runs a new search rather than narrowing this one, so the + // selected entry is always the placeholder naming where you are. + value: "current", + options: [ + { value: "current", label: "Current search", disabled: true }, + ...formulationSearchPresets.slice(0, 4).map((preset) => ({ + value: preset.query, + label: preset.label, + })), + ], + onChange: (value) => { + if (value === "current") return; + setFilterOpen(false); + router.push(presetHref(value)); + }, + }), + resultFilterGroup({ + id: "domain", + label: "Domain", + value: domain, + options: [ + { value: "all", label: "All domains" }, + ...formulationDomains.map((item) => ({ value: item, label: item })), + ], + onChange: setDomain, + }), + ]} + onClearAll={domain === "all" ? undefined : () => setDomain("all")} + footerNote={`${results.length} showing`} + /> +

Matches use patient language, clinical clues, domains, symptoms, and formulation context. Open a mechanism to test fit and competing explanations. diff --git a/src/components/privacy-live-signal-variants-mockups.tsx b/src/components/privacy-live-signal-variants-mockups.tsx new file mode 100644 index 0000000000..30304f4718 --- /dev/null +++ b/src/components/privacy-live-signal-variants-mockups.tsx @@ -0,0 +1,872 @@ +"use client"; + +import { ArrowLeft, ChevronDown, List, MapPin, ShieldAlert } from "lucide-react"; +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; + +import { cn, eyebrowText } from "@/components/ui-primitives"; + +/** + * Live Signal — three perfected variants (no phone top number navigation). + * + * Shared DNA with `/mockups/privacy-live-signal-perfected`: + * - Full-bleed sticky Important (amber obligation only) + * - One-open accordion with scannable gists + * - Processing map as instrument strip + * - Desktop signal index + * - Production governance headings + bodies + * + * Removed from all three: phone sticky jump chips (01 Tool / 02 Collected…). + * + * Variant A — Quiet scroll: sticky chrome is header + Important only; phone + * navigates by scrolling the accordion. + * Variant B — Region-fused: compact region ticker locks into the sticky stack + * under Important (still no number chips). + * Variant C — Contents card: in-flow "On this page" card with text labels only + * (no numbers) sits in the scroll body, not the sticky top. + */ + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +const DRAFT = + "This is draft product information based on the repository's configured behaviour. It is not legal advice, a final privacy policy, or an assertion of governance approval."; + +const IMPORTANT_SHORT = + "Do not enter identifiable patient details. Processing may include Singapore and the OpenAI API."; + +const IMPORTANT_FULL = + "Do not enter identifiable patient details such as names, dates of birth, or record numbers. Requests are processed by the application service in Singapore. With external provider mode configured, question text may be sent to the OpenAI API for retrieval embedding even when the final response is source-only; model-backed answer synthesis also sends the question and selected evidence."; + +type Section = { + heading: string; + short: string; + gist: string; + body: string; +}; + +const SECTIONS: Section[] = [ + { + heading: "What this tool is", + short: "Tool", + gist: "Clinical reference KB — not a patient-record system", + body: "Clinical KB is a knowledge base over clinical reference material. It is not a patient-record system and its provider-backed features do not ask for patient identifiers. The Safety Plan Generator accepts sensitive working content and support contacts but deliberately omits a patient-identifier field.", + }, + { + heading: "What is collected", + short: "Collected", + gist: "Questions, docs, telemetry — safety-plan work stays in-tab", + body: "Questions, generated answers, account identifiers, uploaded documents, retrieved excerpts, document metadata, and operational or retrieval telemetry may be processed. Free text and uploaded material can contain sensitive information if you enter it. Safety-plan working content is different: it remains in the current browser tab and is not sent to the application service or stored by Clinical KB.", + }, + { + heading: "How questions are handled", + short: "Questions", + gist: "Hashed query logs · answers omitted · short cache TTL", + body: "Raw question text is not written to query logs by default; logs use a keyed one-way hash. Generated answer text is also omitted from durable query logs by default. A short-lived response cache can contain the answer while its read TTL is valid.", + }, + { + heading: "Where data is stored and processed", + short: "Regions", + gist: "Sydney storage · Singapore app + worker", + body: "Documents, extracted evidence, metadata, account records, and owner-scoped operational records are stored in the configured Supabase project in Sydney. The production application and ingestion worker currently run on Railway in Singapore, so questions, retrieved evidence, answers, and ingestion material are processed in or transit through Singapore. File buckets are private and links are time-limited. The operator must verify deployed regions and contractual controls.", + }, + { + heading: "External provider processing", + short: "Providers", + gist: "OpenAI embedding / synthesis may leave Australia", + body: "When external provider mode is configured, question text may be sent to the OpenAI API to create a retrieval embedding, including when the final response is source-only. When model-backed answer synthesis is used, the question and selected source excerpts are also sent. This processing may occur outside Australia. The operator must verify provider regions, retention terms, contracts, and cross-border obligations.", + }, + { + heading: "Retention", + short: "Retention", + gist: "30-day queries · 90-day logs · hourly cache purge", + body: "Repository migrations configure 30-day retention for RAG query records, 90-day retention for retrieval logs and query-miss telemetry, and a bounded hourly purge of expired response-cache rows when the database scheduler is available. The operator must verify that those scheduled jobs are active. Uploaded documents remain until removed under the applicable process. Safety-plan working content has no Clinical KB retention: it is discarded when the component is cleared or the tab is closed. Clipboard, print, and PDF copies are outside the app and must follow the organisation's approved record-handling process.", + }, + { + heading: "Your responsibilities", + short: "You", + gist: "No identifiers · verify sources · report issues", + body: "Do not enter patient-identifiable information. In the Safety Plan Generator, add any patient identifier only after export through your organisation's approved clinical-record process. Upload only material you are authorised to use, keep access credentials private, review original linked sources before relying on clinical output, and report suspected privacy or access issues through your organisation's approved process.", + }, +]; + +type VariantId = "quiet-scroll" | "region-fused" | "contents-card"; + +const VARIANTS: Array<{ + id: VariantId; + number: string; + name: string; + verdict: string; + summary: string; + strengths: string[]; +}> = [ + { + id: "quiet-scroll", + number: "A", + name: "Quiet scroll", + verdict: "Sticky = header + Important only", + summary: + "Phone drops the top number chip row entirely. Sticky chrome is back + brand + Live Signal + Important. Section discovery is the accordion itself — denser gists, stronger open spine.", + strengths: ["Least chrome", "No top number nav", "Accordion is the map"], + }, + { + id: "region-fused", + number: "B", + name: "Region-fused", + verdict: "Regions lock into sticky stack", + summary: + "Still no number chips. A compact processing strip fuses under Important so Sydney / Singapore / External never scroll away — the obligation and the map stay co-located.", + strengths: ["Regions always visible", "No top number nav", "Instrument chrome"], + }, + { + id: "contents-card", + number: "C", + name: "Contents card", + verdict: "In-flow text TOC — no sticky chips", + summary: + "Sticky stays header + Important. An in-flow “On this page” card with text labels only (no numbers) sits in the scroll body; tap opens a section without a top chip rail.", + strengths: ["Text-only contents", "No top number nav", "Desktop index kept"], + }, +]; + +const atmosphere = + "bg-[radial-gradient(ellipse_at_8%_-8%,color-mix(in_srgb,var(--warning-bg)_78%,transparent),transparent_34%),radial-gradient(ellipse_at_92%_4%,color-mix(in_srgb,var(--clinical-accent-soft)_48%,transparent),transparent_42%),linear-gradient(180deg,color-mix(in_srgb,var(--surface)_55%,transparent),transparent_18rem),var(--background)]"; + +function findScrollContainer(start: HTMLElement | null): HTMLElement | null { + let node: HTMLElement | null = start?.parentElement ?? null; + while (node && node !== document.documentElement) { + const { overflowY } = getComputedStyle(node); + if ( + (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") && + node.scrollHeight > node.clientHeight + 1 + ) { + return node; + } + node = node.parentElement; + } + return null; +} + +function scrollContainerToTarget(container: HTMLElement, target: HTMLElement, stickyChrome: HTMLElement | null) { + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const chromeHeight = + stickyChrome?.getBoundingClientRect().height ?? (Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0); + container.scrollTop += targetRect.top - containerRect.top - chromeHeight; +} + +function BrandMark({ size = "sm" }: { size?: "sm" | "md" }) { + return ( + + ); +} + +function BackControl() { + return ( + + ); +} + +function StatusBar() { + return ( +

+ ); +} + +function LiveDot() { + return ( + + + + + ); +} + +function RegionTicker({ + compact = false, + fused = false, +}: { + compact?: boolean; + /** When true, strip sits inside sticky chrome — tighter padding, no section eyebrow. */ + fused?: boolean; +}) { + const cells = [ + { place: "Sydney", role: "Supabase storage", tone: "accent" as const }, + { place: "Singapore", role: "App + worker", tone: "neutral" as const }, + { place: "External", role: "OpenAI API", tone: "warn" as const }, + ]; + + if (fused) { + return ( +
+ {cells.map((cell) => ( + + + ))} +
+ ); + } + + return ( +
+
+

+ Processing map +

+ {!compact ? ( +

Operator must verify regions

+ ) : null} +
+ {compact ? ( +
+ {cells.map((cell) => ( + + {cell.place} + · + {cell.role} + + ))} +
+ ) : ( +
+ {cells.map((cell, index) => ( +
0 && "border-l border-[color:var(--border)]")} + > +

+ {cell.place} +

+

+ {cell.role} +

+
+ ))} +
+ )} +
+ ); +} + +function StickySignalChrome({ + phone, + noticeOpen, + onToggleNotice, + noticePanelId, + fuseRegions, + chromeRef, +}: { + phone: boolean; + noticeOpen: boolean; + onToggleNotice: () => void; + noticePanelId: string; + fuseRegions: boolean; + chromeRef: React.RefObject; +}) { + return ( +
+
+
+ + +
+
+

Privacy

+ + + Live signal + +
+

+ Privacy & data handling +

+
+ + Draft + +
+
+ +
+
+
+ +
+ + {/* Variant B only: regions fuse into sticky chrome. No number chips on any variant. */} + {fuseRegions ? : null} +
+ ); +} + +function ContentsCard({ + openId, + onSelectSection, + phone, +}: { + openId: string; + onSelectSection: (heading: string) => void; + phone: boolean; +}) { + return ( +
+
+ + +
+

+ On this page +

+

+ Jump by name — no numbered chip rail +

+
+
+ +
+ ); +} + +function SectionAccordion({ + phone, + openId, + setOpenId, + expandAll, + sectionRefs, + idPrefix, + denser, +}: { + phone: boolean; + openId: string; + setOpenId: (id: string) => void; + expandAll: boolean; + sectionRefs: React.RefObject>; + idPrefix: string; + denser?: boolean; +}) { + return ( +
+ {SECTIONS.map((section, index) => { + const expanded = expandAll || openId === section.heading; + const panelId = `${idPrefix}-section-${index}`; + return ( +
{ + sectionRefs.current[section.heading] = node; + }} + className={cn( + // Clear sticky chrome without phone chip strip — shorter scroll-mt than perfected chips build. + phone ? "scroll-mt-[9.5rem]" : "scroll-mt-[10.5rem]", + index > 0 && "border-t border-[color:var(--border)]", + )} + > + + +
+ ); + })} +
+ ); +} + +function LiveSignalVariantFrame({ phone = false, variant }: { phone?: boolean; variant: VariantId }) { + const noticeId = useId(); + const sectionIdPrefix = useId(); + const sectionRefs = useRef>({}); + const stickyChromeRef = useRef(null); + const pendingScrollHeading = useRef(null); + const [noticeOpen, setNoticeOpen] = useState(false); + const [openId, setOpenId] = useState(SECTIONS[0]?.heading ?? ""); + const [expandAll, setExpandAll] = useState(false); + const [stickyChromeHeightPx, setStickyChromeHeightPx] = useState(null); + + const fuseRegions = variant === "region-fused"; + const showContentsCard = variant === "contents-card"; + const denser = variant === "quiet-scroll"; + + useEffect(() => { + const el = stickyChromeRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const update = () => { + setStickyChromeHeightPx(el.getBoundingClientRect().height); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, [noticeOpen, phone, fuseRegions]); + + const scrollSectionIntoView = (heading: string) => { + const target = sectionRefs.current[heading]; + if (!target) return; + const container = findScrollContainer(target); + if (!container) return; + scrollContainerToTarget(container, target, stickyChromeRef.current); + }; + + const selectSection = (heading: string) => { + if (!expandAll && openId === heading) { + pendingScrollHeading.current = null; + scrollSectionIntoView(heading); + return; + } + pendingScrollHeading.current = heading; + setExpandAll(false); + setOpenId(heading); + }; + + useLayoutEffect(() => { + const heading = pendingScrollHeading.current; + if (!heading || openId !== heading || expandAll) return; + pendingScrollHeading.current = null; + scrollSectionIntoView(heading); + }, [openId, expandAll]); + + return ( +
+ setNoticeOpen((value) => !value)} + noticePanelId={noticeId} + fuseRegions={fuseRegions} + chromeRef={stickyChromeRef} + /> + +
+ {!phone ? ( + + ) : null} + +
+

{DRAFT}

+ {showContentsCard ? : null} + {/* Region-fused already shows regions in sticky chrome; skip the body map on phone to avoid twin strips. */} + {!(fuseRegions && phone) ? : null} + { + setExpandAll(false); + setOpenId(id); + }} + expandAll={!phone && expandAll} + sectionRefs={sectionRefs} + denser={denser} + /> +
+
+
+ ); +} + +function DeviceChrome({ + label, + widthLabel, + phone = false, + children, +}: { + label: string; + widthLabel: string; + phone?: boolean; + children: React.ReactNode; +}) { + return ( +
+
+ + {label} + + {widthLabel} +
+
+ {phone ? : null} +
{children}
+
+
+ ); +} + +export function PrivacyLiveSignalVariantsMockups() { + return ( +
+
+
+

+ Privacy · Live Signal · no top number nav +

+

+ Live Signal — three perfected variants +

+

+ Same Live Signal craft as the selected direction, with the phone top number chip row removed. Amber stays + for Important only. Governance wording unchanged. +

+
    + {[ + "No phone number chips", + "Sticky Important kept", + "Desktop signal index kept", + "Three perfected forks", + ].map((item) => ( +
  • + {item} +
  • + ))} +
+
+
+ +
+ {VARIANTS.map((variant) => ( +
+
+
+ + {variant.number} + +
+
+

+ {variant.name} +

+ + {variant.verdict} + +
+

{variant.summary}

+
+
+
+ {variant.strengths.map((strength) => ( + + {strength} + + ))} +
+
+ +
+ + + + + + +
+
+ ))} +
+
+ ); +} diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 1e05c2e792..66b7c537b6 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -19,17 +19,21 @@ import { X, type LucideIcon, } from "lucide-react"; -import { useMemo, useState, useDeferredValue } from "react"; +import { useId, useMemo, useState, useDeferredValue } from "react"; import { cn } from "@/components/ui-primitives"; import { Chip as DesignChip, type ChipStatusTone } from "@/components/ui/chip"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; import { - MobileResultFilterControl, SearchResultsEmptyState, SearchResultsHeaderBand, SearchResultsSkeleton, } from "@/components/clinical-dashboard/search-results-header-band"; +import { + ResultFilterSheet, + ResultFilterTrigger, + resultFilterGroup, +} from "@/components/clinical-dashboard/result-filter-control"; import { appModeHomeHref } from "@/lib/app-modes"; import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; @@ -594,6 +598,8 @@ export function ServicesNavigatorPage() { const activeQuickFilter = serviceQuickFilters.find( (filter) => filter.query.toLowerCase() === query.trim().toLowerCase(), ); + const filterPanelId = useId(); + const [filterOpen, setFilterOpen] = useState(false); return ( ({ value: filter.query, label: filter.label })), - ]} - onChange={(value) => { - if (value !== "current") applyServiceQuery(value); - }} + setFilterOpen((current) => !current)} /> } filterControls={ @@ -697,6 +698,55 @@ export function ServicesNavigatorPage() {
} /> + {/* Phone-only by construction: the trigger that opens it lives in the + ribbon's `mobileControls` slot, which the band hides from `sm` up. + A quick filter rewrites the query rather than narrowing a result + set, so applying one closes the sheet — the list underneath is a + different search by the time it settles. */} + setFilterOpen(false)} + panelId={filterPanelId} + testId="service-filter-panel" + title="Filter services" + description="Quick filters run a new service search." + groups={[ + resultFilterGroup({ + id: "quick-filter", + label: "Quick filters", + value: activeQuickFilter?.query ?? "current", + options: [ + // The placeholder is only offered while nothing is applied, and + // it is `disabled` so it cannot be chosen — it names the state + // the reader is already in rather than an action. + ...(activeQuickFilter + ? [] + : [ + { + value: "current", + label: query.trim() ? "Current search" : "All services", + disabled: true, + }, + ]), + ...serviceQuickFilters.map((filter) => ({ value: filter.query, label: filter.label })), + ], + onChange: (value) => { + if (value === "current") return; + setFilterOpen(false); + applyServiceQuery(value); + }, + }), + ]} + onClearAll={ + activeQuickFilter + ? () => { + setFilterOpen(false); + setLocalQuery({ urlQuery, value: "" }); + } + : undefined + } + footerNote={`${displayedMatches.length} showing`} + /> } sidebar={ diff --git a/src/components/specifiers/specifiers-home-page.tsx b/src/components/specifiers/specifiers-home-page.tsx index 6921067eca..0e967c0eef 100644 --- a/src/components/specifiers/specifiers-home-page.tsx +++ b/src/components/specifiers/specifiers-home-page.tsx @@ -1,15 +1,17 @@ "use client"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { ArrowRight, ChevronRight, GitCompareArrows, ListChecks, Search, Tags } from "lucide-react"; import { ClinicalPathwayStrip } from "@/components/clinical-record-panels"; import { ModeHomeMain, ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; +import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { - MobileResultFilterControl, - SearchResultsHeaderBand, -} from "@/components/clinical-dashboard/search-results-header-band"; + ResultFilterSheet, + ResultFilterTrigger, + resultFilterGroup, +} from "@/components/clinical-dashboard/result-filter-control"; import { CategoryTag, ReviewStatusBadge, @@ -199,6 +201,8 @@ function SpecifierCatalogueMatches({ matches }: { matches: SpecifierCatalogMatch function SpecifierResults({ query }: { query: string }) { const [family, setFamily] = useState<"all" | SpecifierFamily>("all"); const [diagnosis, setDiagnosis] = useState(""); + const filterPanelId = useId(); + const [filterOpen, setFilterOpen] = useState(false); const results = useMemo(() => searchSpecifiers(query, { family, diagnosis }), [diagnosis, family, query]); // The full-catalogue section is additive and diagnosis-specific, so it is NOT // de-duped against the curated cards: those are generic mood-only specifiers, and @@ -219,25 +223,18 @@ function SpecifierResults({ query }: { query: string }) { matchCount={totalMatches} headingLevel={1} filterLabel="Filter specifier results" + // One compact badged trigger replaces the two-column grid of selects, so + // the band collapses to one line here too. + mobileControlsPlacement="inline" mobileControls={ -
- ({ value: option.id, label: option.shortLabel }))} - onChange={setFamily} - /> - -
+ setFilterOpen((current) => !current)} + /> } filterControls={
@@ -246,6 +243,42 @@ function SpecifierResults({ query }: { query: string }) {
} /> + {/* Phone-only by construction: the trigger that opens it lives in the + ribbon's `mobileControls` slot, which the band hides from `sm` up. Both + dimensions narrow the same list, so the badge counts them independently + — unlike formulation, where one group is a new search. */} + setFilterOpen(false)} + panelId={filterPanelId} + testId="specifier-filter-panel" + title="Filter specifiers" + groups={[ + resultFilterGroup({ + id: "family", + label: "Family", + value: family, + options: specifierFamilies.map((option) => ({ value: option.id, label: option.shortLabel })), + onChange: setFamily, + }), + resultFilterGroup({ + id: "diagnosis", + label: "Diagnosis", + value: diagnosis, + options: diagnosisOptions, + onChange: setDiagnosis, + }), + ]} + onClearAll={ + family === "all" && diagnosis === "" + ? undefined + : () => { + setFamily("all"); + setDiagnosis(""); + } + } + footerNote={`${totalMatches} showing`} + /> {totalMatches === 0 ? ( diff --git a/tests/document-viewer-non-pdf-preview.dom.test.tsx b/tests/document-viewer-non-pdf-preview.dom.test.tsx index dc0fb97d4d..768dbd49f0 100644 --- a/tests/document-viewer-non-pdf-preview.dom.test.tsx +++ b/tests/document-viewer-non-pdf-preview.dom.test.tsx @@ -1,9 +1,20 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { NonPdfSourcePreview } from "@/components/document-viewer/non-pdf-source-preview"; import { LiveAnnouncer, resetAnnouncerForTests } from "@/components/ui/live-announcer"; +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => ({ + status: "authenticated", + session: { user: { id: "user-a" } }, + authorizationHeader: { Authorization: "Bearer user-a" }, + markSessionExpired: vi.fn(), + registerAuthRequest: vi.fn(() => ({ epoch: 1, release: vi.fn() })), + isAuthEpochCurrent: vi.fn(() => true), + }), +})); + describe("DocumentViewer non-PDF image preview", () => { beforeEach(() => resetAnnouncerForTests()); @@ -12,6 +23,29 @@ describe("DocumentViewer non-PDF image preview", () => { vi.useRealTimers(); }); + it("opens the shared immersive lightbox from the primary expand control", async () => { + render( + , + ); + + expect(screen.queryByTestId("image-lightbox")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Expand image: Clinical chart" })); + expect(await screen.findByTestId("image-lightbox")).toBeInTheDocument(); + expect(screen.getByTestId("image-lightbox-stage")).toHaveAttribute("data-source-mode", "url"); + expect(screen.getAllByRole("img", { name: "Clinical chart" }).length).toBeGreaterThanOrEqual(1); + + expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "https://example.test/chart.png"); + expect(screen.getByRole("link", { name: "Download" })).toHaveAttribute( + "href", + "https://example.test/chart-download.png", + ); + }); + it("observably announces sub-second fail-retry-fail transitions while same-state rerenders stay silent", () => { vi.useFakeTimers(); const props = { @@ -53,4 +87,26 @@ describe("DocumentViewer non-PDF image preview", () => { act(() => vi.advanceTimersByTime(50)); expect(assertiveRegion).toHaveTextContent("Image preview could not load"); }); + + it("unmounts the lightbox when the parent blanks the signed URL", async () => { + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "View immersive" })); + expect(await screen.findByTestId("image-lightbox")).toBeInTheDocument(); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.queryByTestId("image-lightbox")).not.toBeInTheDocument(); + }); + expect(screen.getByText(/signed URL is generated/i)).toBeInTheDocument(); + }); }); diff --git a/tests/image-lightbox-url-mode.dom.test.tsx b/tests/image-lightbox-url-mode.dom.test.tsx new file mode 100644 index 0000000000..2a471a6574 --- /dev/null +++ b/tests/image-lightbox-url-mode.dom.test.tsx @@ -0,0 +1,73 @@ +/** @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ImageLightbox } from "@/components/clinical-dashboard/image-lightbox"; +import { clearSignedUrlCache, setCachedSignedUrl } from "@/lib/signed-url-cache"; + +const ENDPOINT = "/api/images/lightbox-url-mode/signed-url"; +const PRIOR_USER_URL = "https://example.supabase.co/storage/v1/object/sign/prior-user.png?token=stale"; +const DIRECT_URL = "https://example.test/whole-document.png"; + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => ({ + status: "authenticated", + session: { user: { id: "user-b" } }, + authorizationHeader: { Authorization: "Bearer user-b" }, + markSessionExpired: vi.fn(), + registerAuthRequest: vi.fn(() => ({ epoch: 1, release: vi.fn() })), + isAuthEpochCurrent: vi.fn(() => true), + }), +})); + +describe("ImageLightbox URL mode", () => { + beforeEach(() => { + clearSignedUrlCache(); + }); + + afterEach(() => { + clearSignedUrlCache(); + }); + + it("renders a parent-owned URL without reading the signed-URL LRU", async () => { + setCachedSignedUrl(ENDPOINT, { url: PRIOR_USER_URL, expiresAt: new Date(Date.now() + 60_000).toISOString() }); + + render(); + + const stage = await screen.findByTestId("image-lightbox-stage"); + expect(stage).toHaveAttribute("data-source-mode", "url"); + const image = screen.getByRole("img", { name: "Whole document chart" }); + expect(image).toHaveAttribute("src", DIRECT_URL); + expect(image).not.toHaveAttribute("src", PRIOR_USER_URL); + }); + + it("blanks the stage when the parent clears the direct URL while open", async () => { + const { rerender } = render(); + expect(screen.getByRole("img", { name: "Whole document chart" })).toBeInTheDocument(); + + // Parent auth-clear: signed URL gone. URL mode must not revive PRIOR_USER_URL from LRU. + setCachedSignedUrl(ENDPOINT, { url: PRIOR_USER_URL, expiresAt: new Date(Date.now() + 60_000).toISOString() }); + rerender(); + + await waitFor(() => { + expect(screen.queryByRole("img", { name: "Whole document chart" })).not.toBeInTheDocument(); + }); + expect(screen.getByRole("status")).toHaveTextContent("Loading image"); + expect(screen.queryByRole("img")).toBeNull(); + }); + + it("announces load failure with role=alert and retries without endpoint fetch", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + render(); + + fireEvent.error(screen.getByRole("img", { name: "Broken chart" })); + expect(screen.getByRole("alert")).toHaveTextContent("Image could not load."); + expect(fetchSpy).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByRole("img", { name: "Broken chart" })).toHaveAttribute("src", DIRECT_URL); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); diff --git a/tests/search-results-header-band.dom.test.tsx b/tests/search-results-header-band.dom.test.tsx index 7ed98a01e7..53bddad786 100644 --- a/tests/search-results-header-band.dom.test.tsx +++ b/tests/search-results-header-band.dom.test.tsx @@ -6,10 +6,14 @@ import { describe, expect, it, vi } from "vitest"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { - MobileResultFilterControl, SearchResultsEmptyState, SearchResultsHeaderBand, } from "@/components/clinical-dashboard/search-results-header-band"; +import { + ResultFilterSheet, + ResultFilterTrigger, + resultFilterGroup, +} from "@/components/clinical-dashboard/result-filter-control"; describe("SearchResultsHeaderBand", () => { it("presents the query and completed count as one labelled results ribbon", () => { @@ -469,10 +473,10 @@ describe("SearchResultsHeaderBand", () => { expect(onFilterTables).toHaveBeenCalledOnce(); }); - it("pairs sort with a page-specific dropdown on mobile without changing either action", async () => { + it("pairs sort with a page-specific filter trigger on mobile without changing either action", async () => { const user = userEvent.setup(); const onSortChange = vi.fn(); - const onFilterChange = vi.fn(); + const onToggleFilters = vi.fn(); render( { sortValue="relevance" onSortChange={onSortChange} filterLabel="Filter differential result type" + mobileControlsPlacement="inline" mobileControls={ - } filterControls={ @@ -510,12 +512,344 @@ describe("SearchResultsHeaderBand", () => { const pageFilters = screen.getByTestId("search-query-ribbon-mobile-controls"); expect(utilities.lastElementChild).toBe(pageFilters); await user.click(within(utilities).getByRole("button", { name: "A–Z" })); - await user.selectOptions(within(pageFilters).getByLabelText("Filter by result type"), "diagnosis"); + await user.click(within(pageFilters).getByTestId("differential-filter-trigger-phone")); expect(onSortChange).toHaveBeenCalledWith("alpha"); - expect(onFilterChange).toHaveBeenCalledWith("diagnosis"); + expect(onToggleFilters).toHaveBeenCalledTimes(1); expect(screen.getByTestId("search-query-ribbon-filters")).toHaveClass("hidden", "sm:block"); }); + + // The trigger is what makes the one-line phone bar legal, so its two states + // are asserted here rather than left to a journey: a resting trigger must not + // claim a count, and an active one must announce it in text as well as in the + // badge, because the badge is the only visual difference between them. + it("reports active filter count on the trigger, in the badge and in text", () => { + const { rerender } = render( + , + ); + expect(screen.getByTestId("trigger")).toHaveAccessibleName(/No filters active/); + expect(screen.getByTestId("trigger")).not.toHaveAttribute("aria-controls"); + + rerender( + , + ); + expect(screen.getByTestId("trigger")).toHaveAccessibleName(/2 filters active/); + expect(screen.getByTestId("trigger")).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByTestId("trigger")).toHaveAttribute("aria-controls", "panel"); + }); +}); + +describe("ResultFilterSheet", () => { + it("exposes each dimension as a radio group and reports the selection back typed", async () => { + const user = userEvent.setup(); + const onFamilyChange = vi.fn(); + + render( + , + ); + + const group = screen.getByRole("radiogroup", { name: "Family" }); + // One-of-N, so exactly one option may be checked — an `aria-pressed` bank + // would let the DOM claim two mutually exclusive filters at once. + expect(within(group).getByRole("radio", { name: "All" })).toBeChecked(); + expect(within(group).getByRole("radio", { name: "Course" })).not.toBeChecked(); + + await user.click(within(group).getByRole("radio", { name: "Course" })); + expect(onFamilyChange).toHaveBeenCalledWith("course-onset"); + }); + + // `role="radio"` is a promise about the keyboard, not only about the + // announcement: one tab stop per group, arrows to move and select. Asserted + // here because the failure mode is silent — the group still reads correctly to + // a screen reader while answering none of the keys it just advertised. + it("gives each dimension one tab stop and moves selection with the arrow keys", async () => { + const user = userEvent.setup(); + const onFamilyChange = vi.fn(); + const onDiagnosisChange = vi.fn(); + + render( + , + ); + + const family = screen.getByRole("radiogroup", { name: "Family" }); + const diagnosis = screen.getByRole("radiogroup", { name: "Diagnosis" }); + + // Exactly one tab stop per group — the checked option — so a reader does not + // Tab through five options across two dimensions to reach Done. + expect(within(family).getByRole("radio", { name: "All" })).toHaveAttribute("tabindex", "0"); + expect(within(family).getByRole("radio", { name: "Features" })).toHaveAttribute("tabindex", "-1"); + expect(within(family).getByRole("radio", { name: "Course" })).toHaveAttribute("tabindex", "-1"); + expect(within(diagnosis).getByRole("radio", { name: "All diagnoses" })).toHaveAttribute("tabindex", "0"); + + within(family).getByRole("radio", { name: "All" }).focus(); + await user.keyboard("{ArrowRight}"); + expect(onFamilyChange).toHaveBeenLastCalledWith("features"); + await user.keyboard("{End}"); + expect(onFamilyChange).toHaveBeenLastCalledWith("course"); + await user.keyboard("{Home}"); + expect(onFamilyChange).toHaveBeenLastCalledWith("all"); + // Wrapping stays inside the dimension: ArrowLeft from the first option must + // not walk into the neighbouring group. + await user.keyboard("{ArrowLeft}"); + expect(onFamilyChange).toHaveBeenLastCalledWith("course"); + expect(onDiagnosisChange).not.toHaveBeenCalled(); + }); + + it("makes a checked placeholder the tab stop and arrows off it onto a real option", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + const group = screen.getByRole("radiogroup", { name: "Quick filters" }); + const placeholder = within(group).getByRole("radio", { name: "Current search" }); + expect(placeholder).toHaveAttribute("tabindex", "0"); + expect(within(group).getByRole("radio", { name: "Crisis" })).toHaveAttribute("tabindex", "-1"); + + placeholder.focus(); + await user.keyboard("{ArrowRight}"); + expect(onChange).toHaveBeenLastCalledWith("crisis"); + expect(within(group).getByRole("radio", { name: "Crisis" })).toHaveFocus(); + }); + + // Defensive today — no shipped call site renders an unselected disabled option + // — but the arrangement is the whole answer to "how does a keyboard reader + // hear why this one is unavailable", so it is pinned rather than assumed. + it("puts a dead end on the arrow path without ever selecting it", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + const group = screen.getByRole("radiogroup", { name: "Kind" }); + const deadEnd = within(group).getByRole("radio", { name: /Retired/ }); + expect(deadEnd).toHaveAttribute("aria-disabled", "true"); + // Not a second tab stop: the group keeps exactly one. + expect(deadEnd).toHaveAttribute("tabindex", "-1"); + expect(within(group).getByRole("radio", { name: "All" })).toHaveAttribute("tabindex", "0"); + + within(group).getByRole("radio", { name: "All" }).focus(); + await user.keyboard("{ArrowRight}"); + // Focus lands on it — that is how the "Not selectable from here" note is + // announced — but nothing was selected, and "All" was not committed either. + expect(deadEnd).toHaveFocus(); + expect(onChange).not.toHaveBeenCalled(); + + await user.keyboard("{ArrowRight}"); + expect(onChange).toHaveBeenCalledExactlyOnceWith("acute"); + }); + + it("keeps the group tabbable when the selected value matches no option at all", () => { + render( + , + ); + + const group = screen.getByRole("radiogroup", { name: "Category" }); + expect(within(group).getByRole("radio", { name: "All" })).toHaveAttribute("tabindex", "0"); + expect(within(group).getByRole("radio", { name: "Acute" })).toHaveAttribute("tabindex", "-1"); + expect(within(group).queryAllByRole("radio", { checked: true })).toHaveLength(0); + }); + + it("keeps an unselectable placeholder focusable and explained rather than removing it", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + // Selected-and-disabled is the state the reader is already in, so it renders + // as the checked option rather than as a dead end. + const placeholder = screen.getByRole("radio", { name: "Current search" }); + expect(placeholder).toBeChecked(); + expect(placeholder).not.toHaveAttribute("aria-disabled"); + + await user.click(screen.getByRole("radio", { name: "Crisis" })); + expect(onChange).toHaveBeenCalledWith("crisis"); + }); + + it("offers Clear only when something is clearable", () => { + const groups = [ + resultFilterGroup({ + id: "category", + label: "Category", + value: "all", + options: [{ value: "all", label: "All" }], + onChange: vi.fn(), + }), + ]; + const { rerender } = render( + , + ); + expect(screen.queryByTestId("factsheet-filter-panel-clear")).toBeNull(); + + rerender( + , + ); + expect(screen.getByTestId("factsheet-filter-panel-clear")).toBeVisible(); + }); }); describe("SearchResultsEmptyState", () => { diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index fde2b807f5..2206316a6d 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -472,18 +472,53 @@ test.describe("Clinical KB accessibility coverage", () => { await differentialSubmit.click(); await expect(visibleByTestId(page, "differentials-search-results")).toBeVisible(); - const filterSelect = page.getByTestId("differential-result-type-select"); - await expect(filterSelect).toBeVisible(); - await expect(filterSelect).toHaveAccessibleName("Filter by result type"); - await expect(filterSelect).toHaveValue("all"); + // The phone filter is a trigger opening a sheet, not a native select. Its + // resting accessible name must still say there is nothing applied, because + // the badge that carries that information visually is absent at zero. + const filterTrigger = page.getByTestId("differential-filter-trigger-phone"); + await expect(filterTrigger).toBeVisible(); + await expect(filterTrigger).toHaveAccessibleName(/No filters active/); await expect(page.getByRole("tab")).toHaveCount(0); - await filterSelect.focus(); - await expect(filterSelect).toBeFocused(); - await filterSelect.selectOption("presentation"); - await expect(filterSelect).toHaveValue("presentation"); - await filterSelect.selectOption("diagnosis"); - await expect(filterSelect).toHaveValue("diagnosis"); + await filterTrigger.focus(); + await expect(filterTrigger).toBeFocused(); + await filterTrigger.press("Enter"); + + // One-of-N, expressed as radios: selecting one dimension must uncheck the + // other rather than leaving two contradictory filters both "on". + const filterGroup = page.getByRole("radiogroup", { name: "Show" }); + await expect(filterGroup).toBeVisible(); + await expect(filterGroup.getByRole("radio", { name: /^All/ })).toBeChecked(); + await filterGroup.getByRole("radio", { name: /^Presentations/ }).click(); + await expect(filterGroup.getByRole("radio", { name: /^Presentations/ })).toBeChecked(); + await expect(filterGroup.getByRole("radio", { name: /^All/ })).not.toBeChecked(); + await filterGroup.getByRole("radio", { name: /^Diagnoses/ }).click(); + await expect(filterGroup.getByRole("radio", { name: /^Diagnoses/ })).toBeChecked(); + + // The radio role promises a keyboard model, so prove it in a real browser and + // not only in jsdom: one tab stop, arrows moving focus AND selection, Home and + // End reaching the ends. jsdom cannot vouch for focus behaviour under a real + // focus trap, which is what the sheet puts around this group. + const all = filterGroup.getByRole("radio", { name: /^All/ }); + const presentations = filterGroup.getByRole("radio", { name: /^Presentations/ }); + const diagnoses = filterGroup.getByRole("radio", { name: /^Diagnoses/ }); + await diagnoses.focus(); + await diagnoses.press("Home"); + await expect(all).toBeFocused(); + await expect(all).toBeChecked(); + await all.press("ArrowRight"); + await expect(presentations).toBeFocused(); + await expect(presentations).toBeChecked(); + await presentations.press("End"); + await expect(diagnoses).toBeFocused(); + await expect(diagnoses).toBeChecked(); + // Exactly one tab stop for the group — the checked option. + await expect(diagnoses).toHaveAttribute("tabindex", "0"); + await expect(all).toHaveAttribute("tabindex", "-1"); + await expect(presentations).toHaveAttribute("tabindex", "-1"); + + await page.getByTestId("differential-filter-panel-done").click(); + await expect(filterTrigger).toHaveAccessibleName(/1 filter active/); }); test("guest upload action exposes the admin boundary and opens Sources", async ({ page }) => { diff --git a/tests/ui-formulation.spec.ts b/tests/ui-formulation.spec.ts index 20abe6f619..afb2a463da 100644 --- a/tests/ui-formulation.spec.ts +++ b/tests/ui-formulation.spec.ts @@ -108,10 +108,16 @@ test("keeps mobile search, domain filtering, record actions, and universal chrom await expect(queryRibbon.getByRole("heading", { level: 1, name: "What if something goes wrong" })).toBeVisible(); await expect(queryRibbon.getByRole("group", { name: "Filter formulation mechanisms" })).toBeVisible(); await expect(page.getByRole("link", { name: "Worry", exact: true })).toBeVisible(); - await expect(queryRibbon.getByTestId("formulation-pattern-select")).toBeVisible(); - const domainSelect = queryRibbon.getByTestId("formulation-domain-select"); - await expect(domainSelect).toBeVisible(); - await expect(domainSelect).toHaveAccessibleName("Filter by formulation domain"); + // Both dimensions used to be side-by-side selects in the ribbon; they are now + // one compact trigger opening a sheet that holds both groups. + const filterTrigger = queryRibbon.getByTestId("formulation-filter-trigger-phone"); + await expect(filterTrigger).toBeVisible(); + await filterTrigger.click(); + await expect(page.getByRole("radiogroup", { name: "Pattern" })).toBeVisible(); + const domainGroup = page.getByRole("radiogroup", { name: "Domain" }); + await expect(domainGroup.getByRole("radio", { name: "All domains" })).toBeChecked(); + await page.getByTestId("formulation-filter-panel-done").click(); + await expect(domainGroup).toBeHidden(); await expect(page.getByTestId("global-search-input").filter({ visible: true }).first()).toBeVisible(); await expect(page.getByText("Source status", { exact: true })).toHaveCount(0); await expect(page.getByText("Source", { exact: true })).toHaveCount(0); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 9f1da4a10e..b80c24e52d 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -3052,12 +3052,19 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(queryRibbon.getByRole("heading", { name: "sertraline" })).toBeVisible(); await expect(queryRibbon.getByRole("group", { name: "Result view" })).toBeVisible(); await expect(queryRibbon.getByRole("group", { name: "Filter factsheets by category" })).toBeVisible(); - const categorySelect = queryRibbon.getByTestId("factsheet-category-select"); + // Phone gets the compact trigger; from `sm` up the ribbon shows the chip + // row instead and the trigger is not rendered at all. + const categoryTrigger = queryRibbon.getByTestId("factsheet-filter-trigger-phone"); if (viewport.width < 640) { - await expect(categorySelect).toBeVisible(); - await expect(categorySelect).toHaveAccessibleName("Filter factsheets by category"); + await expect(categoryTrigger).toBeVisible(); + await expect(categoryTrigger).toHaveAccessibleName(/No filters active/); + await categoryTrigger.click(); + const categoryGroup = page.getByRole("radiogroup", { name: "Category" }); + await expect(categoryGroup.getByRole("radio", { name: "All" })).toBeChecked(); + await page.getByTestId("factsheet-filter-panel-done").click(); + await expect(categoryGroup).toBeHidden(); } else { - await expect(categorySelect).toBeHidden(); + await expect(categoryTrigger).toBeHidden(); } await expectNoPageHorizontalOverflow(page); } diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts index ab182f9e90..aec4945c9a 100644 --- a/tests/ui-specifiers.spec.ts +++ b/tests/ui-specifiers.spec.ts @@ -112,21 +112,26 @@ test("keeps mobile search, filters, results, and the fixed composer usable", asy await expect(queryRibbon.getByRole("heading", { level: 1, name: "returns every winter" })).toBeVisible(); await expect(queryRibbon.getByRole("group", { name: "Filter specifier results" })).toBeVisible(); await expect(page.getByRole("link", { name: "Open With seasonal pattern" })).toBeVisible(); - const familySelect = queryRibbon.getByTestId("specifier-family-select"); - const diagnosisSelect = queryRibbon.getByTestId("specifier-diagnosis-select"); - await expect(familySelect).toBeVisible(); - await expect(familySelect).toHaveAccessibleName("Filter by specifier family"); - await expect(diagnosisSelect).toBeVisible(); - await expect(diagnosisSelect).toHaveAccessibleName("Filter by diagnosis"); + // Both dimensions used to be side-by-side selects in the ribbon; they are now + // one compact trigger opening a sheet that holds both groups. + const filterTrigger = queryRibbon.getByTestId("specifier-filter-trigger-phone"); + await expect(filterTrigger).toBeVisible(); + await expect(filterTrigger).toHaveAccessibleName(/No filters active/); await expect(page.getByTestId("global-search-input").filter({ visible: true }).first()).toBeVisible(); await expect(page.getByText("Source status", { exact: true })).toHaveCount(0); await expect(page.getByText("Source", { exact: true })).toHaveCount(0); - await familySelect.selectOption("course-onset"); - await expect(familySelect).toHaveValue("course-onset"); - await expect(page.getByRole("link", { name: "Open With seasonal pattern" })).toBeVisible(); - - await diagnosisSelect.selectOption("depressive"); + await filterTrigger.click(); + const familyGroup = page.getByRole("radiogroup", { name: "Family" }); + const diagnosisGroup = page.getByRole("radiogroup", { name: "Diagnosis" }); + await familyGroup.getByRole("radio", { name: "Course" }).click(); + await expect(familyGroup.getByRole("radio", { name: "Course" })).toBeChecked(); + await diagnosisGroup.getByRole("radio", { name: "Depressive" }).click(); + await expect(diagnosisGroup.getByRole("radio", { name: "Depressive" })).toBeChecked(); + await page.getByTestId("specifier-filter-panel-done").click(); + + // Two dimensions applied, counted independently on the badge. + await expect(filterTrigger).toHaveAccessibleName(/2 filters active/); await expect(page.getByRole("link", { name: "Open With seasonal pattern" })).toBeVisible(); await expectNoHorizontalOverflow(page); diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 2be0daa05b..7dc5e6b8e5 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -475,7 +475,7 @@ test.describe("Medication responsive stress coverage", () => { const card = document.querySelector('[data-testid="medication-result-acamprosate-phone"]'); const firstFilter = viewportWidth < 640 - ? document.querySelector('[data-testid="medication-result-filter-select"]') + ? document.querySelector('[data-testid="medication-filter-trigger-phone"]') : filters?.querySelector("button"); if (!workspace || !patient || !filters || !card || !firstFilter) return null; const workspaceRect = workspace.getBoundingClientRect(); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 19f42c55e4..29021b7dfb 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -414,12 +414,15 @@ test.describe("Clinical KB tools launcher", () => { await expect(page.locator("#launcher-results-panel")).toHaveAttribute("role", "group"); await expect(page.locator("#launcher-results-panel")).toHaveAttribute("aria-label", "All tools"); if (viewport.name === "mobile") { - const categorySelect = page.getByTestId("tool-category-select"); - await expect(categorySelect).toBeVisible(); - await expect(categorySelect).toHaveAccessibleName("Filter by tool category"); - await categorySelect.selectOption("assessment"); + const categoryTrigger = page.getByTestId("tool-filter-trigger-phone"); + await expect(categoryTrigger).toBeVisible(); + await expect(categoryTrigger).toHaveAccessibleName(/No filters active/); + await categoryTrigger.click(); + await page.getByRole("radiogroup", { name: "Category" }).getByRole("radio", { name: "Assess" }).click(); await expect(page.locator("#launcher-results-panel")).toHaveAttribute("aria-label", "Assess tools"); - await categorySelect.selectOption("all"); + await expect(categoryTrigger).toHaveAccessibleName(/1 filter active/); + await categoryTrigger.click(); + await page.getByRole("radiogroup", { name: "Category" }).getByRole("radio", { name: "All tools" }).click(); await page.getByTestId("application-row-medication-prescribing").click(); const selectedSheet = page.getByRole("dialog", { name: "Medication Prescribing" }); await expect(selectedSheet).toBeVisible(); @@ -855,10 +858,11 @@ test.describe("Clinical KB tools launcher", () => { await expect(page.getByRole("button", { name: "Mode Services" })).toBeVisible(); const input = visibleGlobalSearchInput(page).first(); await expect(input).toBeVisible(); - const quickFilter = page.getByTestId("service-quick-filter-select"); + const quickFilter = page.getByTestId("service-filter-trigger-phone"); await expect(quickFilter).toBeVisible(); - await expect(quickFilter).toHaveAccessibleName("Apply a quick service filter"); - await quickFilter.selectOption("crisis"); + await expect(quickFilter).toHaveAccessibleName(/No filters active/); + await quickFilter.click(); + await page.getByRole("radiogroup", { name: "Quick filters" }).getByRole("radio", { name: "Crisis" }).click(); await expect(page).toHaveURL(/\/services\?.*q=crisis/); // Phones keep the full search results in the page instead of opening a @@ -1639,13 +1643,17 @@ test.describe("Clinical KB tools launcher", () => { await submitDifferentialSearch(page, "acute confusion"); await expect(visibleByTestId(page, "differentials-search-results")).toBeVisible(); - const typeSelect = page.getByTestId("differential-result-type-select"); - await expect(typeSelect).toBeVisible(); - await expect(typeSelect).toHaveAccessibleName("Filter by result type"); - await expect(typeSelect).toHaveValue("all"); - await typeSelect.selectOption("diagnosis"); - await expect(typeSelect).toHaveValue("diagnosis"); - await typeSelect.selectOption("all"); + const typeTrigger = page.getByTestId("differential-filter-trigger-phone"); + await expect(typeTrigger).toBeVisible(); + await expect(typeTrigger).toHaveAccessibleName(/No filters active/); + await typeTrigger.click(); + const typeGroup = page.getByRole("radiogroup", { name: "Show" }); + await typeGroup.getByRole("radio", { name: /^Diagnoses/ }).click(); + await expect(typeGroup.getByRole("radio", { name: /^Diagnoses/ })).toBeChecked(); + await typeGroup.getByRole("radio", { name: /^All/ }).click(); + await page.getByTestId("differential-filter-panel-done").click(); + await expect(typeGroup).toBeHidden(); + await expect(typeTrigger).toHaveAccessibleName(/No filters active/); // Sort is `sm`-and-up, so on a phone the page filter is the whole utilities // group: it renders last, hard against the ribbon's right edge, and it is @@ -1750,13 +1758,16 @@ test.describe("Clinical KB tools launcher", () => { await submitDifferentialSearch(page, "acute confusion"); await expect(visibleByTestId(page, "differentials-search-results")).toBeVisible(); - const typeSelect = page.getByTestId("differential-result-type-select"); - await expect(typeSelect).toBeVisible(); - await expect(typeSelect).toHaveAccessibleName("Filter by result type"); - await expect(typeSelect).toHaveValue("all"); - await typeSelect.selectOption("presentation"); - await expect(typeSelect).toHaveValue("presentation"); - await typeSelect.selectOption("all"); + const typeTrigger = page.getByTestId("differential-filter-trigger-phone"); + await expect(typeTrigger).toBeVisible(); + await expect(typeTrigger).toHaveAccessibleName(/No filters active/); + await typeTrigger.click(); + const typeGroup = page.getByRole("radiogroup", { name: "Show" }); + await typeGroup.getByRole("radio", { name: /^Presentations/ }).click(); + await expect(typeGroup.getByRole("radio", { name: /^Presentations/ })).toBeChecked(); + await typeGroup.getByRole("radio", { name: /^All/ }).click(); + await page.getByTestId("differential-filter-panel-done").click(); + await expect(typeGroup).toBeHidden(); // Sort is `sm`-and-up, so on a phone the page filter is the whole utilities // group: it renders last, hard against the ribbon's right edge, and it is @@ -2434,9 +2445,9 @@ test.describe("Responsive layout guards", () => { await expect(queryRibbon).toBeVisible(); await expect(queryRibbon.getByRole("heading", { name: "acamprosate renal dose" })).toBeVisible(); await expect(queryRibbon.getByRole("status")).toBeVisible(); - const resultFilter = queryRibbon.getByTestId("medication-result-filter-select"); + const resultFilter = queryRibbon.getByTestId("medication-filter-trigger-phone"); await expect(resultFilter).toBeVisible(); - await expect(resultFilter).toHaveAccessibleName("Filter medication results"); + await expect(resultFilter).toHaveAccessibleName(/No filters active/); await expect(bottomDock).toBeVisible(); await scrollPrimarySurface(page, "end"); await expect