From 02e7ec12d485b187f91141ce003895e85d23aae1 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:38:21 +0800 Subject: [PATCH 1/3] fix(ci): pin Lighthouse Chromium and stop advisory Chrome-drift fails Ubuntu runner Chrome majors disagreed across jobs (PR #1697: 151 baseline vs 150 measurement), so the advisory budget failed closed with no app regression. Pin Playwright Chromium, warn on browser mismatch while enforce is false, and let --update restamp through Chrome drift. Co-authored-by: Cursor --- .github/workflows/ci.yml | 16 +++-- docs/testing.md | 9 +++ scripts/check-lighthouse-budget.mjs | 84 +++++++++++++++++++++------ tests/check-lighthouse-budget.test.ts | 63 ++++++++++++++++++++ 4 files changed, 148 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10e004b5f1..720254587b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -716,13 +716,19 @@ jobs: with: persist-credentials: false - # Lighthouse drives the Chrome that ships in the ubuntu-24.04 runner image, so - # no browser install is needed here (matching live-web-vitals.yml). - - name: Setup Node and dependencies - uses: ./.github/actions/setup-node-cached + # Pin Chromium via Playwright (same archive as UI e2e). Relying on the + # ubuntu-24.04 image Chrome made the relative baseline fail closed whenever + # runners disagreed during a Chrome rollout (PR #1697: Chrome 151 baseline vs + # Chrome 150 measurement) even when the metrics themselves were fine. + - name: Setup Node, dependencies, and Chromium + uses: ./.github/actions/setup-ui-e2e - name: Measure routes and grade against the baseline - run: npm run verify:lighthouse -- --keep --dir lighthouse + run: | + CHROME_PATH="$(node -e "process.stdout.write(require('playwright').chromium.executablePath())")" + export CHROME_PATH + echo "Using pinned Chromium at ${CHROME_PATH}" + npm run verify:lighthouse -- --keep --dir lighthouse - name: Upload Lighthouse reports if: always() diff --git a/docs/testing.md b/docs/testing.md index 6801fb9272..4c72bd6ab6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -135,6 +135,15 @@ following the same shape as `check:bundle-budget`. floor, so 12 ms → 16 ms is not reported as a 33% regression. Refresh the baseline deliberately after a known-good run: `npm run check:lighthouse-budget -- --update`. +`--update` only requires structural completeness (reports exist with usable metrics for the right +pages); Chrome-version drift and missing baseline rows are what the refresh rewrites, so they must +not block it. + +The pre-merge CI job pins Chromium through Playwright (`setup-ui-e2e` + `CHROME_PATH`) rather than +the ubuntu runner image Chrome. Image Chrome majors can disagree across runners during a rollout, +which made advisory Lighthouse fail closed as "different browser" with no application regression +(PR #1697). While `enforce` is still false, a remaining browser mismatch warns instead of failing; +once enforce flips, mismatch fails until `--update` restamps the baseline on the pinned browser. This is distinct from `.github/workflows/live-web-vitals.yml`, which measures the deployed origin for ledger #017 and is dispatch-only — by the time it runs, `main` has already auto-deployed. Both pin diff --git a/scripts/check-lighthouse-budget.mjs b/scripts/check-lighthouse-budget.mjs index 4b1b4cbecb..c364f9aa8f 100644 --- a/scripts/check-lighthouse-budget.mjs +++ b/scripts/check-lighthouse-budget.mjs @@ -18,11 +18,15 @@ * compare against). * - Within tolerance -> ok. * - Over tolerance -> fail when enforcing, warn otherwise. - * - Evidence incomplete -> ALWAYS fail. A route that produced no report is not a + * - Structural incomplete -> ALWAYS fail. A route that produced no report is not a * pass; this is the failure mode `summarise-web-vitals.mjs` documents at length. + * - Browser mismatch -> fail when enforcing, warn otherwise (runner-image Chrome + * can disagree across jobs during a rollout; see PR #1697). CI pins Playwright + * Chromium so the browser only moves with the lockfile. * * Refresh the baseline from an intentional, known-good run: * npm run check:lighthouse-budget -- --update + * (`--update` ignores baseline-comparability checks so a Chrome bump can restamp.) * * Flags: --update, --json, --dir , --require-reports (an empty directory is a * failure, not a no-op — used by run-lighthouse-budget.mjs, which owns the reports). @@ -60,15 +64,26 @@ export function expectedBudgetRuns(budget) { return strategies.flatMap((strategy) => slugs.map((slug) => `${strategy}-${slug}`)); } +/** True when an incompleteness string is a baseline-browser drift, not a missing report. */ +export function isBrowserMismatchProblem(problem) { + return String(problem).includes("measured by a different browser"); +} + /** * Runs that cannot be graded at all: no report, no usable metrics, or a report that * measured a different page than the one requested (a redirect to /login produces * perfectly good numbers for the wrong route). * - * Fails closed and is never downgraded by `enforce` — an ungraded route silently - * counted as a pass is exactly how unmeasured latency claims got acted on before. + * Structural incompleteness (missing/bad reports) fails closed and is never + * downgraded by `enforce` — an ungraded route silently counted as a pass is exactly + * how unmeasured latency claims got acted on before. + * + * Baseline-comparability problems (missing baseline row, different Chrome) are + * included by default so a grade refuses to invent a comparison. Pass + * `{ includeBaselineComparability: false }` for `--update`, which exists to rewrite + * that baseline — otherwise a runner Chrome bump cannot refresh the file it blocks on. */ -export function incompleteBudgetEvidence(rows, budget) { +export function incompleteBudgetEvidence(rows, budget, { includeBaselineComparability = true } = {}) { const tolerance = { ...DEFAULT_TOLERANCE, ...(budget?.tolerance ?? {}) }; const baseline = budget?.baseline ?? null; const hasBaseline = Boolean(baseline) && Object.keys(baseline).length > 0; @@ -100,7 +115,7 @@ export function incompleteBudgetEvidence(rows, budget) { for (const metric of Object.keys(tolerance)) { if (typeof row[metric] !== "number") problems.add(`${run}: report has no ${metric} number`); } - if (!hasBaseline) continue; + if (!includeBaselineComparability || !hasBaseline) continue; const before = baseline[run]; // A route or strategy added after the baseline was recorded has nothing to // compare against, and gradeRun returns no breaches for a missing row — so an @@ -175,11 +190,37 @@ export function compareToLighthouseBudget(rows, budget) { const baseline = budget?.baseline ?? null; const enforce = Boolean(budget?.enforce); const incomplete = incompleteBudgetEvidence(rows, budget); + const browserMismatches = incomplete.filter(isBrowserMismatchProblem); + const structural = incomplete.filter((problem) => !isBrowserMismatchProblem(problem)); - // Incompleteness is fatal regardless of `enforce`: there is nothing to grade, so - // "warn" would report a pass for a route that was never measured. - if (incomplete.length > 0) { - return { status: "fail", reason: "evidence incomplete", breaches: [], incomplete, baseline, enforce, tolerance }; + // Missing/bad reports are fatal regardless of `enforce`: there is nothing to + // grade, so "warn" would report a pass for a route that was never measured. + if (structural.length > 0) { + return { + status: "fail", + reason: "evidence incomplete", + breaches: [], + incomplete: structural, + baseline, + enforce, + tolerance, + }; + } + + // Runner-image Chrome can differ across jobs during a rollout (PR #1697: baseline + // Chrome 151 vs measurement Chrome 150). While the gate is advisory, report that + // as a warning so the job stays green; once `enforce` is true, fail closed until + // `--update` refreshes the baseline on the pinned browser. + if (browserMismatches.length > 0) { + return { + status: enforce ? "fail" : "warn", + reason: enforce ? "evidence incomplete" : "baseline browser mismatch — refresh with --update", + breaches: [], + incomplete: browserMismatches, + baseline, + enforce, + tolerance, + }; } if (!baseline || Object.keys(baseline).length === 0) { @@ -187,7 +228,7 @@ export function compareToLighthouseBudget(rows, budget) { status: "warn", reason: "no baseline recorded — run with --update after a known-good build", breaches: [], - incomplete, + incomplete: [], baseline, enforce, tolerance, @@ -196,13 +237,13 @@ export function compareToLighthouseBudget(rows, budget) { const breaches = rows.flatMap((row) => gradeRun(row, baseline[row.run], tolerance)); if (breaches.length === 0) { - return { status: "ok", reason: "within tolerance", breaches, incomplete, baseline, enforce, tolerance }; + return { status: "ok", reason: "within tolerance", breaches, incomplete: [], baseline, enforce, tolerance }; } return { status: enforce ? "fail" : "warn", reason: `${breaches.length} metric(s) outside tolerance`, breaches, - incomplete, + incomplete: [], baseline, enforce, tolerance, @@ -247,7 +288,10 @@ export function renderBudgetTable(rows, result) { lines.push(""); if (result.incomplete.length > 0) { - lines.push(`**Evidence incomplete.** ${result.incomplete.join("; ")}. Nothing is graded from this run.`); + const browserOnly = + result.incomplete.length > 0 && result.incomplete.every((problem) => isBrowserMismatchProblem(problem)); + const label = browserOnly ? "**Baseline browser mismatch.**" : "**Evidence incomplete.**"; + lines.push(`${label} ${result.incomplete.join("; ")}. Nothing is graded from this run.`); return lines.join("\n"); } if (result.status === "warn" && result.breaches.length === 0) { @@ -317,13 +361,13 @@ function main() { return; } - const result = compareToLighthouseBudget(rows, budget); - if (update) { - if (result.incomplete.length > 0) { - console.error( - `::error::refusing to update the baseline from incomplete evidence: ${result.incomplete.join("; ")}`, - ); + // Only structural completeness blocks a refresh. Baseline-comparability checks + // (Chrome drift, missing baseline rows) are exactly what --update rewrites; if + // they blocked here, a runner Chrome bump could never restamp the baseline. + const structural = incompleteBudgetEvidence(rows, budget, { includeBaselineComparability: false }); + if (structural.length > 0) { + console.error(`::error::refusing to update the baseline from incomplete evidence: ${structural.join("; ")}`); process.exit(1); } const next = { @@ -336,6 +380,8 @@ function main() { return; } + const result = compareToLighthouseBudget(rows, budget); + const table = renderBudgetTable(rows, result); console.log(table); if (asJson) console.log(JSON.stringify({ ...result, rows }, null, 2)); diff --git a/tests/check-lighthouse-budget.test.ts b/tests/check-lighthouse-budget.test.ts index 31b974118d..4ff693a788 100644 --- a/tests/check-lighthouse-budget.test.ts +++ b/tests/check-lighthouse-budget.test.ts @@ -10,6 +10,7 @@ import { expectedBudgetRuns, gradeRun, incompleteBudgetEvidence, + isBrowserMismatchProblem, renderBudgetTable, } from "../scripts/check-lighthouse-budget.mjs"; @@ -140,6 +141,22 @@ describe("incompleteBudgetEvidence — completeness derived from what is graded" expect(problems).toHaveLength(10); expect(problems[0]).toContain("measured by a different browser"); + expect(problems.every(isBrowserMismatchProblem)).toBe(true); + }); + + it("skips baseline-comparability checks when refreshing the baseline", () => { + // --update rewrites chromeVersion and missing rows; if those checks blocked the + // refresh, a runner Chrome bump could never restamp the file (PR #1697). + const rows = completeRows(); + const stale = baselineFromRows( + rows + .filter((entry: Row) => entry.run !== "mobile-forms") + .map((entry: Row) => ({ ...entry, chromeVersion: "HeadlessChrome/131" })), + ); + + expect( + incompleteBudgetEvidence(rows, budget({ baseline: stale }), { includeBaselineComparability: false }), + ).toEqual([]); }); it("accepts a baseline that recorded no browser identity at all", () => { @@ -252,6 +269,30 @@ describe("compareToLighthouseBudget", () => { expect(result.incomplete).toEqual(["mobile-forms: no Lighthouse report produced"]); }); + it("warns on a browser mismatch when the budget is still advisory", () => { + // Runner-image Chrome disagreed across jobs in PR #1697; advisory must not red + // for that alone. Numbers are not graded until --update refreshes the baseline. + const rows = completeRows(); + const stale = baselineFromRows(rows.map((entry: Row) => ({ ...entry, chromeVersion: "HeadlessChrome/131" }))); + const result = compareToLighthouseBudget(rows, budget({ baseline: stale, enforce: false })); + + expect(result.status).toBe("warn"); + expect(result.reason).toContain("baseline browser mismatch"); + expect(result.breaches).toEqual([]); + expect(result.incomplete).toHaveLength(10); + expect(result.incomplete.every(isBrowserMismatchProblem)).toBe(true); + }); + + it("fails on a browser mismatch once the budget is enforcing", () => { + const rows = completeRows(); + const stale = baselineFromRows(rows.map((entry: Row) => ({ ...entry, chromeVersion: "HeadlessChrome/131" }))); + const result = compareToLighthouseBudget(rows, budget({ baseline: stale, enforce: true })); + + expect(result.status).toBe("fail"); + expect(result.reason).toBe("evidence incomplete"); + expect(result.incomplete.every(isBrowserMismatchProblem)).toBe(true); + }); + it("fails on incomplete evidence before it reports a missing baseline", () => { const rows = completeRows().filter((entry: Row) => entry.run !== "mobile-forms"); const result = compareToLighthouseBudget(rows, budget({ baseline: null })); @@ -326,6 +367,18 @@ describe("committed lighthouse-budget.json", () => { expect(runner).not.toMatch(/spawnSync\(\s*"npx",/); expect(runner).not.toMatch(/spawnSync\(\s*"npx\.cmd",/); }); + + it("pins Chromium through Playwright in the pre-merge CI job", () => { + // The ubuntu-24.04 image Chrome major can differ across runners; PR #1697 failed + // advisory Lighthouse on Chrome 151 baseline vs Chrome 150 measurement. + const workflow = readFileSync(path.join(process.cwd(), ".github", "workflows", "ci.yml"), "utf8"); + const lighthouseJob = workflow.split(/\n lighthouse-budget:/)[1]?.split(/\n [a-z0-9-]+:/)[0] ?? ""; + + expect(lighthouseJob).toContain("uses: ./.github/actions/setup-ui-e2e"); + expect(lighthouseJob).toContain("CHROME_PATH="); + expect(lighthouseJob).toContain("playwright').chromium.executablePath()"); + expect(lighthouseJob).not.toContain("uses: ./.github/actions/setup-node-cached"); + }); }); describe("renderBudgetTable", () => { @@ -336,6 +389,16 @@ describe("renderBudgetTable", () => { expect(renderBudgetTable(rows, result)).toContain("Evidence incomplete"); }); + it("labels a browser-only mismatch distinctly from missing reports", () => { + const rows = completeRows(); + const stale = baselineFromRows(rows.map((entry: Row) => ({ ...entry, chromeVersion: "HeadlessChrome/131" }))); + const result = compareToLighthouseBudget(rows, budget({ baseline: stale, enforce: false })); + + const table = renderBudgetTable(rows, result); + expect(table).toContain("Baseline browser mismatch"); + expect(table).not.toContain("Evidence incomplete"); + }); + it("says a warned regression was reported only", () => { const rows = completeRows({ "mobile-dsm": { lcpMs: 4000 } }); const result = compareToLighthouseBudget( From 6d6f7f866bf27ff7844eb10d1fcf389d8f880f29 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:42:50 +0800 Subject: [PATCH 2/3] docs(ledger): record lighthouse chrome-pin review for PR #1703 Co-authored-by: Cursor --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index f75bd714a1..a56235c41c 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -711,3 +711,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 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/fix-lighthouse-chrome-pin | 02e7ec12d485b187f91141ce003895e85d23aae1 | lighthouse chrome pin + advisory mismatch warn | fix PR #1703 for PR #1697 advisory Chrome 150/151 drift | artifact grade exit0; --update catch-22 proved; node contract PASS; vitest blocked by shared nm | From 621180854248fcc10982f3fd58762229fee999d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 18:22:18 +0000 Subject: [PATCH 3/3] merge(main): sync PR #1703 with origin/main --- docs/branch-review-ledger.md | 1 + docs/design-system/COMPONENTS.md | 2 +- docs/design-system/adoption-manifest.json | 2 + docs/outstanding-issues.md | 30 +- docs/search-chrome-behaviour.md | 25 +- docs/search-results-bar-decisions.md | 29 +- src/components/applications-launcher-page.tsx | 64 ++- .../clinical-dashboard/differentials-home.tsx | 56 ++- .../medication-prescribing-workspace.tsx | 55 ++- .../result-filter-control.tsx | 408 ++++++++++++++++++ .../search-results-header-band.tsx | 91 +--- .../factsheets/factsheets-search-page.tsx | 61 ++- .../formulation/formulation-home-page.tsx | 97 +++-- .../services/services-navigator-page.tsx | 82 +++- .../specifiers/specifiers-home-page.tsx | 77 +++- tests/search-results-header-band.dom.test.tsx | 364 +++++++++++++++- tests/ui-accessibility.spec.ts | 55 ++- tests/ui-formulation.spec.ts | 14 +- tests/ui-smoke.spec.ts | 15 +- tests/ui-specifiers.spec.ts | 27 +- tests/ui-stress.spec.ts | 2 +- tests/ui-tools.spec.ts | 59 +-- 22 files changed, 1318 insertions(+), 298 deletions(-) create mode 100644 src/components/clinical-dashboard/result-filter-control.tsx diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index a56235c41c..51f21dbd7b 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -712,3 +712,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 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/fix-lighthouse-chrome-pin | 02e7ec12d485b187f91141ce003895e85d23aae1 | lighthouse chrome pin + advisory mismatch warn | fix PR #1703 for PR #1697 advisory Chrome 150/151 drift | artifact grade exit0; --update catch-22 proved; node contract PASS; vitest blocked by shared nm | +| 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/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/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/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