diff --git a/docs/filter-contract.md b/docs/filter-contract.md
index ec26f06e2b..cfcf20d012 100644
--- a/docs/filter-contract.md
+++ b/docs/filter-contract.md
@@ -118,16 +118,30 @@ corpus of that size, and it stays.
Facet groups only. Density scales with option and group volume across three tiers:
-| Options / Groups | Renderer |
-| --------------------------- | -------------------------------------------------------------------------------------- |
-| ≤ 5 options | chips, single row / wrapping chips |
-| 6–20 options | dense full-width vertical list with right-aligned count column and group headings |
-| > 3 groups, or > 20 options | list/chips plus find-a-filter and collapse-by-default, every group behind a disclosure |
+| Options / Groups | Renderer |
+| ------------------------------- | -------------------------------------------------------------------------------------- |
+| ≤ 5 options, no counts | chips, single row / wrapping chips |
+| 2–5 options **carrying counts** | the row renderer below, in a two-column grid |
+| 6–20 options | dense full-width vertical list with right-aligned count column and group headings |
+| > 3 groups, or > 20 options | list/chips plus find-a-filter and collapse-by-default, every group behind a disclosure |
`ResultFilterSheet` computes the threshold across facet groups. Facet groups containing 6–20 options
render as compact full-width rows with a right-aligned count column for fast scanning. When a sheet
exceeds 3 groups or 20 total options, it additionally adds find-a-filter and collapse-by-default chrome.
+**A counted chip is not a chip.** A two-to-five-option group whose options carry counts uses the same
+row renderer as the 6–20 tier, laid out in two columns. A chip carrying a count is wide enough that
+four of them wrap one per line and leave most of each row empty — documents' Source status (4) and
+Clinical validation (3) were exactly that, a ragged single column down a phone sheet. Two columns
+halve the height and align the counts. A group with no counts keeps the wrapping chip row, which is
+still the right renderer for short bare labels.
+
+**`hint` is announced, `hintLabel` is displayed.** `hint` carries the unit (`"1 loaded source"`) and
+is what the option's accessible name is built from; `hintLabel` is the short visible form (`"1"`).
+Set both when a count has a unit — spelling the unit into every visible option is what made the
+counted rows too wide to sit two-up in the first place. `hintLabel` alone is never enough: the
+announced name must keep the unit.
+
Collapse rules, when they apply: groups start collapsed; a group holding a selection opens
itself; an explicit user collapse beats that; an active needle forces every matched group open
and owns openness. A selected option always survives the needle, so an active constraint can
diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx
index 823ccfe481..74382da931 100644
--- a/src/components/clinical-dashboard/document-search-results.tsx
+++ b/src/components/clinical-dashboard/document-search-results.tsx
@@ -220,6 +220,14 @@ function loadedSourceCountHint(count: number) {
return `${count.toLocaleString()} loaded ${count === 1 ? "source" : "sources"}`;
}
+// What the option DISPLAYS. The announced name keeps the full phrase above, so
+// a reader still hears the unit; the visible column is a bare numeral. Spelling
+// "0 loaded sources" seven times down a phone sheet made every option wide
+// enough to wrap onto its own line and repeated the same two words in each one.
+function loadedSourceCountLabel(count: number) {
+ return count.toLocaleString();
+}
+
function relevanceTone(document: DocumentMatch) {
const verdict = document.relevance?.verdict as string | undefined;
const percent = documentRelevancePercent(document);
@@ -462,10 +470,10 @@ function DocumentResultMoreMenu({
}}
className={cn(
documentActionClass,
- "min-h-12 w-full min-w-0 rounded-br-xl px-2 !text-sm font-bold text-[color:var(--text-heading)]",
+ "min-h-12 w-full min-w-0 rounded-br-xl px-2 !text-sm !font-semibold text-[color:var(--text)]",
)}
>
-
+
More
{open && menuPosition
@@ -1165,6 +1173,7 @@ function DocumentSearchResultsPanelImpl({
label: documentDisplayTitle(document),
searchText: `${document.title} ${document.file_name}`,
hint: loadedSourceCountHint(count),
+ hintLabel: loadedSourceCountLabel(count),
disabled: count === 0 && !draftSelectedDocumentIds.has(document.id),
};
}),
@@ -1200,13 +1209,16 @@ function DocumentSearchResultsPanelImpl({
},
]
: [];
- for (const group of governanceGroups) {
+ for (const [groupIndex, group] of governanceGroups.entries()) {
const selected = new Set((activeDraft.scopeFilters[group.key] as string[] | undefined) ?? []);
documentFilterGroups.push(
resultFilterFacetGroup({
id: group.key,
label: group.label,
- description: "Source governance is applied before document retrieval.",
+ // The three governance groups are adjacent and share one rule, so the
+ // rule is stated once above the first of them. Repeating it verbatim
+ // under each heading spent three lines saying the same thing.
+ description: groupIndex === 0 ? "Source governance is applied before document retrieval." : undefined,
selected,
options: group.values.map((value) => {
const count = projectedDocumentScopeCount({
@@ -1220,6 +1232,7 @@ function DocumentSearchResultsPanelImpl({
value,
label: group.labels[value] ?? value,
hint: loadedSourceCountHint(count),
+ hintLabel: loadedSourceCountLabel(count),
disabled: loadedSourceCountsAreComplete && count === 0 && !selected.has(value),
};
}),
@@ -1229,6 +1242,11 @@ function DocumentSearchResultsPanelImpl({
}
if (filterPanelOpen) {
+ const anyLocalityCount = filterDocumentsByRetrievalScope(
+ draftSourceDocuments,
+ { ...activeDraft.scopeFilters, locality: undefined },
+ draftSelectedDocumentIds,
+ ).length;
documentFilterGroups.push(
resultFilterGroup({
id: "locality",
@@ -1239,13 +1257,8 @@ function DocumentSearchResultsPanelImpl({
{
value: "all",
label: "Any locality",
- hint: loadedSourceCountHint(
- filterDocumentsByRetrievalScope(
- draftSourceDocuments,
- { ...activeDraft.scopeFilters, locality: undefined },
- draftSelectedDocumentIds,
- ).length,
- ),
+ hint: loadedSourceCountHint(anyLocalityCount),
+ hintLabel: loadedSourceCountLabel(anyLocalityCount),
},
...(["local", "non_local"] as const).map((value) => {
const count = filterDocumentsByRetrievalScope(
@@ -1257,6 +1270,7 @@ function DocumentSearchResultsPanelImpl({
value,
label: value === "local" ? "Local" : "Non-local",
hint: loadedSourceCountHint(count),
+ hintLabel: loadedSourceCountLabel(count),
disabled: loadedSourceCountsAreComplete && count === 0 && activeDraft.scopeFilters.locality !== value,
};
}),
@@ -1295,6 +1309,7 @@ function DocumentSearchResultsPanelImpl({
value,
label: value,
hint: loadedSourceCountHint(count),
+ hintLabel: loadedSourceCountLabel(count),
disabled: loadedSourceCountsAreComplete && count === 0 && !selected.has(value),
};
}),
@@ -1367,7 +1382,7 @@ function DocumentSearchResultsPanelImpl({
panelId={filterPanelId}
testId="document-filter-panel"
title="Filter documents"
- description="Set retrieval scope and refine the matches already returned. Source-scope counts cover loaded sources; changes run together across the full indexed library."
+ description="Set retrieval scope, then refine the matches. Changes apply together."
chromeResetKey={query}
groups={documentFilterGroups}
applicationMode="staged"
@@ -1575,7 +1590,10 @@ function DocumentSearchResultsPanelImpl({
{renderedMatches.map((document, index) => {
const relevanceDisplay = relevanceTone(document);
- const relevanceVariant = relevanceDisplay.short === "High relevance" ? "high" : "relevant";
+ // One accent per card. `high` is accent TEXT on the raised
+ // surface (no fill), so a top hit showing both "Best match"
+ // and "High relevance" still has a single filled accent.
+ const relevanceVariant = relevanceDisplay.short === "High relevance" ? "high" : "neutral";
const openHref = documentOpenHref(document);
return (
@@ -1594,14 +1612,18 @@ function DocumentSearchResultsPanelImpl({
{index + 1}
Result {index + 1}: {documentDisplayTitle(document)}
@@ -1609,17 +1631,14 @@ function DocumentSearchResultsPanelImpl({
{index === 0 ? (
-
+
Best match
) : null}
{relevanceDisplay.short}
, {relevanceDisplay.detail}
@@ -1627,24 +1646,24 @@ function DocumentSearchResultsPanelImpl({
{documentPageLabel(document)}
{document.tableCount > 0 ? (
{document.tableCount} table{document.tableCount === 1 ? "" : "s"}
) : null}
{document.imageCount > 0 ? (
{document.imageCount} image{document.imageCount === 1 ? "" : "s"}
@@ -1667,7 +1686,7 @@ function DocumentSearchResultsPanelImpl({
Open
@@ -1675,7 +1694,7 @@ function DocumentSearchResultsPanelImpl({
onAnswerFromDocument(document.document_id)}
icon={MessageSquareText}
- className="min-h-12 min-w-0 px-2 !text-sm font-bold text-[color:var(--text-heading)] [&_svg]:h-5 [&_svg]:w-5"
+ className="min-h-12 min-w-0 px-2 !text-sm !font-semibold text-[color:var(--text)]"
aria-label={`Ask about ${document.title}`}
>
Ask
diff --git a/src/components/clinical-dashboard/result-filter-control.tsx b/src/components/clinical-dashboard/result-filter-control.tsx
index e9ede86aeb..8847a08fcd 100644
--- a/src/components/clinical-dashboard/result-filter-control.tsx
+++ b/src/components/clinical-dashboard/result-filter-control.tsx
@@ -39,6 +39,12 @@ export type ResultFilterOption = {
label: string;
/** Trailing detail — a count, a qualifier. Never the only thing distinguishing two options. */
hint?: string;
+ /** Short display form of `hint`. The announced name keeps `hint`'s units
+ ("1 loaded source"); the visible column shows only `hintLabel` ("1"). A
+ count phrased in full is wide enough that four options wrap one per line,
+ which is what pushed documents' governance groups into a ragged column.
+ Omit to display `hint` verbatim. */
+ hintLabel?: string;
/** Canonical text used to find an abbreviated display label. */
searchText?: string;
/** Renders as a dead end: focusable and explained, but not selectable. */
@@ -340,7 +346,7 @@ function FilterRadioGroup({ group, panelId }: { group: ResultFilterLensGroup; pa
return (
-
+
{/* The id is on the label text alone, not the heading — same fix as
ResultFilterFacetChips' own badge: with the id on the h3 itself, a
sibling `note` would concatenate into the group's accessible name
@@ -416,7 +422,9 @@ function FilterRadioGroup({ group, panelId }: { group: ResultFilterLensGroup; pa
>
{selected ? : null}
{option.label}
- {option.hint ? {option.hint} : null}
+ {option.hint ? (
+ {option.hintLabel ?? option.hint}
+ ) : null}
{deadEnd ? (
Not selectable from here.
@@ -483,8 +491,22 @@ export function ResultFilterFacetChips({
!group.optionSections &&
((group.options.length >= 6 && group.options.length <= 20) ||
(visibleOptions.length >= 6 && visibleOptions.length <= 20));
+ // Counted groups of two to five options sit below the six-option dense
+ // threshold, so they used to render as wrapping chips. A chip carrying a
+ // count is wide enough that four of them wrap one per line and leave a ragged
+ // right edge with most of the row empty — documents' Source status and
+ // Clinical validation are exactly that shape. The dense row renderer in two
+ // columns packs the same options into half the height with an aligned count
+ // column. A group with no counts keeps the chip row, which is still the right
+ // renderer for short bare labels.
+ const isCountedCompact =
+ !group.optionSections &&
+ visibleOptions.length >= 2 &&
+ visibleOptions.length <= 5 &&
+ visibleOptions.some((option) => Boolean(option.hint));
+ const usesRowRenderer = isDenseList || isCountedCompact;
- const renderOptions = (items: ReadonlyArray>, isDense: boolean = isDenseList) =>
+ const renderOptions = (items: ReadonlyArray>, isDense: boolean = usesRowRenderer) =>
items.map((option) => {
const selected = group.selected.has(option.value);
const deadEnd = Boolean(option.disabled) && !selected;
@@ -503,7 +525,7 @@ export function ResultFilterFacetChips({
}}
className={cn(
isDense
- ? "flex min-h-tap w-full min-w-0 items-center justify-between gap-2.5 rounded-lg border px-3 py-2 text-left text-xs font-semibold shadow-[var(--shadow-inset)] transition motion-reduce:transition-none sm:min-h-9 sm:py-1.5"
+ ? "flex min-h-tap w-full min-w-0 items-center justify-between gap-2.5 rounded-lg border px-3 py-2 text-left text-xs font-semibold shadow-[var(--shadow-inset)] transition motion-reduce:transition-none sm:min-h-10 sm:py-1.5"
: "inline-flex min-h-tap max-w-full items-center gap-1.5 rounded-md border px-2.5 text-2xs font-semibold shadow-[var(--shadow-inset)] transition motion-reduce:transition-none sm:min-h-10 sm:gap-1 sm:px-2",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
selected
@@ -528,8 +550,8 @@ export function ResultFilterFacetChips({
{option.label}