From 99133a9ea5a63628c114cb68570825301be8a303 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:14:59 +0000 Subject: [PATCH 1/9] feat(search): rebuild the results band as a single-line spine The shared results band spent its space on labels rather than answers, and below `sm` one of its two controls was cut mid-word ("Current search" -> "Current searcl"). Root cause of the clipping and of the controls out-shouting the query: the unlayered iOS anti-zoom rule in globals.css pins every input/select/textarea to 16px below 640px so Safari does not zoom on focus. It deliberately beats Tailwind's text-* utilities, so `text-xs` never applied to those selects -- 16px extra-bold values rendered in boxes sized for 12px text. That rule is correct and stays; hierarchy is restored around it instead. - Drop the QUERY / RESULTS FOR eyebrow; query, divider and count share one row. Phone height ~148px -> 120px, desktop min-height 4.5rem -> 3.75rem. - Count becomes neutral text with a tabular-nums figure instead of a success pill, so green keeps meaning a state that was achieved. The role="status" live announcement is unchanged. - Sort becomes a segmented aria-pressed group rather than a two-option select, removing a menu you had to open to learn nothing. - Active scopes render as accent chips at the head of the utility group, with a desktop-only spacer keeping them near the query while controls stay at the trailing edge. - The utility group is a swipe rail below `lg` instead of a second tinted band; children keep natural width and the right-edge fade applies only while it actually overflows, measured by a ResizeObserver. - Query steps to 18px and select values to semibold with `truncate`, so the 16px-floored controls read quieter and ellipse instead of clipping. Records the band's rules, including the 16px floor, in docs/search-chrome-behaviour.md so the next change does not fight it with !important. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- docs/search-chrome-behaviour.md | 33 ++++ .../search-results-header-band.tsx | 180 +++++++++++------- tests/search-results-header-band.dom.test.tsx | 37 +++- tests/ui-tools.spec.ts | 16 +- 4 files changed, 190 insertions(+), 76 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 6310fb782b..6775516f63 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -37,6 +37,39 @@ This repo uses one shared search experience across the global shell, dashboard r 20. Browser-mode phones keep `.phone-viewport-shell` in normal flow and use the document as the vertical scroll owner. This is required for Safari to minimize its own browser chrome; do not restore a fixed/inset root or a phone `overflow-y: auto` canvas. 21. Installed standalone phones use the same normal-flow root with the final `display-mode: standalone` `100vh` bound and an internal `.phone-scroll-surface`. Keep that override after the browser contract; do not substitute `svh`, `dvh`, `visualViewport.height`, or a fixed root on this WebKit workaround path. Every phone footer uses `.phone-footer-layer`: fixed to the viewport in browser tabs and absolute to the positioned 100vh frame in standalone, so the composer and its backdrop share the repaired PWA edge. Page-owned footer layers must render through `PhoneFooterLayerPortal`; `PhoneFooterLayerFrame` provides a frame-scoped, paint-free host after the scroll surface. An absolute footer left inside `.phone-scroll-surface` still scrolls and clips with that surface. +## Results band (`SearchResultsHeaderBand`) + +The band above every result list is not a composer and owns no dock reserve, but it is shared +chrome and changes to it land on every mode at once. Keep these rules: + +1. **The query is the only heading-weight thing in the band.** It renders at `text-lg` + `font-extrabold` with no eyebrow — the magnifier tile already says "search", and a `QUERY` / + `RESULTS FOR` label costs a line to repeat it. The query truncates; the count does not. +2. **The count is neutral text, not a success pill.** `text-muted` with the figure itself + `font-extrabold tabular-nums`. Success colour is reserved for states that were actually + achieved, so it still carries meaning where it appears. The `role="status"` / + `aria-live="polite"` announcement stays either way. +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. +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 + override — a sub-16px control zooms the viewport on focus in Safari. Any control that must + read quieter than the query steps down in **weight and colour**, never in size, and any + select carrying variable-length values must set `truncate` or it clips mid-word rather than + ellipsing (the "Current search" → "Current searcl" defect fixed 2026-07-27). +5. **The utility group is a swipe rail below `lg`, an inline row at `lg+`.** Children are + `shrink-0` so they keep their natural width; overflow scrolls instead of wrapping into a + second tinted band. The right-edge fade is applied via `data-overflowing` only while the rail + actually overflows — never as a permanent mask. +6. **Active scopes render as removable chips at the head of that group**, in accent tone, so a + constraint on the list is one tap from where it is read. Do not move them into a separate + strip; `hasUtilities` already suppresses the whole group when nothing is active. + +Coverage: `tests/search-results-header-band.dom.test.tsx` (structure, sort wiring, count tone), +`tests/ui-tools.spec.ts` (phone control pair geometry and tap heights). + ## Scroll hide/reveal The universal **top bar** (mode, new chat, menu) is the only sticky desktop chrome: it hides on a deliberate scroll down and returns on a deliberate scroll up at **every** breakpoint. Tablet search stays pinned below it. Desktop search is mounted at the top of normal page content, so it scrolls away with that content and is independent of the header's hide state. Only the phone bottom search dock scroll-hides, and that stays phone-only. The top bar and phone dock read one `useScrollHideReporter` per host, so they can never disagree about direction. diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index 272d19a7f9..cea35b5c64 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -1,7 +1,7 @@ "use client"; -import { Bookmark, CheckCircle2, ChevronsUpDown, LayoutList, LoaderCircle, Search, Table2, X } from "lucide-react"; -import type { ReactNode } from "react"; +import { Bookmark, ChevronsUpDown, LayoutList, LoaderCircle, Search, Table2, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { searchCommandSurfaceConfig } from "@/lib/search-command-surface"; import { cn } from "@/components/ui-primitives"; @@ -12,6 +12,40 @@ import { readResultSort, type ResultSortValue } from "@/lib/result-sort"; const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; +/** 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. */ +const sortOptions: ReadonlyArray<{ value: ResultSortValue; label: string }> = [ + { value: "relevance", label: "Relevance" }, + { value: "alpha", label: "A–Z" }, +]; + +/** Below `lg` the utility group is a swipe rail rather than a wrapping block, so a + sixth control lands off the right edge instead of growing the band. Fade that + edge only while it actually overflows — a permanent mask would dim the last + control on the common case where everything fits. */ +function useRailOverflow() { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + const measure = useCallback(() => { + const node = ref.current; + if (!node) return; + setOverflowing(node.scrollWidth - node.clientWidth > 1); + }, []); + + useEffect(() => { + const node = ref.current; + if (!node || typeof ResizeObserver === "undefined") return; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(node); + for (const child of Array.from(node.children)) observer.observe(child); + return () => observer.disconnect(); + }, [measure]); + + return { ref, overflowing } as const; +} + export function SearchResultsHeaderBand({ modeId, query, @@ -57,11 +91,11 @@ export function SearchResultsHeaderBand({ return scope ? [scope] : []; }); const displayQuery = query.trim() || "All"; - const statusLabel = loading ? "Searching…" : `${matchCount} ${matchCount === 1 ? "match" : "matches"}`; const hasUtilities = visibleScopes.length > 0 || Boolean(onSortChange || onViewChange || onSaveSearch || utilityControls || mobileControls); const QueryHeading = headingLevel === 1 ? "h1" : "h2"; + const { ref: railRef, overflowing: railOverflowing } = useRailOverflow(); return (
-
-
+
+
- - - Query - {loading ? "Searching for" : "Results for"} - - - {displayQuery} - - + {/* No eyebrow: the icon already says "search", and the query is the only + thing in this band set at heading weight. */} + + {displayQuery} + + + {/* Neutral, not a success pill: a count is not a state that was achieved, + and green has to keep meaning something where it does appear. */} {loading ? ( - + + + Searching… + ) : ( - + <> + {matchCount}{" "} + {matchCount === 1 ? "match" : "matches"} + )} - {statusLabel}
{hasUtilities ? (
+ {/* Scope reads as a removable chip beside the query — it is a constraint on + the list, the same kind of thing the query is. */} {visibleScopes.map((scope) => ( ))} + {/* Desktop only: pushes the controls to the trailing edge while the chips + stay next to the query. On the phone rail this collapses away. */} + {onSortChange && mobileControls ? (
- +
{mobileControls}
) : ( <> - {onSortChange ? ( - - ) : null} + {onSortChange ? : null} {mobileControls ? (
{mobileControls}
@@ -177,7 +212,7 @@ export function SearchResultsHeaderBand({ {utilityControls} {onViewChange ? (
@@ -218,7 +253,7 @@ export function SearchResultsHeaderBand({ type="button" onClick={onSaveSearch} className={cn( - "inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 text-xs font-extrabold text-[color:var(--text-muted)] shadow-[var(--shadow-inset)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] sm:min-h-10", + "inline-flex min-h-tap shrink-0 items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 text-xs font-extrabold text-[color:var(--text-muted)] shadow-[var(--shadow-inset)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] sm:min-h-10", focusRing, )} > @@ -250,39 +285,42 @@ export function ResultSortControl({ value, onChange, className, - compact = false, }: { value: ResultSortValue; onChange: (value: ResultSortValue) => void; className?: string; - /** Hide the visual "Sort" label on narrow viewports; the select keeps its accessible name. */ - compact?: boolean; }) { return ( - + {sortOptions.map((option, index) => { + const selected = option.value === value; + return ( + + ); + })} +
); } @@ -312,12 +350,18 @@ export function MobileResultFilterControl({ )} > {label} + {/* Two things keep this readable. `truncate` ends a long option ("Current + search", a service name) in an ellipsis instead of the mid-word cut it used + to get. And the weight steps down to semibold because the size cannot: the + unlayered iOS anti-zoom rule in globals.css pins every native select to 16px + below `sm`, so weight and colour are the only hierarchy left against the + 18px query heading. */}