diff --git a/docs/branch-review-records/2149be40c9723dfdcf39c3831bee991df0e35a86a2f2426a566763d4c7df1916.record.md b/docs/branch-review-records/2149be40c9723dfdcf39c3831bee991df0e35a86a2f2426a566763d4c7df1916.record.md
new file mode 100644
index 0000000000..2b8ae599f9
--- /dev/null
+++ b/docs/branch-review-records/2149be40c9723dfdcf39c3831bee991df0e35a86a2f2426a566763d4c7df1916.record.md
@@ -0,0 +1 @@
+| 2026-08-18 | claude/header-redesign-mockups-3ms5kn | 430a58a98f9d8bedd21cbbf8e4e522381aa5c4c9 | Dictionary browse header rebuilt on the selected direction (production /dictionary/browse) | approved | verify:pr-local all 18 steps completed / none failed, check:bundle-budget within tolerance, Chromium 390px+1440px dark/light review; Playwright suite delegated to CI Production UI (browser-revision drift #255) |
diff --git a/docs/branch-review-records/599419ed3f41f0407ee7e9c44816a1c23691e853437cb963c33d0dcd860f4099.record.md b/docs/branch-review-records/599419ed3f41f0407ee7e9c44816a1c23691e853437cb963c33d0dcd860f4099.record.md
new file mode 100644
index 0000000000..80aa64a969
--- /dev/null
+++ b/docs/branch-review-records/599419ed3f41f0407ee7e9c44816a1c23691e853437cb963c33d0dcd860f4099.record.md
@@ -0,0 +1 @@
+| 2026-08-18 | claude/header-redesign-mockups-3ms5kn | a33ab97e5b55edcb26ace179471d97b7cf71118e | Dictionary Browse header redesign mockup study (design scratch) | approved | verify:pr-local (673 files/7276 tests pass), build, check:bundle-budget, check:rag:fixtures, check:medication-interactions, check:medication-lexicon-report, Chromium dark+light screenshot review |
diff --git a/docs/branch-review-records/5f15d81f006cc85637c12e37a6c1fc41c800de4a1f9f93e6f6d66745a57a5636.record.md b/docs/branch-review-records/5f15d81f006cc85637c12e37a6c1fc41c800de4a1f9f93e6f6d66745a57a5636.record.md
new file mode 100644
index 0000000000..50a2612e95
--- /dev/null
+++ b/docs/branch-review-records/5f15d81f006cc85637c12e37a6c1fc41c800de4a1f9f93e6f6d66745a57a5636.record.md
@@ -0,0 +1 @@
+| 2026-08-18 | claude/header-redesign-mockups-3ms5kn | 8083a20d6a3496ed154b927157bd621735886795 | Dictionary Browse header round two — letter dropdown + Abbreviations in Filters (design scratch) | approved | verify:pr-local all 18 steps completed / none failed, check:bundle-budget within tolerance, Chromium dark+light screenshot review |
diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md
index e84ea49e4b..5951d5361d 100644
--- a/docs/design-system/COMPONENTS.md
+++ b/docs/design-system/COMPONENTS.md
@@ -999,7 +999,7 @@ This generated snapshot is a local source-derived inventory. It does not assert
| `RetrievalStateBanner` | answer | yes | yes | inherited-global-root | yes | no | 1 |
| `SafeBoldText` | layout | yes | yes | inherited-global-root | yes | no | 8 |
| `SearchField` | controls | yes | yes | no | yes | no | 0 |
-| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 8 |
+| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 9 |
| `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 |
| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 25 |
| `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 |
diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json
index 2d88695c2e..5b3dde031f 100644
--- a/docs/design-system/adoption-manifest.json
+++ b/docs/design-system/adoption-manifest.json
@@ -1454,6 +1454,7 @@
"src/components/applications-launcher-page.tsx",
"src/components/clinical-dashboard/patient-profile-panel.tsx",
"src/components/clinical-dashboard/settings-dialog.tsx",
+ "src/components/dictionary/dictionary-catalogue-pages.tsx",
"src/components/factsheets/factsheets-search-page.tsx",
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/therapy-compass/screens/compare-screen.tsx",
@@ -1464,6 +1465,7 @@
"src/components/applications-launcher-page.tsx",
"src/components/clinical-dashboard/patient-profile-panel.tsx",
"src/components/clinical-dashboard/settings-dialog.tsx",
+ "src/components/dictionary/dictionary-catalogue-pages.tsx",
"src/components/factsheets/factsheets-search-page.tsx",
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/therapy-compass/screens/compare-screen.tsx",
diff --git a/docs/site-map.md b/docs/site-map.md
index 9bd77fe332..2f546effe1 100644
--- a/docs/site-map.md
+++ b/docs/site-map.md
@@ -1018,6 +1018,8 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir
- `/mockups/caring-contacts/templates` - Route discovered from app directory Source: `src/app/mockups/caring-contacts/templates/page.tsx`.
- `/mockups/caring-contacts/templates/[pathwayId]` - Route discovered from app directory Source: `src/app/mockups/caring-contacts/templates/[pathwayId]/page.tsx`.
- `/mockups/development` - Route discovered from app directory Source: `src/app/mockups/development/page.tsx`.
+- `/mockups/dictionary-browse-header` - Route discovered from app directory Source: `src/app/mockups/dictionary-browse-header/page.tsx`.
+- `/mockups/dictionary-browse-header-compact` - Route discovered from app directory Source: `src/app/mockups/dictionary-browse-header-compact/page.tsx`.
- `/mockups/document-navigation-contract` - Route discovered from app directory Source: `src/app/mockups/document-navigation-contract/page.tsx`.
- `/mockups/document-navigation-final` - Route discovered from app directory Source: `src/app/mockups/document-navigation-final/page.tsx`.
- `/mockups/document-navigation-final-review` - Route discovered from app directory Source: `src/app/mockups/document-navigation-final-review/page.tsx`.
diff --git a/mockups/README.md b/mockups/README.md
index 696a41a229..1e9c3ac758 100644
--- a/mockups/README.md
+++ b/mockups/README.md
@@ -43,6 +43,42 @@ Some document-search mockups include live handoff routes (for example `document-
- Full three-direction study: [`/mockups/privacy-page-directions`](../src/app/mockups/privacy-page-directions/page.tsx)
- Static comps: [`public/mockups/privacy-page-redesign-2026-08/`](../public/mockups/privacy-page-redesign-2026-08/README.md)
+## Dictionary Browse header study (2026-08-18)
+
+Runnable study at [`/mockups/dictionary-browse-header`](../src/app/mockups/dictionary-browse-header/page.tsx). The
+brief was to drop the description line under **Browse terms** and the orphaned A–Z / Z–A sort pill that floats on its
+own row, then rebuild the header for the phone. All three directions move sort into the Filters sheet, where the other
+modes already keep it; they differ in how much letter navigation stays on screen.
+
+| Direction | Phone chrome before a result | Trade-off |
+| --------------------------- | ---------------------------- | ------------------------------------------------------------- |
+| 01 Compact title bar | 2 bands | Keeps both browse views explicit; least vertical saving |
+| 02 Fused letter rail (rec.) | 2 bands | Abbreviations becomes a rail chip rather than a separate view |
+| 03 Index rail + jump sheet | 1 band | Edge rail is a fine-motor target; jump sheet is the a11y path |
+
+The current header is rendered side by side at the top of the page for comparison. Shared mockup chrome is suppressed
+because each frame draws its own top bar, mode nav and composer.
+
+## Dictionary Browse header, round two (2026-08-18)
+
+Runnable study at [`/mockups/dictionary-browse-header-compact`](../src/app/mockups/dictionary-browse-header-compact/page.tsx),
+a follow-up to the round-one study above. Every version replaces the 27-chip horizontal letter rail with a **letter
+dropdown on phones** and moves **Abbreviations out of the header into the Filters sheet** beside sort.
+
+| Version | Phone chrome | Trade-off |
+| ------------------------------ | ------------ | ------------------------------------------------------------------- |
+| 01 Title bar + letter dropdown | 2 rows | Title still costs a row the mode nav already implies |
+| 02 Single fused row (rec.) | 1 row | An active filter chip costs the row its title and count at 390 px |
+| 03 Slim toolbar, title retired | 1 slim bar | Phone loses its visual page title; depends on the mode nav above it |
+
+Demoting a view switch into a sheet hides state, so each version surfaces an active **Abbreviations** chip beside the
+letter control. Without it the header would claim 96 terms while listing 24 abbreviations.
+
+Note for anyone extending these: the mockup stylesheet only emits Tailwind classes that some source actually uses, and
+no production file uses a bare `grid-cols-6` (only `xl:grid-cols-6`). The 26-letter pickers therefore pin
+`gridTemplateColumns` inline rather than depending on class generation — a bare `grid-cols-6` silently collapses them
+to one column.
+
## Phone Choose mode sheet YES comps
Runnable study at [`/mockups/phone-mode-sheet-yes`](../src/app/mockups/phone-mode-sheet-yes/page.tsx): design review of the shipping phone mode sheet plus **YES 01 perfected** (sectioned clinical list — shipping recommendation) and YES 02 (icon deck alternate). Shared mockup chrome is suppressed so only the in-frame sheet is judged.
diff --git a/src/app/mockups/dictionary-browse-header-compact/page.tsx b/src/app/mockups/dictionary-browse-header-compact/page.tsx
new file mode 100644
index 0000000000..e8d1832631
--- /dev/null
+++ b/src/app/mockups/dictionary-browse-header-compact/page.tsx
@@ -0,0 +1,5 @@
+import { DictionaryBrowseHeaderCompactMockupsPage } from "@/components/dictionary-browse-header-compact-mockups";
+
+export default function DictionaryBrowseHeaderCompactMockupRoute() {
+ return ;
+}
diff --git a/src/app/mockups/dictionary-browse-header/page.tsx b/src/app/mockups/dictionary-browse-header/page.tsx
new file mode 100644
index 0000000000..62433a6413
--- /dev/null
+++ b/src/app/mockups/dictionary-browse-header/page.tsx
@@ -0,0 +1,5 @@
+import { DictionaryBrowseHeaderMockupsPage } from "@/components/dictionary-browse-header-mockups";
+
+export default function DictionaryBrowseHeaderMockupRoute() {
+ return ;
+}
diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx
index 455b81b95b..cfc472e524 100644
--- a/src/app/mockups/mockups-layout-client.tsx
+++ b/src/app/mockups/mockups-layout-client.tsx
@@ -24,6 +24,9 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) {
const isStandaloneDocumentFlow = pathname === "/mockups/document-search";
const isUniversalSearchRedesignMockup = pathname === "/mockups/universal-search-redesign";
const isSearchHeadingMockup = pathname === "/mockups/search-heading";
+ // Draws its own phone/desktop frames with a top bar, mode nav and composer in
+ // every frame, because the header under study sits directly beneath them.
+ const isDictionaryBrowseHeaderMockup = pathname.startsWith("/mockups/dictionary-browse-header");
// Renders the results header inside its own device frames; the shared composer
// would read as a second, real search bar over the study.
const isSearchRefineAdaptiveMockup = pathname === "/mockups/search-refine-adaptive";
@@ -122,7 +125,8 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) {
!isPinnedPlusMenuMockup &&
!isPhoneModeSheetYesMockup &&
!isSidebarLiveMockup &&
- !isCaringContactMockup
+ !isCaringContactMockup &&
+ !isDictionaryBrowseHeaderMockup
}
chromeVisible={
!isSourceOverlayRedesignMockup &&
@@ -144,7 +148,8 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) {
!isPinnedPlusMenuMockup &&
!isPhoneModeSheetYesMockup &&
!isSidebarLiveMockup &&
- !isCaringContactMockup
+ !isCaringContactMockup &&
+ !isDictionaryBrowseHeaderMockup
}
>
{children}
diff --git a/src/components/dictionary-browse-header-compact-mockups.tsx b/src/components/dictionary-browse-header-compact-mockups.tsx
new file mode 100644
index 0000000000..e8318b0ae0
--- /dev/null
+++ b/src/components/dictionary-browse-header-compact-mockups.tsx
@@ -0,0 +1,678 @@
+"use client";
+
+import { useState } from "react";
+import { ArrowUpDown, Check, ChevronDown, SlidersHorizontal, X } from "lucide-react";
+
+import {
+ CountPill,
+ DesktopFrame,
+ PhoneFrame,
+ ResultRow,
+ focusRing,
+ letters,
+ sampleEntries,
+} from "@/components/dictionary-browse-header-mockups";
+import { cn } from "@/components/ui-primitives";
+
+/* ------------------------------------------------------------------ *
+ * Dictionary → Browse header, round two (2026-08-18)
+ *
+ * Follow-up to /mockups/dictionary-browse-header. Same two removals as
+ * before (description line, orphaned A–Z / Z–A sort pill), plus two new
+ * moves the whole round shares:
+ *
+ * 1. The 27-chip horizontal letter rail becomes a dropdown on phones.
+ * 2. Abbreviations stops being a header segment and becomes a Show
+ * option inside the Filters sheet, next to sort.
+ *
+ * Demoting a view switch into a sheet hides state, so every version
+ * surfaces an active "Abbreviations" chip beside the letter trigger
+ * when that option is on. Without it the header lies about what the
+ * list contains.
+ * ------------------------------------------------------------------ */
+
+type Version = "titlebar" | "single" | "toolbar";
+
+const versions: ReadonlyArray<{
+ id: Version;
+ number: string;
+ name: string;
+ summary: string;
+ strengths: readonly string[];
+ cost: string;
+ chrome: string;
+ recommended?: boolean;
+}> = [
+ {
+ id: "titlebar",
+ number: "01",
+ name: "Title bar + letter dropdown",
+ summary:
+ "Keeps a full-size page title, then one sticky toolbar: the alphabetical dropdown takes the width and Filters & sort keeps its full label beside it. Desktop keeps the chip rail, which still fits there.",
+ strengths: ["Strongest page identity", "Full-width dropdown target", "Desktop keeps one-tap letters"],
+ cost: "Two bands on phone — the title still costs a row that the mode nav already implies, and an active filter chip adds a third.",
+ chrome: "2 rows on phone",
+ recommended: true,
+ },
+ {
+ id: "single",
+ number: "02",
+ name: "Single fused row",
+ summary:
+ "Title, letter dropdown, count and Filters share one sticky line. The dropdown shrinks to a chip beside the title, and the same row serves desktop.",
+ strengths: ["One row, both breakpoints", "Adapts rather than overflows", "Nothing scrolls horizontally"],
+ cost: "The letter chip is a smaller target than a full-width control, and at 390 px an active filter chip costs the row its title and count — both stand down to the mode nav until the filter clears.",
+ chrome: "1 row on phone",
+ },
+ {
+ id: "toolbar",
+ number: "03",
+ name: "Slim toolbar, title retired",
+ summary:
+ "The phone mode nav already reads Browse, so the visible heading goes (kept for screen readers) and only a slim toolbar remains: letters, count, Filters. Desktop keeps its title, having no tab bar to inherit it from.",
+ strengths: ["Smallest possible chrome", "Results start immediately", "Toolbar reads as controls, not decoration"],
+ cost: "Phone loses its visual page title; it only works while the mode nav above it stays.",
+ chrome: "1 slim bar on phone",
+ },
+];
+
+/* ------------------------------- state ------------------------------- */
+
+function useBrowseState() {
+ const [letter, setLetter] = useState("All");
+ const [abbrOnly, setAbbrOnly] = useState(false);
+ const [sort, setSort] = useState<"az" | "za">("az");
+ const [letterOpen, setLetterOpen] = useState(false);
+ const [filtersOpen, setFiltersOpen] = useState(false);
+ const activeFilters = (abbrOnly ? 1 : 0) + (sort === "za" ? 1 : 0);
+ return {
+ letter,
+ setLetter,
+ abbrOnly,
+ setAbbrOnly,
+ sort,
+ setSort,
+ letterOpen,
+ setLetterOpen,
+ filtersOpen,
+ setFiltersOpen,
+ activeFilters,
+ };
+}
+
+type BrowseState = ReturnType;
+
+/* ------------------------------ controls ----------------------------- */
+
+function LetterTrigger({
+ value,
+ onOpen,
+ grow = false,
+ size = "regular",
+}: {
+ value: string;
+ onOpen: () => void;
+ grow?: boolean;
+ size?: "regular" | "chip";
+}) {
+ return (
+
+ );
+}
+
+function AbbrChip({ onClear, size = "regular" }: { onClear: () => void; size?: "regular" | "chip" }) {
+ return (
+
+ );
+}
+
+function FiltersControl({
+ count,
+ labelled = false,
+ onOpen,
+ size = "regular",
+}: {
+ count: number;
+ labelled?: boolean;
+ onOpen: () => void;
+ size?: "regular" | "chip";
+}) {
+ return (
+
+ );
+}
+
+/* ------------------------------ overlays ----------------------------- */
+
+function SheetShell({
+ title,
+ onClose,
+ children,
+ anchored = false,
+}: {
+ title: string;
+ onClose: () => void;
+ children: React.ReactNode;
+ anchored?: boolean;
+}) {
+ if (anchored) {
+ return (
+
+ Letters in a dropdown, Abbreviations in Filters
+
+
+ Three compact takes on the title-bar direction. All three replace the 27-chip horizontal rail with an
+ alphabetical dropdown on phones and move Abbreviations out of the header into the Filters sheet beside sort.
+ They differ only in how much header survives around those two controls.{" "}
+
+ 01 is the selected direction
+
+ ; 02 and 03 stay here as the compact alternatives it was chosen over.
+
+
+ Every frame is live. Open the letter dropdown, then open Filters and switch to{" "}
+ Abbreviations only — a purple chip appears beside the letter
+ control. That chip is load-bearing: a view switch demoted into a sheet is otherwise invisible, and the
+ header would claim to list all 96 terms while showing 24 abbreviations.
+
+
+ );
+}
diff --git a/src/components/dictionary-browse-header-mockups.tsx b/src/components/dictionary-browse-header-mockups.tsx
new file mode 100644
index 0000000000..d6e095264b
--- /dev/null
+++ b/src/components/dictionary-browse-header-mockups.tsx
@@ -0,0 +1,855 @@
+"use client";
+
+import { useState } from "react";
+import {
+ ArrowRight,
+ Check,
+ ChevronDown,
+ Filter,
+ GitCompareArrows,
+ Menu,
+ MessageSquarePlus,
+ Plus,
+ Search,
+ SendHorizontal,
+ SlidersHorizontal,
+ X,
+} from "lucide-react";
+
+import { cn } from "@/components/ui-primitives";
+
+/* ------------------------------------------------------------------ *
+ * Dictionary → Browse header redesign study (2026-08-18)
+ *
+ * Brief: drop the description line under "Browse terms", drop the
+ * orphaned A–Z / Z–A sort pill that floats on its own row, and rebuild
+ * the header so the phone layout reaches a result without five stacked
+ * bands. Sort survives inside the Filters sheet in all three, which is
+ * where the other modes already keep it.
+ * ------------------------------------------------------------------ */
+
+export const focusRing =
+ "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]";
+
+export const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
+
+type Direction = "titlebar" | "rail" | "index";
+
+export type SampleEntry = {
+ term: string;
+ kind: string;
+ alias?: string;
+ definition: string;
+};
+
+export const sampleEntries: readonly SampleEntry[] = [
+ {
+ term: "Acceptance and commitment therapy",
+ kind: "Therapy",
+ alias: "ACT",
+ definition: "A therapy that develops acceptance, present-moment awareness and action guided by personal values.",
+ },
+ {
+ term: "Acute dystonia",
+ kind: "Clinical finding",
+ definition:
+ "A sudden medication-associated sustained muscle contraction that produces abnormal posture or movement.",
+ },
+ {
+ term: "Advance statement",
+ kind: "Legal / ethical",
+ definition:
+ "A written record of a person's treatment preferences made while they have capacity, for use in later care.",
+ },
+];
+
+const directions: ReadonlyArray<{
+ id: Direction;
+ number: string;
+ name: string;
+ summary: string;
+ strengths: readonly string[];
+ cost: string;
+ bands: string;
+ recommended?: boolean;
+}> = [
+ {
+ id: "titlebar",
+ number: "01",
+ name: "Compact title bar",
+ summary:
+ "The smallest honest edit: title and count share a line, the view switch and letter rail fuse into one sticky control band, and sort moves into Filters.",
+ strengths: ["Lowest migration risk", "Keeps both browse views explicit", "Rail stays pinned while scrolling"],
+ cost: "Still two bands of chrome before the first result on a phone.",
+ bands: "2 bands on phone",
+ },
+ {
+ id: "rail",
+ number: "02",
+ name: "Fused letter rail",
+ summary:
+ "The A–Z / Abbreviations segment disappears. All and Abbr become pinned lead chips on the letter rail itself, so one row does both jobs.",
+ strengths: ["One control row total", "Abbr stays visible, never scrolls away", "Largest vertical saving"],
+ cost: "Abbreviations reads as a peer of a letter rather than a separate view.",
+ bands: "2 bands on phone",
+ recommended: true,
+ },
+ {
+ id: "index",
+ number: "03",
+ name: "Index rail + jump sheet",
+ summary:
+ "No horizontal scroller at all. The title carries the active scope and opens a jump sheet — tap it in either frame below; a Contacts-style index rail rides the right edge of the list.",
+ strengths: ["Results start after one band", "Scales past 96 terms", "Sticky letter headings orient the scroll"],
+ cost: "The edge rail is a fine-motor target; the jump sheet is the accessible path and must stay first-class.",
+ bands: "1 band on phone",
+ },
+];
+
+/* ---------------------------- shared bits ---------------------------- */
+
+export function ResultRow({ entry }: { entry: SampleEntry }) {
+ return (
+
+
+
+ Three headers for Browse terms, rebuilt for the phone
+
+
+ All three drop the description line and the orphaned A–Z / Z–A sort pill. Sort moves into the Filters sheet,
+ which is where every other mode already keeps it. What differs is how much of the letter navigation stays on
+ screen.
+
+
+
+
+
+ {showBefore ? (
+
+
+
+
+ Today — five stacked bands
+
+
+ Kicker, title, description, view switch, a sort pill alone on its own row, then the letter rail. On a
+ 390 px phone the first result lands below the fold.
+
+ );
+}
diff --git a/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx
index e562cbe11d..4a98f1ea82 100644
--- a/src/components/dictionary/dictionary-catalogue-pages.tsx
+++ b/src/components/dictionary/dictionary-catalogue-pages.tsx
@@ -9,6 +9,7 @@ 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";
@@ -17,9 +18,11 @@ 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 { SegmentedControl } from "@/components/ui/segmented-control";
import {
allDictionaryEntries,
browseDictionary,
+ dictionaryBrowseLetter,
dictionaryKindLabel,
dictionaryTopicEntries,
findDictionaryTopic,
@@ -325,6 +328,18 @@ export function DictionaryBrowsePage() {
const sort = searchParams.get("sort") === "za" ? "za" : "az";
const [filterOpen, setFilterOpen] = useState(false);
const hits = browseDictionary({ view, letter, topics, kinds, sort });
+ // Both counts come from the same predicate as the list, so a segment's number
+ // is what pressing it actually returns (docs/filter-contract.md).
+ const viewCount = (candidate: "az" | "abbreviations") =>
+ browseDictionary({ view: candidate, letter, topics, kinds, sort }).length;
+ // Letters the current view and facets can actually fill. The rest stay
+ // visible — the alphabet is a fixed mental model — but inert, so the index
+ // can never strand the reader on an empty page.
+ const availableLetters = new Set(
+ browseDictionary({ view, letter: "all", topics, kinds, sort }).map(dictionaryBrowseLetter),
+ );
+ // Sort lives in the sheet but is not a filter: it reorders rather than
+ // narrows, so counting it in the badge would claim results were withheld.
const activeCount = topics.length + kinds.length;
const clearFilters = () =>
replace((next) => {
@@ -332,6 +347,17 @@ export function DictionaryBrowsePage() {
next.delete("kind");
});
const groups = [
+ resultFilterGroup({
+ id: "sort",
+ label: "Sort",
+ value: sort,
+ options: [
+ { value: "az", label: "A–Z" },
+ { value: "za", label: "Z–A" },
+ ],
+ onChange: (value) => setOne("sort", value, "az"),
+ note: "one only",
+ }),
resultFilterFacetGroup({
id: "topics",
label: "Topics",
@@ -354,97 +380,149 @@ export function DictionaryBrowsePage() {
return (
<>
-
-
+
+ {/* Desktop-only: on a phone the mode-nav rail sitting directly above
+ already reads "Browse", so the kicker repeats it for a whole band. */}
+
Complete catalogue
-
+
Browse terms
-
+
-
- {(["az", "abbreviations"] as const).map((option) => (
+ setOne("view", next, "az")}
+ options={[
+ { value: "az", label: "Definitions", hint: String(viewCount("az")) },
+ { value: "abbreviations", label: "Abbreviations", hint: String(viewCount("abbreviations")) },
+ ]}
+ layout="equal"
+ ariaControls="dictionary-browse-results"
+ // Equal segments need an explicit desktop width: shrink-to-fit
+ // derives the width from spans that are themselves truncating,
+ // which settles on a box too narrow for "Abbreviations".
+ className="w-full sm:w-[22rem]"
+ />
+
+ {/* Phone takes the alphabet as a native select — 27 chips cost a
+ whole band and a horizontal scroll, and the native picker is
+ a better target than any of them. The rail below is the same
+ control at a width that can afford it. */}
+
+
+ setFilterOpen((open) => !open)}
+ title="Filter and sort browse results"
+ />
+
+
+
+ {/* Wraps rather than scrolls: 27 chips overrun the 76rem container by
+ a chip's width, and a rail that clips Z is worse than a rail that
+ takes two rows on the narrower desktop widths. */}
+
All published entries link a source · Specialist clinical approval remains pending
@@ -455,10 +533,20 @@ export function DictionaryBrowsePage() {
onClose={() => setFilterOpen(false)}
panelId="dictionary-browse-filters"
testId="dictionary-browse-filters"
- title="Filter browse results"
+ title="Filter and sort"
groups={groups}
onClearAll={activeCount ? clearFilters : undefined}
- summary={{ count: hits.length, noun: hits.length === 1 ? "term" : "terms" }}
+ summary={{
+ count: hits.length,
+ noun:
+ view === "abbreviations"
+ ? hits.length === 1
+ ? "abbreviation"
+ : "abbreviations"
+ : hits.length === 1
+ ? "term"
+ : "terms",
+ }}
onApply={() => setFilterOpen(false)}
/>
>
diff --git a/src/lib/dictionary.ts b/src/lib/dictionary.ts
index eed07dfe6a..0f38c6ad45 100644
--- a/src/lib/dictionary.ts
+++ b/src/lib/dictionary.ts
@@ -220,6 +220,19 @@ export function searchDictionary(filters: DictionaryFilters): DictionarySearchHi
});
}
+/**
+ * The letter a hit files under in the browse index.
+ *
+ * Exported because the alphabetical control has to offer exactly the letters the
+ * list can actually show. Deriving that from a second copy of this expression is
+ * how an index comes to offer a letter that strands the reader on an empty page.
+ */
+export function dictionaryBrowseLetter(hit: DictionarySearchHit) {
+ const title =
+ hit.type === "entry" ? hit.entry.term : hit.type === "abbreviation" ? hit.abbreviation : hit.topic.title;
+ return title.charAt(0).toLocaleUpperCase();
+}
+
export function browseDictionary(params: {
view: "az" | "abbreviations";
letter: string;
@@ -237,9 +250,7 @@ export function browseDictionary(params: {
};
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();
+ return dictionaryBrowseLetter(hit) === params.letter.toLocaleUpperCase();
});
if (params.sort === "za") hits = hits.reverse();
return hits;
diff --git a/tests/dictionary-data.test.ts b/tests/dictionary-data.test.ts
index 4679d4031c..0ae940e3c1 100644
--- a/tests/dictionary-data.test.ts
+++ b/tests/dictionary-data.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
+ browseDictionary,
dictionaryAliasSenses,
+ dictionaryBrowseLetter,
dictionaryCatalogueIssues,
dictionaryCompareHref,
dictionaryComparisonPair,
@@ -95,4 +97,27 @@ describe("clinical dictionary catalogue", () => {
);
expect(dictionaryComparisonPair("affect", "delirium")).toBeNull();
});
+
+ it("files every browse hit under the letter the alphabetical index offers", () => {
+ // The browse header derives its selectable letters from
+ // `dictionaryBrowseLetter` and offers the rest inert. If the two ever
+ // disagreed the index would strand the reader on an empty page, which is the
+ // failure this pins: for both views, a letter the helper reports must return
+ // results, and a letter it does not report must return none.
+ for (const view of ["az", "abbreviations"] as const) {
+ const all = browseDictionary({ view, letter: "all", topics: [], kinds: [], sort: "az" });
+ expect(all.length).toBeGreaterThan(0);
+ const available = new Set(all.map(dictionaryBrowseLetter));
+ for (const letter of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
+ const hits = browseDictionary({ view, letter, topics: [], kinds: [], sort: "az" });
+ expect(hits.length > 0).toBe(available.has(letter));
+ }
+ // Every hit accounted for exactly once across the per-letter partition.
+ const partitioned = [...available].reduce(
+ (total, letter) => total + browseDictionary({ view, letter, topics: [], kinds: [], sort: "az" }).length,
+ 0,
+ );
+ expect(partitioned).toBe(all.length);
+ }
+ });
});