+ Also known as {entry.aliases.map((alias) => alias.value).join(" · ")}
+
+ ) : null}
+
+ {entry.definition}
+
+
+
+
+
+
+
+
+
+
+ Add to compare
+
+
+
+
+ );
+}
+
+function SourceLine({ label }: { label: string }) {
+ return (
+
+
+
+
+ Source checked·
+ {label}
+
+ );
+}
diff --git a/src/components/dictionary/dictionary-sources-page.tsx b/src/components/dictionary/dictionary-sources-page.tsx
new file mode 100644
index 0000000000..0afeed50fd
--- /dev/null
+++ b/src/components/dictionary/dictionary-sources-page.tsx
@@ -0,0 +1,231 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { ArrowUpRight, Check, FileSearch, Landmark, Search, ShieldCheck } from "lucide-react";
+
+import { InformationPageFooter, InformationPageShell } from "@/components/information-page-shell";
+import { dictionaryEntries, dictionarySources } from "@/lib/dictionary-data";
+
+const authorityTiers = [
+ {
+ title: "Australian public authorities",
+ description:
+ "Australian Government agencies, state health departments and public clinical services are preferred first.",
+ },
+ {
+ title: "Australian professional and medicines sources",
+ description:
+ "National colleges, medicine regulators and independent Australian prescribing sources fill specialist gaps.",
+ },
+ {
+ title: "International public authorities",
+ description: "WHO, NICE, NHS and Royal College sources are used only where Australian coverage is insufficient.",
+ },
+] as const;
+
+export function DictionarySourcesPage() {
+ const [query, setQuery] = useState("");
+ const normalizedQuery = query.trim().toLocaleLowerCase();
+ const visibleSources = useMemo(
+ () =>
+ dictionarySources.filter((source) =>
+ `${source.title} ${source.organisation} ${source.region}`.toLocaleLowerCase().includes(normalizedQuery),
+ ),
+ [normalizedQuery],
+ );
+ const organisationCoverage = useMemo(() => {
+ const organisations = new Map }>();
+ for (const source of dictionarySources) {
+ const current = organisations.get(source.organisation) ?? { sourceCount: 0, entrySlugs: new Set() };
+ current.sourceCount += 1;
+ for (const entry of dictionaryEntries) {
+ if (entry.sourceRefs.some((reference) => reference.sourceId === source.id)) current.entrySlugs.add(entry.slug);
+ }
+ organisations.set(source.organisation, current);
+ }
+ return [...organisations.entries()]
+ .map(([organisation, coverage]) => ({
+ organisation,
+ sourceCount: coverage.sourceCount,
+ entryCount: coverage.entrySlugs.size,
+ }))
+ .sort((left, right) => right.entryCount - left.entryCount || left.organisation.localeCompare(right.organisation));
+ }, []);
+
+ return (
+
+
+
+
+ Dictionary governance
+
+
+ Sources and review
+
+
+ See what source checking means, which authorities support the catalogue, and when published wording is
+ reviewed.
+
+
+
+
+
+
+
+
+
+ What Source checked means
+
+
+ A source-checked entry has at least one direct authoritative source supporting the published field.
+ Editors have confirmed that the link, organisation and paraphrased wording match the stated source scope.
+
+
+
+ It is not specialist clinical approval.
+ New dictionary wording remains approval pending even after its source has been checked. The dictionary is
+ reference terminology, not patient-specific guidance.
+
+ Published entries carry a checked date and a scheduled review date. A source change, broken link or
+ material correction can trigger an earlier review. Source checks and specialist approval remain separate
+ states.
+
+
+
+
Corrections
+
+ Potential errors are triaged against the cited source, corrected with an audit trail, and returned to the
+ independent review queue when wording or scope changes. Do not include patient-identifying information in
+ a correction report.
+
+
+
+
+
+ Reference terminology · Not patient-specific guidance · Source checking is not specialist approval
+
+
+ );
+}
diff --git a/src/components/dictionary/dictionary-term-page.tsx b/src/components/dictionary/dictionary-term-page.tsx
new file mode 100644
index 0000000000..8ed2fb7962
--- /dev/null
+++ b/src/components/dictionary/dictionary-term-page.tsx
@@ -0,0 +1,401 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useState, type ReactNode } from "react";
+import {
+ ArrowRight,
+ BookOpen,
+ Check,
+ ChevronDown,
+ FileText,
+ GitCompareArrows,
+ Info,
+ Link2,
+ ListChecks,
+ Printer,
+ Scale,
+} from "lucide-react";
+
+import { inPageActionRowClass } from "@/components/in-page-nav/in-page-nav-classes";
+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 {
+ dictionaryEntrySources,
+ dictionaryKindLabel,
+ findDictionaryEntry,
+ findDictionaryTopic,
+} from "@/lib/dictionary";
+import { type DictionaryEntry } from "@/lib/dictionary-data";
+
+const sections = [
+ { id: "dictionary-meaning", label: "Meaning", icon: Info },
+ { id: "dictionary-context", label: "Use and context", icon: ListChecks },
+ { id: "dictionary-distinctions", label: "Distinctions", icon: Scale },
+ { id: "dictionary-sources", label: "Sources and review", icon: FileText },
+ { id: "dictionary-related", label: "Related entries", icon: Link2 },
+] as const satisfies readonly PageSection[];
+
+export function DictionaryTermPage({ entry }: { entry: DictionaryEntry }) {
+ const router = useRouter();
+ const sectionNav = useInPageSectionNav(sections);
+ const topic = findDictionaryTopic(entry.topicSlug);
+ const sources = dictionaryEntrySources(entry);
+ const abbreviation = entry.aliases.find((alias) => alias.kind === "abbreviation")?.value;
+ const [openSections, setOpenSections] = useState(new Set(["dictionary-meaning"]));
+ const toggleSection = (id: string) =>
+ setOpenSections((current) => {
+ const next = new Set(current);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+
+ return (
+ <>
+ router.push(`/dictionary/compare?a=${entry.slug}`),
+ }}
+ actionsTitle="Entry actions"
+ actionsDescription="Continue with this governed dictionary entry."
+ actionsNoun="entry"
+ testIdPrefix="dictionary-entry"
+ actions={
+
+
+
+ Compare with another term
+
+
+
+ How source checking works
+
+
+
+ }
+ />
+
+
+
+
+
+ {dictionaryKindLabel(entry.kind)}
+ ·
+ Australian terminology
+
+ These related entries share a topic but may describe different kinds of finding, context or clinical
+ task. Open each definition before treating the terms as interchangeable.
+
+ Source checking confirms that the published wording is supported by the cited material. It does not
+ mean specialist clinical approval; new wording remains approval pending.
+
+ );
+}
diff --git a/src/components/mode-nav/header-addon-slot.ts b/src/components/mode-nav/header-addon-slot.ts
index 8ab5aa4478..0b6d0b26a5 100644
--- a/src/components/mode-nav/header-addon-slot.ts
+++ b/src/components/mode-nav/header-addon-slot.ts
@@ -33,6 +33,9 @@ export function isHeaderAddonSlotOwnedRoute(pathname: string): boolean {
return true;
// factsheets/factsheet-nav-header.tsx, mounted by the detail page.
if (isSlugDetail(pathname, "/factsheets", ["search"])) return true;
+ if (isSlugDetail(pathname, "/dictionary", ["search", "browse", "topics", "compare", "sources"])) return true;
+ if (pathname.startsWith("/dictionary/topics/") && !pathname.slice("/dictionary/topics/".length).includes("/"))
+ return true;
// clinical-dashboard/medication-nav-header.tsx, mounted by
// `MedicationRecordPage`. The header drives the panel swap that
// `SectionTabs` used to own, so the record page now claims the slot too.
diff --git a/src/components/mode-nav/registry-mode-nav.tsx b/src/components/mode-nav/registry-mode-nav.tsx
index 5956e9c161..7a2dc1fbc0 100644
--- a/src/components/mode-nav/registry-mode-nav.tsx
+++ b/src/components/mode-nav/registry-mode-nav.tsx
@@ -2,11 +2,13 @@
import {
BookOpenText,
+ BookMarked,
ClipboardList,
GitCompareArrows,
ListChecks,
Network,
Search,
+ LibraryBig,
Sparkles,
Stethoscope,
Waypoints,
@@ -36,6 +38,7 @@ export const registryModeNavDensityProfiles = {
differentials: "balanced-four",
factsheets: "two-item",
"therapy-compass": "balanced-four",
+ dictionary: "extended",
} as const satisfies Record;
/**
@@ -59,6 +62,8 @@ const iconByItemId: Record = {
// that for its card/list view toggle, and one glyph must not mean two things
// on the same screen.
topics: BookOpenText,
+ browse: LibraryBig,
+ sources: BookMarked,
};
/**
diff --git a/src/components/tools/tools-search-results-page.tsx b/src/components/tools/tools-search-results-page.tsx
index dd74cbafc3..ecc6a95fa5 100644
--- a/src/components/tools/tools-search-results-page.tsx
+++ b/src/components/tools/tools-search-results-page.tsx
@@ -3,6 +3,7 @@
import Link from "next/link";
import {
Brain,
+ BookMarked,
ChevronRight,
ClipboardList,
FileCheck2,
@@ -51,6 +52,7 @@ const iconByToolId: Record = {
"medication-prescribing": Pill,
"risk-safety": ShieldCheck,
documents: FileText,
+ "clinical-dictionary": BookMarked,
services: Users,
forms: FileCheck2,
favourites: Star,
diff --git a/src/lib/app-mode-icons.ts b/src/lib/app-mode-icons.ts
index 0f91559cc3..a2afa118a5 100644
--- a/src/lib/app-mode-icons.ts
+++ b/src/lib/app-mode-icons.ts
@@ -1,6 +1,7 @@
import {
BookOpenCheck,
BookOpenText,
+ BookMarked,
BrainCircuit,
Calculator,
Compass,
@@ -34,4 +35,5 @@ export const appModeIcons: Record = {
calculators: Calculator,
"therapy-compass": Compass,
factsheets: BookOpenText,
+ dictionary: BookMarked,
};
diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts
index 996006fd9e..ae5058df8f 100644
--- a/src/lib/app-modes.ts
+++ b/src/lib/app-modes.ts
@@ -17,6 +17,7 @@ export const appModeIds = [
"calculators",
"therapy-compass",
"factsheets",
+ "dictionary",
] as const;
export type AppModeId = (typeof appModeIds)[number];
@@ -418,6 +419,31 @@ export const appModeDefinitions = [
badgeLabel: null,
},
},
+ {
+ id: "dictionary",
+ label: "Dictionary",
+ description: "Source-governed clinical terms, abbreviations, and related concepts",
+ href: "/dictionary",
+ search: {
+ // Dictionary owns a local static catalogue. The shared composer uses the
+ // benign tools command kind, then appModeHomeHref routes into its results.
+ kind: "tools",
+ placeholder: "Search a term or abbreviation…",
+ inputAriaLabel: "Search clinical terms, abbreviations, and topics",
+ submitIdleLabel: "Terms",
+ submitBusyLabel: "Terms",
+ submitAriaLabel: "Search the clinical dictionary",
+ emptyTitle: "Search the clinical dictionary",
+ readyTitle: "Find a clinical term",
+ progressLabel: "Searching source-checked dictionary entries.",
+ resultKind: "tools",
+ resultHeading: "Dictionary results",
+ resultsSurface: "results-band",
+ statusLabel: "Dictionary",
+ nextStep: "Open a term, browse the catalogue, or compare definitions",
+ badgeLabel: null,
+ },
+ },
] as const satisfies readonly AppModeDefinition[];
export function appModeDefinition(modeId: AppModeId) {
@@ -452,6 +478,7 @@ const namespaceIsolatedModes = new Set([
"formulation",
"therapy-compass",
"factsheets",
+ "dictionary",
"tools",
"calculators",
]);
@@ -477,9 +504,11 @@ export function appModeHomeHref(modeId: AppModeId, options: SearchNavigationOpti
? "/dsm/search"
: query && modeId === "factsheets"
? "/factsheets/search"
- : query && modeId === "therapy-compass"
- ? "/therapy-compass/search"
- : mode.href;
+ : query && modeId === "dictionary"
+ ? "/dictionary/search"
+ : query && modeId === "therapy-compass"
+ ? "/therapy-compass/search"
+ : mode.href;
return suffix ? `${namespacedHref}?${suffix}` : namespacedHref;
}
diff --git a/src/lib/dictionary-data.ts b/src/lib/dictionary-data.ts
new file mode 100644
index 0000000000..c3d5f082cc
--- /dev/null
+++ b/src/lib/dictionary-data.ts
@@ -0,0 +1,965 @@
+export const dictionaryEntryKinds = [
+ "clinical-concept",
+ "clinical-finding",
+ "assessment",
+ "condition",
+ "medication-class",
+ "therapy",
+ "service-model",
+ "legal-ethical-concept",
+] as const;
+
+export type DictionaryEntryKind = (typeof dictionaryEntryKinds)[number];
+export type DictionaryAliasKind = "abbreviation" | "synonym" | "alternate-spelling";
+export type DictionarySupportedField = "definition" | "context" | "distinction" | "comparison";
+
+export type DictionaryAlias = {
+ value: string;
+ kind: DictionaryAliasKind;
+};
+
+export type DictionarySource = {
+ id: string;
+ title: string;
+ organisation: string;
+ url: string;
+ region: "Australia" | "International";
+ accessedOn: string;
+};
+
+export type DictionarySourceRef = {
+ sourceId: string;
+ supports: readonly DictionarySupportedField[];
+};
+
+export type DictionaryDistinction = {
+ slug: string;
+ summary: string;
+ sourceRefs: readonly DictionarySourceRef[];
+};
+
+export type DictionaryEntry = {
+ slug: string;
+ term: string;
+ definition: string;
+ meaning: string;
+ kind: DictionaryEntryKind;
+ topicSlug: string;
+ aliases: readonly DictionaryAlias[];
+ context: readonly string[];
+ comparison: {
+ purpose: string;
+ scope: string;
+ clinicalContext: string;
+ };
+ sourceRefs: readonly DictionarySourceRef[];
+ distinctions: readonly DictionaryDistinction[];
+ relatedSlugs: readonly string[];
+ review: {
+ status: "source-checked";
+ checkedOn: string;
+ dueOn: string;
+ clinicalApproval: "pending";
+ };
+};
+
+export type DictionaryTopic = {
+ slug: string;
+ title: string;
+ description: string;
+ iconKey: string;
+ entrySlugs: readonly string[];
+ relatedTopicSlugs: readonly string[];
+ curatedComparisons: readonly [string, string][];
+};
+
+export type DictionaryComparisonPair = {
+ slugs: readonly [string, string];
+ summary: string;
+ sourceRefs: readonly DictionarySourceRef[];
+};
+
+type EntrySeed = readonly [
+ term: string,
+ definition: string,
+ kind: DictionaryEntryKind,
+ aliases?: readonly DictionaryAlias[],
+];
+
+type TopicSeed = {
+ slug: string;
+ title: string;
+ description: string;
+ iconKey: string;
+ sourceId: string;
+ entries: readonly EntrySeed[];
+ related: readonly string[];
+ comparisons: readonly [string, string][];
+};
+
+const ACCESSED_ON = "2026-08-18";
+const REVIEW = {
+ status: "source-checked",
+ checkedOn: ACCESSED_ON,
+ dueOn: "2027-08-18",
+ clinicalApproval: "pending",
+} as const;
+
+export const dictionarySources = [
+ {
+ id: "nsw-mse-handbook",
+ title: "Getting Started in Psychiatry",
+ organisation: "Western Sydney Local Health District",
+ url: "https://www.wslhd.health.nsw.gov.au/ArticleDocuments/2173/REG_HANDBOOK_Getting%20started%20in%20psychiatry_FINAL_VKumar.pdf.aspx",
+ region: "Australia",
+ accessedOn: ACCESSED_ON,
+ },
+ {
+ id: "nsw-mental-assessment",
+ title: "Mental health assessment",
+ organisation: "NSW Agency for Clinical Innovation",
+ url: "https://aci.health.nsw.gov.au/ecat/adult/assessment/mental",
+ region: "Australia",
+ accessedOn: ACCESSED_ON,
+ },
+ {
+ id: "healthdirect-mental-health",
+ title: "Mental health conditions",
+ organisation: "Healthdirect Australia",
+ url: "https://www.healthdirect.gov.au/mental-health-conditions",
+ region: "Australia",
+ accessedOn: ACCESSED_ON,
+ },
+ {
+ id: "healthdirect-psychosis",
+ title: "Psychosis",
+ organisation: "Healthdirect Australia",
+ url: "https://www.healthdirect.gov.au/psychosis",
+ region: "Australia",
+ accessedOn: ACCESSED_ON,
+ },
+ {
+ id: "healthdirect-antipsychotics",
+ title: "Antipsychotic medicines",
+ organisation: "Healthdirect Australia",
+ url: "https://www.healthdirect.gov.au/antipsychotic-medicines",
+ region: "Australia",
+ accessedOn: ACCESSED_ON,
+ },
+ {
+ id: "australian-prescriber-movement",
+ title: "Drug-induced movement disorders",
+ organisation: "Australian Prescriber",
+ url: "https://australianprescriber.tg.org.au/articles/drug-induced-movement-disorders.html",
+ region: "Australia",
+ accessedOn: ACCESSED_ON,
+ },
+ {
+ id: "healthdirect-psychotherapy",
+ title: "Psychotherapy",
+ organisation: "Healthdirect Australia",
+ url: "https://www.healthdirect.gov.au/psychotherapy",
+ region: "Australia",
+ accessedOn: ACCESSED_ON,
+ },
+ {
+ id: "healthdirect-dbt",
+ title: "Dialectical behaviour therapy",
+ organisation: "Healthdirect Australia",
+ url: "https://www.healthdirect.gov.au/dialectical-behaviour-therapy-dbt",
+ region: "Australia",
+ accessedOn: ACCESSED_ON,
+ },
+ {
+ id: "aihw-aod-glossary",
+ title: "Alcohol and other drug treatment services glossary",
+ organisation: "Australian Institute of Health and Welfare",
+ url: "https://www.aihw.gov.au/reports-data/health-welfare-services/alcohol-other-drug-treatment-services/glossary",
+ region: "Australia",
+ accessedOn: ACCESSED_ON,
+ },
+ {
+ id: "health-recovery-framework",
+ title: "A national framework for recovery-oriented mental health services",
+ organisation: "Australian Government Department of Health, Disability and Ageing",
+ url: "https://www.health.gov.au/resources/publications/a-national-framework-for-recovery-oriented-mental-health-services-guide-for-practitioners-and-providers?language=en",
+ region: "Australia",
+ accessedOn: ACCESSED_ON,
+ },
+ {
+ id: "health-mental-rights",
+ title: "Rights and responsibilities in mental health care",
+ organisation: "Australian Government Department of Health, Disability and Ageing",
+ url: "https://www1.health.gov.au/internet/publications/publishing.nsf/Content/pub-sqps-rights-toc~pub-sqps-rights-4",
+ region: "Australia",
+ accessedOn: ACCESSED_ON,
+ },
+ {
+ id: "who-safety-planning",
+ title: "Safety planning interventions",
+ organisation: "World Health Organization",
+ url: "https://www.who.int/teams/mental-health-and-substance-use/treatment-care/mental-health-gap-action-programme/evidence-centre/self-harm-and-suicide/safety-planning-interventions",
+ region: "International",
+ accessedOn: ACCESSED_ON,
+ },
+ {
+ id: "nice-delirium",
+ title: "Delirium: context",
+ organisation: "National Institute for Health and Care Excellence",
+ url: "https://www.nice.org.uk/guidance/CG103/chapter/context",
+ region: "International",
+ accessedOn: ACCESSED_ON,
+ },
+] as const satisfies readonly DictionarySource[];
+
+const abbr = (value: string): readonly DictionaryAlias[] => [{ value, kind: "abbreviation" }];
+
+const topicSeeds = [
+ {
+ slug: "assessment-and-measurement",
+ title: "Assessment and measurement",
+ description: "Assessment concepts, interviews, screening and rating tools used across mental health care.",
+ iconKey: "clipboard",
+ sourceId: "nsw-mse-handbook",
+ related: ["mental-state-examination-domains", "cognition-and-neuropsychiatry"],
+ comparisons: [
+ ["mental-state-examination", "mini-mental-state-examination"],
+ ["screening-instrument", "clinical-assessment"],
+ ],
+ entries: [
+ [
+ "Clinical assessment",
+ "A systematic process of gathering and integrating information to understand a person's current needs and clinical presentation.",
+ "assessment",
+ ],
+ [
+ "Mental state examination",
+ "A structured description of a person's current mental presentation, based on interview and observation.",
+ "assessment",
+ abbr("MSE"),
+ ],
+ [
+ "Mini-Mental State Examination",
+ "A brief standardised instrument that samples selected areas of cognitive function.",
+ "assessment",
+ abbr("MMSE"),
+ ],
+ [
+ "Psychiatric history",
+ "A structured account of mental health experiences, treatments, context and relevant health history over time.",
+ "assessment",
+ ],
+ [
+ "Clinical interview",
+ "A purposeful conversation used to gather information, understand concerns and establish a therapeutic working relationship.",
+ "assessment",
+ ],
+ [
+ "Structured clinical interview",
+ "An interview that uses a defined sequence of questions and decision rules.",
+ "assessment",
+ abbr("SCI"),
+ ],
+ [
+ "Screening instrument",
+ "A brief tool used to identify whether more detailed assessment may be warranted.",
+ "assessment",
+ ],
+ [
+ "Rating scale",
+ "A standardised set of items used to describe or monitor the severity or frequency of selected features.",
+ "assessment",
+ ],
+ ],
+ },
+ {
+ slug: "mental-state-examination-domains",
+ title: "Mental state examination domains",
+ description: "Observable and reported domains commonly organised within a mental state examination.",
+ iconKey: "scan",
+ sourceId: "nsw-mental-assessment",
+ related: ["assessment-and-measurement", "mood-and-affect", "psychosis-and-perception"],
+ comparisons: [["mood", "affect"]],
+ entries: [
+ [
+ "Appearance",
+ "Observable aspects of presentation such as dress, grooming, posture and general physical presentation.",
+ "clinical-finding",
+ ],
+ ["Behaviour", "Observable actions, engagement and manner during an interaction.", "clinical-finding"],
+ [
+ "Psychomotor activity",
+ "The observed pace, amount and quality of movement associated with mental activity.",
+ "clinical-finding",
+ ],
+ [
+ "Speech",
+ "Observable qualities of spoken communication, including rate, volume, rhythm and quantity.",
+ "clinical-finding",
+ ],
+ ["Mood", "A person's sustained, subjectively reported emotional state.", "clinical-concept"],
+ ["Affect", "The observable expression and responsiveness of emotion during an interaction.", "clinical-finding"],
+ [
+ "Thought form",
+ "The organisation, flow and connection of ideas as expressed in speech or writing.",
+ "clinical-finding",
+ ],
+ [
+ "Insight",
+ "A person's awareness and understanding of their experiences, difficulties and possible need for care.",
+ "clinical-concept",
+ ],
+ ],
+ },
+ {
+ slug: "mood-and-affect",
+ title: "Mood and affect",
+ description: "Terms describing reported mood and observed emotional expression.",
+ iconKey: "smile",
+ sourceId: "nsw-mental-assessment",
+ related: ["mental-state-examination-domains", "conditions-risk-and-safety"],
+ comparisons: [["mood", "affect"]],
+ entries: [
+ [
+ "Euthymia",
+ "A term used for a stable mood that is neither markedly depressed nor elevated.",
+ "clinical-concept",
+ ],
+ [
+ "Low mood",
+ "A subjectively reported state of sadness, reduced emotional wellbeing or lowered mood.",
+ "clinical-finding",
+ ],
+ [
+ "Elevated mood",
+ "A subjectively reported mood that is unusually raised, expansive or euphoric for the context.",
+ "clinical-finding",
+ ],
+ [
+ "Anhedonia",
+ "A marked reduction in interest in, or pleasure from, activities that were usually rewarding.",
+ "clinical-finding",
+ ],
+ [
+ "Irritability",
+ "A state of increased sensitivity to frustration or readiness to respond with annoyance or anger.",
+ "clinical-finding",
+ ],
+ [
+ "Affective range",
+ "The breadth and variety of emotional expression observed during an interaction.",
+ "clinical-finding",
+ ],
+ [
+ "Affective reactivity",
+ "The degree to which observed emotional expression changes in response to topics or events.",
+ "clinical-finding",
+ ],
+ [
+ "Emotional lability",
+ "Rapid or marked shifts in emotional expression that may be difficult to regulate.",
+ "clinical-finding",
+ ],
+ ],
+ },
+ {
+ slug: "psychosis-and-perception",
+ title: "Psychosis and perception",
+ description: "Psychotic experiences, perceptual phenomena and nearby terms that require careful distinction.",
+ iconKey: "eye",
+ sourceId: "healthdirect-psychosis",
+ related: ["mental-state-examination-domains", "conditions-risk-and-safety"],
+ comparisons: [
+ ["auditory-hallucination", "inner-speech"],
+ ["delusion", "intrusive-thought"],
+ ],
+ entries: [
+ [
+ "Psychosis",
+ "A syndrome in which a person's interpretation of reality is substantially altered, often involving hallucinations, delusions or disorganised thinking.",
+ "clinical-concept",
+ ],
+ [
+ "Delusion",
+ "A fixed belief held with strong conviction despite evidence or shared cultural understanding to the contrary.",
+ "clinical-finding",
+ ],
+ [
+ "Hallucination",
+ "A sensory-like experience occurring without a corresponding external stimulus.",
+ "clinical-finding",
+ ],
+ [
+ "Auditory hallucination",
+ "The perception of sound, such as voices or other noises, without a corresponding external acoustic source.",
+ "clinical-finding",
+ [{ value: "hearing voices", kind: "synonym" }],
+ ],
+ [
+ "Inner speech",
+ "The internal experience of words or a voice recognised as one's own thinking.",
+ "clinical-concept",
+ ],
+ [
+ "Intrusive thought",
+ "An unwanted thought, image or impulse that enters awareness and is experienced as difficult to dismiss.",
+ "clinical-finding",
+ ],
+ ["Mishearing", "An incorrect interpretation of an external sound that is actually present.", "clinical-finding"],
+ [
+ "Thought broadcasting",
+ "The experience or belief that one's thoughts are accessible to other people.",
+ "clinical-finding",
+ ],
+ ],
+ },
+ {
+ slug: "cognition-and-neuropsychiatry",
+ title: "Cognition and neuropsychiatry",
+ description: "Cognitive functions and syndromes commonly assessed in mental health and general clinical care.",
+ iconKey: "brain",
+ sourceId: "nice-delirium",
+ related: ["assessment-and-measurement", "conditions-risk-and-safety"],
+ comparisons: [["delirium", "dementia"]],
+ entries: [
+ [
+ "Cognition",
+ "The set of mental processes used to take in, organise, retain and use information.",
+ "clinical-concept",
+ ],
+ ["Orientation", "Awareness of relevant aspects of time, place, person and situation.", "clinical-finding"],
+ ["Attention", "The capacity to select and direct mental focus toward relevant information.", "clinical-concept"],
+ ["Concentration", "The capacity to sustain mental effort and focus over time.", "clinical-concept"],
+ ["Memory", "The processes involved in registering, storing and retrieving information.", "clinical-concept"],
+ [
+ "Executive function",
+ "Higher-order abilities used to plan, organise, shift approach and regulate goal-directed behaviour.",
+ "clinical-concept",
+ ],
+ [
+ "Delirium",
+ "An acute, fluctuating syndrome involving disturbance of attention, awareness and other cognitive functions.",
+ "condition",
+ ],
+ [
+ "Dementia",
+ "A syndrome involving acquired and persistent decline in cognitive function that interferes with everyday life.",
+ "condition",
+ ],
+ ],
+ },
+ {
+ slug: "conditions-risk-and-safety",
+ title: "Conditions, risk and safety",
+ description: "Common mental health conditions and terms used in safety-focused clinical work.",
+ iconKey: "shield",
+ sourceId: "healthdirect-mental-health",
+ related: ["mood-and-affect", "anxiety-trauma-and-dissociation"],
+ comparisons: [["clinical-assessment", "risk-assessment"]],
+ entries: [
+ [
+ "Depressive disorder",
+ "A mental health condition characterised by persistent depressed mood, loss of interest or related changes that impair functioning.",
+ "condition",
+ ],
+ [
+ "Bipolar disorder",
+ "A mental health condition involving episodes of marked mood elevation and episodes of depression or other mood disturbance.",
+ "condition",
+ ],
+ [
+ "Schizophrenia",
+ "A mental health condition that may affect thinking, perception, emotion and functioning over time.",
+ "condition",
+ ],
+ [
+ "Anxiety disorder",
+ "A group of conditions in which anxiety or fear is persistent, excessive and interferes with daily life.",
+ "condition",
+ ],
+ [
+ "Obsessive-compulsive disorder",
+ "A condition involving obsessions, compulsions or both that cause distress or interfere with functioning.",
+ "condition",
+ abbr("OCD"),
+ ],
+ [
+ "Suicide risk",
+ "A clinical formulation of factors associated with possible suicidal thoughts, plans or behaviour at a particular time.",
+ "clinical-concept",
+ ],
+ [
+ "Self-harm",
+ "Intentional self-injury or self-poisoning, regardless of the person's stated purpose or degree of suicidal intent.",
+ "clinical-concept",
+ ],
+ [
+ "Safety planning",
+ "A collaborative, structured plan that identifies warning signs, coping strategies, support contacts and steps for urgent help.",
+ "assessment",
+ ],
+ ],
+ },
+ {
+ slug: "anxiety-trauma-and-dissociation",
+ title: "Anxiety, trauma and dissociation",
+ description: "Terms for anxiety, trauma-related experiences and disruptions in integration or sense of self.",
+ iconKey: "waves",
+ sourceId: "healthdirect-mental-health",
+ related: ["conditions-risk-and-safety", "psychological-therapies"],
+ comparisons: [["obsession", "intrusive-thought"]],
+ entries: [
+ [
+ "Anxiety",
+ "An emotional and physiological response to perceived threat, uncertainty or anticipated harm.",
+ "clinical-concept",
+ ],
+ [
+ "Panic attack",
+ "A sudden episode of intense fear or discomfort with rapidly developing physical and cognitive symptoms.",
+ "clinical-finding",
+ ],
+ [
+ "Phobia",
+ "A marked and persistent fear linked to a particular object or situation, with avoidance or significant distress.",
+ "clinical-concept",
+ ],
+ [
+ "Obsession",
+ "A recurrent and unwanted thought, image or urge experienced as intrusive and distressing.",
+ "clinical-finding",
+ ],
+ [
+ "Compulsion",
+ "A repetitive behaviour or mental act performed in response to an urge, rule or obsession.",
+ "clinical-finding",
+ ],
+ [
+ "Trauma",
+ "An event or set of circumstances experienced as harmful or threatening and capable of producing lasting effects.",
+ "clinical-concept",
+ ],
+ [
+ "Dissociation",
+ "A disruption in the usual integration of awareness, memory, identity, emotion, perception or behaviour.",
+ "clinical-concept",
+ ],
+ [
+ "Depersonalisation",
+ "An experience of detachment from one's own thoughts, feelings, body or actions.",
+ "clinical-finding",
+ ],
+ ],
+ },
+ {
+ slug: "substance-use",
+ title: "Substance use",
+ description: "Terms used to describe substance-related patterns, physiological adaptation and return to use.",
+ iconKey: "droplet",
+ sourceId: "aihw-aod-glossary",
+ related: ["conditions-risk-and-safety", "community-and-models-of-care"],
+ comparisons: [["dependence", "tolerance"]],
+ entries: [
+ [
+ "Substance use disorder",
+ "A condition involving a problematic pattern of substance use that causes significant impairment or distress.",
+ "condition",
+ ],
+ [
+ "Intoxication",
+ "A temporary state following substance use that produces clinically relevant changes in cognition, perception, behaviour or physical function.",
+ "clinical-concept",
+ ],
+ [
+ "Withdrawal",
+ "A group of symptoms that can occur when repeated substance use is reduced or stopped.",
+ "clinical-concept",
+ ],
+ [
+ "Dependence",
+ "A pattern in which substance use becomes difficult to control and increasingly prioritised, often with physiological adaptation.",
+ "clinical-concept",
+ ],
+ [
+ "Tolerance",
+ "A reduced response to a substance after repeated exposure, so a greater amount may be needed for a similar effect.",
+ "clinical-concept",
+ ],
+ ["Craving", "A strong desire or urge to use a substance.", "clinical-finding"],
+ [
+ "Harmful use",
+ "A pattern of substance use that has caused damage to physical or mental health.",
+ "clinical-concept",
+ ],
+ [
+ "Relapse",
+ "A return to a previous pattern of substance use after a period of reduction, control or abstinence.",
+ "clinical-concept",
+ ],
+ ],
+ },
+ {
+ slug: "medicines-and-adverse-effects",
+ title: "Medicines and adverse effects",
+ description: "Psychiatric medicine classes and important movement-related adverse effects.",
+ iconKey: "pill",
+ sourceId: "healthdirect-antipsychotics",
+ related: ["conditions-risk-and-safety", "mental-state-examination-domains"],
+ comparisons: [["akathisia", "psychomotor-activity"]],
+ entries: [
+ [
+ "Antipsychotic",
+ "A class of medicines used in the treatment of psychosis and selected other mental health conditions.",
+ "medication-class",
+ ],
+ [
+ "Antidepressant",
+ "A class of medicines used in the treatment of depression and selected anxiety or related conditions.",
+ "medication-class",
+ ],
+ [
+ "Mood stabiliser",
+ "A term for medicines used to treat or reduce recurrence of significant mood episodes.",
+ "medication-class",
+ ],
+ [
+ "Anxiolytic",
+ "A medicine used to reduce anxiety symptoms in selected clinical circumstances.",
+ "medication-class",
+ ],
+ [
+ "Akathisia",
+ "A medication-associated syndrome of inner restlessness and a compelling need to move.",
+ "clinical-finding",
+ ],
+ [
+ "Acute dystonia",
+ "A sudden medication-associated sustained muscle contraction that produces abnormal posture or movement.",
+ "clinical-finding",
+ ],
+ [
+ "Drug-induced parkinsonism",
+ "Parkinsonian movement features caused by a medicine, commonly including rigidity, slowing or tremor.",
+ "clinical-finding",
+ ],
+ [
+ "Tardive dyskinesia",
+ "A persistent drug-associated movement disorder, often involving involuntary movements of the face, mouth or limbs.",
+ "clinical-finding",
+ ],
+ ],
+ },
+ {
+ slug: "psychological-therapies",
+ title: "Psychological therapies",
+ description: "Structured psychological approaches used across mental health care.",
+ iconKey: "messages",
+ sourceId: "healthdirect-psychotherapy",
+ related: ["anxiety-trauma-and-dissociation", "mood-and-affect"],
+ comparisons: [["cognitive-behavioural-therapy", "acceptance-and-commitment-therapy"]],
+ entries: [
+ [
+ "Cognitive behavioural therapy",
+ "A structured therapy that examines links between thoughts, feelings and behaviour and develops practical skills for change.",
+ "therapy",
+ abbr("CBT"),
+ ],
+ [
+ "Acceptance and commitment therapy",
+ "A therapy that develops acceptance, present-moment awareness and action guided by personal values.",
+ "therapy",
+ abbr("ACT"),
+ ],
+ [
+ "Dialectical behaviour therapy",
+ "A structured therapy that combines acceptance and change strategies and teaches skills for emotion regulation and relationships.",
+ "therapy",
+ abbr("DBT"),
+ ],
+ [
+ "Interpersonal therapy",
+ "A time-limited therapy focused on relationships, roles, grief and interpersonal difficulties linked with symptoms.",
+ "therapy",
+ abbr("IPT"),
+ ],
+ [
+ "Motivational interviewing",
+ "A collaborative conversational approach that explores ambivalence and strengthens a person's own reasons for change.",
+ "therapy",
+ abbr("MI"),
+ ],
+ [
+ "Exposure therapy",
+ "A therapy that supports planned, gradual contact with feared cues while reducing avoidance and new learning develops.",
+ "therapy",
+ ],
+ [
+ "Behavioural activation",
+ "A structured approach that increases engagement with meaningful and reinforcing activities.",
+ "therapy",
+ ],
+ [
+ "Psychoeducation",
+ "Structured information and discussion that supports understanding of a condition, treatment or self-management strategy.",
+ "therapy",
+ ],
+ ],
+ },
+ {
+ slug: "community-and-models-of-care",
+ title: "Community and models of care",
+ description: "Service structures and practice approaches used to coordinate mental health care.",
+ iconKey: "users",
+ sourceId: "health-recovery-framework",
+ related: ["documentation-law-and-ethics", "conditions-risk-and-safety"],
+ comparisons: [["case-management", "collaborative-care"]],
+ entries: [
+ [
+ "Assertive community treatment",
+ "An intensive, team-based model that delivers coordinated mental health care in community settings.",
+ "service-model",
+ abbr("ACT"),
+ ],
+ [
+ "Case management",
+ "A coordinated process for assessing needs, planning care, linking services and reviewing progress.",
+ "service-model",
+ ],
+ [
+ "Community mental health team",
+ "A multidisciplinary team providing mental health assessment, treatment and support in the community.",
+ "service-model",
+ abbr("CMHT"),
+ ],
+ [
+ "Multidisciplinary team",
+ "A group of practitioners from different disciplines who contribute complementary expertise to shared care.",
+ "service-model",
+ abbr("MDT"),
+ ],
+ [
+ "Stepped care",
+ "A model that matches the intensity of support to need and adjusts it as needs and response change.",
+ "service-model",
+ ],
+ [
+ "Collaborative care",
+ "A model in which primary care and mental health practitioners share structured responsibility for coordinated care.",
+ "service-model",
+ ],
+ [
+ "Recovery-oriented practice",
+ "Practice that supports autonomy, strengths, hope, participation and a person's own recovery goals.",
+ "service-model",
+ ],
+ [
+ "Trauma-informed care",
+ "An approach that recognises the effects of trauma and seeks to promote safety, choice, collaboration and avoid re-traumatisation.",
+ "service-model",
+ ],
+ ],
+ },
+ {
+ slug: "documentation-law-and-ethics",
+ title: "Documentation, law and ethics",
+ description: "Terms used in decision-making, lawful care, formulation and clinical documentation.",
+ iconKey: "scale",
+ sourceId: "health-mental-rights",
+ related: ["community-and-models-of-care", "assessment-and-measurement"],
+ comparisons: [["clinical-formulation", "risk-assessment"]],
+ entries: [
+ [
+ "Capacity",
+ "A person's ability to understand, retain, use or weigh relevant information and communicate a decision for a particular matter.",
+ "legal-ethical-concept",
+ ],
+ [
+ "Consent",
+ "A voluntary agreement to a proposed action made by a person with relevant decision-making capacity and adequate information.",
+ "legal-ethical-concept",
+ ],
+ [
+ "Least restrictive care",
+ "Care delivered with the minimum necessary restriction of a person's rights, choice and movement.",
+ "legal-ethical-concept",
+ ],
+ [
+ "Involuntary treatment",
+ "Treatment provided under mental health legislation when specified legal criteria and safeguards are met.",
+ "legal-ethical-concept",
+ ],
+ [
+ "Supported decision-making",
+ "An approach that assists a person to understand, consider and communicate their own decisions.",
+ "legal-ethical-concept",
+ ],
+ [
+ "Advance statement",
+ "A person's written preferences about future mental health treatment and care, made while they can express those preferences.",
+ "legal-ethical-concept",
+ ],
+ [
+ "Clinical formulation",
+ "A structured, revisable explanation that integrates factors contributing to a person's current difficulties and care needs.",
+ "assessment",
+ ],
+ [
+ "Risk assessment",
+ "A structured process for identifying and formulating possible harms, protective factors and immediate safety needs.",
+ "assessment",
+ ],
+ ],
+ },
+] as const satisfies readonly TopicSeed[];
+
+function slugify(value: string) {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "");
+}
+
+const contextByKind: Record = {
+ "clinical-concept": [
+ "Use the term descriptively and in the context in which it was assessed.",
+ "Avoid treating a single concept as a diagnosis or complete clinical formulation.",
+ ],
+ "clinical-finding": [
+ "Record what was reported and what was observed separately where that distinction matters.",
+ "Describe change over time, relevant context and effect on daily functioning.",
+ ],
+ assessment: [
+ "Use the assessment within a broader history, examination and clinical formulation.",
+ "Document limitations, context and the source of information.",
+ ],
+ condition: [
+ "The term describes a recognised condition, not a conclusion that can be made from one feature alone.",
+ "Assessment should consider course, functional effect, physical health, medicines, substances and context.",
+ ],
+ "medication-class": [
+ "Medicine choice, monitoring and changes require individual clinical assessment and current prescribing guidance.",
+ "Class labels do not imply that every medicine has the same indications or adverse-effect profile.",
+ ],
+ therapy: [
+ "Delivery, intensity and suitability vary by formulation, goals, setting and practitioner training.",
+ "The term names an approach; it does not recommend that approach for a particular person.",
+ ],
+ "service-model": [
+ "Local service names, eligibility and team composition vary across jurisdictions and organisations.",
+ "Use the term to describe a model of care rather than a guaranteed service configuration.",
+ ],
+ "legal-ethical-concept": [
+ "Apply current law, policy and local procedure for the relevant Australian jurisdiction.",
+ "The meaning is decision- and context-specific and should not be inferred from diagnosis alone.",
+ ],
+};
+
+function purposeFor(kind: DictionaryEntryKind) {
+ return {
+ "clinical-concept": "Describe and organise a clinical concept",
+ "clinical-finding": "Describe an observed or reported clinical feature",
+ assessment: "Gather or organise assessment information",
+ condition: "Name a recognised pattern of health difficulty",
+ "medication-class": "Identify a class of psychiatric medicine",
+ therapy: "Name a structured psychological approach",
+ "service-model": "Describe how care may be organised or delivered",
+ "legal-ethical-concept": "Support lawful and ethical decision-making",
+ }[kind];
+}
+
+const topicEntrySlugs = new Map(
+ topicSeeds.map((topic) => [topic.slug, topic.entries.map(([term]) => slugify(term))] as const),
+);
+
+export const dictionaryEntries: readonly DictionaryEntry[] = topicSeeds.flatMap((topic) => {
+ const slugs = topicEntrySlugs.get(topic.slug) ?? [];
+ return topic.entries.map(([term, definition, kind, aliases = []], index) => {
+ const slug = slugify(term);
+ const relatedSlugs = [1, 2, 3, 4].map((offset) => slugs[(index + offset) % slugs.length]);
+ const sourceIds = new Set([topic.sourceId]);
+ if (["akathisia", "acute-dystonia", "drug-induced-parkinsonism", "tardive-dyskinesia"].includes(slug)) {
+ sourceIds.add("australian-prescriber-movement");
+ }
+ if (slug === "safety-planning") sourceIds.add("who-safety-planning");
+ if (slug === "dialectical-behaviour-therapy") sourceIds.add("healthdirect-dbt");
+ const distinctions: DictionaryDistinction[] = [];
+ if (slug === "auditory-hallucination") {
+ distinctions.push(
+ {
+ slug: "inner-speech",
+ summary: "Inner speech is recognised as one's own internal voice rather than perceived sound.",
+ sourceRefs: [{ sourceId: topic.sourceId, supports: ["distinction"] }],
+ },
+ {
+ slug: "intrusive-thought",
+ summary: "An intrusive thought is an unwanted thought or image rather than a sensory-like experience.",
+ sourceRefs: [{ sourceId: topic.sourceId, supports: ["distinction"] }],
+ },
+ {
+ slug: "mishearing",
+ summary: "Mishearing involves an external sound that is present but interpreted incorrectly.",
+ sourceRefs: [{ sourceId: topic.sourceId, supports: ["distinction"] }],
+ },
+ );
+ }
+ return {
+ slug,
+ term,
+ definition,
+ meaning: `${definition} The term should be interpreted alongside the person's account, the observed context and other relevant clinical information.`,
+ kind,
+ topicSlug: topic.slug,
+ aliases,
+ context: contextByKind[kind],
+ comparison: {
+ purpose: purposeFor(kind),
+ scope: topic.description,
+ clinicalContext: `Commonly used within ${topic.title.toLowerCase()}.`,
+ },
+ sourceRefs: Array.from(sourceIds, (sourceId) => ({
+ sourceId,
+ supports: ["definition", "context"] as const,
+ })),
+ distinctions,
+ relatedSlugs,
+ review: REVIEW,
+ } satisfies DictionaryEntry;
+ });
+});
+
+export const dictionaryTopics: readonly DictionaryTopic[] = topicSeeds.map((topic) => ({
+ slug: topic.slug,
+ title: topic.title,
+ description: topic.description,
+ iconKey: topic.iconKey,
+ entrySlugs: topicEntrySlugs.get(topic.slug) ?? [],
+ relatedTopicSlugs: topic.related,
+ curatedComparisons: topic.comparisons,
+}));
+
+export const dictionaryComparisonPairs: readonly DictionaryComparisonPair[] = [
+ {
+ slugs: ["mental-state-examination", "mini-mental-state-examination"],
+ summary: "MSE describes current mental presentation; MMSE is a structured cognitive screening instrument.",
+ sourceRefs: [
+ { sourceId: "nsw-mse-handbook", supports: ["comparison"] },
+ { sourceId: "nsw-mental-assessment", supports: ["comparison"] },
+ ],
+ },
+ {
+ slugs: ["delirium", "dementia"],
+ summary:
+ "Delirium is typically acute and fluctuating; dementia usually describes a persistent acquired cognitive decline.",
+ sourceRefs: [{ sourceId: "nice-delirium", supports: ["comparison"] }],
+ },
+ {
+ slugs: ["mood", "affect"],
+ summary:
+ "Mood is primarily reported by the person; affect is the emotional expression observed during the interaction.",
+ sourceRefs: [{ sourceId: "nsw-mental-assessment", supports: ["comparison"] }],
+ },
+];
+
+export function dictionarySource(sourceId: string): DictionarySource | null {
+ return dictionarySources.find((source) => source.id === sourceId) ?? null;
+}
diff --git a/src/lib/dictionary.ts b/src/lib/dictionary.ts
new file mode 100644
index 0000000000..50ba9f4dbc
--- /dev/null
+++ b/src/lib/dictionary.ts
@@ -0,0 +1,299 @@
+import { normalizeSearchText } from "@/lib/catalog-search";
+import {
+ dictionaryComparisonPairs,
+ dictionaryEntries,
+ dictionaryEntryKinds,
+ dictionarySource,
+ dictionarySources,
+ dictionaryTopics,
+ type DictionaryAlias,
+ type DictionaryComparisonPair,
+ type DictionaryEntry,
+ type DictionaryEntryKind,
+ type DictionarySource,
+ type DictionaryTopic,
+} from "@/lib/dictionary-data";
+
+export type DictionarySearchHit =
+ | { type: "entry"; entry: DictionaryEntry; score: number; reason: string }
+ | {
+ type: "abbreviation";
+ abbreviation: string;
+ senses: readonly DictionaryEntry[];
+ score: number;
+ reason: string;
+ }
+ | { type: "topic"; topic: DictionaryTopic; score: number; reason: string };
+
+export type DictionarySearchView = "all" | "definitions" | "abbreviations" | "topics";
+export type DictionarySort = "relevance" | "az";
+export type DictionaryUpdated = "any" | "year" | "six-months";
+
+export type DictionaryFilters = {
+ q: string;
+ view: DictionarySearchView;
+ topics: readonly string[];
+ kinds: readonly DictionaryEntryKind[];
+ sources: readonly string[];
+ updated: DictionaryUpdated;
+ sort: DictionarySort;
+};
+
+const validSearchViews = new Set(["all", "definitions", "abbreviations", "topics"]);
+const validUpdated = new Set(["any", "year", "six-months"]);
+const validSort = new Set(["relevance", "az"]);
+
+export const allDictionaryEntries = [...dictionaryEntries].sort((a, b) => a.term.localeCompare(b.term));
+
+export function dictionaryKindLabel(kind: DictionaryEntryKind) {
+ return {
+ "clinical-concept": "Clinical concept",
+ "clinical-finding": "Clinical finding",
+ assessment: "Assessment",
+ condition: "Condition",
+ "medication-class": "Medication class",
+ therapy: "Therapy",
+ "service-model": "Service model",
+ "legal-ethical-concept": "Legal / ethical",
+ }[kind];
+}
+
+export function findDictionaryEntry(slug: string | null | undefined) {
+ return allDictionaryEntries.find((entry) => entry.slug === slug) ?? null;
+}
+
+export function findDictionaryTopic(slug: string | null | undefined) {
+ return dictionaryTopics.find((topic) => topic.slug === slug) ?? null;
+}
+
+export function dictionaryEntrySources(entry: DictionaryEntry): DictionarySource[] {
+ return entry.sourceRefs.flatMap((reference) => {
+ const source = dictionarySource(reference.sourceId);
+ return source ? [source] : [];
+ });
+}
+
+export function dictionaryTopicEntries(topic: DictionaryTopic) {
+ const slugs = new Set(topic.entrySlugs);
+ return allDictionaryEntries.filter((entry) => slugs.has(entry.slug));
+}
+
+export function dictionaryComparisonPair(a: string, b: string): DictionaryComparisonPair | null {
+ return (
+ dictionaryComparisonPairs.find(
+ (pair) => (pair.slugs[0] === a && pair.slugs[1] === b) || (pair.slugs[0] === b && pair.slugs[1] === a),
+ ) ?? null
+ );
+}
+
+function uniqueKnown(values: readonly string[], known: ReadonlySet) {
+ return Array.from(new Set(values.filter((value) => known.has(value))));
+}
+
+function valuesFrom(params: URLSearchParams, key: string) {
+ return params
+ .getAll(key)
+ .flatMap((value) => value.split(","))
+ .map((value) => value.trim())
+ .filter(Boolean);
+}
+
+export function parseDictionaryFilters(params: URLSearchParams): DictionaryFilters {
+ const rawView = params.get("view") as DictionarySearchView | null;
+ const rawUpdated = params.get("updated") as DictionaryUpdated | null;
+ const rawSort = params.get("sort") as DictionarySort | null;
+ return {
+ q: (params.get("q") ?? "").trim(),
+ view: rawView && validSearchViews.has(rawView) ? rawView : "all",
+ topics: uniqueKnown(valuesFrom(params, "topic"), new Set(dictionaryTopics.map((topic) => topic.slug))),
+ kinds: uniqueKnown(valuesFrom(params, "kind"), new Set(dictionaryEntryKinds)) as DictionaryEntryKind[],
+ sources: uniqueKnown(valuesFrom(params, "source"), new Set(dictionarySources.map((source) => source.id))),
+ updated: rawUpdated && validUpdated.has(rawUpdated) ? rawUpdated : "any",
+ sort: rawSort && validSort.has(rawSort) ? rawSort : "relevance",
+ };
+}
+
+function entryPassesFilters(entry: DictionaryEntry, filters: DictionaryFilters) {
+ if (filters.topics.length && !filters.topics.includes(entry.topicSlug)) return false;
+ if (filters.kinds.length && !filters.kinds.includes(entry.kind)) return false;
+ if (filters.sources.length && !entry.sourceRefs.some((reference) => filters.sources.includes(reference.sourceId))) {
+ return false;
+ }
+ // The catalogue currently has one governed review cohort. Keep the URL lens
+ // explicit and predicate-owned so future cohorts do not need a second filter path.
+ if (filters.updated !== "any" && entry.review.checkedOn < "2025-08-18") return false;
+ return true;
+}
+
+function entryScore(entry: DictionaryEntry, query: string) {
+ if (!query) return { score: 10, reason: "Browse result" };
+ const normalized = normalizeSearchText(query);
+ const term = normalizeSearchText(entry.term);
+ const aliases = entry.aliases.map((alias) => ({ alias, normalized: normalizeSearchText(alias.value) }));
+ const exactAlias = aliases.find(({ normalized: value }) => value === normalized);
+ if (term === normalized) return { score: 100, reason: "Exact term" };
+ if (exactAlias) {
+ return {
+ score: exactAlias.alias.kind === "abbreviation" ? 98 : 94,
+ reason: exactAlias.alias.kind === "abbreviation" ? `Abbreviation: ${exactAlias.alias.value}` : "Exact alias",
+ };
+ }
+ if (term.startsWith(normalized)) return { score: 88, reason: "Term begins with the search" };
+ if (aliases.some(({ normalized: value }) => value.startsWith(normalized))) {
+ return { score: 82, reason: "Alias begins with the search" };
+ }
+ if (term.includes(normalized)) return { score: 72, reason: "Term contains the search" };
+ if (aliases.some(({ normalized: value }) => value.includes(normalized))) return { score: 68, reason: "Alias match" };
+ const context = normalizeSearchText(
+ `${entry.definition} ${entry.meaning} ${entry.context.join(" ")} ${dictionaryKindLabel(entry.kind)}`,
+ );
+ if (context.includes(normalized)) return { score: 44, reason: "Definition or context match" };
+ return null;
+}
+
+function abbreviationGroups(entries: readonly DictionaryEntry[]) {
+ const groups = new Map();
+ for (const entry of entries) {
+ for (const alias of entry.aliases) {
+ if (alias.kind !== "abbreviation") continue;
+ const key = alias.value.toLocaleUpperCase();
+ groups.set(key, [...(groups.get(key) ?? []), entry]);
+ }
+ }
+ return groups;
+}
+
+export function searchDictionary(filters: DictionaryFilters): DictionarySearchHit[] {
+ const filteredEntries = allDictionaryEntries.filter((entry) => entryPassesFilters(entry, filters));
+ const hits: DictionarySearchHit[] = [];
+ const q = filters.q;
+
+ if (filters.view === "all" || filters.view === "definitions") {
+ for (const entry of filteredEntries) {
+ const match = entryScore(entry, q);
+ if (match) hits.push({ type: "entry", entry, ...match });
+ }
+ }
+
+ if (filters.view === "all" || filters.view === "abbreviations") {
+ const normalizedQuery = normalizeSearchText(q);
+ for (const [abbreviation, senses] of abbreviationGroups(filteredEntries)) {
+ const normalizedAbbreviation = normalizeSearchText(abbreviation);
+ const searchable = normalizeSearchText(`${abbreviation} ${senses.map((entry) => entry.term).join(" ")}`);
+ if (!normalizedQuery || searchable.includes(normalizedQuery)) {
+ hits.push({
+ type: "abbreviation",
+ abbreviation,
+ senses,
+ score:
+ normalizedAbbreviation === normalizedQuery
+ ? 99
+ : normalizedAbbreviation.startsWith(normalizedQuery)
+ ? 84
+ : 55,
+ reason: senses.length > 1 ? `${senses.length} recognised meanings` : "Governed abbreviation",
+ });
+ }
+ }
+ }
+
+ if (filters.view === "all" || filters.view === "topics") {
+ const allowedTopics = new Set(filteredEntries.map((entry) => entry.topicSlug));
+ const normalizedQuery = normalizeSearchText(q);
+ for (const topic of dictionaryTopics) {
+ if (!allowedTopics.has(topic.slug)) continue;
+ const searchable = normalizeSearchText(`${topic.title} ${topic.description}`);
+ if (!normalizedQuery || searchable.includes(normalizedQuery)) {
+ hits.push({
+ type: "topic",
+ topic,
+ score: normalizeSearchText(topic.title) === normalizedQuery ? 96 : 42,
+ reason: `${topic.entrySlugs.length} governed terms`,
+ });
+ }
+ }
+ }
+
+ return hits.sort((a, b) => {
+ if (filters.sort === "az") {
+ const aTitle = a.type === "entry" ? a.entry.term : a.type === "topic" ? a.topic.title : a.abbreviation;
+ const bTitle = b.type === "entry" ? b.entry.term : b.type === "topic" ? b.topic.title : b.abbreviation;
+ return aTitle.localeCompare(bTitle);
+ }
+ return b.score - a.score;
+ });
+}
+
+export function browseDictionary(params: {
+ view: "az" | "abbreviations";
+ letter: string;
+ topics: readonly string[];
+ kinds: readonly DictionaryEntryKind[];
+ sort: "az" | "za";
+}) {
+ const filters: DictionaryFilters = {
+ q: "",
+ view: params.view === "abbreviations" ? "abbreviations" : "definitions",
+ topics: params.topics,
+ kinds: params.kinds,
+ sources: [],
+ updated: "any",
+ sort: "az",
+ };
+ let hits = searchDictionary(filters).filter((hit) => {
+ if (!params.letter || params.letter === "all") return true;
+ const title =
+ hit.type === "entry" ? hit.entry.term : hit.type === "abbreviation" ? hit.abbreviation : hit.topic.title;
+ return title.charAt(0).toLocaleUpperCase() === params.letter.toLocaleUpperCase();
+ });
+ if (params.sort === "za") hits = hits.reverse();
+ return hits;
+}
+
+export function dictionaryCompareHref(slugs: readonly string[]) {
+ const valid = Array.from(new Set(slugs.filter((slug) => Boolean(findDictionaryEntry(slug))))).slice(0, 2);
+ const params = new URLSearchParams();
+ if (valid[0]) params.set("a", valid[0]);
+ if (valid[1]) params.set("b", valid[1]);
+ return `/dictionary/compare${params.size ? `?${params.toString()}` : ""}`;
+}
+
+export function dictionaryCatalogueIssues() {
+ const issues: string[] = [];
+ const slugs = new Set();
+ const topicSlugs = new Set(dictionaryTopics.map((topic) => topic.slug));
+ const sourceIds = new Set(dictionarySources.map((source) => source.id));
+ const entrySlugs = new Set(dictionaryEntries.map((entry) => entry.slug));
+ if (dictionaryEntries.length !== 96) issues.push(`expected 96 entries, found ${dictionaryEntries.length}`);
+ if (dictionaryTopics.length !== 12) issues.push(`expected 12 topics, found ${dictionaryTopics.length}`);
+ for (const source of dictionarySources) {
+ try {
+ const protocol = new URL(source.url).protocol;
+ if (protocol !== "https:" && protocol !== "http:") issues.push(`${source.id}: unsafe URL`);
+ } catch {
+ issues.push(`${source.id}: invalid URL`);
+ }
+ }
+ for (const entry of dictionaryEntries) {
+ if (slugs.has(entry.slug)) issues.push(`${entry.slug}: duplicate slug`);
+ slugs.add(entry.slug);
+ if (!topicSlugs.has(entry.topicSlug)) issues.push(`${entry.slug}: unknown topic`);
+ if (!entry.definition.trim() || !entry.meaning.trim()) issues.push(`${entry.slug}: missing required copy`);
+ if (!entry.sourceRefs.length) issues.push(`${entry.slug}: no source`);
+ if (entry.sourceRefs.some((reference) => !sourceIds.has(reference.sourceId))) {
+ issues.push(`${entry.slug}: unknown source`);
+ }
+ if (entry.relatedSlugs.length !== 4 || entry.relatedSlugs.some((slug) => !entrySlugs.has(slug))) {
+ issues.push(`${entry.slug}: invalid related entries`);
+ }
+ if (entry.review.clinicalApproval !== "pending") issues.push(`${entry.slug}: approval state must remain pending`);
+ }
+ return issues;
+}
+
+export function dictionaryAliasSenses(value: string) {
+ const normalized = normalizeSearchText(value);
+ return allDictionaryEntries.filter((entry) =>
+ entry.aliases.some((alias: DictionaryAlias) => normalizeSearchText(alias.value) === normalized),
+ );
+}
diff --git a/src/lib/information-pages.ts b/src/lib/information-pages.ts
index 6efde34162..608dce9557 100644
--- a/src/lib/information-pages.ts
+++ b/src/lib/information-pages.ts
@@ -15,6 +15,7 @@ export type InformationPageMode =
| "specifiers"
| "formulation"
| "factsheets"
+ | "dictionary"
| "therapy-compass"
| "differentials"
| "dsm"
@@ -48,6 +49,9 @@ export function isInformationPage(pathname: string): boolean {
if (isSlugDetail(pathname, "/specifiers")) return true;
if (isSlugDetail(pathname, "/formulation")) return true;
if (isSlugDetail(pathname, "/factsheets", ["search"])) return true;
+ if (isSlugDetail(pathname, "/dictionary", ["search", "browse", "topics", "compare", "sources"])) return true;
+ if (pathname.startsWith("/dictionary/topics/") && !pathname.slice("/dictionary/topics/".length).includes("/"))
+ return true;
// Therapy compass detail: /therapy-compass/[slug]/brief or /sheet (and bare slug if present)
if (
@@ -81,6 +85,7 @@ export const informationPageShellModes = [
"specifiers",
"formulation",
"factsheets",
+ "dictionary",
"therapy-compass",
"dsm",
] as const satisfies readonly InformationPageMode[];
diff --git a/src/lib/mode-secondary-navigation.ts b/src/lib/mode-secondary-navigation.ts
index 889fec9882..29d0cbc0e6 100644
--- a/src/lib/mode-secondary-navigation.ts
+++ b/src/lib/mode-secondary-navigation.ts
@@ -75,6 +75,13 @@ export const modeSecondaryNavigationRegistry = {
{ id: "topics", label: "Topics", href: appModeHomeHref("factsheets") },
{ id: "search", label: "Search", href: "/factsheets/search" },
],
+ dictionary: [
+ { id: "search", label: "Search", href: "/dictionary/search" },
+ { id: "browse", label: "Browse", href: "/dictionary/browse" },
+ { id: "topics", label: "Topics", href: "/dictionary/topics" },
+ { id: "compare", label: "Compare", href: "/dictionary/compare" },
+ { id: "sources", label: "Sources", href: "/dictionary/sources" },
+ ],
} as const satisfies Record;
type RegistryEntry = (typeof modeSecondaryNavigationRegistry)[AppModeId][number];
@@ -107,6 +114,7 @@ export const MODE_NAV_ADOPTED_MODES = [
"differentials",
"factsheets",
"therapy-compass",
+ "dictionary",
] as const satisfies readonly AppModeId[];
export type ModeNavAdoptedMode = (typeof MODE_NAV_ADOPTED_MODES)[number];
@@ -173,6 +181,14 @@ export function activeModeSecondaryNavigationId(modeId: AppModeId, pathname: str
if (pathname === "/therapy-compass/pathways") return "pathways";
return null;
}
+ if (modeId === "dictionary") {
+ if (pathname === "/dictionary/search") return "search";
+ if (pathname === "/dictionary/browse") return "browse";
+ if (pathname === "/dictionary/topics" || pathname.startsWith("/dictionary/topics/")) return "topics";
+ if (pathname === "/dictionary/compare") return "compare";
+ if (pathname === "/dictionary/sources") return "sources";
+ return null;
+ }
// Every mode with destinations has a branch above; the rest register none, so
// nothing can be current. This used to be
// `modeSecondaryNavigationRegistry[modeId][0]?.id ?? null`, which existed only
@@ -217,6 +233,15 @@ export function isModeSecondaryNavigationRoute(params: {
// `hasSubmittedSearch` early return, and Topics is marked current there.
if (modeId === "factsheets") return pathname === "/factsheets/search";
if (modeId === "therapy-compass") return pathname !== "/therapy-compass";
+ if (modeId === "dictionary") {
+ return [
+ "/dictionary/search",
+ "/dictionary/browse",
+ "/dictionary/topics",
+ "/dictionary/compare",
+ "/dictionary/sources",
+ ].includes(pathname);
+ }
return false;
}
@@ -360,5 +385,29 @@ export function modeSecondaryNavigationHref(params: {
]);
}
+ if (modeId === "dictionary") {
+ if (itemId === "search") {
+ return navigationHrefWithParams(href, [
+ ...(query ? ([["q", query]] as const) : []),
+ ...currentSearchParams.getAll("topic").map((value) => ["topic", value] as const),
+ ...currentSearchParams.getAll("kind").map((value) => ["kind", value] as const),
+ ]);
+ }
+ if (itemId === "browse") {
+ return navigationHrefWithParams(href, [
+ ...(currentSearchParams.get("view") ? ([["view", currentSearchParams.get("view") ?? ""]] as const) : []),
+ ...(currentSearchParams.get("letter") ? ([["letter", currentSearchParams.get("letter") ?? ""]] as const) : []),
+ ...currentSearchParams.getAll("topic").map((value) => ["topic", value] as const),
+ ...currentSearchParams.getAll("kind").map((value) => ["kind", value] as const),
+ ]);
+ }
+ if (itemId === "compare") {
+ return navigationHrefWithParams(href, [
+ ...(currentSearchParams.get("a") ? ([["a", currentSearchParams.get("a") ?? ""]] as const) : []),
+ ...(currentSearchParams.get("b") ? ([["b", currentSearchParams.get("b") ?? ""]] as const) : []),
+ ]);
+ }
+ }
+
return href;
}
diff --git a/src/lib/search-route-ownership.ts b/src/lib/search-route-ownership.ts
index f5a69c5e39..b078f52908 100644
--- a/src/lib/search-route-ownership.ts
+++ b/src/lib/search-route-ownership.ts
@@ -15,6 +15,7 @@ const routeOwnedSubmittedSearchModes = new Set([
"formulation",
"therapy-compass",
"factsheets",
+ "dictionary",
"tools",
"calculators",
]);
@@ -33,6 +34,7 @@ const standaloneModeHomePaths = new Set([
"/specifiers",
"/formulation",
"/factsheets",
+ "/dictionary",
"/therapy-compass",
"/tools",
"/calculators",
@@ -78,6 +80,7 @@ const alwaysStandaloneShellPathPrefixes = [
"/specifiers",
"/formulation",
"/factsheets",
+ "/dictionary",
"/therapy-compass",
"/medications",
"/calculators",
diff --git a/src/lib/search-shell-props.ts b/src/lib/search-shell-props.ts
index 7c2d162546..8590ecd627 100644
--- a/src/lib/search-shell-props.ts
+++ b/src/lib/search-shell-props.ts
@@ -87,5 +87,9 @@ export function searchShellPropsForPathname(pathname: string): SearchShellPathPr
return { initialMode: "factsheets", desktopSearchPlacement: "hero" };
}
+ if (pathname.startsWith("/dictionary")) {
+ return { initialMode: "dictionary", desktopSearchPlacement: "hero" };
+ }
+
return { initialMode: "answer" };
}
diff --git a/src/lib/tools-catalog.ts b/src/lib/tools-catalog.ts
index 29bbc569c0..8f9f3f64e1 100644
--- a/src/lib/tools-catalog.ts
+++ b/src/lib/tools-catalog.ts
@@ -84,6 +84,25 @@ export const toolCatalogRecords: ToolCatalogRecord[] = [
neededInput: ["Source topic", "Optional document name", "Preferred date or local scope"],
output: "Matching documents, page context, snippets, and source links.",
},
+ {
+ id: "clinical-dictionary",
+ title: "Clinical Dictionary",
+ mobileTitle: "Dictionary",
+ description: "Search source-governed psychiatric terms, abbreviations, topics, and distinctions.",
+ bestFor: "Terminology and abbreviation lookup",
+ detail:
+ "Open concise definitions, resolve ambiguous abbreviations, compare terms, and review their direct sources.",
+ href: "/dictionary",
+ area: "reference",
+ status: "ready",
+ sourceBacked: true,
+ highYield: true,
+ actionLabel: "Search",
+ keywords: ["dictionary", "definition", "term", "terminology", "abbreviation", "acronym", "compare"],
+ checkFirst: ["Term or abbreviation", "Clinical topic or context", "Whether a distinction or comparison is needed"],
+ neededInput: ["Term, abbreviation, or topic"],
+ output: "Source-checked definition, related terminology, distinctions, and source links.",
+ },
{
id: "guidelines",
title: "Guidelines",
diff --git a/src/lib/ui-copy.ts b/src/lib/ui-copy.ts
index fec7bfe772..e9745cf630 100644
--- a/src/lib/ui-copy.ts
+++ b/src/lib/ui-copy.ts
@@ -61,6 +61,9 @@ export const sharedHomePresentation = {
factsheets: {
title: "Patient Factsheets",
},
+ dictionary: {
+ title: "Clinical Dictionary",
+ },
} as const satisfies Record;
export const sharedHomeEmptyState = {
diff --git a/src/lib/universal-search-domains.ts b/src/lib/universal-search-domains.ts
index 016b5b392f..f7af4ea135 100644
--- a/src/lib/universal-search-domains.ts
+++ b/src/lib/universal-search-domains.ts
@@ -14,6 +14,7 @@ export type UniversalSearchDomain =
| "specifiers"
| "formulation"
| "therapies"
+ | "dictionary"
| "tools";
// Canonical order: the default group order in responses AND the topHit tiebreak when
@@ -40,5 +41,6 @@ export const universalSearchDomains: UniversalSearchDomain[] = [
// not collide with other domains' titles, so this position only sets default group
// order, not a topHit tiebreak.
"therapies",
+ "dictionary",
"tools",
];
diff --git a/src/lib/universal-search-mode-context.ts b/src/lib/universal-search-mode-context.ts
index d90dcbe2ba..2505411488 100644
--- a/src/lib/universal-search-mode-context.ts
+++ b/src/lib/universal-search-mode-context.ts
@@ -22,6 +22,7 @@ const preferredDomainsByMode: Record = {
@@ -35,6 +36,7 @@ const modeByDomain: Record = {
formulation: "formulation",
dsm: "dsm",
therapies: "therapy-compass",
+ dictionary: "dictionary",
tools: "tools",
};
diff --git a/src/lib/universal-search.ts b/src/lib/universal-search.ts
index e65fe512a9..58b43953ac 100644
--- a/src/lib/universal-search.ts
+++ b/src/lib/universal-search.ts
@@ -9,6 +9,7 @@ import {
rankPresentationWorkflows,
} from "@/lib/differentials";
import { dsmDiagnosisSummary, rankDsmDiagnoses } from "@/lib/dsm";
+import { dictionaryKindLabel, searchDictionary } from "@/lib/dictionary";
import { formRecords, rankFormRecords, type FormRecord } from "@/lib/forms";
import { rowToMedicationRecord } from "@/lib/medication-records";
import { defaultMedicationRecords, fetchOwnerMedicationRowsWithSeed } from "@/lib/medication-seed";
@@ -390,6 +391,55 @@ async function searchToolsDomain(args: ResolvedSearchArgs): Promise {
+ return searchDictionary({
+ q: args.baseQuery,
+ view: "all",
+ topics: [],
+ kinds: [],
+ sources: [],
+ updated: "any",
+ sort: "relevance",
+ })
+ .slice(0, args.limitPerDomain)
+ .map((hit): UniversalSearchItem => {
+ if (hit.type === "entry") {
+ return {
+ id: hit.entry.slug,
+ kind: "dictionary",
+ title: hit.entry.term,
+ subtitle: hit.entry.definition,
+ href: `/dictionary/${hit.entry.slug}`,
+ score: hit.score,
+ badge: dictionaryKindLabel(hit.entry.kind),
+ meta: hit.reason,
+ };
+ }
+ if (hit.type === "abbreviation") {
+ return {
+ id: `abbreviation:${hit.abbreviation}`,
+ kind: "dictionary",
+ title: hit.abbreviation,
+ subtitle: hit.senses.map((entry) => entry.term).join(" · "),
+ href: `/dictionary/search?q=${encodeURIComponent(hit.abbreviation)}&view=abbreviations`,
+ score: hit.score,
+ badge: hit.senses.length > 1 ? `${hit.senses.length} meanings` : "Abbreviation",
+ meta: hit.reason,
+ };
+ }
+ return {
+ id: `topic:${hit.topic.slug}`,
+ kind: "dictionary",
+ title: hit.topic.title,
+ subtitle: hit.topic.description,
+ href: `/dictionary/topics/${hit.topic.slug}`,
+ score: hit.score,
+ badge: "Topic",
+ meta: hit.reason,
+ };
+ });
+}
+
async function searchFormulationDomain(args: ResolvedSearchArgs): Promise {
return searchFormulationMechanisms(args.baseQuery)
.slice(0, args.limitPerDomain)
@@ -514,6 +564,7 @@ const domainAdapters: Record<
specifiers: { run: searchSpecifiersDomain, timeoutMs: registryDomainTimeoutMs },
formulation: { run: searchFormulationDomain, timeoutMs: registryDomainTimeoutMs },
therapies: { run: searchTherapiesDomain, timeoutMs: registryDomainTimeoutMs },
+ dictionary: { run: searchDictionaryDomain, timeoutMs: registryDomainTimeoutMs },
tools: { run: searchToolsDomain, timeoutMs: registryDomainTimeoutMs },
};
@@ -725,6 +776,8 @@ export function universalSearchViewAllHref(domain: UniversalSearchDomain, query:
return `/formulation?q=${encodeURIComponent(query)}&run=1`;
case "therapies":
return `/therapy-compass/search?q=${encodeURIComponent(query)}&run=1`;
+ case "dictionary":
+ return `/dictionary/search?q=${encodeURIComponent(query)}`;
case "tools":
return `/tools?q=${encodeURIComponent(query)}&run=1`;
}
diff --git a/tests/app-modes.test.ts b/tests/app-modes.test.ts
index f42d3e7795..a4ae603758 100644
--- a/tests/app-modes.test.ts
+++ b/tests/app-modes.test.ts
@@ -406,6 +406,7 @@ describe("app mode search contract", () => {
documents: "/documents/search?mode=documents&q=clozapine&run=1",
dsm: "/dsm/search?q=clozapine&run=1",
factsheets: "/factsheets/search?q=clozapine&run=1",
+ dictionary: "/dictionary/search?q=clozapine&run=1",
// Same route, submitted branch.
services: "/services?q=clozapine&run=1",
forms: "/forms?q=clozapine&run=1",
diff --git a/tests/design-system-adoption.test.ts b/tests/design-system-adoption.test.ts
index cbbf98a4b9..86912e772b 100644
--- a/tests/design-system-adoption.test.ts
+++ b/tests/design-system-adoption.test.ts
@@ -1237,9 +1237,9 @@ describe("design-system adoption manifest", () => {
["committed", "not-committed", "not-applicable"].includes(surface.baseline.status),
),
).toBe(true);
- // 51: Presentations catalogue page plus a real Compare page (no longer a
- // route-handler redirect) on top of Documents/Medication mode homes.
- expect(manifest.routeCoverage.discovered).toHaveLength(51);
+ // 59: the previous 51 production pages plus the eight-route Dictionary
+ // surface, including its shared mode home and governed detail routes.
+ expect(manifest.routeCoverage.discovered).toHaveLength(59);
expect(manifest.routeCoverage.declared).toEqual(manifest.routeCoverage.discovered);
expect(manifest.routeCoverage.undeclared).toEqual([]);
expect(manifest.routeCoverage.missing).toEqual([]);
diff --git a/tests/dictionary-data.test.ts b/tests/dictionary-data.test.ts
new file mode 100644
index 0000000000..5b5df22126
--- /dev/null
+++ b/tests/dictionary-data.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ dictionaryAliasSenses,
+ dictionaryCatalogueIssues,
+ dictionaryCompareHref,
+ dictionaryComparisonPair,
+ parseDictionaryFilters,
+ searchDictionary,
+} from "@/lib/dictionary";
+import { dictionaryEntries, dictionaryEntryKinds, dictionarySources, dictionaryTopics } from "@/lib/dictionary-data";
+
+const baseFilters = {
+ q: "",
+ view: "all" as const,
+ topics: [],
+ kinds: [],
+ sources: [],
+ updated: "any" as const,
+ sort: "relevance" as const,
+};
+
+describe("clinical dictionary catalogue", () => {
+ it("publishes exactly 96 unique canonical entries across 12 unique topics", () => {
+ expect(dictionaryEntries).toHaveLength(96);
+ expect(new Set(dictionaryEntries.map((entry) => entry.slug)).size).toBe(96);
+ expect(dictionaryTopics).toHaveLength(12);
+ expect(new Set(dictionaryTopics.map((topic) => topic.slug)).size).toBe(12);
+ expect(dictionaryTopics.every((topic) => topic.entrySlugs.length === 8)).toBe(true);
+ expect(dictionaryCatalogueIssues()).toEqual([]);
+ });
+
+ it("keeps every published entry source checked, approval pending, and fully linked", () => {
+ const sourceIds = new Set(dictionarySources.map((source) => source.id));
+ const entrySlugs = new Set(dictionaryEntries.map((entry) => entry.slug));
+ for (const entry of dictionaryEntries) {
+ expect(entry.sourceRefs.length).toBeGreaterThan(0);
+ expect(entry.sourceRefs.every((reference) => sourceIds.has(reference.sourceId))).toBe(true);
+ expect(entry.review.status).toBe("source-checked");
+ expect(entry.review.checkedOn).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ expect(entry.review.dueOn).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ expect(entry.review.clinicalApproval).toBe("pending");
+ expect(entry.relatedSlugs).toHaveLength(4);
+ expect(entry.relatedSlugs.every((slug) => entrySlugs.has(slug))).toBe(true);
+ }
+ for (const source of dictionarySources) expect(source.url).toMatch(/^https?:\/\//);
+ });
+
+ it("groups ACT into two recognised abbreviation senses and resolves unambiguous aliases", () => {
+ expect(
+ dictionaryAliasSenses("ACT")
+ .map((entry) => entry.slug)
+ .sort(),
+ ).toEqual(["acceptance-and-commitment-therapy", "assertive-community-treatment"]);
+ expect(dictionaryAliasSenses("MSE").map((entry) => entry.slug)).toEqual(["mental-state-examination"]);
+ const actHits = searchDictionary({ ...baseFilters, q: "ACT", view: "abbreviations" });
+ expect(actHits).toHaveLength(1);
+ expect(actHits[0]).toMatchObject({ type: "abbreviation", abbreviation: "ACT", senses: { length: 2 } });
+ });
+
+ it("uses one predicate for lens counts and topic, kind, source, and updated filters", () => {
+ const topic = dictionaryTopics[0]!;
+ const topicHits = searchDictionary({ ...baseFilters, view: "definitions", topics: [topic.slug] });
+ expect(topicHits).toHaveLength(topic.entrySlugs.length);
+ expect(topicHits.every((hit) => hit.type === "entry" && hit.entry.topicSlug === topic.slug)).toBe(true);
+
+ const assessmentHits = searchDictionary({ ...baseFilters, view: "definitions", kinds: ["assessment"] });
+ expect(assessmentHits.length).toBeGreaterThan(0);
+ expect(assessmentHits.every((hit) => hit.type === "entry" && hit.entry.kind === "assessment")).toBe(true);
+
+ const source = dictionarySources[0]!;
+ const sourceHits = searchDictionary({ ...baseFilters, view: "definitions", sources: [source.id] });
+ expect(sourceHits.length).toBeGreaterThan(0);
+ expect(
+ sourceHits.every(
+ (hit) => hit.type === "entry" && hit.entry.sourceRefs.some((reference) => reference.sourceId === source.id),
+ ),
+ ).toBe(true);
+
+ expect(searchDictionary({ ...baseFilters, view: "definitions", updated: "year" })).toHaveLength(96);
+ });
+
+ it("normalises invalid URL filters without throwing", () => {
+ const filters = parseDictionaryFilters(
+ new URLSearchParams("q=MSE&view=bad&topic=missing&kind=bad&source=missing&updated=old&sort=random"),
+ );
+ expect(filters).toEqual({ ...baseFilters, q: "MSE" });
+ expect(dictionaryEntryKinds).not.toContain("bad");
+ });
+
+ it("keeps compare links unique and relationship summaries explicitly curated", () => {
+ expect(dictionaryCompareHref(["mood", "mood", "affect"])).toBe("/dictionary/compare?a=mood&b=affect");
+ expect(dictionaryCompareHref(["missing", "mood"])).toBe("/dictionary/compare?a=mood");
+ expect(dictionaryComparisonPair("mental-state-examination", "mini-mental-state-examination")?.summary).toMatch(
+ /MSE/,
+ );
+ expect(dictionaryComparisonPair("affect", "delirium")).toBeNull();
+ });
+});
diff --git a/tests/dictionary-term-page.dom.test.tsx b/tests/dictionary-term-page.dom.test.tsx
new file mode 100644
index 0000000000..81dd664d95
--- /dev/null
+++ b/tests/dictionary-term-page.dom.test.tsx
@@ -0,0 +1,42 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { DictionaryTermPage } from "@/components/dictionary/dictionary-term-page";
+import { findDictionaryEntry } from "@/lib/dictionary";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
+ usePathname: () => "/dictionary/auditory-hallucination",
+}));
+
+describe("DictionaryTermPage", () => {
+ it("renders one canonical heading and one compact source-status summary", () => {
+ const entry = findDictionaryEntry("auditory-hallucination");
+ expect(entry).not.toBeNull();
+ const { container } = render();
+
+ expect(container.querySelectorAll("h1")).toHaveLength(1);
+ expect(screen.getByRole("heading", { level: 1, name: "Auditory hallucination" })).toBeInTheDocument();
+ expect(screen.getAllByTestId("dictionary-source-status-summary")).toHaveLength(1);
+ expect(screen.getByTestId("dictionary-source-status-summary")).toHaveTextContent("Source checked");
+ expect(screen.getByTestId("dictionary-source-status-summary")).toHaveTextContent("covered source");
+ });
+
+ it("keeps Meaning expanded and reveals phone disclosure content on demand", () => {
+ const entry = findDictionaryEntry("auditory-hallucination")!;
+ render();
+
+ expect(screen.getByRole("button", { name: /Meaning/ })).toHaveAttribute("aria-expanded", "true");
+ const contextButton = screen.getByRole("button", { name: /Use and context/ });
+ expect(contextButton).toHaveAttribute("aria-expanded", "false");
+ fireEvent.click(contextButton);
+ expect(contextButton).toHaveAttribute("aria-expanded", "true");
+ });
+
+ it("shows four real related-entry destinations", () => {
+ const entry = findDictionaryEntry("auditory-hallucination")!;
+ render();
+ const related = screen.getByText("4 governed entries from the same collection").closest("section");
+ expect(related?.querySelectorAll('a[href^="/dictionary/"]')).toHaveLength(4);
+ });
+});
diff --git a/tests/information-page-shell.dom.test.tsx b/tests/information-page-shell.dom.test.tsx
index f947c55c43..f9326c61f3 100644
--- a/tests/information-page-shell.dom.test.tsx
+++ b/tests/information-page-shell.dom.test.tsx
@@ -16,6 +16,8 @@ describe("isInformationPage", () => {
expect(isInformationPage("/specifiers/with-anxious-distress")).toBe(true);
expect(isInformationPage("/formulation/avoidance")).toBe(true);
expect(isInformationPage("/factsheets/ssri-start")).toBe(true);
+ expect(isInformationPage("/dictionary/auditory-hallucination")).toBe(true);
+ expect(isInformationPage("/dictionary/topics/psychosis-and-perception")).toBe(true);
expect(isInformationPage("/dsm/diagnoses/mdd")).toBe(true);
expect(isInformationPage("/differentials/diagnoses/delirium")).toBe(true);
expect(isInformationPage("/documents/abc")).toBe(true);
@@ -27,6 +29,8 @@ describe("isInformationPage", () => {
expect(isInformationPage("/specifiers/builder")).toBe(false);
expect(isInformationPage("/formulation/compare")).toBe(false);
expect(isInformationPage("/factsheets/search")).toBe(false);
+ expect(isInformationPage("/dictionary/search")).toBe(false);
+ expect(isInformationPage("/dictionary/topics")).toBe(false);
expect(isInformationPage("/documents/search")).toBe(false);
expect(isInformationPage("/therapy-compass/search")).toBe(false);
});
diff --git a/tests/mode-nav-addon-slot.dom.test.tsx b/tests/mode-nav-addon-slot.dom.test.tsx
index ee8ad9ec40..de8867b043 100644
--- a/tests/mode-nav-addon-slot.dom.test.tsx
+++ b/tests/mode-nav-addon-slot.dom.test.tsx
@@ -236,6 +236,8 @@ describe("header addon slot ownership", () => {
expect(claimants.sort()).toEqual([
"src/components/DocumentViewer.tsx",
"src/components/clinical-dashboard/medication-nav-header.tsx",
+ "src/components/dictionary/dictionary-catalogue-pages.tsx",
+ "src/components/dictionary/dictionary-term-page.tsx",
"src/components/differentials/differential-detail-page.tsx",
"src/components/differentials/differential-presentation-workflow-page.tsx",
"src/components/dsm/dsm-diagnosis-nav-header.tsx",
diff --git a/tests/mode-secondary-navigation.test.ts b/tests/mode-secondary-navigation.test.ts
index 7aa45a9ea2..4aea8b3d2f 100644
--- a/tests/mode-secondary-navigation.test.ts
+++ b/tests/mode-secondary-navigation.test.ts
@@ -27,6 +27,7 @@ const expectedLabels: Record = {
calculators: [],
"therapy-compass": ["Search", "Recommend", "Compare", "Pathways"],
factsheets: ["Topics", "Search"],
+ dictionary: ["Search", "Browse", "Topics", "Compare", "Sources"],
};
const cleanLandingPath: Record = {
@@ -44,6 +45,7 @@ const cleanLandingPath: Record = {
calculators: "/calculators",
"therapy-compass": "/therapy-compass",
factsheets: "/factsheets",
+ dictionary: "/dictionary",
};
/**
@@ -65,9 +67,9 @@ const emptyRegistryModes = [
] as const satisfies readonly AppModeId[];
describe("mode secondary navigation registry", () => {
- it("covers all 14 modes with the approved destinations and no Home item", () => {
+ it("covers all 15 modes with the approved destinations and no Home item", () => {
expect(Object.keys(modeSecondaryNavigationRegistry).sort()).toEqual([...appModeIds].sort());
- expect(appModeIds).toHaveLength(14);
+ expect(appModeIds).toHaveLength(15);
for (const modeId of appModeIds) {
const labels = modeSecondaryNavigationRegistry[modeId].map((item) => item.label);
@@ -247,6 +249,7 @@ describe("mode secondary navigation registry", () => {
// below only inspects modes with fewer than two. A mode silently losing the
// bar is the regression this list exists to make impossible.
expect([...MODE_NAV_ADOPTED_MODES].sort()).toEqual([
+ "dictionary",
"differentials",
"dsm",
"factsheets",
diff --git a/tests/search-results-band-adoption.test.ts b/tests/search-results-band-adoption.test.ts
index 977b43bd11..3d7df9b4f0 100644
--- a/tests/search-results-band-adoption.test.ts
+++ b/tests/search-results-band-adoption.test.ts
@@ -103,6 +103,10 @@ const BAND_ROUTE_ALLOWLIST = new Map([
"src/app/(search-app)/factsheets/page.tsx",
"Factsheets mode home is a catalogue landing; result lists (and the band) live on /factsheets/search.",
],
+ [
+ "src/app/(search-app)/dictionary/page.tsx",
+ "Dictionary mode home uses the shared in-flow composer; its mixed result list and band live on /dictionary/search.",
+ ],
[
"src/app/(search-app)/therapy-compass/page.tsx",
"Therapy home is the library landing; the search results band lives on /therapy-compass/search.",
diff --git a/tests/shared-home-empty-state.dom.test.tsx b/tests/shared-home-empty-state.dom.test.tsx
index 4dec57d65d..9394e085a6 100644
--- a/tests/shared-home-empty-state.dom.test.tsx
+++ b/tests/shared-home-empty-state.dom.test.tsx
@@ -81,6 +81,11 @@ const expectedPresentations = [
title: "Patient Factsheets",
iconClass: "lucide-book-open-text",
},
+ {
+ modeId: "dictionary",
+ title: "Clinical Dictionary",
+ iconClass: "lucide-book-marked",
+ },
] as const satisfies ReadonlyArray<{
modeId: AppModeId;
title: string;
diff --git a/tests/ui-dictionary.spec.ts b/tests/ui-dictionary.spec.ts
new file mode 100644
index 0000000000..7161a86c48
--- /dev/null
+++ b/tests/ui-dictionary.spec.ts
@@ -0,0 +1,162 @@
+import AxeBuilder from "@axe-core/playwright";
+import { expect, test, type Page, type TestInfo } from "playwright/test";
+
+const routes = [
+ { path: "/dictionary", testId: "dictionary-home-main" },
+ { path: "/dictionary/search?q=MSE", testId: "dictionary-search-main" },
+ { path: "/dictionary/browse", testId: "dictionary-browse-main" },
+ { path: "/dictionary/topics", testId: "dictionary-topics-main" },
+ {
+ path: "/dictionary/topics/assessment-and-measurement",
+ testId: "dictionary-topic-detail-main",
+ },
+ { path: "/dictionary/auditory-hallucination", testId: "dictionary-term-main" },
+ {
+ path: "/dictionary/compare?a=mental-state-examination&b=mini-mental-state-examination",
+ testId: "dictionary-compare-main",
+ },
+ { path: "/dictionary/sources", testId: "dictionary-sources-main" },
+] as const;
+
+async function blockExternalRequests(page: Page) {
+ await page.route("**/*", async (route) => {
+ const url = new URL(route.request().url());
+ if (
+ (url.protocol === "http:" || url.protocol === "https:") &&
+ !["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname)
+ ) {
+ await route.abort("blockedbyclient");
+ return;
+ }
+ await route.fallback();
+ });
+}
+
+async function gotoDictionary(page: Page, path: string, testId: string) {
+ await page.goto(path, { waitUntil: "domcontentloaded" });
+ await expect(page.getByTestId(testId).filter({ visible: true }).first()).toBeVisible({ timeout: 20_000 });
+}
+
+async function expectNoHorizontalOverflow(page: Page) {
+ const overflow = await page.evaluate(
+ () => Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0) - window.innerWidth,
+ );
+ expect(overflow).toBeLessThanOrEqual(2);
+}
+
+async function expectDictionaryRoute(page: Page, route: (typeof routes)[number]) {
+ await gotoDictionary(page, route.path, route.testId);
+ await expect(page.locator("#main-content h1")).toHaveCount(1);
+ await expect(page.locator("#main-content table")).toHaveCount(0);
+ await expectNoHorizontalOverflow(page);
+}
+
+async function expectNoBlockingAxeViolations(page: Page, testInfo: TestInfo) {
+ const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
+ await testInfo.attach("dictionary-axe-violations", {
+ body: JSON.stringify(results.violations, null, 2),
+ contentType: "application/json",
+ });
+ const blocking = results.violations
+ .filter((violation) => violation.impact === "critical" || violation.impact === "serious")
+ .map((violation) => `${violation.id}: ${violation.help}`);
+ expect(blocking, "axe found critical or serious Dictionary violations").toEqual([]);
+}
+
+test.beforeEach(async ({ page }) => {
+ await blockExternalRequests(page);
+});
+
+for (const viewport of [
+ { name: "desktop", width: 1440, height: 900 },
+ { name: "phone", width: 390, height: 844 },
+ { name: "compact phone", width: 320, height: 760 },
+] as const) {
+ test(`renders all eight routes without overflow at ${viewport.name}`, async ({ page }) => {
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
+ for (const route of routes) await expectDictionaryRoute(page, route);
+ });
+}
+
+test("keeps mixed result filters truthful, URL-owned, and phone-operable", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await gotoDictionary(page, "/dictionary/search?q=MSE", "dictionary-search-main");
+
+ const trigger = page.getByTestId("dictionary-filter-trigger-phone");
+ await expect(trigger).toBeVisible();
+ expect((await trigger.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(48);
+ await trigger.click();
+
+ const sheet = page.getByTestId("dictionary-filter-sheet");
+ await expect(sheet).toBeVisible();
+ await sheet.getByTestId("dictionary-filter-sheet-find").fill("Assessment and measurement");
+ await sheet.getByRole("button", { name: /^Assessment and measurement/ }).click();
+ await expect(page).toHaveURL(/topic=assessment-and-measurement/);
+ await sheet.getByTestId("dictionary-filter-sheet-done").click();
+ await expect(sheet).toBeHidden();
+ await expect(page.getByRole("button", { name: "Remove Topic: Assessment and measurement filter" })).toBeVisible();
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect(page).toHaveURL(/topic=assessment-and-measurement/);
+ await expectNoHorizontalOverflow(page);
+});
+
+test("uses a readable phone definition and stacked comparison sections", async ({ page }) => {
+ await page.setViewportSize({ width: 320, height: 760 });
+ await gotoDictionary(page, "/dictionary/auditory-hallucination", "dictionary-term-main");
+
+ const status = page.getByTestId("dictionary-source-status-summary");
+ await expect(status).toBeVisible();
+ await expect(status).toContainText("Source checked");
+ await expect(page.getByRole("complementary", { name: "Entry details" })).toBeHidden();
+ const disclosures = page
+ .locator(
+ "#dictionary-meaning, #dictionary-context, #dictionary-distinctions, #dictionary-sources, #dictionary-related",
+ )
+ .getByRole("button");
+ await expect(disclosures).toHaveCount(5);
+ for (const button of await disclosures.all()) {
+ expect((await button.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(48);
+ }
+ await expect(page.locator("#dictionary-meaning-content")).toBeVisible();
+ await expect(page.locator("#dictionary-context-content")).toBeHidden();
+
+ await gotoDictionary(
+ page,
+ "/dictionary/compare?a=mental-state-examination&b=mini-mental-state-examination",
+ "dictionary-compare-main",
+ );
+ const meaning = page.getByRole("group", { name: "Meaning" });
+ await expect(meaning).toHaveCount(0);
+ const phoneSection = page.locator("details.source-print").first();
+ await expect(phoneSection).toBeVisible();
+ const paired = phoneSection.locator("p");
+ await expect(paired).toHaveCount(2);
+ const boxes = await paired.evaluateAll((nodes) => nodes.map((node) => node.getBoundingClientRect().top));
+ expect(boxes[1]).toBeGreaterThan(boxes[0]);
+ await expectNoHorizontalOverflow(page);
+});
+
+test("preserves contrast modes, reduced motion, axe, and print content", async ({ page }, testInfo) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" });
+ await gotoDictionary(page, "/dictionary/auditory-hallucination", "dictionary-term-main");
+ await expectNoBlockingAxeViolations(page, testInfo);
+
+ await page.emulateMedia({ colorScheme: "light", forcedColors: "active", reducedMotion: "reduce" });
+ await expect(page.getByTestId("dictionary-source-status-summary")).toBeVisible();
+ await expectNoHorizontalOverflow(page);
+
+ await page.emulateMedia({ media: "print", forcedColors: "none" });
+ await expect(page.locator("#dictionary-context-content")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Print entry" })).toBeHidden();
+
+ await page.emulateMedia({ media: "screen", colorScheme: "light" });
+ await gotoDictionary(
+ page,
+ "/dictionary/compare?a=mental-state-examination&b=mini-mental-state-examination",
+ "dictionary-compare-main",
+ );
+ await page.emulateMedia({ media: "print" });
+ await expect(page.getByText("A · MSE", { exact: true }).first()).toBeVisible();
+ await expect(page.getByText("B · MMSE", { exact: true }).first()).toBeVisible();
+});
diff --git a/tests/ui-mode-nav-density.spec.ts b/tests/ui-mode-nav-density.spec.ts
index 1bde4d814e..1ac5948965 100644
--- a/tests/ui-mode-nav-density.spec.ts
+++ b/tests/ui-mode-nav-density.spec.ts
@@ -86,6 +86,7 @@ const MODES = [
{ modeId: "formulation", route: "/formulation/compare", items: 4, profile: "compact-four" },
{ modeId: "differentials", route: "/differentials/diagnoses", items: 4, profile: "balanced-four" },
{ modeId: "factsheets", route: "/factsheets/search", items: 2, profile: "two-item" },
+ { modeId: "dictionary", route: "/dictionary/search?q=MSE", items: 5, profile: "extended" },
] as const;
function densityPoints(profile: DensityProfile) {
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts
index 1513fce715..70607d4369 100644
--- a/tests/ui-tools.spec.ts
+++ b/tests/ui-tools.spec.ts
@@ -558,13 +558,15 @@ test.describe("Clinical KB tools directory and legacy launcher", () => {
await expect(results).toBeVisible();
await expect(page.getByTestId("tools-home")).toHaveCount(0);
await expect(results.getByRole("heading", { level: 1, name: "Compare" })).toBeVisible();
- await expect(results.getByText("1 tool", { exact: true })).toBeVisible();
+ await expect(results.getByText("2 tools", { exact: true })).toBeVisible();
await expect(results.getByRole("heading", { level: 2, name: "Differentials" }).first()).toBeVisible();
+ await expect(results.getByRole("heading", { level: 2, name: "Clinical Dictionary" }).first()).toBeVisible();
await expect(results.getByRole("complementary", { name: "Differentials" })).toBeVisible();
const categories = results.getByRole("radiogroup", { name: "Tool category" });
- await expect(categories.getByRole("radio", { name: "All tools (1)" })).toHaveAttribute("aria-checked", "true");
+ await expect(categories.getByRole("radio", { name: "All tools (2)" })).toHaveAttribute("aria-checked", "true");
await expect(categories.getByRole("radio", { name: "Assess (1)" })).toBeEnabled();
+ await expect(categories.getByRole("radio", { name: "Evidence (1)" })).toBeEnabled();
await expect(categories.getByRole("radio", { name: "Treat (0)" })).toBeDisabled();
await expectNoPageHorizontalOverflow(page);
});
@@ -583,7 +585,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => {
await expect(filterSheet).toBeVisible();
await expect(filterSheet.getByRole("radio", { name: /Assess/ })).toHaveAttribute("aria-checked", "false");
await expect(filterSheet.getByRole("radio", { name: /Treat/ })).toHaveAttribute("aria-disabled", "true");
- await expect(filterSheet.getByTestId("tools-search-filter-sheet-done")).toHaveText(/View 1 tool/);
+ await expect(filterSheet.getByTestId("tools-search-filter-sheet-done")).toHaveText(/View 2 tools/);
await filterSheet.getByTestId("tools-search-filter-sheet-done").click();
const details = results.getByRole("button", { name: "View details for Differentials" });
From 1fd496e8d43d90c559564adf59adf62c77ea9a09 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Tue, 18 Aug 2026 13:57:56 +0800
Subject: [PATCH 2/4] chore(ledger): record the Dictionary mode review
Co-Authored-By: Claude Opus 5
---
...95caf5c7b0a29d7daf058011f92c1b70d00c86aec57fb44822b.record.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 docs/branch-review-records/958545248192195caf5c7b0a29d7daf058011f92c1b70d00c86aec57fb44822b.record.md
diff --git a/docs/branch-review-records/958545248192195caf5c7b0a29d7daf058011f92c1b70d00c86aec57fb44822b.record.md b/docs/branch-review-records/958545248192195caf5c7b0a29d7daf058011f92c1b70d00c86aec57fb44822b.record.md
new file mode 100644
index 0000000000..745d91b1a7
--- /dev/null
+++ b/docs/branch-review-records/958545248192195caf5c7b0a29d7daf058011f92c1b70d00c86aec57fb44822b.record.md
@@ -0,0 +1 @@
+| 2026-08-18 | codex/chat-dictionary-ultimate-dictionary-ultimate | 3a8271bd7a9aa13b2a323cd29e84f294de211690 | Dictionary mode: 8 production routes, 96-entry governed catalogue, shared search/filter lib, launcher/ModeNav/universal-search/tools-catalogue integration, design-system adoption manifests | Approved for draft PR with one declared red gate — production bundle budget +10.5% (main alone is +7.8% of a 2026-08-13 baseline; this branch adds 33.9 KiB across 11 dictionary-exclusive chunks). Baseline deliberately not refreshed; decision left to review | verify:pr-local (lint, typecheck, docs/ledger guards passed); vitest 6990 passed / 2 failed, both cleared (private-access-routes passes in isolation 145/145; session-start-hook fails identically on untouched origin/main); next build compiled; check:client-bundle-secrets passed; check:bundle-budget FAIL on production bucket (documented in PR body), routes and mockups within tolerance; check:rag:fixtures 36 cases; medication interaction + lexicon checks passed; post-sync lint + typecheck exit 0 |
From c498bcd3b74ad8238be4abfff2fb8a701eaf3862 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Tue, 18 Aug 2026 14:27:39 +0800
Subject: [PATCH 3/4] fix(dictionary): retire the inert updated lens and say
what the source link proves
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three review findings on the Dictionary mode, all about a surface claiming more
than it does.
Retire the "updated" filter. Every entry shares one `REVIEW` constant, so all 96
carry `checkedOn: "2026-08-18"`, and the predicate compared that against the frozen
literal `"2025-08-18"`. "Past year" and "Past 6 months" were therefore identical to
each other and to no filter at all: picking either rendered an active, removable
filter chip and returned the whole catalogue. Remove the lens, its URL parameter,
its chip and its share of the applied-filter count rather than leave a control that
advertises narrowing it cannot do. An `updated=` parameter left in an old URL is
ignored, which `parseDictionaryFilters` already covers and the test now states.
Re-add a real cutoff when entries carry distinct review dates.
Rename the review status from `source-checked` to `source-linked`, and rewrite the
governance copy to match. Entries are generated from topic seeds: `sourceRefs`
carries the source published for the entry's collection, and the status literal is
stamped by construction. The sources page previously told a clinician that "editors
have confirmed that the link, organisation and paraphrased wording match the stated
source scope" — nothing in the pipeline establishes that. It now says attribution is
recorded at collection level, is not verified sentence by sentence, and that no
clinician has signed off an individual entry. Approval remains pending throughout.
Stop calling positional links "governed". `relatedSlugs` is the next four entries in
array order, so the term page now says "other entries from the same collection".
Also drop `DictionaryTopic.iconKey`: no Dictionary surface ever read it, and an
untyped glyph string sitting beside the typed `CategoryIconKey` registry is an
invitation to diverge from it.
Co-Authored-By: Claude Opus 5
---
docs/redesign/dictionary-reference-spine.md | 2 +-
.../dictionary/dictionary-catalogue-pages.tsx | 33 +++----------------
.../dictionary/dictionary-compare-page.tsx | 9 +++--
.../dictionary/dictionary-result-row.tsx | 2 +-
.../dictionary/dictionary-sources-page.tsx | 15 +++++----
.../dictionary/dictionary-term-page.tsx | 4 +--
src/lib/app-modes.ts | 2 +-
src/lib/dictionary-data.ts | 19 ++---------
src/lib/dictionary.ts | 13 +++-----
src/lib/universal-search.ts | 1 -
tests/dictionary-data.test.ts | 11 +++----
tests/dictionary-term-page.dom.test.tsx | 4 +--
tests/ui-dictionary.spec.ts | 2 +-
13 files changed, 36 insertions(+), 81 deletions(-)
diff --git a/docs/redesign/dictionary-reference-spine.md b/docs/redesign/dictionary-reference-spine.md
index c4787fc669..15a5dcc091 100644
--- a/docs/redesign/dictionary-reference-spine.md
+++ b/docs/redesign/dictionary-reference-spine.md
@@ -33,7 +33,7 @@ Dictionary extends the existing app-mode, mode navigation, universal search, sea
## 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.
+`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, a source-link date, a scheduled source review and internal `clinicalApproval: "pending"`. Source linking records which authoritative document a collection was built from; attribution is at collection level, is not verified sentence by sentence, and never claims 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.
diff --git a/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx
index 7272c8f3c8..38653b2163 100644
--- a/src/components/dictionary/dictionary-catalogue-pages.tsx
+++ b/src/components/dictionary/dictionary-catalogue-pages.tsx
@@ -9,7 +9,6 @@ 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";
@@ -80,7 +79,7 @@ function useDictionaryUrl() {
}
function selectedFilterCount(filters: DictionaryFilters) {
- return filters.topics.length + filters.kinds.length + filters.sources.length + (filters.updated === "any" ? 0 : 1);
+ return filters.topics.length + filters.kinds.length + filters.sources.length;
}
export function DictionarySearchPage() {
@@ -103,7 +102,7 @@ export function DictionarySearchPage() {
const clearFilters = () =>
replace((next) => {
- for (const key of ["topic", "kind", "source", "updated"]) next.delete(key);
+ for (const key of ["topic", "kind", "source"]) next.delete(key);
});
const groups = [
@@ -142,18 +141,6 @@ export function DictionarySearchPage() {
})),
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 = [
@@ -175,16 +162,6 @@ export function DictionarySearchPage() {
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 = (
@@ -346,7 +323,7 @@ export function DictionaryBrowsePage() {
Browse terms
- Scan the same source-checked result system by letter or abbreviation.
+ Scan the same source-linked result system by letter or abbreviation.
@@ -431,7 +408,7 @@ export function DictionaryBrowsePage() {
- All published entries are source checked · Specialist clinical approval remains pending
+ All published entries link a source · Specialist clinical approval remains pending
{topic.entrySlugs.length} terms
- Source checked
+ Source linked
diff --git a/src/components/dictionary/dictionary-compare-page.tsx b/src/components/dictionary/dictionary-compare-page.tsx
index 6ad6c59fa4..a16318cb38 100644
--- a/src/components/dictionary/dictionary-compare-page.tsx
+++ b/src/components/dictionary/dictionary-compare-page.tsx
@@ -65,7 +65,6 @@ export function DictionaryComparePage({ a, b }: { a: DictionaryEntry | null; b:
topics: [],
kinds: [],
sources: [],
- updated: "any",
sort: query ? "relevance" : "az",
})
.filter((hit): hit is Extract => hit.type === "entry")
@@ -113,7 +112,7 @@ export function DictionaryComparePage({ a, b }: { a: DictionaryEntry | null; b:
Compare terms
- Align two source-checked entries field by field without generating clinical advice.
+ Align two source-linked entries field by field without generating clinical advice.
@@ -229,7 +228,7 @@ export function DictionaryComparePage({ a, b }: { a: DictionaryEntry | null; b:
- What Source checked means
+ What Source linked means
- A source-checked entry has at least one direct authoritative source supporting the published field.
- Editors have confirmed that the link, organisation and paraphrased wording match the stated source scope.
+ A source-linked entry names the authoritative source published for its collection, so every definition can
+ be read next to the document it came from. The link is recorded at collection level: it has not been
+ verified sentence by sentence against that document, and no clinician has signed off an individual entry.
It is not specialist clinical approval.
- New dictionary wording remains approval pending even after its source has been checked. The dictionary is
- reference terminology, not patient-specific guidance.
+ Every entry remains approval pending. Read the linked source before relying on any definition clinically.
+ The dictionary is reference terminology, not patient-specific guidance.
diff --git a/src/components/dictionary/dictionary-term-page.tsx b/src/components/dictionary/dictionary-term-page.tsx
index 8ed2fb7962..f4562cca1c 100644
--- a/src/components/dictionary/dictionary-term-page.tsx
+++ b/src/components/dictionary/dictionary-term-page.tsx
@@ -143,7 +143,7 @@ export function DictionaryTermPage({ entry }: { entry: DictionaryEntry }) {
- Source checked
+ Source linked
·
@@ -262,7 +262,7 @@ export function DictionaryTermPage({ entry }: { entry: DictionaryEntry }) {
icon={}
open={openSections.has("dictionary-related")}
onToggle={() => toggleSection("dictionary-related")}
- summary={`${entry.relatedSlugs.length} governed entries from the same collection`}
+ summary={`${entry.relatedSlugs.length} other entries from the same collection`}
>
{entry.relatedSlugs.map((slug) => {
diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts
index ae5058df8f..00193ba8ae 100644
--- a/src/lib/app-modes.ts
+++ b/src/lib/app-modes.ts
@@ -435,7 +435,7 @@ export const appModeDefinitions = [
submitAriaLabel: "Search the clinical dictionary",
emptyTitle: "Search the clinical dictionary",
readyTitle: "Find a clinical term",
- progressLabel: "Searching source-checked dictionary entries.",
+ progressLabel: "Searching source-linked dictionary entries.",
resultKind: "tools",
resultHeading: "Dictionary results",
resultsSurface: "results-band",
diff --git a/src/lib/dictionary-data.ts b/src/lib/dictionary-data.ts
index c3d5f082cc..eb021595a5 100644
--- a/src/lib/dictionary-data.ts
+++ b/src/lib/dictionary-data.ts
@@ -56,7 +56,7 @@ export type DictionaryEntry = {
distinctions: readonly DictionaryDistinction[];
relatedSlugs: readonly string[];
review: {
- status: "source-checked";
+ status: "source-linked";
checkedOn: string;
dueOn: string;
clinicalApproval: "pending";
@@ -67,7 +67,6 @@ export type DictionaryTopic = {
slug: string;
title: string;
description: string;
- iconKey: string;
entrySlugs: readonly string[];
relatedTopicSlugs: readonly string[];
curatedComparisons: readonly [string, string][];
@@ -90,7 +89,6 @@ type TopicSeed = {
slug: string;
title: string;
description: string;
- iconKey: string;
sourceId: string;
entries: readonly EntrySeed[];
related: readonly string[];
@@ -99,7 +97,7 @@ type TopicSeed = {
const ACCESSED_ON = "2026-08-18";
const REVIEW = {
- status: "source-checked",
+ status: "source-linked",
checkedOn: ACCESSED_ON,
dueOn: "2027-08-18",
clinicalApproval: "pending",
@@ -219,7 +217,6 @@ const topicSeeds = [
slug: "assessment-and-measurement",
title: "Assessment and measurement",
description: "Assessment concepts, interviews, screening and rating tools used across mental health care.",
- iconKey: "clipboard",
sourceId: "nsw-mse-handbook",
related: ["mental-state-examination-domains", "cognition-and-neuropsychiatry"],
comparisons: [
@@ -276,7 +273,6 @@ const topicSeeds = [
slug: "mental-state-examination-domains",
title: "Mental state examination domains",
description: "Observable and reported domains commonly organised within a mental state examination.",
- iconKey: "scan",
sourceId: "nsw-mental-assessment",
related: ["assessment-and-measurement", "mood-and-affect", "psychosis-and-perception"],
comparisons: [["mood", "affect"]],
@@ -315,7 +311,6 @@ const topicSeeds = [
slug: "mood-and-affect",
title: "Mood and affect",
description: "Terms describing reported mood and observed emotional expression.",
- iconKey: "smile",
sourceId: "nsw-mental-assessment",
related: ["mental-state-examination-domains", "conditions-risk-and-safety"],
comparisons: [["mood", "affect"]],
@@ -366,7 +361,6 @@ const topicSeeds = [
slug: "psychosis-and-perception",
title: "Psychosis and perception",
description: "Psychotic experiences, perceptual phenomena and nearby terms that require careful distinction.",
- iconKey: "eye",
sourceId: "healthdirect-psychosis",
related: ["mental-state-examination-domains", "conditions-risk-and-safety"],
comparisons: [
@@ -417,7 +411,6 @@ const topicSeeds = [
slug: "cognition-and-neuropsychiatry",
title: "Cognition and neuropsychiatry",
description: "Cognitive functions and syndromes commonly assessed in mental health and general clinical care.",
- iconKey: "brain",
sourceId: "nice-delirium",
related: ["assessment-and-measurement", "conditions-risk-and-safety"],
comparisons: [["delirium", "dementia"]],
@@ -452,7 +445,6 @@ const topicSeeds = [
slug: "conditions-risk-and-safety",
title: "Conditions, risk and safety",
description: "Common mental health conditions and terms used in safety-focused clinical work.",
- iconKey: "shield",
sourceId: "healthdirect-mental-health",
related: ["mood-and-affect", "anxiety-trauma-and-dissociation"],
comparisons: [["clinical-assessment", "risk-assessment"]],
@@ -504,7 +496,6 @@ const topicSeeds = [
slug: "anxiety-trauma-and-dissociation",
title: "Anxiety, trauma and dissociation",
description: "Terms for anxiety, trauma-related experiences and disruptions in integration or sense of self.",
- iconKey: "waves",
sourceId: "healthdirect-mental-health",
related: ["conditions-risk-and-safety", "psychological-therapies"],
comparisons: [["obsession", "intrusive-thought"]],
@@ -555,7 +546,6 @@ const topicSeeds = [
slug: "substance-use",
title: "Substance use",
description: "Terms used to describe substance-related patterns, physiological adaptation and return to use.",
- iconKey: "droplet",
sourceId: "aihw-aod-glossary",
related: ["conditions-risk-and-safety", "community-and-models-of-care"],
comparisons: [["dependence", "tolerance"]],
@@ -602,7 +592,6 @@ const topicSeeds = [
slug: "medicines-and-adverse-effects",
title: "Medicines and adverse effects",
description: "Psychiatric medicine classes and important movement-related adverse effects.",
- iconKey: "pill",
sourceId: "healthdirect-antipsychotics",
related: ["conditions-risk-and-safety", "mental-state-examination-domains"],
comparisons: [["akathisia", "psychomotor-activity"]],
@@ -653,7 +642,6 @@ const topicSeeds = [
slug: "psychological-therapies",
title: "Psychological therapies",
description: "Structured psychological approaches used across mental health care.",
- iconKey: "messages",
sourceId: "healthdirect-psychotherapy",
related: ["anxiety-trauma-and-dissociation", "mood-and-affect"],
comparisons: [["cognitive-behavioural-therapy", "acceptance-and-commitment-therapy"]],
@@ -709,7 +697,6 @@ const topicSeeds = [
slug: "community-and-models-of-care",
title: "Community and models of care",
description: "Service structures and practice approaches used to coordinate mental health care.",
- iconKey: "users",
sourceId: "health-recovery-framework",
related: ["documentation-law-and-ethics", "conditions-risk-and-safety"],
comparisons: [["case-management", "collaborative-care"]],
@@ -763,7 +750,6 @@ const topicSeeds = [
slug: "documentation-law-and-ethics",
title: "Documentation, law and ethics",
description: "Terms used in decision-making, lawful care, formulation and clinical documentation.",
- iconKey: "scale",
sourceId: "health-mental-rights",
related: ["community-and-models-of-care", "assessment-and-measurement"],
comparisons: [["clinical-formulation", "risk-assessment"]],
@@ -931,7 +917,6 @@ export const dictionaryTopics: readonly DictionaryTopic[] = topicSeeds.map((topi
slug: topic.slug,
title: topic.title,
description: topic.description,
- iconKey: topic.iconKey,
entrySlugs: topicEntrySlugs.get(topic.slug) ?? [],
relatedTopicSlugs: topic.related,
curatedComparisons: topic.comparisons,
diff --git a/src/lib/dictionary.ts b/src/lib/dictionary.ts
index 50ba9f4dbc..eed07dfe6a 100644
--- a/src/lib/dictionary.ts
+++ b/src/lib/dictionary.ts
@@ -27,7 +27,6 @@ export type DictionarySearchHit =
export type DictionarySearchView = "all" | "definitions" | "abbreviations" | "topics";
export type DictionarySort = "relevance" | "az";
-export type DictionaryUpdated = "any" | "year" | "six-months";
export type DictionaryFilters = {
q: string;
@@ -35,12 +34,10 @@ export type DictionaryFilters = {
topics: readonly string[];
kinds: readonly DictionaryEntryKind[];
sources: readonly string[];
- updated: DictionaryUpdated;
sort: DictionarySort;
};
const validSearchViews = new Set(["all", "definitions", "abbreviations", "topics"]);
-const validUpdated = new Set(["any", "year", "six-months"]);
const validSort = new Set(["relevance", "az"]);
export const allDictionaryEntries = [...dictionaryEntries].sort((a, b) => a.term.localeCompare(b.term));
@@ -100,7 +97,6 @@ function valuesFrom(params: URLSearchParams, key: string) {
export function parseDictionaryFilters(params: URLSearchParams): DictionaryFilters {
const rawView = params.get("view") as DictionarySearchView | null;
- const rawUpdated = params.get("updated") as DictionaryUpdated | null;
const rawSort = params.get("sort") as DictionarySort | null;
return {
q: (params.get("q") ?? "").trim(),
@@ -108,7 +104,6 @@ export function parseDictionaryFilters(params: URLSearchParams): DictionaryFilte
topics: uniqueKnown(valuesFrom(params, "topic"), new Set(dictionaryTopics.map((topic) => topic.slug))),
kinds: uniqueKnown(valuesFrom(params, "kind"), new Set(dictionaryEntryKinds)) as DictionaryEntryKind[],
sources: uniqueKnown(valuesFrom(params, "source"), new Set(dictionarySources.map((source) => source.id))),
- updated: rawUpdated && validUpdated.has(rawUpdated) ? rawUpdated : "any",
sort: rawSort && validSort.has(rawSort) ? rawSort : "relevance",
};
}
@@ -119,9 +114,10 @@ function entryPassesFilters(entry: DictionaryEntry, filters: DictionaryFilters)
if (filters.sources.length && !entry.sourceRefs.some((reference) => filters.sources.includes(reference.sourceId))) {
return false;
}
- // The catalogue currently has one governed review cohort. Keep the URL lens
- // explicit and predicate-owned so future cohorts do not need a second filter path.
- if (filters.updated !== "any" && entry.review.checkedOn < "2025-08-18") return false;
+ // There is deliberately no "recently updated" lens. The catalogue is one
+ // review cohort stamped with a single `checkedOn`, so any date predicate would
+ // either return everything or nothing while still rendering an active filter
+ // chip. Re-add it with a real cutoff when entries carry distinct review dates.
return true;
}
@@ -237,7 +233,6 @@ export function browseDictionary(params: {
topics: params.topics,
kinds: params.kinds,
sources: [],
- updated: "any",
sort: "az",
};
let hits = searchDictionary(filters).filter((hit) => {
diff --git a/src/lib/universal-search.ts b/src/lib/universal-search.ts
index 58b43953ac..1f5159da29 100644
--- a/src/lib/universal-search.ts
+++ b/src/lib/universal-search.ts
@@ -398,7 +398,6 @@ async function searchDictionaryDomain(args: ResolvedSearchArgs): Promise {
expect(dictionaryCatalogueIssues()).toEqual([]);
});
- it("keeps every published entry source checked, approval pending, and fully linked", () => {
+ it("keeps every published entry source linked, approval pending, and fully linked", () => {
const sourceIds = new Set(dictionarySources.map((source) => source.id));
const entrySlugs = new Set(dictionaryEntries.map((entry) => entry.slug));
for (const entry of dictionaryEntries) {
expect(entry.sourceRefs.length).toBeGreaterThan(0);
expect(entry.sourceRefs.every((reference) => sourceIds.has(reference.sourceId))).toBe(true);
- expect(entry.review.status).toBe("source-checked");
+ expect(entry.review.status).toBe("source-linked");
expect(entry.review.checkedOn).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(entry.review.dueOn).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(entry.review.clinicalApproval).toBe("pending");
@@ -58,7 +57,7 @@ describe("clinical dictionary catalogue", () => {
expect(actHits[0]).toMatchObject({ type: "abbreviation", abbreviation: "ACT", senses: { length: 2 } });
});
- it("uses one predicate for lens counts and topic, kind, source, and updated filters", () => {
+ it("uses one predicate for lens counts and topic, kind and source filters", () => {
const topic = dictionaryTopics[0]!;
const topicHits = searchDictionary({ ...baseFilters, view: "definitions", topics: [topic.slug] });
expect(topicHits).toHaveLength(topic.entrySlugs.length);
@@ -76,14 +75,14 @@ describe("clinical dictionary catalogue", () => {
(hit) => hit.type === "entry" && hit.entry.sourceRefs.some((reference) => reference.sourceId === source.id),
),
).toBe(true);
-
- expect(searchDictionary({ ...baseFilters, view: "definitions", updated: "year" })).toHaveLength(96);
});
it("normalises invalid URL filters without throwing", () => {
const filters = parseDictionaryFilters(
new URLSearchParams("q=MSE&view=bad&topic=missing&kind=bad&source=missing&updated=old&sort=random"),
);
+ // `updated=old` is deliberately still in the query string: the retired
+ // "recently updated" lens must be ignored, not resurrected.
expect(filters).toEqual({ ...baseFilters, q: "MSE" });
expect(dictionaryEntryKinds).not.toContain("bad");
});
diff --git a/tests/dictionary-term-page.dom.test.tsx b/tests/dictionary-term-page.dom.test.tsx
index 81dd664d95..3fd97870fa 100644
--- a/tests/dictionary-term-page.dom.test.tsx
+++ b/tests/dictionary-term-page.dom.test.tsx
@@ -18,7 +18,7 @@ describe("DictionaryTermPage", () => {
expect(container.querySelectorAll("h1")).toHaveLength(1);
expect(screen.getByRole("heading", { level: 1, name: "Auditory hallucination" })).toBeInTheDocument();
expect(screen.getAllByTestId("dictionary-source-status-summary")).toHaveLength(1);
- expect(screen.getByTestId("dictionary-source-status-summary")).toHaveTextContent("Source checked");
+ expect(screen.getByTestId("dictionary-source-status-summary")).toHaveTextContent("Source linked");
expect(screen.getByTestId("dictionary-source-status-summary")).toHaveTextContent("covered source");
});
@@ -36,7 +36,7 @@ describe("DictionaryTermPage", () => {
it("shows four real related-entry destinations", () => {
const entry = findDictionaryEntry("auditory-hallucination")!;
render();
- const related = screen.getByText("4 governed entries from the same collection").closest("section");
+ const related = screen.getByText("4 other entries from the same collection").closest("section");
expect(related?.querySelectorAll('a[href^="/dictionary/"]')).toHaveLength(4);
});
});
diff --git a/tests/ui-dictionary.spec.ts b/tests/ui-dictionary.spec.ts
index 7161a86c48..0bd7e3e183 100644
--- a/tests/ui-dictionary.spec.ts
+++ b/tests/ui-dictionary.spec.ts
@@ -106,7 +106,7 @@ test("uses a readable phone definition and stacked comparison sections", async (
const status = page.getByTestId("dictionary-source-status-summary");
await expect(status).toBeVisible();
- await expect(status).toContainText("Source checked");
+ await expect(status).toContainText("Source linked");
await expect(page.getByRole("complementary", { name: "Entry details" })).toBeHidden();
const disclosures = page
.locator(
From 3c3d84f4763edd1a6f6dcc8b44b83d04c5eda37e Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Tue, 18 Aug 2026 16:32:33 +0800
Subject: [PATCH 4/4] fix(dictionary): distinguish source options, count
curated pair sources, and correct the tool tally
Addresses two Copilot review findings on #2096 and the Advisory UI failure.
- The "Source organisation" facet rendered one option per source id but labelled each
by its organisation, and five of the twelve sources are published by Healthdirect
Australia. Users saw five identical options, each filtering to a different single
document. The facet is now "Source", labelled by each source's own title, with the
organisation kept as search text and the applied chip matching.
- The compare page discarded `pair.sourceRefs`. Those references back the curated
relationship summary specifically, so MSE vs MMSE declared two comparison sources
while the footer counted one. They now count toward the covered-source total and
render as links beside the summary they support.
- `ui-tools-search-mode-mockup.spec.ts` asserted exactly 13 tool results. The mockup
renders the production `ToolsSearchResultsPage` from the shared catalogue, and the
Dictionary makes that 14.
Co-Authored-By: Claude Opus 5
---
.../dictionary/dictionary-catalogue-pages.tsx | 12 ++++--
.../dictionary/dictionary-compare-page.tsx | 42 +++++++++++++++----
tests/ui-tools-search-mode-mockup.spec.ts | 4 +-
3 files changed, 45 insertions(+), 13 deletions(-)
diff --git a/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx
index 38653b2163..7152cc15ba 100644
--- a/src/components/dictionary/dictionary-catalogue-pages.tsx
+++ b/src/components/dictionary/dictionary-catalogue-pages.tsx
@@ -131,12 +131,16 @@ export function DictionarySearchPage() {
}),
resultFilterFacetGroup({
id: "sources",
- label: "Source organisation",
+ // Labelled by the source's own title, not its organisation: five of the
+ // twelve sources are published by Healthdirect Australia, so an
+ // organisation label rendered five identical options that each filtered to
+ // a different single document.
+ label: "Source",
selected: new Set(filters.sources),
options: dictionarySources.map((source) => ({
value: source.id,
- label: source.organisation,
- searchText: source.title,
+ label: source.title,
+ searchText: `${source.title} ${source.organisation}`,
hint: String(searchDictionary({ ...filters, sources: [source.id] }).length),
})),
onToggle: (value) => toggleMany("source", value),
@@ -159,7 +163,7 @@ export function DictionarySearchPage() {
...filters.sources.map((sourceId) => ({
id: `source-${sourceId}`,
groupLabel: "Source",
- valueLabel: dictionarySources.find((source) => source.id === sourceId)?.organisation ?? sourceId,
+ valueLabel: dictionarySources.find((source) => source.id === sourceId)?.title ?? sourceId,
onRemove: () => toggleMany("source", sourceId),
})),
];
diff --git a/src/components/dictionary/dictionary-compare-page.tsx b/src/components/dictionary/dictionary-compare-page.tsx
index a16318cb38..e1371df91a 100644
--- a/src/components/dictionary/dictionary-compare-page.tsx
+++ b/src/components/dictionary/dictionary-compare-page.tsx
@@ -29,7 +29,7 @@ import {
findDictionaryEntry,
searchDictionary,
} from "@/lib/dictionary";
-import { type DictionaryEntry } from "@/lib/dictionary-data";
+import { dictionarySource, type DictionaryEntry } from "@/lib/dictionary-data";
import { type DictionarySearchHit } from "@/lib/dictionary";
type TargetSide = "a" | "b";
@@ -57,6 +57,10 @@ export function DictionaryComparePage({ a, b }: { a: DictionaryEntry | null; b:
return () => media.removeEventListener("change", update);
}, []);
const pair = a && b ? dictionaryComparisonPair(a.slug, b.slug) : null;
+ const pairSources = (pair?.sourceRefs ?? []).flatMap((reference) => {
+ const source = dictionarySource(reference.sourceId);
+ return source ? [source] : [];
+ });
const pickerHits = useMemo(
() =>
searchDictionary({
@@ -209,10 +213,28 @@ export function DictionaryComparePage({ a, b }: { a: DictionaryEntry | null; b:
B · {shortName(b)}{b.comparison.purpose}.
-
- {pair?.summary ??
- "No curated relationship summary is published for this pair; the stored fields below are aligned without interpretation."}
-
+
+
+ {pair?.summary ??
+ "No curated relationship summary is published for this pair; the stored fields below are aligned without interpretation."}
+