Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
| 2026-08-17 | codex/pr-1998-fix | 89d764ec9df835c3cb477d4e71859e62b55311cb | pr | PARTIAL-FIX | typecheck, tests(sheets+ui-tools), merge main, docs format |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
| 2026-08-17 | codex/filter-system-overhaul | 7e55c066e20be34beca8441b2e0f9c40be5fcb95 | pr #1998 clinical filter overhaul | PASS | typecheck, vitest (filters, sheets, search band, dom panels), prettier |
4 changes: 2 additions & 2 deletions docs/design-system/COMPONENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -999,7 +999,7 @@ This generated snapshot is a local source-derived inventory. It does not assert
| `RetrievalStateBanner` | answer | yes | yes | inherited-global-root | yes | no | 1 |
| `SafeBoldText` | layout | yes | yes | inherited-global-root | yes | no | 8 |
| `SearchField` | controls | yes | yes | no | yes | no | 0 |
| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 10 |
| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 6 |
| `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 |
| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 25 |
| `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 |
Expand All@@ -1012,7 +1012,7 @@ This generated snapshot is a local source-derived inventory. It does not assert
| `TextField` | controls | yes | yes | inherited-global-root | yes | no | 4 |
| `TextLink` | controls | yes | yes | no | yes | no | 0 |
| `ToastRegion` | feedback | yes | yes | no | yes | no | 0 |
| `ToggleSwitch` | controls | yes | yes | inherited-global-root | yes | no | 2 |
| `ToggleSwitch` | controls | yes | yes | inherited-global-root | yes | no | 1 |
| `Tooltip` | feedback | yes | yes | no | yes | no | 0 |
| `VerificationNotice` | answer | yes | yes | inherited-global-root | yes | no | 1 |

