diff --git a/docs/branch-review-records/2def7e3195b192726b0e31499f86e26f9e51b9ab0d231421d15e91bed9e13665.record.md b/docs/branch-review-records/2def7e3195b192726b0e31499f86e26f9e51b9ab0d231421d15e91bed9e13665.record.md
new file mode 100644
index 0000000000..6e78cc358d
--- /dev/null
+++ b/docs/branch-review-records/2def7e3195b192726b0e31499f86e26f9e51b9ab0d231421d15e91bed9e13665.record.md
@@ -0,0 +1 @@
+| 2026-08-18 | claude/dictionary-mode-ui-updates-uwoicy (PR #2114) | 189df34d69c01fe08f17df5e49dc0cf2ca37d9c6 | Run PR sweep: CI fix + threads + drift | Before: all required checks already green (PR required success), 0 unresolved review threads, 3 bot-only PR comments (Codex usage limit, Supabase no-op, CodeRabbit rate limit) needing no action. Branch was behind main (mergeable_state: behind). Action: synced via GitHub update-branch API (clean merge, no conflicts) -> new head 189df34d69c01fe08f17df5e49dc0cf2ca37d9c6. After: fresh CI running on synced head, no code changes made, no threads to resolve. | No local gates run — PR already had npm run lint/typecheck/test/verify:ui results in its own description and no code was touched by this sweep; only a GitHub-side branch sync was performed. No provider-backed checks run. |
diff --git a/docs/branch-review-records/3cefd0b317247513501ba8e6a5088ccb5e089481047931ea74fd8400f3f390a3.record.md b/docs/branch-review-records/3cefd0b317247513501ba8e6a5088ccb5e089481047931ea74fd8400f3f390a3.record.md
new file mode 100644
index 0000000000..053588280d
--- /dev/null
+++ b/docs/branch-review-records/3cefd0b317247513501ba8e6a5088ccb5e089481047931ea74fd8400f3f390a3.record.md
@@ -0,0 +1 @@
+| 2026-08-18 | claude/dictionary-mode-ui-updates-uwoicy | 67a290f393702f36021a470e6b00b0d1aa335227 | Dictionary UI: topics/compare copy removal, sources page rebuild + composer suppression, search route header | approved | lint, typecheck, test (670 files/7149 tests), ui-dictionary + ui-mode-nav-density + ui-route-coverage Chromium (70 passed) |
diff --git a/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx
index 7152cc15ba..f993531047 100644
--- a/src/components/dictionary/dictionary-catalogue-pages.tsx
+++ b/src/components/dictionary/dictionary-catalogue-pages.tsx
@@ -168,8 +168,11 @@ export function DictionarySearchPage() {
})),
];
+ // The lens rail lives on the page, not in the band's `filterControls` row: the
+ // band hides that row below `sm` whenever a phone control is supplied, so the
+ // four result lenses were unreachable on a phone. One rail, every width.
const lensControls = (
-
+
{lensOptions.map((option) => (
setOne("view", option.value, "all")}
className={cn(
- "inline-flex min-h-tap items-center gap-1.5 rounded-lg border px-3 text-xs font-bold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-10",
+ "inline-flex min-h-tap shrink-0 items-center gap-1.5 rounded-lg border px-3 text-sm font-bold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-10",
filters.view === option.value
? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]"
- : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:text-[color:var(--text)]",
+ : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)]",
)}
>
{option.label}
- {lensCounts[option.value]}
+
+ {lensCounts[option.value]}
+
))}
@@ -204,28 +214,40 @@ export function DictionarySearchPage() {
return (
<>
- setOne("sort", value === "alpha" ? "az" : "relevance", "relevance")}
- filterControls={
-
- {lensControls}
- {trigger("desktop")}
-
- }
- mobileControls={trigger("phone")}
- mobileControlsPlacement="inline"
- appliedFilters={appliedFilters}
- onClearFilters={activeCount ? clearFilters : undefined}
- />
-
+ {/* The band used to be the first thing under the mode nav, so its card
+ edge sat flush against the tab rule with no page title and no
+ breathing room. The route now opens with its own titled header, and
+ the band is what it is elsewhere: the result spine below the title. */}
+
+
+ Clinical dictionary
+
+
+ Search terms
+
+ {lensControls}
+
+
+
setOne("sort", value === "alpha" ? "az" : "relevance", "relevance")}
+ utilityControls={{trigger("desktop")}
}
+ mobileControls={trigger("phone")}
+ mobileControlsPlacement="inline"
+ appliedFilters={appliedFilters}
+ onClearFilters={activeCount ? clearFilters : undefined}
+ />
+
+
{hits.length ? (
-
+
{hits.map((hit) => {
const key =
hit.type === "entry"
@@ -474,39 +496,34 @@ export function DictionaryTopicsPage() {
Topics
-
- Discover clinical terminology through {dictionaryTopics.length} scoped collections.
-
-
-
-
- Search topics
- setOne("q", event.target.value)}
- placeholder="Search topics"
- className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-[color:var(--text-placeholder)] sm:text-sm"
- />
-
+ {/* The page-level topic search was removed: twelve collections fit on
+ one screen, and the universal composer already searches the whole
+ dictionary. Kind filtering and sort stay, as a compact toolbar. */}
+
+
+ {visible.length} {" "}
+ {visible.length === 1 ? "collection" : "collections"}
+
setFilterOpen(true)}
- className="inline-flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--border)] px-3 text-sm font-bold sm:min-h-10"
+ className="inline-flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-sm font-bold text-[color:var(--text)] hover:border-[color:var(--border-strong)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-10"
>
- Filter{kinds.length ? ` ${kinds.length}` : ""}
+ Filter
+ {kinds.length ? {kinds.length} : null}
setOne("sort", sort === "az" ? "za" : "az", "az")}
- className="hidden min-h-tap items-center gap-1 rounded-lg border border-[color:var(--border)] px-3 text-sm font-bold sm:inline-flex sm:min-h-10"
+ className="inline-flex min-h-tap items-center gap-1 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-sm font-bold text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-10"
>
{sort === "az" ? "A–Z" : "Z–A"}
-
+
{visible.map((topic) => {
const entries = dictionaryTopicEntries(topic);
return (
diff --git a/src/components/dictionary/dictionary-compare-page.tsx b/src/components/dictionary/dictionary-compare-page.tsx
index e1371df91a..799a3c6875 100644
--- a/src/components/dictionary/dictionary-compare-page.tsx
+++ b/src/components/dictionary/dictionary-compare-page.tsx
@@ -115,9 +115,6 @@ export function DictionaryComparePage({ a, b }: { a: DictionaryEntry | null; b:
Compare terms
-
- Align two source-linked entries field by field without generating clinical advice.
-
`18 Aug 2026`. Formatted from the parts rather than through
+ `Intl`, so a server render and a client hydration cannot disagree on locale. */
+function formatAccessedOn(value: string) {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
+ if (!match) return value;
+ const [, year, month, day] = match;
+ return `${Number(day)} ${MONTHS[Number(month) - 1] ?? month} ${year}`;
+}
+
+function organisationCoverage() {
+ 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));
+}
+
+function StatTile({ label, value, hint, compact }: { label: string; value: string; hint?: string; compact?: boolean }) {
+ return (
+
+
{label}
+ {/* `compact` is for the date: at `text-xl` it wrapped to two lines in a
+ half-width phone tile and pulled the row out of alignment. */}
+
+ {value}
+
+ {hint ?
{hint}
: null}
+
+ );
+}
+
+function SectionHeading({ id, step, title }: { id: string; step: string; title: string }) {
+ return (
+
+
+ {step}
+
+
+ {title}
+
+
+ );
+}
+
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 coverage = organisationCoverage();
+ const australianSources = dictionarySources.filter((source) => source.region === "Australia").length;
+ const linkedEntryCount = dictionaryEntries.filter((entry) => entry.sourceRefs.length > 0).length;
+ const checkedOn = formatAccessedOn(
+ dictionarySources.reduce((latest, source) => (source.accessedOn > latest ? source.accessedOn : latest), ""),
);
- 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.
-
+ {/* The stat strip replaces the introductory paragraph: the same four
+ facts, each one checkable, instead of a sentence describing them. */}
+
+
+
+
+
+
-
-
-
-
-
-
- What Source linked means
-
-
- 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.
- Every entry remains approval pending. Read the linked source before relying on any definition clinically.
- The dictionary is reference terminology, not patient-specific guidance.
-
-
-
-
-
- Authority hierarchy
-
-
- {authorityTiers.map((tier, index) => (
-
-
- {index + 1}
+ {/* One column, full width. A 20rem right rail carrying only organisation
+ coverage left two thirds of the viewport empty beside a thirteen-row
+ source index; coverage is now a section of its own, and the index
+ splits into two columns where there is width for it. */}
+
+
+
+
+
+
+
-
-
{tier.title}
-
{tier.description}
-
-
- ))}
-
-
-
-
-
-
-
-
- Source index
-
-
- {visibleSources.length} of {dictionarySources.length} direct authoritative sources
+
+ 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.
-
-
- Search sources
- setQuery(event.target.value)}
- placeholder="Search sources"
- className="min-w-0 flex-1 bg-transparent text-base outline-none sm:text-sm"
+
+
-
+
+
+ It is not specialist clinical approval.
+
+ Every entry remains approval pending. Read the linked source before relying on any definition
+ clinically — this is reference terminology, not patient-specific guidance.
+
+
-
+
+
+ {source.title}
+
+ {source.organisation}
+
+
+ {source.region}
+
+ Accessed {formatAccessedOn(source.accessedOn)}
+
+
+
+ Opens in a new tab
+
+
+ ))}
+
-
-
-
- Organisation coverage
-
-
- {organisationCoverage.map((coverage) => (
-
- {coverage.organisation}
-
- {coverage.entryCount} {coverage.entryCount === 1 ? "entry" : "entries"} · {coverage.sourceCount}{" "}
- {coverage.sourceCount === 1 ? "source" : "sources"}
-
-
+
+
+
+ {coverage.map((organisation) => (
+
+
+
+
+ {organisation.organisation}
+
+
+ {organisation.entryCount} {" "}
+ {organisation.entryCount === 1 ? "entry" : "entries"} ·{" "}
+ {organisation.sourceCount} {" "}
+ {organisation.sourceCount === 1 ? "source" : "sources"}
+
+
+
))}
+
+
+
+
+
+
+
+
+
+ Review cadence
+
+
+ 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.
+
+
-
+
-
-
-
-
-
- Review cadence
-
-
- 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/lib/search-shell-props.ts b/src/lib/search-shell-props.ts
index 8590ecd627..a4fa850ae3 100644
--- a/src/lib/search-shell-props.ts
+++ b/src/lib/search-shell-props.ts
@@ -88,7 +88,15 @@ export function searchShellPropsForPathname(pathname: string): SearchShellPathPr
}
if (pathname.startsWith("/dictionary")) {
- return { initialMode: "dictionary", desktopSearchPlacement: "hero" };
+ // `/dictionary/sources` is a read-only governance page — the source method,
+ // the authority hierarchy, the index and the review cadence. Nothing on it
+ // is searched, so it carries no composer (the mode nav still reaches every
+ // other dictionary surface). Every other dictionary route keeps one.
+ return {
+ initialMode: "dictionary",
+ desktopSearchPlacement: "hero",
+ ...(pathname === "/dictionary/sources" ? { searchComposerVisible: false } : {}),
+ };
}
return { initialMode: "answer" };
diff --git a/tests/search-shell-props.test.ts b/tests/search-shell-props.test.ts
index b39b076779..720fd702c9 100644
--- a/tests/search-shell-props.test.ts
+++ b/tests/search-shell-props.test.ts
@@ -46,6 +46,18 @@ describe("searchShellPropsForPathname", () => {
expect(searchShellPropsForPathname("/")).toEqual({ initialMode: "answer" });
});
+ it("keeps the composer on dictionary search surfaces and drops it on the governance page", () => {
+ expect(searchShellPropsForPathname("/dictionary/search")).toEqual({
+ initialMode: "dictionary",
+ desktopSearchPlacement: "hero",
+ });
+ expect(searchShellPropsForPathname("/dictionary/sources")).toEqual({
+ initialMode: "dictionary",
+ desktopSearchPlacement: "hero",
+ searchComposerVisible: false,
+ });
+ });
+
it("assigns calculator home and results search to the shared shell", () => {
expect(searchShellPropsForPathname("/calculators")).toEqual({
initialMode: "calculators",