From 5eaf049a3eddc252dc0ee062c9aedc9961c93821 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 13:46:22 +0000 Subject: [PATCH 1/7] Polish the dictionary catalogue control row. Replace the jammed Terms/Abbreviations toggle with the shared SegmentedControl so counts sit in a separate hint column, and keep Filter on that same row during search instead of parking it in the query ribbon. Co-authored-by: BigSimmo --- .../dictionary/dictionary-catalogue-pages.tsx | 96 +++++++------------ tests/ui-dictionary.spec.ts | 68 +++++++++---- 2 files changed, 83 insertions(+), 81 deletions(-) diff --git a/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx index 8c09fab1fb..f35797e3af 100644 --- a/src/components/dictionary/dictionary-catalogue-pages.tsx +++ b/src/components/dictionary/dictionary-catalogue-pages.tsx @@ -28,6 +28,7 @@ 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 { SegmentedControl } from "@/components/ui/segmented-control"; import { cn } from "@/components/ui-primitives"; import { allDictionaryEntries, @@ -121,9 +122,11 @@ function catalogueNoun(scope: DictionaryCatalogueScope, count: number) { * a hard constraint with committed tests behind it. * * The phone control row is sized to its own labels rather than to the viewport. - * It keeps the complete “Abbreviations” and “Filter” wordmarks, with tighter - * phone-only horizontal padding so the idle controls remain one balanced row at - * common phone widths and wrap, rather than clip, on compact screens. + * The Terms / Abbreviations switch is the shared SegmentedControl, with counts + * as separate hints rather than jammed digits. Filter stays on this row at + * every breakpoint — including during a search — rather than moving into the + * query ribbon. Idle controls remain one balanced row at common phone widths + * and wrap, rather than clip, on compact screens. */ export function DictionaryCataloguePage() { const { searchParams, replace, setOne, toggleMany } = useDictionaryUrl(); @@ -285,42 +288,24 @@ export function DictionaryCataloguePage() { })), ]; - /* Sized to its own labels, not to the viewport, and joined into one border - with no gap. The counts sit inline, so the row needs no summary line of its - own while browsing. */ + /* Sized to its own labels, not to the viewport. Counts live in `hint` so the + accessible name is "Terms (2)" rather than the concatenated "Terms2" the + previous jammed digits produced. */ const scopeToggle = ( -
- {scopeOptions.map((option) => { - const active = params.scope === option.value; - return ( - - ); - })} +
+ setOne("view", scope, "definitions")} + options={scopeOptions.map((option) => ({ + value: option.value, + label: option.label, + hint: String(scopeCounts[option.value]), + }))} + />
); @@ -337,7 +322,7 @@ export function DictionaryCataloguePage() { data-testid="dictionary-letter-chip" title="Jump to a letter" className={cn( - "inline-flex min-h-tap shrink-0 items-center gap-0.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-1.5 text-xs font-extrabold text-[color:var(--clinical-accent)] sm:hidden", + "inline-flex min-h-tap shrink-0 items-center gap-1 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 text-xs font-semibold text-[color:var(--clinical-accent)] sm:hidden", focusRing, )} > @@ -362,8 +347,9 @@ export function DictionaryCataloguePage() { ); /* Clears the query from the band's own line, which is where the reader is - looking when they decide they are done with it. It is one shared control at - every breakpoint; Filter has the band’s dedicated phone slot. */ + looking when they decide they are done with it. Filter stays on the control + row with Terms / Abbreviations, so the ribbon only carries the query, the + match count, and this dismiss control. */ const clearQueryControl = (
) : null}
-
- {/* The phone gutter and internal gaps are deliberately tight enough - for the complete labels to stay on one row at 390px. At 320px the - intrinsic controls still wrap rather than squeezing counts or - clipping the Filter wordmark. */} +
+ {/* Filter is a permanent member of this row, including during a + search. At 320px the intrinsic controls wrap rather than + squeezing counts or clipping the Filter wordmark. */}
{scopeToggle} {/* The alphabet is meaningless against a ranked result set, so it @@ -440,12 +418,10 @@ export function DictionaryCataloguePage() { same time, so nothing narrows the list without a visible control saying so. */} {searching ? null : letterChip} - {showBand ? null : ( - - {filterTrigger("desktop")} - {filterTrigger("phone")} - - )} + + {filterTrigger("desktop")} + {filterTrigger("phone")} +
{/* 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 diff --git a/tests/ui-dictionary.spec.ts b/tests/ui-dictionary.spec.ts index 8e6cd85b61..42cf657d73 100644 --- a/tests/ui-dictionary.spec.ts +++ b/tests/ui-dictionary.spec.ts @@ -144,25 +144,49 @@ test("merges search and browse into one catalogue with a measured phone header", // offset below it is wrong until it settles (#XPY409, docs/testing.md). await page.waitForTimeout(1200); - // Browsing: one row of controls, no summary line, and the scope toggle sized - // to its own full labels rather than to the viewport. + // Browsing: no summary line, and the scope toggle sized to its own full + // labels rather than stretching to the viewport. Counts are a separate hint + // column, so the accessible name is "Abbreviations (N)" rather than a jammed + // "AbbreviationsN". const toggle = page.getByTestId("dictionary-scope-toggle"); await expect(toggle).toBeVisible(); - await expect(toggle.getByRole("button", { name: /Abbreviations/ })).toBeVisible(); + await expect(toggle.getByRole("radio", { name: /Abbreviations \(\d+\)/ })).toBeVisible(); + await expect(toggle.getByRole("radio", { name: /Terms \(\d+\)/ })).toBeVisible(); const toggleBox = await toggle.boundingBox(); expect(toggleBox?.height ?? 0).toBeGreaterThanOrEqual(48); - // The complete labels remain intrinsic rather than stretching to fill the row. - expect(toggleBox?.width ?? 0).toBeLessThan(260); + // Intrinsic, not full-bleed — the elevated track is wider than the old jammed + // digits, but it still must not consume the Filter slot. + expect(toggleBox?.width ?? 0).toBeLessThan(340); + expect(toggleBox?.width ?? 0).toBeGreaterThan(200); await expect(page.getByTestId("dictionary-letter-chip")).toBeVisible(); - await expect(page.getByTestId("dictionary-filter-trigger-phone").getByText("Filter", { exact: true })).toBeVisible(); + const filter = page.getByTestId("dictionary-filter-trigger-phone"); + await expect(filter.getByText("Filter", { exact: true })).toBeVisible(); await expect(page.getByTestId("search-query-ribbon")).toHaveCount(0); - // Every control on one line: same top edge, no wrap at 390px. - const rowTops = await page.evaluate(() => { - const ids = ["dictionary-scope-toggle", "dictionary-letter-chip", "dictionary-filter-trigger-phone"]; - return ids.map((id) => document.querySelector(`[data-testid="${id}"]`)?.getBoundingClientRect().top ?? -1); + // Filter stays on this row, to the right of the toggle. The elevated track plus + // the letter chip may wrap Filter at 390px; that is preferred to clipping a + // count. Same top edge is required when they do fit. + const browseRow = await page.evaluate(() => { + const box = (id: string) => document.querySelector(`[data-testid="${id}"]`)?.getBoundingClientRect() ?? null; + const toggle = box("dictionary-scope-toggle"); + const letter = box("dictionary-letter-chip"); + const filter = box("dictionary-filter-trigger-phone"); + return { + toggleTop: toggle?.top ?? -1, + letterTop: letter?.top ?? -1, + filterTop: filter?.top ?? -1, + filterRightOfToggle: (filter?.left ?? 0) >= (toggle?.right ?? 9999) - 2, + overflow: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0) - window.innerWidth, + }; }); - expect(rowTops.every((top) => top > 0)).toBe(true); - expect(Math.max(...rowTops) - Math.min(...rowTops)).toBeLessThanOrEqual(2); + expect(browseRow.toggleTop).toBeGreaterThan(0); + expect(browseRow.letterTop).toBeGreaterThan(0); + expect(browseRow.filterTop).toBeGreaterThan(0); + expect(browseRow.overflow).toBeLessThanOrEqual(2); + if (Math.abs(browseRow.filterTop - browseRow.toggleTop) <= 2) { + expect(browseRow.filterRightOfToggle).toBe(true); + } else { + expect(browseRow.filterTop).toBeGreaterThan(browseRow.toggleTop); + } // At 320px the same controls want more than the track has, and the contract // is that the row WRAPS rather than clipping a count out of view. @@ -179,30 +203,32 @@ test("merges search and browse into one catalogue with a measured phone header", }); expect(narrowRow.filterTop).toBeGreaterThan(narrowRow.toggleTop); // Same intrinsic width as at 390px — it wrapped, it did not shrink. - expect(narrowRow.toggleWidth).toBeGreaterThan(160); + expect(narrowRow.toggleWidth).toBeGreaterThan(200); expect(narrowRow.overflow).toBeLessThanOrEqual(2); await page.setViewportSize({ width: 390, height: 844 }); - // Searching: the query gets a line of its own and the alphabet stands down. + // Searching: the query gets a line of its own, the alphabet stands down, and + // Filter stays on the Terms / Abbreviations row rather than joining the ribbon. await gotoDictionary(page, "/dictionary/search?q=tardive+dyskinesia", "dictionary-catalogue-main"); await page.waitForTimeout(1200); const ribbon = page.getByTestId("search-query-ribbon"); await expect(ribbon).toBeVisible(); await expect(ribbon.getByTestId("dictionary-clear-query")).toBeVisible(); - await expect( - ribbon.getByTestId("dictionary-filter-trigger-phone").getByText("Filter", { exact: true }), - ).toBeVisible(); + await expect(ribbon.getByTestId("dictionary-filter-trigger-phone")).toHaveCount(0); + await expect(filter.getByText("Filter", { exact: true })).toBeVisible(); await expect(page.getByTestId("dictionary-letter-chip")).toHaveCount(0); const resultControls = await page.evaluate(() => { - const ribbon = document.querySelector('[data-testid="search-query-ribbon"]'); - const box = (testId: string) => ribbon?.querySelector(`[data-testid="${testId}"]`)?.getBoundingClientRect() ?? null; + const box = (id: string) => document.querySelector(`[data-testid="${id}"]`)?.getBoundingClientRect() ?? null; return { - clearTop: box("dictionary-clear-query")?.top ?? -1, + toggleTop: box("dictionary-scope-toggle")?.top ?? -1, filterTop: box("dictionary-filter-trigger-phone")?.top ?? -1, + filterRightOfToggle: + (box("dictionary-filter-trigger-phone")?.left ?? 0) >= (box("dictionary-scope-toggle")?.right ?? 9999) - 2, }; }); - expect(Math.abs(resultControls.clearTop - resultControls.filterTop)).toBeLessThanOrEqual(2); + expect(Math.abs(resultControls.filterTop - resultControls.toggleTop)).toBeLessThanOrEqual(2); + expect(resultControls.filterRightOfToggle).toBe(true); // Its own line: the band sits entirely above the control row. const geometry = await page.evaluate(() => { From 28cbce0630dc7bc2f9cf9f48989186cf9a9840fa Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:23:56 +0800 Subject: [PATCH 2/7] fix: refresh dictionary design-system adoption --- docs/design-system/COMPONENTS.md | 2 +- docs/design-system/adoption-manifest.json | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 8eb42b13d1..035b644177 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1007,7 +1007,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 | 27 | | `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 a8bafbbba8..4486739367 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1490,6 +1490,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", @@ -1500,6 +1501,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", From 3d8f7daa637dd44e98519b8767f1a89ca567092a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:24:43 +0800 Subject: [PATCH 3/7] docs: record PR 2316 CI remediation review --- ...54ddb96185d4bf0f13acd31d9593bdb3a4654fdeaa67a98fd07.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/f6bc87b54552154ddb96185d4bf0f13acd31d9593bdb3a4654fdeaa67a98fd07.record.md diff --git a/docs/branch-review-records/f6bc87b54552154ddb96185d4bf0f13acd31d9593bdb3a4654fdeaa67a98fd07.record.md b/docs/branch-review-records/f6bc87b54552154ddb96185d4bf0f13acd31d9593bdb3a4654fdeaa67a98fd07.record.md new file mode 100644 index 0000000000..05046a4009 --- /dev/null +++ b/docs/branch-review-records/f6bc87b54552154ddb96185d4bf0f13acd31d9593bdb3a4654fdeaa67a98fd07.record.md @@ -0,0 +1 @@ +| 2026-08-23 | 2316 | 28cbce0630dc7bc2f9cf9f48989186cf9a9840fa | dictionary catalogue control row and CI failure | fixed | design-system-contract,design-system-adoption,format-changed,vitest-focused,merge-tree | From e475f236fb31ac211b82bd92bbfd69ff45d7c82c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 14:25:43 +0000 Subject: [PATCH 4/7] Align Filter height with the dictionary scope track. Stretch the Filter trigger to the SegmentedControl row and assert same-row geometry by overlap rather than identical top edges, which differ by the track padding. Co-authored-by: BigSimmo --- .../dictionary/dictionary-catalogue-pages.tsx | 4 +-- tests/ui-dictionary.spec.ts | 36 ++++++++++++------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx index f35797e3af..82549c0637 100644 --- a/src/components/dictionary/dictionary-catalogue-pages.tsx +++ b/src/components/dictionary/dictionary-catalogue-pages.tsx @@ -410,7 +410,7 @@ export function DictionaryCataloguePage() { {/* Filter is a permanent member of this row, including during a search. At 320px the intrinsic controls wrap rather than squeezing counts or clipping the Filter wordmark. */} -
+
{scopeToggle} {/* The alphabet is meaningless against a ranked result set, so it stands down rather than competing with the words for the line. @@ -418,7 +418,7 @@ export function DictionaryCataloguePage() { same time, so nothing narrows the list without a visible control saying so. */} {searching ? null : letterChip} - + {filterTrigger("desktop")} {filterTrigger("phone")} diff --git a/tests/ui-dictionary.spec.ts b/tests/ui-dictionary.spec.ts index 42cf657d73..17154ec880 100644 --- a/tests/ui-dictionary.spec.ts +++ b/tests/ui-dictionary.spec.ts @@ -170,11 +170,19 @@ test("merges search and browse into one catalogue with a measured phone header", const toggle = box("dictionary-scope-toggle"); const letter = box("dictionary-letter-chip"); const filter = box("dictionary-filter-trigger-phone"); + const sameRow = Boolean( + toggle && + filter && + filter.top < toggle.bottom - 4 && + filter.bottom > toggle.top + 4 && + filter.left >= toggle.right - 2, + ); return { toggleTop: toggle?.top ?? -1, letterTop: letter?.top ?? -1, filterTop: filter?.top ?? -1, - filterRightOfToggle: (filter?.left ?? 0) >= (toggle?.right ?? 9999) - 2, + filterBottomPastToggle: (filter?.top ?? 0) >= (toggle?.bottom ?? 9999) - 2, + sameRow, overflow: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0) - window.innerWidth, }; }); @@ -182,11 +190,7 @@ test("merges search and browse into one catalogue with a measured phone header", expect(browseRow.letterTop).toBeGreaterThan(0); expect(browseRow.filterTop).toBeGreaterThan(0); expect(browseRow.overflow).toBeLessThanOrEqual(2); - if (Math.abs(browseRow.filterTop - browseRow.toggleTop) <= 2) { - expect(browseRow.filterRightOfToggle).toBe(true); - } else { - expect(browseRow.filterTop).toBeGreaterThan(browseRow.toggleTop); - } + expect(browseRow.sameRow || browseRow.filterBottomPastToggle).toBe(true); // At 320px the same controls want more than the track has, and the contract // is that the row WRAPS rather than clipping a count out of view. @@ -220,15 +224,23 @@ test("merges search and browse into one catalogue with a measured phone header", const resultControls = await page.evaluate(() => { const box = (id: string) => document.querySelector(`[data-testid="${id}"]`)?.getBoundingClientRect() ?? null; + const toggle = box("dictionary-scope-toggle"); + const filter = box("dictionary-filter-trigger-phone"); + const toggleMid = toggle ? (toggle.top + toggle.bottom) / 2 : -1; + const filterMid = filter ? (filter.top + filter.bottom) / 2 : -1; return { - toggleTop: box("dictionary-scope-toggle")?.top ?? -1, - filterTop: box("dictionary-filter-trigger-phone")?.top ?? -1, - filterRightOfToggle: - (box("dictionary-filter-trigger-phone")?.left ?? 0) >= (box("dictionary-scope-toggle")?.right ?? 9999) - 2, + sameRow: Boolean( + toggle && + filter && + filter.top < toggle.bottom - 4 && + filter.bottom > toggle.top + 4 && + filter.left >= toggle.right - 2, + ), + centersAligned: Math.abs(filterMid - toggleMid) <= 4, }; }); - expect(Math.abs(resultControls.filterTop - resultControls.toggleTop)).toBeLessThanOrEqual(2); - expect(resultControls.filterRightOfToggle).toBe(true); + expect(resultControls.sameRow).toBe(true); + expect(resultControls.centersAligned).toBe(true); // Its own line: the band sits entirely above the control row. const geometry = await page.evaluate(() => { From 27c845fd5cdcbace8fda40ef70866aca891c9a7b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 15:55:58 +0000 Subject: [PATCH 5/7] Compact the dictionary Terms / Abbreviations toggle. Restore the joined two-cell switch (one border, no gap) and shrink its type and height so the control row stays a strip rather than a second header. Filter remains on the right of that row. Co-authored-by: BigSimmo --- docs/design-system/COMPONENTS.md | 2 +- docs/design-system/adoption-manifest.json | 2 - .../dictionary/dictionary-catalogue-pages.tsx | 79 ++++++++++++------- tests/ui-dictionary.spec.ts | 38 ++++----- 4 files changed, 67 insertions(+), 54 deletions(-) diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 035b644177..8eb42b13d1 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1007,7 +1007,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 | 9 | +| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 8 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | | `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 27 | | `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 4486739367..a8bafbbba8 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1490,7 +1490,6 @@ "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", @@ -1501,7 +1500,6 @@ "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/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx index 82549c0637..6b691c3df7 100644 --- a/src/components/dictionary/dictionary-catalogue-pages.tsx +++ b/src/components/dictionary/dictionary-catalogue-pages.tsx @@ -28,7 +28,6 @@ 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 { SegmentedControl } from "@/components/ui/segmented-control"; import { cn } from "@/components/ui-primitives"; import { allDictionaryEntries, @@ -122,11 +121,13 @@ function catalogueNoun(scope: DictionaryCatalogueScope, count: number) { * a hard constraint with committed tests behind it. * * The phone control row is sized to its own labels rather than to the viewport. - * The Terms / Abbreviations switch is the shared SegmentedControl, with counts - * as separate hints rather than jammed digits. Filter stays on this row at - * every breakpoint — including during a search — rather than moving into the - * query ribbon. Idle controls remain one balanced row at common phone widths - * and wrap, rather than clip, on compact screens. + * The Terms / Abbreviations switch is a joined two-cell toggle (one border, no + * gap), compact in type and height so it does not compete with Filter. Counts + * sit beside the labels with an explicit accessible name ("Terms (2)") so they + * do not concatenate. Filter stays on this row at every breakpoint — including + * during a search — rather than moving into the query ribbon. Idle controls + * remain one balanced row at common phone widths and wrap, rather than clip, + * on compact screens. */ export function DictionaryCataloguePage() { const { searchParams, replace, setOne, toggleMany } = useDictionaryUrl(); @@ -288,30 +289,47 @@ export function DictionaryCataloguePage() { })), ]; - /* Sized to its own labels, not to the viewport. Counts live in `hint` so the - accessible name is "Terms (2)" rather than the concatenated "Terms2" the - previous jammed digits produced. */ + /* Joined two-cell toggle: one border, no gap, sized to its own labels. + Compact (28px, 11px type) so the row stays a control strip rather than a + second header. `aria-label` keeps the accessible name "Terms (2)" rather + than the concatenated "Terms2" jammed digits produced. */ const scopeToggle = ( -
- setOne("view", scope, "definitions")} - options={scopeOptions.map((option) => ({ - value: option.value, - label: option.label, - hint: String(scopeCounts[option.value]), - }))} - /> +
+ {scopeOptions.map((option) => { + const active = params.scope === option.value; + const count = scopeCounts[option.value]; + return ( + + ); + })}
); - /* The phone's whole alphabet in one 63px control. 27 chips cost a band and a + /* The phone's whole alphabet in one compact chip. 27 chips cost a band and a horizontal scroll; the rail below is the same control at a width that can - afford it. */ + afford it. Height matches the joined scope toggle so the row stays even. */ const letterChip = (
) : null}
-
+
{/* Filter is a permanent member of this row, including during a - search. At 320px the intrinsic controls wrap rather than + search. The compact toggle is vertically centred against the + taller Filter; at 320px the intrinsic controls wrap rather than squeezing counts or clipping the Filter wordmark. */} -
+
{scopeToggle} {/* The alphabet is meaningless against a ranked result set, so it stands down rather than competing with the words for the line. @@ -418,7 +437,7 @@ export function DictionaryCataloguePage() { same time, so nothing narrows the list without a visible control saying so. */} {searching ? null : letterChip} - + {filterTrigger("desktop")} {filterTrigger("phone")} diff --git a/tests/ui-dictionary.spec.ts b/tests/ui-dictionary.spec.ts index 17154ec880..1d10d6ecd4 100644 --- a/tests/ui-dictionary.spec.ts +++ b/tests/ui-dictionary.spec.ts @@ -144,27 +144,27 @@ test("merges search and browse into one catalogue with a measured phone header", // offset below it is wrong until it settles (#XPY409, docs/testing.md). await page.waitForTimeout(1200); - // Browsing: no summary line, and the scope toggle sized to its own full - // labels rather than stretching to the viewport. Counts are a separate hint - // column, so the accessible name is "Abbreviations (N)" rather than a jammed - // "AbbreviationsN". + // Browsing: no summary line, and the joined two-cell toggle sized to its own + // labels rather than stretching to the viewport. Compact in type and height; + // counts use an explicit accessible name so it is "Abbreviations (N)" rather + // than a jammed "AbbreviationsN". const toggle = page.getByTestId("dictionary-scope-toggle"); await expect(toggle).toBeVisible(); - await expect(toggle.getByRole("radio", { name: /Abbreviations \(\d+\)/ })).toBeVisible(); - await expect(toggle.getByRole("radio", { name: /Terms \(\d+\)/ })).toBeVisible(); + await expect(toggle.getByRole("button", { name: /Abbreviations \(\d+\)/ })).toBeVisible(); + await expect(toggle.getByRole("button", { name: /Terms \(\d+\)/ })).toBeVisible(); const toggleBox = await toggle.boundingBox(); - expect(toggleBox?.height ?? 0).toBeGreaterThanOrEqual(48); - // Intrinsic, not full-bleed — the elevated track is wider than the old jammed - // digits, but it still must not consume the Filter slot. - expect(toggleBox?.width ?? 0).toBeLessThan(340); - expect(toggleBox?.width ?? 0).toBeGreaterThan(200); + expect(toggleBox?.height ?? 0).toBeGreaterThanOrEqual(24); + expect(toggleBox?.height ?? 0).toBeLessThanOrEqual(36); + // Intrinsic, not full-bleed — the joined cells must not consume the Filter slot. + expect(toggleBox?.width ?? 0).toBeLessThan(300); + expect(toggleBox?.width ?? 0).toBeGreaterThan(140); await expect(page.getByTestId("dictionary-letter-chip")).toBeVisible(); const filter = page.getByTestId("dictionary-filter-trigger-phone"); await expect(filter.getByText("Filter", { exact: true })).toBeVisible(); await expect(page.getByTestId("search-query-ribbon")).toHaveCount(0); - // Filter stays on this row, to the right of the toggle. The elevated track plus - // the letter chip may wrap Filter at 390px; that is preferred to clipping a - // count. Same top edge is required when they do fit. + // Filter stays on this row, to the right of the toggle. The compact toggle + // plus the letter chip may wrap Filter at 390px; that is preferred to + // clipping a count. Same top edge is required when they do fit. const browseRow = await page.evaluate(() => { const box = (id: string) => document.querySelector(`[data-testid="${id}"]`)?.getBoundingClientRect() ?? null; const toggle = box("dictionary-scope-toggle"); @@ -192,22 +192,18 @@ test("merges search and browse into one catalogue with a measured phone header", expect(browseRow.overflow).toBeLessThanOrEqual(2); expect(browseRow.sameRow || browseRow.filterBottomPastToggle).toBe(true); - // At 320px the same controls want more than the track has, and the contract - // is that the row WRAPS rather than clipping a count out of view. + // At 320px the same controls may wrap; the contract is that they do not clip + // a count out of view, whether they stay on one line or wrap. await page.setViewportSize({ width: 320, height: 760 }); await page.waitForTimeout(400); const narrowRow = await page.evaluate(() => { const box = (id: string) => document.querySelector(`[data-testid="${id}"]`)?.getBoundingClientRect() ?? null; return { toggleWidth: box("dictionary-scope-toggle")?.width ?? 0, - toggleTop: box("dictionary-scope-toggle")?.top ?? 0, - filterTop: box("dictionary-filter-trigger-phone")?.top ?? 0, overflow: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0) - window.innerWidth, }; }); - expect(narrowRow.filterTop).toBeGreaterThan(narrowRow.toggleTop); - // Same intrinsic width as at 390px — it wrapped, it did not shrink. - expect(narrowRow.toggleWidth).toBeGreaterThan(200); + expect(narrowRow.toggleWidth).toBeGreaterThan(140); expect(narrowRow.overflow).toBeLessThanOrEqual(2); await page.setViewportSize({ width: 390, height: 844 }); From 30070127920103fca8c34b617030e7ddf14257c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 16:14:40 +0000 Subject: [PATCH 6/7] Keep dictionary toggle focus visible inside the joined track. The compact two-cell switch clips an outset ring, so the cells use an inset focus outline. Match the letter chip height to the same strip. Co-authored-by: BigSimmo --- src/components/dictionary/dictionary-catalogue-pages.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx index 6b691c3df7..17fe6ba5b4 100644 --- a/src/components/dictionary/dictionary-catalogue-pages.tsx +++ b/src/components/dictionary/dictionary-catalogue-pages.tsx @@ -292,7 +292,8 @@ export function DictionaryCataloguePage() { /* Joined two-cell toggle: one border, no gap, sized to its own labels. Compact (28px, 11px type) so the row stays a control strip rather than a second header. `aria-label` keeps the accessible name "Terms (2)" rather - than the concatenated "Terms2" jammed digits produced. */ + than the concatenated "Terms2" jammed digits produced. Focus uses an inset + outline because the joined track clips an outset ring. */ const scopeToggle = (
setOne("view", option.value, "definitions")} className={cn( "inline-flex h-full items-center gap-1 px-2 text-2xs font-semibold leading-none tracking-tight transition-colors motion-reduce:transition-none sm:px-2.5", - focusRing, + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]", active ? "bg-[color:var(--tone-purple)] text-[color:var(--surface)] forced-colors:outline forced-colors:outline-2 forced-colors:[outline-color:Highlight]" : "bg-transparent text-[color:var(--clinical-accent)] hover:bg-[color:var(--tone-purple-soft)]", @@ -340,12 +341,12 @@ export function DictionaryCataloguePage() { data-testid="dictionary-letter-chip" title="Jump to a letter" className={cn( - "inline-flex h-7 shrink-0 items-center gap-0.5 rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-1.5 text-2xs font-semibold leading-none text-[color:var(--clinical-accent)] sm:hidden", + "inline-flex h-7 max-h-7 shrink-0 items-center gap-0.5 overflow-hidden rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-1.5 text-2xs font-semibold leading-none text-[color:var(--clinical-accent)] sm:hidden", focusRing, )} > {params.letter === "all" ? "A–Z" : params.letter} -
); - /* The phone's whole alphabet in one compact chip. 27 chips cost a band and a + /* The phone's whole alphabet in one chip. 27 chips cost a band and a horizontal scroll; the rail below is the same control at a width that can - afford it. Height matches the joined scope toggle so the row stays even. */ + afford it. Keep the 48px tap floor (`min-h-tap min-w-tap`) so this control + matches Filter on both axes; the compact toggle stays visually smaller. */ const letterChip = (