Expand Down
24 changes: 5 additions & 19 deletions docs/design-system/adoption-manifest.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,7 +112,6 @@
"tests/accessible-table.dom.test.tsx",
"tests/design-sync-visual-exports.test.ts",
"tests/design-system-adoption.test.ts",
"tests/ui-tools.spec.ts",
"tests/ward-output.test.ts"
],
"baseline": {
Expand DownExpand Up@@ -1397,26 +1396,18 @@
"entryExported": true,
"directImportFiles": [
"src/components/applications-launcher-page.tsx",
"src/components/clinical-dashboard/differentials-home.tsx",
"src/components/clinical-dashboard/medication-prescribing-workspace.tsx",
"src/components/clinical-dashboard/patient-profile-panel.tsx",
"src/components/clinical-dashboard/settings-dialog.tsx",
"src/components/factsheets/factsheets-search-page.tsx",
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/services/services-navigator-page.tsx",
"src/components/specifiers/specifiers-home-page.tsx",
"src/components/tools/tools-search-results-page.tsx"
],
"productImportFiles": [
"src/components/applications-launcher-page.tsx",
"src/components/clinical-dashboard/differentials-home.tsx",
"src/components/clinical-dashboard/medication-prescribing-workspace.tsx",
"src/components/clinical-dashboard/patient-profile-panel.tsx",
"src/components/clinical-dashboard/settings-dialog.tsx",
"src/components/factsheets/factsheets-search-page.tsx",
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/services/services-navigator-page.tsx",
"src/components/specifiers/specifiers-home-page.tsx",
"src/components/tools/tools-search-results-page.tsx"
],
"designSync": {
Expand DownExpand Up@@ -1468,6 +1459,7 @@
"tests/answer-progress-ui-smoke.spec.ts",
"tests/design-sync-visual-exports.test.ts",
"tests/eval-process-safety.test.ts",
"tests/search-results-header-band.dom.test.tsx",
"tests/ui-formulation.spec.ts",
"tests/ui-smoke.spec.ts",
"tests/ui-v2-components.dom.test.tsx"
Expand DownExpand Up@@ -1513,9 +1505,9 @@
"src/components/document-viewer/document-clinical-summary.tsx",
"src/components/document-viewer/section-nav.tsx",
"src/components/forms/form-detail-page.tsx",
"src/components/forms/forms-search-results-page.tsx",
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/mode-nav/mode-nav.tsx",
"src/components/services/service-group-nav.tsx",
"src/components/tools/tools-search-results-page.tsx",
"src/components/ui/confirm-dialog.tsx"
],
Expand All@@ -1541,9 +1533,9 @@
"src/components/document-viewer/document-clinical-summary.tsx",
"src/components/document-viewer/section-nav.tsx",
"src/components/forms/form-detail-page.tsx",
"src/components/forms/forms-search-results-page.tsx",
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/mode-nav/mode-nav.tsx",
"src/components/services/service-group-nav.tsx",
"src/components/tools/tools-search-results-page.tsx"
],
"designSync": {
Expand DownExpand Up@@ -1901,14 +1893,8 @@
"source": "src/components/ui-primitives.tsx",
"sourceExported": true,
"entryExported": true,
"directImportFiles": [
"src/components/clinical-dashboard/patient-profile-panel.tsx",
"src/components/forms/forms-search-results-page.tsx"
],
"productImportFiles": [
"src/components/clinical-dashboard/patient-profile-panel.tsx",
"src/components/forms/forms-search-results-page.tsx"
],
"directImportFiles": ["src/components/clinical-dashboard/patient-profile-panel.tsx"],
"productImportFiles": ["src/components/clinical-dashboard/patient-profile-panel.tsx"],
"designSync": {
"listedInSourceMap": true,
"listedInDtsProps": true,
Expand Down
68 changes: 28 additions & 40 deletions docs/search-results-bar-decisions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,28 +60,24 @@ contract: see [search-chrome-behaviour.md](search-chrome-behaviour.md).
one control sitting flush against the sort group rendered as a different component. It
now uses the band's own control recipe — the same string `Save search` and `Retry` use —
with the active/resting colours as mutually exclusive branches.
- **Every mode's phone filter is now the badged trigger, not a select.** The `w-full`
native select is gone from all seven surfaces that shipped one — differentials, services,
factsheets, prescribing, the tools launcher, and formulation and specifiers, which each
passed _two_ in a two-column grid. Each now passes one `ResultFilterTrigger`
(`result-filter-control.tsx`) with `mobileControlsPlacement="inline"`, so the one-line
band is universal rather than a documents/therapy-compass exception. What the select cost:
a whole second band row; no way to say how many filters were active without spending
label width on it; and, because the iOS anti-zoom rule pins every native select to 16 px
below `sm`, a value rendered at the same size as the query heading above it. Single-choice
dimensions moved into `ResultFilterSheet`, one `role="radiogroup"` per dimension — real
radio semantics, because these are one-of-N and an `aria-pressed` bank asserts otherwise.
Documents keeps its own panel; multi-select facet groups with counts, a find-a-filter
field and collapse-by-default are not radios.
- **Every mode uses one adaptive filter system, not a phone-only substitute.** Each result
surface passes `ResultFilterTrigger` at phone and desktop breakpoints. The trigger opens
`ResultFilterSheet`, which is a bottom sheet on phones and a restrained right drawer from
`sm` up. The old native selects, bespoke desktop dropdowns and competing rails are gone.
Lenses render as roving radio groups; facets render as individually reachable multi-select
rows; `Search in` is a labelled, count-bearing scope selector. Dense catalogues gain search
and collapsible groups from the shared component rather than from route-local panels.
Documents now uses the same panel with staged application and a typed secondary Browse all
sources action. The filter contract, including mode ownership, is in
[filter-contract.md](filter-contract.md).
- **`mobileControlsPlacement` still defaults to `row`.** Nothing relies on that fallback
now that every caller passes `inline`, and it stays anyway: a new mode that forgets the
prop, or one with a genuine reason to hand over something full-width, should degrade to a
second row rather than to an unreadable 58 px line at 320 px. Do not flip the default.
- **Filter at the right edge, Sort inboard.** Sort is set about once a session. Filter is
the only control carrying state and the one returned to repeatedly, and on a phone the
right edge is where the thumb already is. The page filter is therefore the utility
rail's **last** child; `tests/ui-tools.spec.ts` asserts that placement and a matched
phone tap height, rather than the Sort/Filter adjacency it asserted before.
- **Filter is pinned at the right edge; Sort stays inboard.** Sort is set about once a
session. Filter carries narrowing state and is returned to repeatedly, so it is a pinned
sibling after the optional utility rail rather than a child that can scroll away.
`tests/ui-tools.spec.ts` asserts the placement and matched phone tap height.
- **The shelf.** A labelled `Filtered by` row under the bar, one tap to remove each
filter, trailing `Clear` once more than one is applied. The chips scroll in an inner
track; the label and `Clear` are pinned outside it, because the shelf was one
Expand All@@ -98,31 +94,23 @@ contract: see [search-chrome-behaviour.md](search-chrome-behaviour.md).
on every keystroke. Only a fault removes it, because filtering a result set that never
loaded is meaningless.

## The shelf is scoped to two modes, on purpose
## The shelf is shared state, not a mode exception

`documents` and `therapy-compass` only. Both have multi-valued filters hidden behind a
panel, so what is applied is not otherwise visible. Of the other ten results-band modes:
Any mode with hidden, multi-dimensional or non-default scope state passes `appliedFilters`
and `onClearFilters`. The shelf is prop-driven; the shared bar never infers refinement state
from context. `AppliedFilterChip` carries a compact `valueLabel`, an optional desktop
`groupLabel`, a complete `accessibleLabel` when necessary, and `onRemove`.

- Differentials, prescribing, specifiers, formulation, services, and factsheets keep a
single-choice dimension whose desktop control is already on screen, and whose phone
trigger carries a count badge, so a shelf would restate what is visible either way.
- Forms still ships a Filter trigger whose panel is a coming-soon placeholder, not applied
filter state, so there is nothing for a shelf to show.
- Favourites renders its own active-filter chips inside `filterControls` rather than the
shared shelf props.
- DSM filters by category through navigation links, and tools through a single category
dimension — neither passes `appliedFilters`.
The shelf is required for Documents, Services, Forms, Differential workspaces, DSM,
Specifiers, Formulation, Medication, Therapy, Calculators when narrowed, and Favourites. A
compact category lens may omit it only when the active value remains directly visible at that
breakpoint. Selected retrieval and result facets remain visible at zero results so recovery is
always possible.

Two traps met while drawing that line:

- **Count what a control does, not how many there are.** Formulation's "Pattern" and
factsheets' "Category" look like filters and are navigation (`router.push`). Services'
"quick filter" rewrites the query. None of them belong behind a filter surface.
- **A shared component must not read filter state from context.** The original shelf
pulled `commandScopes` from a context that no page populated. It passed a DOM test which
constructed that context by hand, and rendered for nobody in production. The replacement
is prop-driven — the page supplies `appliedFilters` (`id`, `label`, `onRemove`) and an
optional `onClearFilters`. Keep it that way.
Count what narrows results, not how many controls happen to be visible. Scope is counted when
non-default. Sort, display density, grouping, comparison state, suggested searches and
Recently used views are excluded. Query-replacing presets belong near the composer, never in
the shelf or filter badge.

## Deliberately not done

Expand Down
9 changes: 2 additions & 7 deletions src/app/(search-app)/dsm/search/page.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import type { Metadata } from "next";

import { DsmSearchPage } from "@/components/dsm/dsm-search-page";
import { dsmCategories, dsmDiagnoses, listDsmDiagnosisSummaries } from "@/lib/dsm";
import { dsmCategories, listDsmDiagnosisSummaries } from "@/lib/dsm";

export const metadata: Metadata = {
title: "Search DSM diagnoses | Clinical KB",
Expand All@@ -13,7 +13,6 @@ type DsmSearchRouteProps = {
searchParams?: Promise<{
q?: string | string[];
query?: string | string[];
category?: string | string[];
ids?: string | string[];
}>;
};
Expand All@@ -25,8 +24,6 @@ function firstValue(value?: string | string[]) {
export default async function DsmSearchRoute({ searchParams }: DsmSearchRouteProps) {
const params = searchParams ? await searchParams : {};
const query = (firstValue(params.q) ?? firstValue(params.query) ?? "").trim();
const requestedCategory = firstValue(params.category)?.trim();
const category = dsmCategories.some((item) => item.key === requestedCategory) ? requestedCategory : undefined;
const rawIds = firstValue(params.ids) ?? "";
const initialIds = rawIds
? rawIds
Expand All@@ -38,10 +35,8 @@ export default async function DsmSearchRoute({ searchParams }: DsmSearchRoutePro
return (
<DsmSearchPage
query={query}
category={category}
categories={dsmCategories}
results={listDsmDiagnosisSummaries({ query, category })}
totalCount={dsmDiagnoses.length}
results={listDsmDiagnosisSummaries({ query })}
initialIds={initialIds}
/>
);
Expand Down
16 changes: 15 additions & 1 deletion src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2187,7 +2187,7 @@ export function ClinicalDashboard({
if (searchMode === "documents" && trimmedQuery) {
rememberRecentQuery(trimmedQuery);
autoRunSearchSignatureRef.current = searchSubmissionSignature(searchMode, trimmedQuery, navigationContext);
window.history.pushState(
window.history[replaceExistingAnswer ? "replaceState" : "pushState"](
null,
"",
documentsSearchHref({
Expand DownExpand Up@@ -3217,6 +3217,18 @@ export function ClinicalDashboard({
const handleCrossModeSearch = useEventCallback(crossModeSearch);
const handleDocumentTagSearch = useEventCallback(handleTagSearch);
const handleScopeFiltersChange = useScopeFilterRelax(query, queryMode, setScopeFilters, ask);
const handleDocumentFiltersApply = useEventCallback((filters: SearchScopeFilters, documentIds: string[]) => {
setScopeFilters(filters);
setSelectedDocumentIds(documentIds);
const trimmedQuery = query.trim();
if (!trimmedQuery) return;
// Let the selected-source state commit before `ask` builds the private
// scope reference. Calling the live ref prevents a stale render closure
// from submitting the previous source selection.
window.requestAnimationFrame(() => {
void askRef.current(trimmedQuery, { queryMode, scopeFilters: filters }, true);
});
});
const handleOpenRecentDocuments = useEventCallback(openRecentDocuments);
const handleOpenSourceLibrary = useEventCallback(openSourceLibrary);
const handleDocumentsDrawerOpenChange = useEventCallback((nextOpen: boolean) => {
Expand DownExpand Up@@ -3762,6 +3774,8 @@ export function ClinicalDashboard({
onTagSearch={handleDocumentTagSearch}
scopeFilters={searchMode === "documents" ? scopeFilters : null}
onScopeFiltersChange={searchMode === "documents" ? handleScopeFiltersChange : undefined}
selectedDocumentIds={searchMode === "documents" ? selectedDocumentIds : []}
onDocumentFiltersApply={searchMode === "documents" ? handleDocumentFiltersApply : undefined}
showHome={searchMode === "documents" && !modeSearchSubmitted}
desktopComposerSlotId={desktopHomeComposerSlotId}
/>
Expand Down
Loading
Loading