diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md
index 4702b419df..f3a013677f 100644
--- a/docs/search-chrome-behaviour.md
+++ b/docs/search-chrome-behaviour.md
@@ -118,9 +118,17 @@ chrome and changes to it land on every mode at once. Keep these rules:
it appears. The `role="status"` / `aria-live="polite"` announcement stays either way — except
while faulted, when the spine goes `aria-live="off"` and the fault panel's `role="alert"`
makes the single announcement instead of both speaking.
-3. **Sort is a segmented control, not a select.** Two values do not justify a menu you must open
- to read. `ResultSortControl` renders `sortOptions` as `aria-pressed` buttons inside a
- `role="group"` named "Sort results"; add a third order only if it still fits the rail.
+3. **Sort is a segmented control, not a select — and it is `sm`-and-up.** Two values do not
+ justify a menu you must open to read. `ResultSortControl` renders `sortOptions` as
+ `aria-pressed` buttons inside a `role="group"` named "Sort results"; add a third order only if
+ it still fits the rail. Below 640px it is `hidden`: the two segments cost roughly half the
+ band's one line, and the query truncated to pay for a control set about once a session. Only
+ the affordance is `sm`-and-up — `?sort=` still carries an alpha order onto a phone and the
+ results honour it. The display class belongs in the component's own base string, because `cn`
+ is a plain join with no Tailwind conflict resolution. A page whose only utility is sort hides
+ the whole utilities group below `sm` (`hasPhoneUtilities`) rather than leaving an empty flex
+ child — in `inline` placement under 414px that child is `w-full basis-full`, i.e. a blank
+ second line.
4. **Native selects are pinned to 16px below `sm`.** The unlayered iOS anti-zoom rule in
`globals.css` ("Interactive element defaults") deliberately beats Tailwind's `text-*`
utilities on `input`/`select`/`textarea`. Do not fight it with `!important` or a per-call-site
diff --git a/lighthouse-budget.json b/lighthouse-budget.json
index ef2d06ca3f..112dedb74e 100644
--- a/lighthouse-budget.json
+++ b/lighthouse-budget.json
@@ -20,75 +20,75 @@
},
"baseline": {
"desktop-documents-search": {
- "lcpMs": 912.6572000000001,
- "cls": 0.09676386467668462,
- "tbtMs": 77.5,
+ "lcpMs": 887,
+ "cls": 0.119,
+ "tbtMs": 1,
"fcpMs": 404.55240000000003,
- "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
+ "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"desktop-dsm": {
- "lcpMs": 938.4955500000002,
- "cls": 0.01330779747882682,
+ "lcpMs": 936,
+ "cls": 0.013,
"tbtMs": 0,
"fcpMs": 399.6637,
- "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
+ "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"desktop-forms": {
- "lcpMs": 1567.6434999999997,
- "cls": 0.05555523904347394,
+ "lcpMs": 870,
+ "cls": 0.06,
"tbtMs": 0,
"fcpMs": 393.5287,
- "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
+ "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"desktop-root": {
- "lcpMs": 935.5017499999998,
- "cls": 0,
- "tbtMs": 64.99999999999989,
+ "lcpMs": 822,
+ "cls": 0.007,
+ "tbtMs": 3,
"fcpMs": 358.3345,
- "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
+ "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"desktop-therapy-compass": {
- "lcpMs": 1024.6195499999997,
+ "lcpMs": 822,
"cls": 0,
- "tbtMs": 42,
+ "tbtMs": 0,
"fcpMs": 397.3599,
- "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
+ "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"mobile-documents-search": {
- "lcpMs": 2357.43,
+ "lcpMs": 2311,
"cls": 0,
- "tbtMs": 398.87499999999864,
+ "tbtMs": 399,
"fcpMs": 2329.335,
- "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
+ "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"mobile-dsm": {
- "lcpMs": 2744.673,
+ "lcpMs": 2319,
"cls": 0,
- "tbtMs": 367.6859999999997,
+ "tbtMs": 322,
"fcpMs": 2325.841,
- "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
+ "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"mobile-forms": {
- "lcpMs": 2359.54,
- "cls": 0.08045469317717091,
- "tbtMs": 362.06400000000167,
+ "lcpMs": 2304,
+ "cls": 0.08,
+ "tbtMs": 324,
"fcpMs": 2291.215,
- "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
+ "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"mobile-root": {
- "lcpMs": 3325.742,
+ "lcpMs": 3274,
"cls": 0,
- "tbtMs": 638.9019999999982,
- "fcpMs": 3325.742,
- "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
+ "tbtMs": 763,
+ "fcpMs": 3274,
+ "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"mobile-therapy-compass": {
- "lcpMs": 2791.386,
+ "lcpMs": 2338,
"cls": 0,
- "tbtMs": 341.201,
+ "tbtMs": 302,
"fcpMs": 2327.357,
- "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
+ "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
}
},
- "updatedAt": "2026-08-05T08:26:50.745Z"
+ "updatedAt": "2026-08-07T13:42:17.043Z"
}
diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx
index 94e168b75f..b86520a55d 100644
--- a/src/components/clinical-dashboard/search-results-header-band.tsx
+++ b/src/components/clinical-dashboard/search-results-header-band.tsx
@@ -65,7 +65,9 @@ function singularNoun(plural: string) {
}
/** Sort is a two-state choice, so it reads as a segmented control rather than a
- select: a dropdown over two values makes you open a menu to learn nothing. */
+ select: a dropdown over two values makes you open a menu to learn nothing.
+ It is a `sm`-and-up control — see `ResultSortControl` for why the phone line
+ drops it. */
const sortOptions: ReadonlyArray<{ value: ResultSortValue; label: string }> = [
{ value: "relevance", label: "Relevance" },
{ value: "alpha", label: "A–Z" },
@@ -259,6 +261,14 @@ export function SearchResultsHeaderBand({
const hasUtilities = Boolean(
onSortChange || onViewChange || onSaveSearch || utilityControls || pageMobileControls || (partial && onRetry),
);
+ // Sort is the one utility that does not render below `sm`. A page whose only
+ // utility is sort would otherwise keep this group mounted on a phone as an
+ // empty flex child — and in `inline` placement below 414px that child is
+ // `w-full basis-full`, so it would take a whole empty second line under the
+ // count. Hide the group itself there instead of leaving a ghost row.
+ const hasPhoneUtilities = Boolean(
+ onViewChange || onSaveSearch || utilityControls || pageMobileControls || (partial && onRetry),
+ );
// See `mobileControlsPlacement`: absent a declaration, a page control is
// assumed to be a full-width select and keeps its own row, and a band with no
// page control collapses to one line.
@@ -428,7 +438,8 @@ export function SearchResultsHeaderBand({
diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts
index d915ff8891..fde2b807f5 100644
--- a/tests/ui-accessibility.spec.ts
+++ b/tests/ui-accessibility.spec.ts
@@ -569,9 +569,10 @@ test.describe("Clinical KB accessibility coverage", () => {
const bounds = element.getBoundingClientRect();
return { width: bounds.width, height: bounds.height };
});
- // 44, not 48. This trigger sits in the ribbon's utility row beside
- // `ResultSortControl`, which is `min-h-tap` (44px) — raising only this
- // control would leave the row visibly ragged. The repo's `min-h-12` rule
+ // 44, not 48. This trigger sits in the ribbon's utility row, which is
+ // `min-h-tap` (44px) throughout — raising only this control would leave the
+ // row visibly ragged. (`ResultSortControl` set that rhythm before it became
+ // `sm`-and-up; the row still holds it.) The repo's `min-h-12` rule
// exists to stop generic a11y advice pulling production down to `min-h-11`
// (a known `ui-smoke` flake), not to override a page's own row rhythm; the
// sheet's own toggles, which have the room, are `min-h-12`.
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index c147f25a88..98b0181328 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -3553,10 +3553,22 @@ test.describe("Clinical KB UI smoke coverage", () => {
const queryRibbon = documentWorkspace.getByTestId("search-query-ribbon");
await expect(queryRibbon).toBeVisible();
// One filter surface, two slots: the ribbon's full-width row is suppressed
- // below `sm` and the phone copy sits in the utility row beside Sort. Both
- // are in the DOM, which is why they carry distinct test ids.
+ // below `sm` and the phone copy sits in the utility row. Both are in the
+ // DOM, which is why they carry distinct test ids.
await expect(queryRibbon.getByTestId("document-filter-trigger-wide")).toBeHidden();
- await expect(queryRibbon.getByLabel("Sort results")).toBeVisible();
+ // Sort is `sm`-and-up. Its two segments cost about half the band's one line
+ // on a phone, and relevance — the default, and what `?sort=` still carries
+ // in from a link — is the order a phone reader wants.
+ //
+ // Mounted-and-hidden, asserted as two facts, because `toBeHidden()` alone
+ // cannot tell them apart: it passes for a hidden node AND for a node that
+ // does not exist. So `includeHidden` (plain `getByRole` filters hidden nodes
+ // out and would resolve to nothing) plus a count, then the visibility. A
+ // deleted control fails the count; a control returned to the phone line
+ // fails the hidden check.
+ const phoneSort = queryRibbon.getByRole("group", { name: "Sort results", includeHidden: true });
+ await expect(phoneSort).toHaveCount(1);
+ await expect(phoneSort).toBeHidden();
const mobileFilterTrigger = queryRibbon.getByTestId("document-filter-trigger-phone");
await expect(mobileFilterTrigger).toBeVisible();
await expect(mobileFilterTrigger).toHaveAccessibleName(/Filter/);
@@ -3587,17 +3599,22 @@ test.describe("Clinical KB UI smoke coverage", () => {
.poll(
async () =>
utilityTrack.evaluate((track) => {
- const sort = track.querySelector('[role="group"][aria-label="Sort results"]');
- const clipped = sort ? sort.getBoundingClientRect().right - track.getBoundingClientRect().right : 0;
+ // The last *rendered* child, not a named control. Sort is
+ // `sm`-and-up, so below 640 it is `display:none` and every width
+ // in this sweep would measure a zero-sized node and report a
+ // dutiful 0 — the sweep would go blind while still passing.
+ const rendered = Array.from(track.children).filter((child) => child.getClientRects().length > 0);
+ const last = rendered[rendered.length - 1];
+ const clipped = last ? last.getBoundingClientRect().right - track.getBoundingClientRect().right : 0;
return {
overflow: Math.max(0, track.scrollWidth - track.clientWidth),
- sortClipped: Math.max(0, Math.round(clipped)),
+ controlClipped: Math.max(0, Math.round(clipped)),
masked: track.getAttribute("data-overflowing") === "true",
};
}),
{ message: `results-band utility rail clipped its own controls at ${width}px` },
)
- .toEqual({ overflow: 0, sortClipped: 0, masked: false });
+ .toEqual({ overflow: 0, controlClipped: 0, masked: false });
// Below 414px the wrap is the active mechanism. Track overflow alone is
// blind to wrap failure: if the utilities group is pushed off-screen by
// the band's `overflow-hidden`, both `scrollWidth`/`clientWidth` and
@@ -3749,14 +3766,6 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(phoneFilterPanel).toHaveCount(0);
await expect(documentResults).toBeVisible();
- // Sort is a segmented group of pressed buttons, not a select: the active order
- // is readable without opening anything.
- const documentSort = queryRibbon.getByRole("group", { name: "Sort results" });
- await documentSort.getByRole("button", { name: "A–Z" }).click();
- await expect(page).toHaveURL(/[?&]sort=alpha/);
- await expect(documentSort.getByRole("button", { name: "A–Z" })).toHaveAttribute("aria-pressed", "true");
- await documentSort.getByRole("button", { name: "Relevance" }).click();
-
const openDocumentLink = documentResults
.getByRole("link", { name: /Open Synthetic lithium monitoring protocol/i })
.last();
@@ -3773,6 +3782,18 @@ test.describe("Clinical KB UI smoke coverage", () => {
await page.setViewportSize({ width: 1440, height: 900 });
await expectNoPageHorizontalOverflow(page);
+
+ // Sort is a segmented group of pressed buttons, not a select: the active
+ // order is readable without opening anything. Exercised here rather than at
+ // 390px because the control is `sm`-and-up — the phone assertion above is
+ // that it is hidden; this is the proof it still works where it renders.
+ const documentSort = queryRibbon.getByRole("group", { name: "Sort results" });
+ await expect(documentSort).toBeVisible();
+ await documentSort.getByRole("button", { name: "A–Z" }).click();
+ await expect(page).toHaveURL(/[?&]sort=alpha/);
+ await expect(documentSort.getByRole("button", { name: "A–Z" })).toHaveAttribute("aria-pressed", "true");
+ await documentSort.getByRole("button", { name: "Relevance" }).click();
+
// The same panel, reached from the wide-viewport copy of the trigger.
const wideFilterTrigger = queryRibbon.getByTestId("document-filter-trigger-wide");
await expect(wideFilterTrigger).toBeVisible();
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts
index 23c93c06c4..19f42c55e4 100644
--- a/tests/ui-tools.spec.ts
+++ b/tests/ui-tools.spec.ts
@@ -1647,10 +1647,10 @@ test.describe("Clinical KB tools launcher", () => {
await expect(typeSelect).toHaveValue("diagnosis");
await typeSelect.selectOption("all");
- // Sort is a segmented group and the page filter is a select. They no longer
- // sit paired: Sort is inboard and the page filter renders last, hard against
- // the ribbon's right edge. What still has to hold is that both keep a matched
- // phone tap height, and that the utility rail owns any overflow.
+ // Sort is `sm`-and-up, so on a phone the page filter is the whole utilities
+ // group: it renders last, hard against the ribbon's right edge, and it is
+ // what has to carry the phone tap height. Sort stays mounted but hidden —
+ // asserted, not assumed, so returning it to the phone line fails here.
const utilities = page.getByTestId("search-query-ribbon-utilities");
const pageFilters = page.getByTestId("search-query-ribbon-mobile-controls");
const railMetrics = await utilities.evaluate((element) => ({
@@ -1660,12 +1660,16 @@ test.describe("Clinical KB tools launcher", () => {
element.lastElementChild?.getAttribute("data-testid") === "search-query-ribbon-mobile-controls",
}));
expect(railMetrics.lastChildIsPageFilter).toBe(true);
- const sortHeight = await utilities
- .getByRole("group", { name: "Sort results" })
- .evaluate((element) => element.getBoundingClientRect().height);
+ // Mounted-and-hidden is two facts, and `toBeHidden()` alone cannot separate
+ // them: it passes for a hidden node AND for one that does not exist. Plain
+ // `getByRole` also filters hidden nodes out, so it would resolve to nothing
+ // here and pass even with the control deleted. Count under `includeHidden`,
+ // then visibility.
+ const phoneSort = utilities.getByRole("group", { name: "Sort results", includeHidden: true });
+ await expect(phoneSort).toHaveCount(1);
+ await expect(phoneSort).toBeHidden();
const filterHeight = await pageFilters.evaluate((element) => element.getBoundingClientRect().height);
- expect(Math.abs(sortHeight - filterHeight)).toBeLessThanOrEqual(1);
- expect(Math.min(sortHeight, filterHeight)).toBeGreaterThanOrEqual(43);
+ expect(filterHeight).toBeGreaterThanOrEqual(43);
const emergentBadge = page.getByTestId("differential-status-badge").first();
await expect(emergentBadge).toBeVisible();
@@ -1754,10 +1758,10 @@ test.describe("Clinical KB tools launcher", () => {
await expect(typeSelect).toHaveValue("presentation");
await typeSelect.selectOption("all");
- // Sort is a segmented group and the page filter is a select. They no longer
- // sit paired: Sort is inboard and the page filter renders last, hard against
- // the ribbon's right edge. What still has to hold is that both keep a matched
- // phone tap height, and that the utility rail owns any overflow.
+ // Sort is `sm`-and-up, so on a phone the page filter is the whole utilities
+ // group: it renders last, hard against the ribbon's right edge, and it is
+ // what has to carry the phone tap height. Sort stays mounted but hidden —
+ // asserted, not assumed, so returning it to the phone line fails here.
const utilities = page.getByTestId("search-query-ribbon-utilities");
const pageFilters = page.getByTestId("search-query-ribbon-mobile-controls");
const railMetrics = await utilities.evaluate((element) => ({
@@ -1767,12 +1771,16 @@ test.describe("Clinical KB tools launcher", () => {
element.lastElementChild?.getAttribute("data-testid") === "search-query-ribbon-mobile-controls",
}));
expect(railMetrics.lastChildIsPageFilter).toBe(true);
- const sortHeight = await utilities
- .getByRole("group", { name: "Sort results" })
- .evaluate((element) => element.getBoundingClientRect().height);
+ // Mounted-and-hidden is two facts, and `toBeHidden()` alone cannot separate
+ // them: it passes for a hidden node AND for one that does not exist. Plain
+ // `getByRole` also filters hidden nodes out, so it would resolve to nothing
+ // here and pass even with the control deleted. Count under `includeHidden`,
+ // then visibility.
+ const phoneSort = utilities.getByRole("group", { name: "Sort results", includeHidden: true });
+ await expect(phoneSort).toHaveCount(1);
+ await expect(phoneSort).toBeHidden();
const filterHeight = await pageFilters.evaluate((element) => element.getBoundingClientRect().height);
- expect(Math.abs(sortHeight - filterHeight)).toBeLessThanOrEqual(1);
- expect(Math.min(sortHeight, filterHeight)).toBeGreaterThanOrEqual(43);
+ expect(filterHeight).toBeGreaterThanOrEqual(43);
const emergentBadge = page.getByTestId("differential-status-badge").first();
await expect(emergentBadge).toBeVisible();