From ca46ac0c170a5cec725d88be0d14a77fdf71695a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:39:00 +0800 Subject: [PATCH 1/4] feat(dictionary): add the eight-route Dictionary reference mode Add Dictionary as a full app mode with a governed 96-entry static catalogue, shared search/filter logic, and eight routes under src/app/(search-app)/dictionary. - Routes: Search, Browse, Topics, Topic detail, Definition, Compare, Sources and the shared mode home. - Unified open result rows, abbreviation ambiguity handling, URL-owned filters, topic collections, responsive comparisons and source governance surfaces. - Reference Spine visual system, simplified definition status, phone disclosures, and shared navigation/search ownership (no second composer, no responsive tables). - Integrated with the application launcher, ModeNav, universal search, tools catalogue, sitemap, codebase index, route reachability and the design-system adoption manifests. - Static source-governed content only: no generated clinical synthesis, no retrieval or ranking surface touched. Co-Authored-By: Claude Opus 5 --- docs/codebase-index.md | 10 +- docs/design-system/ADOPTION.md | 8 +- docs/design-system/COMPONENTS.md | 4 +- docs/design-system/adoption-contract.json | 8 + docs/design-system/adoption-manifest.json | 122 ++- docs/redesign/dictionary-reference-spine.md | 50 + docs/site-map.md | 9 + playwright.config.ts | 4 +- scripts/generate-site-map.ts | 1 + scripts/playwright-pr-shards.mjs | 4 +- .../(search-app)/dictionary/[slug]/page.tsx | 26 + .../(search-app)/dictionary/browse/page.tsx | 18 + .../(search-app)/dictionary/compare/page.tsx | 25 + src/app/(search-app)/dictionary/layout.tsx | 5 + src/app/(search-app)/dictionary/page.tsx | 12 + .../(search-app)/dictionary/search/page.tsx | 18 + .../(search-app)/dictionary/sources/page.tsx | 12 + .../dictionary/topics/[slug]/page.tsx | 32 + .../(search-app)/dictionary/topics/page.tsx | 18 + src/components/ClinicalDashboard.tsx | 2 +- src/components/applications-launcher-page.tsx | 2 + .../clinical-dashboard/ClinicalSidebar.tsx | 1 + .../master-search-header.tsx | 28 +- .../clinical-dashboard/mode-action-popup.tsx | 43 +- .../universal-search-command-surface.tsx | 6 +- .../clinical-dashboard/use-sidebar-pins.ts | 1 + .../dictionary/dictionary-catalogue-pages.tsx | 891 ++++++++++++++++ .../dictionary/dictionary-compare-page.tsx | 531 ++++++++++ .../dictionary/dictionary-home-page.tsx | 75 ++ .../dictionary/dictionary-result-row.tsx | 185 ++++ .../dictionary/dictionary-sources-page.tsx | 231 +++++ .../dictionary/dictionary-term-page.tsx | 401 ++++++++ src/components/mode-nav/header-addon-slot.ts | 3 + src/components/mode-nav/registry-mode-nav.tsx | 5 + .../tools/tools-search-results-page.tsx | 2 + src/lib/app-mode-icons.ts | 2 + src/lib/app-modes.ts | 35 +- src/lib/dictionary-data.ts | 965 ++++++++++++++++++ src/lib/dictionary.ts | 299 ++++++ src/lib/information-pages.ts | 5 + src/lib/mode-secondary-navigation.ts | 49 + src/lib/search-route-ownership.ts | 3 + src/lib/search-shell-props.ts | 4 + src/lib/tools-catalog.ts | 19 + src/lib/ui-copy.ts | 3 + src/lib/universal-search-domains.ts | 2 + src/lib/universal-search-mode-context.ts | 2 + src/lib/universal-search.ts | 53 + tests/app-modes.test.ts | 1 + tests/design-system-adoption.test.ts | 6 +- tests/dictionary-data.test.ts | 99 ++ tests/dictionary-term-page.dom.test.tsx | 42 + tests/information-page-shell.dom.test.tsx | 4 + tests/mode-nav-addon-slot.dom.test.tsx | 2 + tests/mode-secondary-navigation.test.ts | 7 +- tests/search-results-band-adoption.test.ts | 4 + tests/shared-home-empty-state.dom.test.tsx | 5 + tests/ui-dictionary.spec.ts | 162 +++ tests/ui-mode-nav-density.spec.ts | 1 + tests/ui-tools.spec.ts | 8 +- 60 files changed, 4540 insertions(+), 35 deletions(-) create mode 100644 docs/redesign/dictionary-reference-spine.md create mode 100644 src/app/(search-app)/dictionary/[slug]/page.tsx create mode 100644 src/app/(search-app)/dictionary/browse/page.tsx create mode 100644 src/app/(search-app)/dictionary/compare/page.tsx create mode 100644 src/app/(search-app)/dictionary/layout.tsx create mode 100644 src/app/(search-app)/dictionary/page.tsx create mode 100644 src/app/(search-app)/dictionary/search/page.tsx create mode 100644 src/app/(search-app)/dictionary/sources/page.tsx create mode 100644 src/app/(search-app)/dictionary/topics/[slug]/page.tsx create mode 100644 src/app/(search-app)/dictionary/topics/page.tsx create mode 100644 src/components/dictionary/dictionary-catalogue-pages.tsx create mode 100644 src/components/dictionary/dictionary-compare-page.tsx create mode 100644 src/components/dictionary/dictionary-home-page.tsx create mode 100644 src/components/dictionary/dictionary-result-row.tsx create mode 100644 src/components/dictionary/dictionary-sources-page.tsx create mode 100644 src/components/dictionary/dictionary-term-page.tsx create mode 100644 src/lib/dictionary-data.ts create mode 100644 src/lib/dictionary.ts create mode 100644 tests/dictionary-data.test.ts create mode 100644 tests/dictionary-term-page.dom.test.tsx create mode 100644 tests/ui-dictionary.spec.ts diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 0f6aae5f17..755dc6562b 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -62,7 +62,7 @@ Smaller top-level directories that are easy to miss: - **PWA:** `docs/pwa.md` — install assets, privacy-first service worker/offline shell, lifecycle, security, and verification - **Home:** `src/app/(search-app)/page.tsx` — dashboard rendered by shell - **Dashboard:** `src/components/ClinicalDashboard.tsx` + `src/components/clinical-dashboard/` -- **Modes (13):** `src/lib/app-modes.ts` — answer, documents, services, forms, favourites, differentials, DSM-5 diagnosis, specifiers, formulation, prescribing, tools, Therapy, Factsheets +- **Modes (15):** `src/lib/app-modes.ts` — answer, documents, services, forms, favourites, differentials, DSM-5 diagnosis, specifiers, formulation, prescribing, tools, calculators, Therapy, Factsheets, Dictionary ### Product pages (`src/app/`) @@ -76,6 +76,7 @@ Smaller top-level directories that are easy to miss: | `/dsm`, `/dsm/search`, `/dsm/compare`, `/dsm/diagnoses/[slug]` | `src/app/(search-app)/dsm/` | | `/documents/search`, `/source`, `/evidence`, `/[id]` | `src/app/(search-app)/documents/` | | `/factsheets`, `/factsheets/search`, `/factsheets/[slug]` | `src/app/(search-app)/factsheets/` | +| `/dictionary`, Search, Browse, Topics, Definition, Compare, Sources | `src/app/(search-app)/dictionary/` | | `/favourites` | `src/app/(search-app)/favourites/page.tsx` | | `/forms`, `/forms/[slug]` | `src/app/(search-app)/forms/` | | `/medications`, `/medications/[slug]` | `src/app/(search-app)/medications/` | @@ -166,6 +167,7 @@ domain-extracted directory; imported as `@/lib/rag/rag*`). Other modules below r | Module | Role | | -------------------------------------------------------------------- | ------------------------------------------------------- | | `differentials.ts`, `forms.ts`, `services.ts`, `registry-records.ts` | Shared catalogue content with optional owner overrides | +| `dictionary-data.ts`, `dictionary.ts` | Governed terminology, sources, topics, aliases, filters | | `dsm.ts` | Local DSM diagnosis catalogue and comparison helpers | | `formulation.ts` | Local formulation mechanism library and builder helpers | | `clinical-safety.ts`, `demo-data.ts`, `ui-copy.ts` | Safety copy and demo mode | @@ -324,12 +326,12 @@ sequenceDiagram One shared composer (`master-search-header.tsx`) serves every mode. Placement: -- **Mode homes** (`/services`, `/forms`, `/favourites`, `/differentials`, `/dsm`, `/specifiers`, `/formulation`, `/factsheets`, `/therapy-compass`, `/tools`, and dashboard homes): inline in the hero via the `mode-home-composer-slot` portal, on phone and tablet+ alike. (`/applications` is a redirect to `/tools`, not a composer surface.) -- **Information (detail) pages**: catalogue/record routes under each mode (`/services/[slug]`, `/forms/[slug]`, `/medications/[slug]`, `/specifiers/[slug]`, `/formulation/[slug]`, `/factsheets/[slug]`, `/therapy-compass/[slug]`, `/dsm/diagnoses/[slug]`, …). Route detection: `src/lib/information-pages.ts` (`isInformationPage`). Shared outer chrome: `src/components/information-page-shell.tsx` (`InformationPageShell`, breadcrumbs, optional footer). Specifier/formulation mode shells re-export that primitive. Intentional opt-outs: document viewer and the differentials presentation workflow. +- **Mode homes** (`/services`, `/forms`, `/favourites`, `/differentials`, `/dsm`, `/specifiers`, `/formulation`, `/factsheets`, `/dictionary`, `/therapy-compass`, `/tools`, and dashboard homes): inline in the hero via the `mode-home-composer-slot` portal, on phone and tablet+ alike. (`/applications` is a redirect to `/tools`, not a composer surface.) +- **Information (detail) pages**: catalogue/record routes under each mode (`/services/[slug]`, `/forms/[slug]`, `/medications/[slug]`, `/specifiers/[slug]`, `/formulation/[slug]`, `/factsheets/[slug]`, `/dictionary/[slug]`, `/dictionary/topics/[slug]`, `/therapy-compass/[slug]`, `/dsm/diagnoses/[slug]`, …). Route detection: `src/lib/information-pages.ts` (`isInformationPage`). Shared outer chrome: `src/components/information-page-shell.tsx` (`InformationPageShell`, breadcrumbs, optional footer). Specifier/formulation mode shells re-export that primitive. Intentional opt-outs: document viewer and the differentials presentation workflow. - **Result and detail views**: fixed bottom dock on phone (compact variant on submitted searches), sticky top from `sm` up. - **Results routing**: standalone routes own their submitted searches via `?q=…&run=1` (`/services` → `ServicesNavigatorPage`, `/forms` → `FormsSearchResultsPage`, `/differentials` → `DifferentialsHome` results view, `/formulation` → local mechanism results, `/favourites` filters the command library in place). Answer, Documents, and Prescribing submitted searches render inside `ClinicalDashboard` — intentional, since they need retrieval/answer state. Bare `/?mode=` always renders the shared home with that mode preselected; only a submitted deep link (`q` plus `run=1`) resolves to the mode's own search surface (proxy early-redirect still covers favourites/differentials/specifiers for those submitted aliases). - **Intentionally composer-free routes**: `/differentials/presentations/*` and `/differentials/compare` (comparison workflow owns its chrome), `/documents/[id]` viewer (has its own in-document ask composer), `/documents/source/*` (document flow owns mobile chrome). Do not re-flag these in search-consistency audits. -- **Shared in-page navigation**: `src/components/in-page-nav/` is the default template for section navigation on any mode page (`docs/search-chrome-behaviour.md`). `in-page-nav-header.tsx` (`InPageNavHeader`) owns the header row, both sheets and the `PhoneHeaderCollapsePortal` wrapper; `page-section-index.ts` (`PageSection`, `toDocumentSections`, `sectionTargetIds`) is the declaration shape; `use-resolved-page-sections.ts` narrows a declaration to the anchors actually rendered at this breakpoint; `use-in-page-section-nav.ts` composes that with `useDocumentSectionSpy` and `jumpToDocumentSection`; `use-page-section-weights.ts` measures segment weights; `use-in-page-chrome-metrics.ts` publishes `--inpage-anchor-offset`; `in-page-nav-classes.ts` holds the shared anchor (`inPageAnchor`) and actions-sheet row classes; `in-page-section-rail.tsx` (`InPageSectionRail`) is the optional visible second rail, opted into with `rail={{ label }}` by panel-swap routes with few sections (medications only) in place of the weighted track. Anchor measurement itself is `src/components/sticky-chrome-metrics.ts` (`useStickyChromeMetrics`), shared with the document viewer's `use-document-chrome-metrics.ts`. Mounted by `differentials/differential-detail-page.tsx`, `services/service-detail-page.tsx`, `forms/form-detail-page.tsx`, `dsm/dsm-differential-considerations-page.tsx`, and — through a colocated `"use client"` nav-header sibling that owns and exports the route's section table — `specifiers/specifier-nav-header.tsx`, `formulation/formulation-nav-header.tsx`, `dsm/dsm-diagnosis-nav-header.tsx`, `factsheets/factsheet-nav-header.tsx` and `clinical-dashboard/medication-nav-header.tsx`. The sibling is mandatory for the four Server Component pages (neither `onSelectSection` nor a `LucideIcon` crosses the RSC boundary) and the convention for the rest. Two adopters swap panels instead of scrolling — `differential-detail-page.tsx` and the medication record page — so they pass explicit weights, carry no `inPageAnchor`, and use neither `useResolvedPageSections` nor the scroll spy. Every declared section is pinned against rendered DOM by `tests/in-page-nav-route-sections.dom.test.tsx` (anchors for the scrolling routes, swapped-in panels for the tab routes). +- **Shared in-page navigation**: `src/components/in-page-nav/` is the default template for section navigation on any mode page (`docs/search-chrome-behaviour.md`). `in-page-nav-header.tsx` (`InPageNavHeader`) owns the header row, both sheets and the `PhoneHeaderCollapsePortal` wrapper; `page-section-index.ts` (`PageSection`, `toDocumentSections`, `sectionTargetIds`) is the declaration shape; `use-resolved-page-sections.ts` narrows a declaration to the anchors actually rendered at this breakpoint; `use-in-page-section-nav.ts` composes that with `useDocumentSectionSpy` and `jumpToDocumentSection`; `use-page-section-weights.ts` measures segment weights; `use-in-page-chrome-metrics.ts` publishes `--inpage-anchor-offset`; `in-page-nav-classes.ts` holds the shared anchor (`inPageAnchor`) and actions-sheet row classes; `in-page-section-rail.tsx` (`InPageSectionRail`) is the optional visible second rail, opted into with `rail={{ label }}` by panel-swap routes with few sections (medications only) in place of the weighted track. Anchor measurement itself is `src/components/sticky-chrome-metrics.ts` (`useStickyChromeMetrics`), shared with the document viewer's `use-document-chrome-metrics.ts`. Mounted by `dictionary/dictionary-term-page.tsx`, Dictionary topic detail in `dictionary/dictionary-catalogue-pages.tsx`, `differentials/differential-detail-page.tsx`, `services/service-detail-page.tsx`, `forms/form-detail-page.tsx`, `dsm/dsm-differential-considerations-page.tsx`, and — through a colocated `"use client"` nav-header sibling that owns and exports the route's section table — `specifiers/specifier-nav-header.tsx`, `formulation/formulation-nav-header.tsx`, `dsm/dsm-diagnosis-nav-header.tsx`, `factsheets/factsheet-nav-header.tsx` and `clinical-dashboard/medication-nav-header.tsx`. The sibling is mandatory for the four Server Component pages (neither `onSelectSection` nor a `LucideIcon` crosses the RSC boundary) and the convention for the rest. Two adopters swap panels instead of scrolling — `differential-detail-page.tsx` and the medication record page — so they pass explicit weights, carry no `inPageAnchor`, and use neither `useResolvedPageSections` nor the scroll spy. Every declared section is pinned against rendered DOM by `tests/in-page-nav-route-sections.dom.test.tsx` (anchors for the scrolling routes, swapped-in panels for the tab routes) and focused Dictionary DOM contracts. - **Shared secondary navigation**: `src/components/page-secondary-navigation.tsx` (`PageSecondaryNavigation`, mode destinations only). Mode destinations come from `src/lib/mode-secondary-navigation.ts` (`modeSecondaryNavigationRegistry`, no "Home" item). `GlobalSearchShell` renders it in normal flow at the top of `#main-content` for its owned namespaced modes; it self-suppresses on clean mode homes, on Therapy Compass, and on every information page — `hasLocalInformationPageNavigation` is now just `isInformationPage`, because each of those routes owns its own in-page navigation. The older shared `SecondaryNavigation` component was deleted here (`/issues #271`): its `section` kind and "On this page" pill rail went when the last six information routes moved onto `InPageNavHeader`, and the surviving `route`/`action` kinds had no production constructor left — `RegistryModeNav` renders `ModeNav`, not `SecondaryNavigation`, so the only remaining caller was its own test file, which went with it. - **Patient context (decision support)**: the app's patient-specific surface, spanning two engines over one session-scoped profile. Store: `src/lib/patient-profile-storage.ts` (sessionStorage external store, anonymous physiology + catalogue medication slugs, cleared on tab close) behind `clinical-dashboard/patient-profile-context.tsx` (`PatientProfileProvider`, mounted for the whole shell). Engines: `src/lib/medication-patient-alerts.ts` (physiology — age/renal/hepatic/QTc/pregnancy/allergy, with an `unassessed` fail-safe) and `src/lib/medication-interactions.ts` (drug–drug, against the entered medication list). The interaction engine reads the generated `data/medication-interaction-index.json`, built by `scripts/build-medication-interaction-index.ts` (`npm run medications:interactions`, staleness-gated by `check:medication-interactions`) from the curated `src/lib/medication-interaction-lexicon.ts` — the catalogue's `Key Interactions` rows are prose, and that lexicon is what resolves their class/mechanism/non-drug terms to catalogue targets. `composeMedicationVerdict` folds both engines into the one tone a result row wears; **green is unreachable whenever either engine left something unassessed or unresolved**, degrading to a neutral "needs manual review" instead. UI: `clinical-dashboard/patient-profile-panel.tsx` (the form, including the medication picker), `medication-considerations.tsx` (both detail-page blocks and the verdict badge/edge helpers), and `patient-details-dock-action.tsx` (the phone dock pill — see the addon-slot section of `docs/search-chrome-behaviour.md`). Regulatory status is **open**: `docs/samd-classification-medication-considerations.md`. - **Local filter fields** (sidebar "Search chats", document drawer "Find a document"/"Find a source PDF") are scoped filters, not global search; they share the `fieldControlWithIcon`/`fieldIcon` primitives. diff --git a/docs/design-system/ADOPTION.md b/docs/design-system/ADOPTION.md index 8219dd6f87..024c7f989a 100644 --- a/docs/design-system/ADOPTION.md +++ b/docs/design-system/ADOPTION.md @@ -359,12 +359,12 @@ product exclusions; the only route-only disposition is the documented legacy doc redirect. Shared shell/component roots carry their own explicit `shared-shell` disposition. Registered public components: 54 -Declared product roots: 59 +Declared product roots: 67 Roots with a literal `.ckb-v2` opt-in: 1 -Roots inheriting `.ckb-v2` from the global ``: 58 +Roots inheriting `.ckb-v2` from the global ``: 66 Production surfaces observed under v2: 14/14 Dynamic `ckb-v2` constructions: 0 -Declared production page routes: 51/51 +Declared production page routes: 59/59 Source observation and contract declaration are independent. A literal `ckb-v2` on the global `` makes every production surface inherit v2, but it does not approve that adoption. The Proof column summarizes each surface's dark, forced-colours, 320px, print and browser declarations; exact statuses and evidence paths live in the manifest. @@ -373,7 +373,7 @@ Observed v2 under a compatibility declaration fails closed. A declared v2 shell | Surface | Disposition | Routes | Roots | Declared shell | Observed shell (mount) | Proof | Baseline | | ---------------------------------- | --------------- | -----: | ----: | -------------- | -------------------------- | -------------- | -------------- | | `root-shell-and-settings` | shared-shell | 3 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | -| `catalogues-forms-and-info` | owned | 13 | 13 | v2 | v2 (inherited-global-root) | passed | not-committed | +| `catalogues-forms-and-info` | owned | 21 | 21 | v2 | v2 (inherited-global-root) | passed | not-committed | | `differentials` | owned | 6 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | | `formulation` | owned | 5 | 5 | v2 | v2 (inherited-global-root) | passed | not-committed | | `specifiers` | owned | 5 | 5 | v2 | v2 (inherited-global-root) | passed | not-committed | diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 5d22c379bc..76d845ebae 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -987,7 +987,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `IconButton` | controls | yes | yes | inherited-global-root | yes | no | 2 | | `InlineNotice` | feedback | yes | yes | inherited-global-root | yes | no | 7 | | `LinkAction` | controls | yes | yes | no | yes | no | 0 | -| `LoadingPanel` | feedback | yes | yes | inherited-global-root | yes | no | 7 | +| `LoadingPanel` | feedback | yes | yes | inherited-global-root | yes | no | 11 | | `MissingValue` | feedback | yes | yes | inherited-global-root | yes | no | 3 | | `OverlayRoot` | layout | yes | yes | inherited-global-root | yes | no | 1 | | `PageHeader` | layout | yes | yes | inherited-global-root | yes | no | 2 | @@ -1001,7 +1001,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 | 8 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 25 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 26 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 2 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-contract.json b/docs/design-system/adoption-contract.json index c8a679bb5d..db2af5e638 100644 --- a/docs/design-system/adoption-contract.json +++ b/docs/design-system/adoption-contract.json @@ -118,6 +118,14 @@ "id": "catalogues-forms-and-info", "disposition": "owned", "routes": [ + "src/app/(search-app)/dictionary/[slug]/page.tsx", + "src/app/(search-app)/dictionary/browse/page.tsx", + "src/app/(search-app)/dictionary/compare/page.tsx", + "src/app/(search-app)/dictionary/page.tsx", + "src/app/(search-app)/dictionary/search/page.tsx", + "src/app/(search-app)/dictionary/sources/page.tsx", + "src/app/(search-app)/dictionary/topics/[slug]/page.tsx", + "src/app/(search-app)/dictionary/topics/page.tsx", "src/app/(search-app)/dsm/compare/page.tsx", "src/app/(search-app)/dsm/diagnoses/[slug]/differentials/page.tsx", "src/app/(search-app)/dsm/diagnoses/[slug]/page.tsx", diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 6f34baf01c..039ce26547 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -999,6 +999,10 @@ "sourceExported": true, "entryExported": true, "directImportFiles": [ + "src/app/(search-app)/dictionary/browse/page.tsx", + "src/app/(search-app)/dictionary/search/page.tsx", + "src/app/(search-app)/dictionary/topics/[slug]/page.tsx", + "src/app/(search-app)/dictionary/topics/page.tsx", "src/components/clinical-dashboard/clinical-dashboard-lazy.tsx", "src/components/clinical-dashboard/dashboard-nav.tsx", "src/components/clinical-dashboard/document-search-results.tsx", @@ -1008,6 +1012,10 @@ "src/components/therapy-compass/ui.tsx" ], "productImportFiles": [ + "src/app/(search-app)/dictionary/browse/page.tsx", + "src/app/(search-app)/dictionary/search/page.tsx", + "src/app/(search-app)/dictionary/topics/[slug]/page.tsx", + "src/app/(search-app)/dictionary/topics/page.tsx", "src/components/clinical-dashboard/clinical-dashboard-lazy.tsx", "src/components/clinical-dashboard/dashboard-nav.tsx", "src/components/clinical-dashboard/document-search-results.tsx", @@ -1512,6 +1520,7 @@ "src/components/clinical-dashboard/patient-details-dock-action.tsx", "src/components/clinical-dashboard/result-filter-control.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", + "src/components/dictionary/dictionary-compare-page.tsx", "src/components/differentials/diagnosis-map-panel.tsx", "src/components/document-viewer/document-clinical-summary.tsx", "src/components/document-viewer/section-nav.tsx", @@ -1540,6 +1549,7 @@ "src/components/clinical-dashboard/patient-details-dock-action.tsx", "src/components/clinical-dashboard/result-filter-control.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", + "src/components/dictionary/dictionary-compare-page.tsx", "src/components/differentials/diagnosis-map-panel.tsx", "src/components/document-viewer/document-clinical-summary.tsx", "src/components/document-viewer/section-nav.tsx", @@ -2137,6 +2147,14 @@ "disposition": "owned", "documentedDisposition": null, "routes": [ + "src/app/(search-app)/dictionary/[slug]/page.tsx", + "src/app/(search-app)/dictionary/browse/page.tsx", + "src/app/(search-app)/dictionary/compare/page.tsx", + "src/app/(search-app)/dictionary/page.tsx", + "src/app/(search-app)/dictionary/search/page.tsx", + "src/app/(search-app)/dictionary/sources/page.tsx", + "src/app/(search-app)/dictionary/topics/[slug]/page.tsx", + "src/app/(search-app)/dictionary/topics/page.tsx", "src/app/(search-app)/dsm/compare/page.tsx", "src/app/(search-app)/dsm/diagnoses/[slug]/differentials/page.tsx", "src/app/(search-app)/dsm/diagnoses/[slug]/page.tsx", @@ -2189,6 +2207,86 @@ "permittedComponentFamilies": ["controls", "feedback", "layout", "source"], "sanctionedSpecialPatterns": ["DetailPage", "ModeHome", "SearchPage"], "roots": [ + { + "file": "src/app/(search-app)/dictionary/[slug]/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [] + }, + { + "file": "src/app/(search-app)/dictionary/browse/page.tsx", + "exists": true, + "imports": ["LoadingPanel"], + "importedFamilies": ["feedback"], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [] + }, + { + "file": "src/app/(search-app)/dictionary/compare/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [] + }, + { + "file": "src/app/(search-app)/dictionary/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [] + }, + { + "file": "src/app/(search-app)/dictionary/search/page.tsx", + "exists": true, + "imports": ["LoadingPanel"], + "importedFamilies": ["feedback"], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["SearchPage"] + }, + { + "file": "src/app/(search-app)/dictionary/sources/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [] + }, + { + "file": "src/app/(search-app)/dictionary/topics/[slug]/page.tsx", + "exists": true, + "imports": ["LoadingPanel"], + "importedFamilies": ["feedback"], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["DetailPage"] + }, + { + "file": "src/app/(search-app)/dictionary/topics/page.tsx", + "exists": true, + "imports": ["LoadingPanel"], + "importedFamilies": ["feedback"], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [] + }, { "file": "src/app/(search-app)/dsm/compare/page.tsx", "exists": true, @@ -3314,6 +3412,14 @@ "routeCoverage": { "discovered": [ "src/app/(search-app)/calculators/page.tsx", + "src/app/(search-app)/dictionary/[slug]/page.tsx", + "src/app/(search-app)/dictionary/browse/page.tsx", + "src/app/(search-app)/dictionary/compare/page.tsx", + "src/app/(search-app)/dictionary/page.tsx", + "src/app/(search-app)/dictionary/search/page.tsx", + "src/app/(search-app)/dictionary/sources/page.tsx", + "src/app/(search-app)/dictionary/topics/[slug]/page.tsx", + "src/app/(search-app)/dictionary/topics/page.tsx", "src/app/(search-app)/differentials/compare/page.tsx", "src/app/(search-app)/differentials/diagnoses/[slug]/page.tsx", "src/app/(search-app)/differentials/diagnoses/page.tsx", @@ -3367,6 +3473,14 @@ ], "declared": [ "src/app/(search-app)/calculators/page.tsx", + "src/app/(search-app)/dictionary/[slug]/page.tsx", + "src/app/(search-app)/dictionary/browse/page.tsx", + "src/app/(search-app)/dictionary/compare/page.tsx", + "src/app/(search-app)/dictionary/page.tsx", + "src/app/(search-app)/dictionary/search/page.tsx", + "src/app/(search-app)/dictionary/sources/page.tsx", + "src/app/(search-app)/dictionary/topics/[slug]/page.tsx", + "src/app/(search-app)/dictionary/topics/page.tsx", "src/app/(search-app)/differentials/compare/page.tsx", "src/app/(search-app)/differentials/diagnoses/[slug]/page.tsx", "src/app/(search-app)/differentials/diagnoses/page.tsx", @@ -3424,7 +3538,7 @@ }, "adoption": { "literalCkbV2RootCount": 1, - "inheritedCkbV2RootCount": 58, + "inheritedCkbV2RootCount": 66, "dynamicCkbV2RootCount": 0, "v2MountedSurfaceCount": 14, "declaredV2SurfaceCount": 14 @@ -3433,8 +3547,8 @@ "registeredComponentCount": 54, "previewCount": 54, "productImportedComponentCount": 33, - "rootCount": 59, - "productionRouteCount": 51, - "nextUiEntryCount": 103 + "rootCount": 67, + "productionRouteCount": 59, + "nextUiEntryCount": 112 } } diff --git a/docs/redesign/dictionary-reference-spine.md b/docs/redesign/dictionary-reference-spine.md new file mode 100644 index 0000000000..c4787fc669 --- /dev/null +++ b/docs/redesign/dictionary-reference-spine.md @@ -0,0 +1,50 @@ +# Dictionary Reference Spine + +## Product goal + +Dictionary is a source-governed psychiatric terminology reference. It supports rapid lookup, abbreviation resolution, governed topic discovery, term distinction and field-aligned comparison. It does not generate clinical synthesis or patient-specific guidance. + +## Production routes + +- `/dictionary` — shared mode home and in-flow composer. +- `/dictionary/search` — mixed definitions, grouped abbreviations and topic hits. +- `/dictionary/browse` — A–Z and abbreviation catalogue views. +- `/dictionary/topics` — governed clinical collections. +- `/dictionary/topics/[slug]` — searchable collection detail. +- `/dictionary/[slug]` — canonical definition and sources. +- `/dictionary/compare` — empty, partial, picker and populated comparison states. +- `/dictionary/sources` — source method, coverage, review and corrections. + +## Visual grammar + +The accepted Reference Spine is the primary system: + +- open ruled lists, not card walls or responsive tables; +- a three-pixel categorical spine for definition, abbreviation and topic identity; +- definitions remain visually primary while badges, metadata and source state remain compact; +- clinical blue identifies canonical entries, violet abbreviations and teal topic collections; +- phone controls have a 48-pixel minimum target and preserve readable body type; +- definition pages use one prominent definition block, one quiet source summary and progressive disclosure below it; +- desktop rails hold contextual metadata only and never duplicate the primary reading canvas. + +## Shared owners + +Dictionary extends the existing app-mode, mode navigation, universal search, search shell, information-page, launcher, tools-catalogue and site-map registries. Search, Browse, Topic detail and Definition reuse the shared composer/header owners. `InPageNavHeader` and `PhoneHeaderCollapsePortal` are the only phone in-page header owner on definition and topic-detail routes. + +## Content and governance + +`src/lib/dictionary-data.ts` is the static governed catalogue. UI counts are derived from its records. Every published entry has direct HTTP(S) source references, source-check dates, a scheduled source review and internal `clinicalApproval: "pending"`. Source checking confirms source support; it does not claim specialist clinical approval. + +Aliases may map to more than one canonical sense. ACT intentionally resolves to Acceptance and commitment therapy and Assertive community treatment. Curated relationship summaries appear only for explicitly sourced comparison pairs; arbitrary comparison aligns stored fields without interpretation. + +## Responsive behaviour + +- Search uses the shared submitted-search phone dock and never adds a second composer. +- Topic and definition pages portal their in-page header into the universal phone collapse row. +- Definition Meaning is expanded by default; other sections are 48-pixel disclosure rows on phone. +- Comparison stacks A and B within accordion sections on phone. +- Desktop-only columns and rails become disclosure content, never compressed phone tables. + +## Safety statement + +All catalogue copy is static and paraphrased from direct public authorities. The implementation makes no provider call, uses no patient data, reproduces no proprietary criteria or rating instrument, and retains the footer: reference terminology, not patient-specific guidance. diff --git a/docs/site-map.md b/docs/site-map.md index b06422bb1b..67bb7e52c5 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -6,6 +6,14 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/` - Main Clinical KB shell. Source: `src/app/(search-app)/page.tsx`. - `/calculators` - Route discovered from app directory Source: `src/app/(search-app)/calculators/page.tsx`. +- `/dictionary` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/page.tsx`. +- `/dictionary/[slug]` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/[slug]/page.tsx`. +- `/dictionary/browse` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/browse/page.tsx`. +- `/dictionary/compare` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/compare/page.tsx`. +- `/dictionary/search` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/search/page.tsx`. +- `/dictionary/sources` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/sources/page.tsx`. +- `/dictionary/topics` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/topics/page.tsx`. +- `/dictionary/topics/[slug]` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/topics/[slug]/page.tsx`. - `/differentials` - Differentials home and search surface. Source: `src/app/(search-app)/differentials/page.tsx`. - `/differentials/compare` - Compare queue: empty state or selected diagnosis ids (Search edit links preserve ids); Open comparison launches a catalogue presentation workflow or an ad-hoc workspace (`workspace=1`). Source: `src/app/(search-app)/differentials/compare/page.tsx`. - `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/(search-app)/differentials/diagnoses/page.tsx`. @@ -62,6 +70,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/calculators` - Calculators mode. Search kind: `calculators`. Query example: `/calculators?q=PHQ-9&focus=1&run=1`. - `/therapy-compass` - Therapy mode. Search kind: `therapies`. Query example: `/therapy-compass/search?q=behavioural+activation&focus=1&run=1`. - `/factsheets` - Factsheets mode. Search kind: `tools`. Query example: `/factsheets/search?q=sertraline&focus=1&run=1`. +- `/dictionary` - Dictionary mode. Search kind: `tools`. Query example: `/dictionary/search?q=mental+state+examination&focus=1&run=1`. ## Mode page index diff --git a/playwright.config.ts b/playwright.config.ts index 73b5b30004..d2b5042511 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,7 +23,7 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; // `tests/playwright-project-isolation.test.ts` asserts every such file on disk is // matched here. const productionSpecPattern = - /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|document-canvas|tools|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/; + /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|dictionary|document-canvas|tools|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/; const mockupSpecPattern = /.*ui-(document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory)\.spec\.ts/; const mockupTag = /@mockup/; @@ -31,7 +31,7 @@ const mockupTag = /@mockup/; export default defineConfig({ testDir: "./tests", testMatch: - /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|document-canvas|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/, + /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|dictionary|document-canvas|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/, timeout: 60_000, retries: 0, // Fail the run if a stray `test.only` is committed: otherwise it silently diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index dd263175c9..7d46982b30 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -280,6 +280,7 @@ function renderModeRoutes() { calculators: appModeHomeHref("calculators", { query: "PHQ-9", focus: true, run: true }), "therapy-compass": appModeHomeHref("therapy-compass", { query: "behavioural activation", focus: true, run: true }), factsheets: appModeHomeHref("factsheets", { query: "sertraline", focus: true, run: true }), + dictionary: appModeHomeHref("dictionary", { query: "mental state examination", focus: true, run: true }), }; return appModeDefinitions.map((mode) => { diff --git a/scripts/playwright-pr-shards.mjs b/scripts/playwright-pr-shards.mjs index 14f4404fc6..561ede14e2 100644 --- a/scripts/playwright-pr-shards.mjs +++ b/scripts/playwright-pr-shards.mjs @@ -19,7 +19,7 @@ import { childProcessExitCode } from "./child-process-result.mjs"; /** Same matcher as playwright.config.ts `productionSpecPattern` (keep in sync). */ export const productionSpecFilePattern = - /^(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(?:smoke|stress|accessibility|document-canvas|tools|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts$/; + /^(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(?:smoke|stress|accessibility|dictionary|document-canvas|tools|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts$/; /** * One source of truth for shard membership and its latest hosted timing sample. @@ -36,6 +36,8 @@ export const prUiSpecProfiles = Object.freeze([ { file: "tests/ui-accessibility.spec.ts", shard: 1, fullSeconds: 17.1, criticalSeconds: 0 }, { file: "tests/ui-route-coverage.spec.ts", shard: 1, fullSeconds: 21.1, criticalSeconds: 0 }, { file: "tests/ui-formulation.spec.ts", shard: 1, fullSeconds: 11.0, criticalSeconds: 0 }, + // New route-focused suite; keep on the lightest measured shard until hosted timing is available. + { file: "tests/ui-dictionary.spec.ts", shard: 1, fullSeconds: 0, criticalSeconds: 0 }, { file: "tests/ui-phone-scroll-routes.spec.ts", shard: 2, fullSeconds: 129.6, criticalSeconds: 0 }, { file: "tests/ui-phone-scroll.spec.ts", shard: 2, fullSeconds: 66.3, criticalSeconds: 0 }, diff --git a/src/app/(search-app)/dictionary/[slug]/page.tsx b/src/app/(search-app)/dictionary/[slug]/page.tsx new file mode 100644 index 0000000000..ec0020b06c --- /dev/null +++ b/src/app/(search-app)/dictionary/[slug]/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { DictionaryTermPage } from "@/components/dictionary/dictionary-term-page"; +import { findDictionaryEntry } from "@/lib/dictionary"; +import { dictionaryEntries } from "@/lib/dictionary-data"; + +export const dynamicParams = false; + +export function generateStaticParams() { + return dictionaryEntries.map((entry) => ({ slug: entry.slug })); +} + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { + const entry = findDictionaryEntry((await params).slug); + return { + title: entry ? `${entry.term} | Clinical Dictionary` : "Dictionary entry not found", + description: entry?.definition, + }; +} + +export default async function DictionaryTermRoute({ params }: { params: Promise<{ slug: string }> }) { + const entry = findDictionaryEntry((await params).slug); + if (!entry) notFound(); + return ; +} diff --git a/src/app/(search-app)/dictionary/browse/page.tsx b/src/app/(search-app)/dictionary/browse/page.tsx new file mode 100644 index 0000000000..601eb5df34 --- /dev/null +++ b/src/app/(search-app)/dictionary/browse/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { DictionaryBrowsePage } from "@/components/dictionary/dictionary-catalogue-pages"; +import { LoadingPanel } from "@/components/ui-primitives"; + +export const metadata: Metadata = { + title: "Browse clinical terms | Clinical KB", + description: "Browse 96 source-governed clinical terms and abbreviations.", +}; + +export default function DictionaryBrowseRoute() { + return ( + }> + + + ); +} diff --git a/src/app/(search-app)/dictionary/compare/page.tsx b/src/app/(search-app)/dictionary/compare/page.tsx new file mode 100644 index 0000000000..bd0f12bfcd --- /dev/null +++ b/src/app/(search-app)/dictionary/compare/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; + +import { DictionaryComparePage } from "@/components/dictionary/dictionary-compare-page"; +import { findDictionaryEntry } from "@/lib/dictionary"; + +export const metadata: Metadata = { + title: "Compare clinical terms | Clinical KB", + description: "Align two source-governed dictionary entries field by field.", +}; + +function firstValue(value?: string | string[]) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function DictionaryCompareRoute({ + searchParams, +}: { + searchParams?: Promise<{ a?: string | string[]; b?: string | string[] }>; +}) { + const params = searchParams ? await searchParams : {}; + const a = findDictionaryEntry(firstValue(params.a)); + const candidateB = findDictionaryEntry(firstValue(params.b)); + const b = candidateB?.slug === a?.slug ? null : candidateB; + return ; +} diff --git a/src/app/(search-app)/dictionary/layout.tsx b/src/app/(search-app)/dictionary/layout.tsx new file mode 100644 index 0000000000..6b742e73b9 --- /dev/null +++ b/src/app/(search-app)/dictionary/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from "react"; + +export default function DictionaryLayout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/(search-app)/dictionary/page.tsx b/src/app/(search-app)/dictionary/page.tsx new file mode 100644 index 0000000000..fc714f56c1 --- /dev/null +++ b/src/app/(search-app)/dictionary/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { DictionaryHomePage } from "@/components/dictionary/dictionary-home-page"; + +export const metadata: Metadata = { + title: "Clinical Dictionary | Clinical KB", + description: "Search source-governed psychiatric terminology, abbreviations, topics, and distinctions.", +}; + +export default function DictionaryRoute() { + return ; +} diff --git a/src/app/(search-app)/dictionary/search/page.tsx b/src/app/(search-app)/dictionary/search/page.tsx new file mode 100644 index 0000000000..a58ce0fdb1 --- /dev/null +++ b/src/app/(search-app)/dictionary/search/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { DictionarySearchPage } from "@/components/dictionary/dictionary-catalogue-pages"; +import { LoadingPanel } from "@/components/ui-primitives"; + +export const metadata: Metadata = { + title: "Search clinical terms | Clinical KB", + description: "Search definitions, governed abbreviations, and clinical topic collections.", +}; + +export default function DictionarySearchRoute() { + return ( + }> + + + ); +} diff --git a/src/app/(search-app)/dictionary/sources/page.tsx b/src/app/(search-app)/dictionary/sources/page.tsx new file mode 100644 index 0000000000..ec313e4553 --- /dev/null +++ b/src/app/(search-app)/dictionary/sources/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { DictionarySourcesPage } from "@/components/dictionary/dictionary-sources-page"; + +export const metadata: Metadata = { + title: "Dictionary sources and review | Clinical KB", + description: "Understand source checking, authority coverage, and dictionary review cadence.", +}; + +export default function DictionarySourcesRoute() { + return ; +} diff --git a/src/app/(search-app)/dictionary/topics/[slug]/page.tsx b/src/app/(search-app)/dictionary/topics/[slug]/page.tsx new file mode 100644 index 0000000000..14d7e4dcf2 --- /dev/null +++ b/src/app/(search-app)/dictionary/topics/[slug]/page.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { Suspense } from "react"; + +import { DictionaryTopicDetailPage } from "@/components/dictionary/dictionary-catalogue-pages"; +import { LoadingPanel } from "@/components/ui-primitives"; +import { findDictionaryTopic } from "@/lib/dictionary"; +import { dictionaryTopics } from "@/lib/dictionary-data"; + +export const dynamicParams = false; + +export function generateStaticParams() { + return dictionaryTopics.map((topic) => ({ slug: topic.slug })); +} + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { + const topic = findDictionaryTopic((await params).slug); + return { + title: topic ? `${topic.title} | Clinical Dictionary` : "Dictionary topic not found", + description: topic?.description, + }; +} + +export default async function DictionaryTopicRoute({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + if (!findDictionaryTopic(slug)) notFound(); + return ( + }> + + + ); +} diff --git a/src/app/(search-app)/dictionary/topics/page.tsx b/src/app/(search-app)/dictionary/topics/page.tsx new file mode 100644 index 0000000000..3f2b2b4198 --- /dev/null +++ b/src/app/(search-app)/dictionary/topics/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { DictionaryTopicsPage } from "@/components/dictionary/dictionary-catalogue-pages"; +import { LoadingPanel } from "@/components/ui-primitives"; + +export const metadata: Metadata = { + title: "Clinical dictionary topics | Clinical KB", + description: "Browse twelve governed clinical terminology collections.", +}; + +export default function DictionaryTopicsRoute() { + return ( + }> + + + ); +} diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index ac291b5d2b..61d785a110 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3665,7 +3665,7 @@ export function ClinicalDashboard({ ) : null} {showSharedHome ? ( - // The one home surface, shared by all 13 modes. It sits above every + // The one home surface, shared by every registered mode. It sits above every // mode-specific branch so picking a mode on `/` changes only its // presentation and composer target; mode-owned content stays behind // its own route (/tools, /favourites, /dsm, …). diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index d21492da7c..3ecb0ecb0a 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { Brain, + BookMarked, Calculator, ChevronRight, ClipboardCheck, @@ -100,6 +101,7 @@ const launcherIconById: Record = { "clinical-kb-search": Search, differentials: Brain, documents: FileText, + "clinical-dictionary": BookMarked, guidelines: ShieldCheck, "risk-safety": ShieldCheck, "medication-prescribing": Pill, diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 14eccbabe5..8b7d67d98b 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -93,6 +93,7 @@ const sidebarMoreModeIds = [ "formulation", "calculators", "therapy-compass", + "dictionary", ] as const satisfies readonly AppModeId[]; const sidebarModeItems = [ diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 931262d859..aabfb56a13 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -114,8 +114,8 @@ const phoneModeGroups = [ { id: "care", label: "Care", - hint: "Medication, calculators, tools, therapy", - modeIds: ["prescribing", "calculators", "tools", "therapy-compass", "factsheets"], + hint: "Medication, calculators, reference, therapy", + modeIds: ["prescribing", "calculators", "tools", "therapy-compass", "factsheets", "dictionary"], }, ] as const satisfies ReadonlyArray<{ id: string; @@ -665,7 +665,9 @@ export function MasterSearchHeader({ ? "calculators" : searchMode === "factsheets" ? "factsheets" - : "answer"; + : searchMode === "dictionary" + ? "dictionary" + : "answer"; const actionMenuItems = modeActionItemsFor(actionMenuSetId); const actionMenuButtonLabel = `Open ${selectedAppMode.label.toLowerCase()} options`; @@ -765,6 +767,26 @@ export function MasterSearchHeader({ onQueryChange(""); return; } + if (actionId === "dictionary-search") { + router.push(`/dictionary/search${trimmedQuery ? `?q=${encodeURIComponent(trimmedQuery)}` : ""}`); + return; + } + if (actionId === "dictionary-browse") { + router.push("/dictionary/browse"); + return; + } + if (actionId === "dictionary-topics") { + router.push("/dictionary/topics"); + return; + } + if (actionId === "dictionary-compare") { + router.push("/dictionary/compare"); + return; + } + if (actionId === "dictionary-sources") { + router.push("/dictionary/sources"); + return; + } if (actionId === "services-search") { onSearchModeChange("services"); return; diff --git a/src/components/clinical-dashboard/mode-action-popup.tsx b/src/components/clinical-dashboard/mode-action-popup.tsx index dbe4a78a7b..65788b69e9 100644 --- a/src/components/clinical-dashboard/mode-action-popup.tsx +++ b/src/components/clinical-dashboard/mode-action-popup.tsx @@ -64,7 +64,8 @@ export type ModeActionSetId = | "specifiers" | "formulation" | "prescribing" - | "factsheets"; + | "factsheets" + | "dictionary"; export type ModeActionPlacement = "up" | "down"; const actionSurfaceId = "daily-actions-sheet"; @@ -142,7 +143,12 @@ export type ModeActionId = | "formulation-compare" | "formulation-map" | "factsheets-search" - | "factsheets-browse"; + | "factsheets-browse" + | "dictionary-search" + | "dictionary-browse" + | "dictionary-topics" + | "dictionary-compare" + | "dictionary-sources"; export type ModeActionItem = { id: ModeActionId; @@ -364,6 +370,39 @@ const modeActionSets = { icon: BookOpenText, }, ], + dictionary: [ + { + id: "dictionary-search", + label: "Search terms", + description: "Find definitions and abbreviations", + icon: Search, + primary: true, + }, + { + id: "dictionary-browse", + label: "Browse A–Z", + description: "Scan the governed catalogue", + icon: BookOpenText, + }, + { + id: "dictionary-topics", + label: "Browse topics", + description: "Open clinical collections", + icon: Tags, + }, + { + id: "dictionary-compare", + label: "Compare terms", + description: "Align stored fields", + icon: GitCompareArrows, + }, + { + id: "dictionary-sources", + label: "Sources and review", + description: "See governance and coverage", + icon: ShieldCheck, + }, + ], } as const satisfies Record; export function modeActionItemsFor(setId: ModeActionSetId): readonly ModeActionItem[] { diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index aed0dca634..7bb6253a74 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -61,6 +61,7 @@ const domainsByTargetMode: Partial> = specifiers: ["specifiers"], formulation: ["formulation"], "therapy-compass": ["therapies"], + dictionary: ["dictionary"], tools: ["tools"], }; @@ -75,6 +76,7 @@ const domainHeadings: Record = { specifiers: "Specifiers", formulation: "Formulation", therapies: "Therapies", + dictionary: "Dictionary", tools: "Tools", }; @@ -845,7 +847,9 @@ export function UniversalSearchCommandSurface({ ? "answer" : modeId === "tools" ? "tools" - : null; + : modeId === "dictionary" + ? "dictionary" + : null; if (actionSetId) { const actions = modeActionItemsFor(actionSetId).slice(0, 3); diff --git a/src/components/clinical-dashboard/use-sidebar-pins.ts b/src/components/clinical-dashboard/use-sidebar-pins.ts index 290224f67a..1838ea65eb 100644 --- a/src/components/clinical-dashboard/use-sidebar-pins.ts +++ b/src/components/clinical-dashboard/use-sidebar-pins.ts @@ -24,6 +24,7 @@ export const pinnableSidebarModeIds = [ "specifiers", "formulation", "therapy-compass", + "dictionary", ] as const satisfies readonly AppModeId[]; const changeEvent = "clinical-kb-sidebar-pins-change"; diff --git a/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx new file mode 100644 index 0000000000..7272c8f3c8 --- /dev/null +++ b/src/components/dictionary/dictionary-catalogue-pages.tsx @@ -0,0 +1,891 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useMemo, useState } from "react"; +import { ArrowRight, BookOpenText, Check, ChevronDown, Filter, GitCompareArrows, Layers3, Search } from "lucide-react"; + +import { + ResultFilterSheet, + ResultFilterTrigger, + resultFilterFacetGroup, + resultFilterGroup, +} from "@/components/clinical-dashboard/result-filter-control"; +import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; +import { DictionaryResultRow } from "@/components/dictionary/dictionary-result-row"; +import { InPageNavHeader } from "@/components/in-page-nav/in-page-nav-header"; +import { type PageSection } from "@/components/in-page-nav/page-section-index"; +import { useInPageSectionNav } from "@/components/in-page-nav/use-in-page-section-nav"; +import { InformationPageFooter, InformationPageShell } from "@/components/information-page-shell"; +import { cn } from "@/components/ui-primitives"; +import { + allDictionaryEntries, + browseDictionary, + dictionaryKindLabel, + dictionaryTopicEntries, + findDictionaryTopic, + parseDictionaryFilters, + searchDictionary, + type DictionaryFilters, + type DictionarySearchView, +} from "@/lib/dictionary"; +import { + dictionaryEntryKinds, + dictionarySources, + dictionaryTopics, + type DictionaryEntryKind, +} from "@/lib/dictionary-data"; + +const lensOptions: ReadonlyArray<{ value: DictionarySearchView; label: string }> = [ + { value: "all", label: "All" }, + { value: "definitions", label: "Definitions" }, + { value: "abbreviations", label: "Abbreviations" }, + { value: "topics", label: "Topics" }, +]; + +const topicDetailSections = [ + { id: "dictionary-topic-terms", label: "Terms", icon: BookOpenText }, + { + id: "dictionary-topic-details", + label: "Collection details", + icon: Layers3, + targetIds: ["dictionary-topic-details-phone", "dictionary-topic-details-desktop"], + }, +] as const satisfies readonly PageSection[]; + +function useDictionaryUrl() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const replace = (edit: (params: URLSearchParams) => void) => { + const next = new URLSearchParams(searchParams.toString()); + edit(next); + const suffix = next.toString(); + router.replace(`${pathname}${suffix ? `?${suffix}` : ""}`, { scroll: false }); + }; + const setOne = (key: string, value: string, defaultValue?: string) => + replace((next) => { + if (!value || value === defaultValue) next.delete(key); + else next.set(key, value); + }); + const toggleMany = (key: string, value: string) => + replace((next) => { + const values = new Set(next.getAll(key)); + next.delete(key); + if (values.has(value)) values.delete(value); + else values.add(value); + for (const selected of values) next.append(key, selected); + }); + return { searchParams, replace, setOne, toggleMany }; +} + +function selectedFilterCount(filters: DictionaryFilters) { + return filters.topics.length + filters.kinds.length + filters.sources.length + (filters.updated === "any" ? 0 : 1); +} + +export function DictionarySearchPage() { + const { searchParams, replace, setOne, toggleMany } = useDictionaryUrl(); + const serializedSearchParams = searchParams.toString(); + const filters = useMemo( + () => parseDictionaryFilters(new URLSearchParams(serializedSearchParams)), + [serializedSearchParams], + ); + const [filterOpen, setFilterOpen] = useState(false); + const hits = useMemo(() => searchDictionary(filters), [filters]); + const activeCount = selectedFilterCount(filters); + const lensCounts = useMemo( + () => + Object.fromEntries( + lensOptions.map((option) => [option.value, searchDictionary({ ...filters, view: option.value }).length]), + ) as Record, + [filters], + ); + + const clearFilters = () => + replace((next) => { + for (const key of ["topic", "kind", "source", "updated"]) next.delete(key); + }); + + const groups = [ + resultFilterFacetGroup({ + id: "topics", + label: "Topics", + description: "Select one or more governed collections.", + selected: new Set(filters.topics), + options: dictionaryTopics.map((topic) => ({ + value: topic.slug, + label: topic.title, + hint: String(searchDictionary({ ...filters, topics: [topic.slug] }).length), + })), + onToggle: (value) => toggleMany("topic", value), + }), + resultFilterFacetGroup({ + id: "kinds", + label: "Entry kind", + selected: new Set(filters.kinds), + options: dictionaryEntryKinds.map((kind) => ({ + value: kind, + label: dictionaryKindLabel(kind), + hint: String(searchDictionary({ ...filters, kinds: [kind] }).length), + })), + onToggle: (value) => toggleMany("kind", value), + }), + resultFilterFacetGroup({ + id: "sources", + label: "Source organisation", + selected: new Set(filters.sources), + options: dictionarySources.map((source) => ({ + value: source.id, + label: source.organisation, + searchText: source.title, + hint: String(searchDictionary({ ...filters, sources: [source.id] }).length), + })), + onToggle: (value) => toggleMany("source", value), + }), + resultFilterGroup({ + id: "updated", + label: "Updated", + value: filters.updated, + note: "one only", + options: [ + { value: "any", label: "Any time" }, + { value: "six-months", label: "Past 6 months" }, + { value: "year", label: "Past year" }, + ], + onChange: (value) => setOne("updated", value, "any"), + }), + ]; + + const appliedFilters = [ + ...filters.topics.map((slug) => ({ + id: `topic-${slug}`, + groupLabel: "Topic", + valueLabel: findDictionaryTopic(slug)?.title ?? slug, + onRemove: () => toggleMany("topic", slug), + })), + ...filters.kinds.map((kind) => ({ + id: `kind-${kind}`, + groupLabel: "Kind", + valueLabel: dictionaryKindLabel(kind), + onRemove: () => toggleMany("kind", kind), + })), + ...filters.sources.map((sourceId) => ({ + id: `source-${sourceId}`, + groupLabel: "Source", + valueLabel: dictionarySources.find((source) => source.id === sourceId)?.organisation ?? sourceId, + onRemove: () => toggleMany("source", sourceId), + })), + ...(filters.updated === "any" + ? [] + : [ + { + id: "updated", + groupLabel: "Updated", + valueLabel: filters.updated === "year" ? "Past year" : "Past 6 months", + onRemove: () => setOne("updated", "any", "any"), + }, + ]), + ]; + + const lensControls = ( +
+ {lensOptions.map((option) => ( + + ))} +
+ ); + + const trigger = (slot: "desktop" | "phone") => ( + setFilterOpen((value) => !value)} + title="Filter dictionary results" + /> + ); + + return ( + <> + + setOne("sort", value === "alpha" ? "az" : "relevance", "relevance")} + filterControls={ +
+ {lensControls} + {trigger("desktop")} +
+ } + mobileControls={trigger("phone")} + mobileControlsPlacement="inline" + appliedFilters={appliedFilters} + onClearFilters={activeCount ? clearFilters : undefined} + /> +
+ {hits.length ? ( +
+ {hits.map((hit) => { + const key = + hit.type === "entry" + ? `entry-${hit.entry.slug}` + : hit.type === "topic" + ? `topic-${hit.topic.slug}` + : `abbr-${hit.abbreviation}`; + return ; + })} +
+ ) : ( +
+
+ )} +
+ Reference terminology · Not patient-specific guidance +
+ setFilterOpen(false)} + panelId="dictionary-filter-sheet" + testId="dictionary-filter-sheet" + title="Filter results" + description="Facets narrow the current search; result type remains a separate lens." + groups={groups} + onClearAll={activeCount ? clearFilters : undefined} + summary={{ count: hits.length, noun: hits.length === 1 ? "result" : "results" }} + primaryActionLabel={`Show ${hits.length} ${hits.length === 1 ? "result" : "results"}`} + onApply={() => setFilterOpen(false)} + chromeResetKey={filters.q} + /> + + ); +} + +export function DictionaryBrowsePage() { + const { searchParams, replace, setOne, toggleMany } = useDictionaryUrl(); + const rawView = searchParams.get("view"); + const view = rawView === "abbreviations" ? "abbreviations" : "az"; + const rawLetter = (searchParams.get("letter") ?? "all").toLocaleUpperCase(); + const letter = /^[A-Z]$/.test(rawLetter) ? rawLetter : "all"; + const topics = searchParams.getAll("topic").filter((slug) => Boolean(findDictionaryTopic(slug))); + const kinds = searchParams + .getAll("kind") + .filter((kind): kind is DictionaryEntryKind => dictionaryEntryKinds.includes(kind as DictionaryEntryKind)); + const sort = searchParams.get("sort") === "za" ? "za" : "az"; + const [filterOpen, setFilterOpen] = useState(false); + const hits = browseDictionary({ view, letter, topics, kinds, sort }); + const activeCount = topics.length + kinds.length; + const clearFilters = () => + replace((next) => { + next.delete("topic"); + next.delete("kind"); + }); + const groups = [ + resultFilterFacetGroup({ + id: "topics", + label: "Topics", + selected: new Set(topics), + options: dictionaryTopics.map((topic) => ({ + value: topic.slug, + label: topic.title, + hint: String(topic.entrySlugs.length), + })), + onToggle: (value) => toggleMany("topic", value), + }), + resultFilterFacetGroup({ + id: "kinds", + label: "Entry kind", + selected: new Set(kinds), + options: dictionaryEntryKinds.map((kind) => ({ value: kind, label: dictionaryKindLabel(kind) })), + onToggle: (value) => toggleMany("kind", value), + }), + ]; + return ( + <> + +
+

+ Complete catalogue +

+

+ Browse terms +

+

+ Scan the same source-checked result system by letter or abbreviation. +

+
+
+
+
+
+ {(["az", "abbreviations"] as const).map((option) => ( + + ))} +
+ + +
+ +
+
+
+

+ {hits.length} showing +

+
+ {hits.map((hit) => ( + + ))} +
+
+ + All published entries are source checked · Specialist clinical approval remains pending + +
+ setFilterOpen(false)} + panelId="dictionary-browse-filters" + testId="dictionary-browse-filters" + title="Filter browse results" + groups={groups} + onClearAll={activeCount ? clearFilters : undefined} + summary={{ count: hits.length, noun: hits.length === 1 ? "term" : "terms" }} + onApply={() => setFilterOpen(false)} + /> + + ); +} + +function topicMatches(topicSlug: string, query: string, kinds: readonly DictionaryEntryKind[]) { + const topic = findDictionaryTopic(topicSlug); + if (!topic) return false; + const entries = dictionaryTopicEntries(topic); + if (kinds.length && !entries.some((entry) => kinds.includes(entry.kind))) return false; + const needle = query.trim().toLocaleLowerCase(); + if (!needle) return true; + return `${topic.title} ${topic.description} ${entries.map((entry) => entry.term).join(" ")}` + .toLocaleLowerCase() + .includes(needle); +} + +export function DictionaryTopicsPage() { + const { searchParams, replace, setOne, toggleMany } = useDictionaryUrl(); + const query = searchParams.get("q") ?? ""; + const kinds = searchParams + .getAll("kind") + .filter((kind): kind is DictionaryEntryKind => dictionaryEntryKinds.includes(kind as DictionaryEntryKind)); + const sort = searchParams.get("sort") === "za" ? "za" : "az"; + const [filterOpen, setFilterOpen] = useState(false); + const visible = dictionaryTopics + .filter((topic) => topicMatches(topic.slug, query, kinds)) + .sort((a, b) => (sort === "az" ? a.title.localeCompare(b.title) : b.title.localeCompare(a.title))); + const groups = [ + resultFilterFacetGroup({ + id: "kinds", + label: "Entry kind", + selected: new Set(kinds), + options: dictionaryEntryKinds.map((kind) => ({ value: kind, label: dictionaryKindLabel(kind) })), + onToggle: (value) => toggleMany("kind", value), + }), + ]; + return ( + <> + +
+
+
+

+ Governed collections +

+

+ Topics +

+

+ Discover clinical terminology through {dictionaryTopics.length} scoped collections. +

+
+
+ + + +
+
+ {visible.map((topic) => { + const entries = dictionaryTopicEntries(topic); + return ( + + + + + + {topic.title} + + + {topic.description} + + + {entries.length} terms + {entries.slice(0, 3).map((entry) => ( + + {entry.aliases.find((alias) => alias.kind === "abbreviation")?.value ?? entry.term} + + ))} + + +
+
+ + +
+
+ +
+ + {dictionaryTopics.length} source-governed collections · {allDictionaryEntries.length} canonical entries + +
+ setFilterOpen(false)} + panelId="dictionary-topic-filters" + testId="dictionary-topic-filters" + title="Filter topics" + groups={groups} + onClearAll={kinds.length ? () => replace((next) => next.delete("kind")) : undefined} + summary={{ count: visible.length, noun: visible.length === 1 ? "topic" : "topics" }} + onApply={() => setFilterOpen(false)} + /> + + ); +} + +function DisclosureLink({ title, summary, href }: { title: string; summary: string; href: string }) { + return ( + + + {title} + {summary} + +