Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0b5b92c
feat(ui): implement filter density tiers, mobile route deduplication,…
BigSimmo Aug 17, 2026
83b46bb
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 17, 2026
fdf3937
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 17, 2026
cc73816
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 17, 2026
9dc10e4
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 17, 2026
8bae896
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 17, 2026
2994989
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 17, 2026
c132a10
fix(ui): repair CI blockers on filter density tiers (PR #2074)
BigSimmo Aug 17, 2026
5481d6e
Merge remote-tracking branch 'origin/gemini/ui-filter-density-mobile-…
BigSimmo Aug 17, 2026
3a6c202
fix(ui): keep document-viewer retry panel ids unique under density ti…
BigSimmo Aug 17, 2026
3a8cf12
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 18, 2026
398bfd2
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 18, 2026
b2d7f48
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 18, 2026
0f64169
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 18, 2026
a98c42e
fix(favourites): fix O(N²) storage callbacks and hydration mismatch i…
Copilot Aug 18, 2026
80e7a30
Merge remote-tracking branch 'origin/main' into HEAD
claude Aug 18, 2026
2b1ba0c
style: fix prettier formatting in favourites command library page
claude Aug 18, 2026
09bb336
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 18, 2026
09761d8
fix(ui): keep document rail high-yield summary visible when printing
claude Aug 18, 2026
fb6bf13
fix(test): update high-yield summary nav assertion for mobile dedup
claude Aug 18, 2026
e63ceb0
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 18, 2026
ba9fc8b
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 18, 2026
3c3609e
Merge branch 'main' into gemini/ui-filter-density-mobile-polish
BigSimmo Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions docs/filter-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,17 +116,17 @@ corpus of that size, and it stays.

## 5. Density is a function of option count

Facet groups only. Two states, not three: the shared renderer uses the same threshold for its
find-a-filter field and collapse-by-default disclosures.
Facet groups only. Density scales with option and group volume across three tiers:

| Options | Renderer |
| --------------------------- | ---------------------------------------------------------------------------------------- |
| ≤ 3 groups and ≤ 20 options | chips, single row where they fit (unchanged from before this section) |
| > 3 groups, or > 20 options | chips plus find-a-filter and collapse-by-default, every group behind a disclosure header |
| 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 |

`ResultFilterSheet` computes the threshold once across all facet groups. The option-count limb
catches a small number of very large groups, while the group-count limb covers services' six
facet groups. Below the threshold every group renders as before.
`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.

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
Expand Down
5 changes: 4 additions & 1 deletion src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1404,7 +1404,10 @@ export function DocumentViewer({
a phone reader sees the clinical priorities digest before scrolling
past the PDF. */}
{readyDocument ? (
<div className="min-w-0 max-sm:order-2 lg:col-span-2">
<div
id="source-summary-card"
className="min-w-0 max-sm:order-2 lg:col-span-2 scroll-mt-[var(--document-anchor-offset,6rem)]"
>
<DocumentClinicalSummary
document={readyDocument}
pageHref={usefulPageHref}
Expand Down
13 changes: 7 additions & 6 deletions src/components/clinical-dashboard/answer-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { useClientTime } from "@/lib/use-client-time";
import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips";
import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences";
import { ModeHomeTemplate } from "@/components/mode-home-template";
import { ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template";
import { cn, floatingControl, sourceCard } from "@/components/ui-primitives";
import { appModeIcons } from "@/lib/app-mode-icons";
import type { AppModeId } from "@/lib/app-modes";
Expand Down Expand Up @@ -94,11 +94,12 @@ export function SharedHomeEmptyState({
icon={History}
/>
)}
{/* No privacy link here: the composer's PrivacyInputNotice is the
single site-wide notice, so the hero footer must not repeat it. */}
{/* Pre-query copy must describe what the search does, not assert that
every indexed source is verified/current (PT-06): validation status
varies per document and is surfaced on the results themselves. */}
{modeId === "answer" ? (
<ModeHomeVerificationFooter
label="Clinical decision support"
body="Verify against cited sources before clinical use"
/>
) : null}
</div>
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ import {
type FavouriteItem as PrototypeFavouriteItem,
} from "@/components/clinical-dashboard/favourites-prototype-data";
import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites";
import {
formatLastOpened,
lastOpenedScore,
loadFavouriteLastOpened,
loadFavouritePinnedIds,
recordFavouriteOpened,
subscribeFavouritesStorage,
} from "@/components/favourites/favourites-storage";
import {
SearchResultsEmptyState,
SearchResultsHeaderBand,
Expand Down Expand Up @@ -112,8 +120,6 @@ const lastUsedByItemId: Record<string, string> = {
"qt-prolongation-quote": "Mon 11:03",
};

const pinnedItemIds = new Set(["acamprosate-renal-screen", "lithium-monitoring-guideline"]);

const typeByPrototypeType: Record<PrototypeFavouriteItem["type"], FavouriteType> = {
medications: "Medication",
documents: "Document",
Expand Down Expand Up @@ -186,7 +192,11 @@ async function copyFavouriteCitation(item: FavouriteItem): Promise<boolean> {
}
}

function toCommandItem(item: PrototypeFavouriteItem): FavouriteItem {
function toCommandItem(
item: PrototypeFavouriteItem,
lastOpenedMap: Record<string, number>,
pinnedIds: ReadonlySet<string>,
): FavouriteItem {
const type =
item.type === "sources" && item.primaryAction === "Run"
? "Saved search"
Expand All @@ -199,11 +209,14 @@ function toCommandItem(item: PrototypeFavouriteItem): FavouriteItem {
tabId: item.type,
set: item.set || (item.type === "services" ? "Saved services" : item.type === "forms" ? "Saved forms" : "Unsorted"),
evidence: item.sourceMeta,
lastUsed: lastUsedByItemId[item.id] ?? "Saved",
lastUsed:
lastOpenedMap[item.id] !== undefined
? formatLastOpened(lastOpenedMap[item.id])
: (lastUsedByItemId[item.id] ?? "Saved"),
action: item.primaryAction,
href: item.href,
icon: item.icon ?? fallbackIconByType[item.type],
pinned: pinnedItemIds.has(item.id),
pinned: pinnedIds.has(item.id),
};
}

Expand Down Expand Up @@ -1106,9 +1119,18 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?:
status: favouritesHookStatus,
refetch: refetchFavouritesRegistry,
} = useSavedRegistryFavourites();
const lastOpenedMap = useSyncExternalStore(
subscribeFavouritesStorage,
loadFavouriteLastOpened,
() => ({}) as Record<string, number>,
);
const pinnedIds = useSyncExternalStore(subscribeFavouritesStorage, loadFavouritePinnedIds, () => new Set<string>());
const items = useMemo(
() => [...(demoMode ? prototypeFavouriteItems : []), ...savedRegistryFavourites].map(toCommandItem),
[demoMode, savedRegistryFavourites],
() =>
[...(demoMode ? prototypeFavouriteItems : []), ...savedRegistryFavourites].map((item) =>
toCommandItem(item, lastOpenedMap, pinnedIds),
),
[demoMode, savedRegistryFavourites, lastOpenedMap, pinnedIds],
);
// Demo prototypes live outside the hook. If they are the only items while a
// registry/account read failed, keep their honest nonzero count but mark it
Expand Down Expand Up @@ -1162,7 +1184,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?:
const recentItems = useMemo(
() =>
[...items]
.sort((first, second) => lastUsedScore(second.lastUsed) - lastUsedScore(first.lastUsed))
.sort((first, second) => lastOpenedScore(second.lastUsed) - lastOpenedScore(first.lastUsed))
.slice(0, recentPreviewLimit),
[items],
);
Expand Down Expand Up @@ -1517,7 +1539,10 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?:
sortMode={sortMode}
selectedItemId={selectedItemId}
onSortModeChange={setSortMode}
onSelectItem={setSelectedItemId}
onSelectItem={(id) => {
if (id) recordFavouriteOpened(id);
setSelectedItemId(id);
}}
/>
)}

Expand Down Expand Up @@ -1548,6 +1573,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?:
</span>
<Link
href={item.href}
onClick={() => recordFavouriteOpened(item.id)}
aria-label={`Open ${item.title}`}
className={cn(
"inline-flex min-h-tap shrink-0 items-center rounded-lg border border-[color:var(--border)] px-2.5 text-xs font-bold text-[color:var(--text)] hover:bg-[color:var(--surface-subtle)] sm:min-h-9",
Expand Down
57 changes: 39 additions & 18 deletions src/components/clinical-dashboard/result-filter-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,12 @@ export function ResultFilterFacetChips({
const panelId = idPrefix;
const groupLabelId = `${panelId}-${group.id}-label`;
const visibleOptions = options ?? group.options;
const renderOptions = (items: ReadonlyArray<ResultFilterOption<string>>) =>
const isDenseList =
!group.optionSections &&
((group.options.length >= 6 && group.options.length <= 20) ||
(visibleOptions.length >= 6 && visibleOptions.length <= 20));

const renderOptions = (items: ReadonlyArray<ResultFilterOption<string>>, isDense: boolean = isDenseList) =>
items.map((option) => {
const selected = group.selected.has(option.value);
const deadEnd = Boolean(option.disabled) && !selected;
Expand All @@ -497,7 +502,9 @@ export function ResultFilterFacetChips({
group.onToggle(option.value);
}}
className={cn(
"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",
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"
: "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
? "border-[color:var(--clinical-accent)]/35 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
Expand All @@ -506,19 +513,25 @@ export function ResultFilterFacetChips({
: "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)]",
)}
>
<span
aria-hidden
className={cn(
"grid size-icon-sm shrink-0 place-items-center rounded-xs border transition-colors",
selected
? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--surface)]"
: "border-[color:var(--border-strong)] bg-[color:var(--surface)]",
)}
>
{selected ? <Check aria-hidden="true" className="h-2.5 w-2.5" strokeWidth={3.5} /> : null}
</span>
<span className="truncate">{option.label}</span>
{option.hint ? <span className="nums text-[color:var(--text-muted)]">{option.hint}</span> : null}
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden
className={cn(
"grid size-icon-sm shrink-0 place-items-center rounded-xs border transition-colors",
selected
? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--surface)]"
: "border-[color:var(--border-strong)] bg-[color:var(--surface)]",
)}
>
{selected ? <Check aria-hidden="true" className="h-2.5 w-2.5" strokeWidth={3.5} /> : null}
</span>
<span className="truncate">{option.label}</span>
</div>
{option.hint ? (
<span className="nums shrink-0 text-right text-xs font-bold tabular-nums text-[color:var(--text-muted)]">
{option.hint}
</span>
) : null}
{deadEnd ? (
<span id={deadEndDescId} className="sr-only">
No matches with your current filters.
Expand Down Expand Up @@ -583,12 +596,18 @@ export function ResultFilterFacetChips({
hidden={disclosure ? !disclosure.open : false}
role="group"
aria-labelledby={groupLabelId}
className={cn("pb-2.5", group.optionSections ? "grid gap-3" : "flex flex-wrap gap-2 sm:gap-1.5")}
className={cn(
"pb-2.5",
group.optionSections ? "grid gap-3" : isDenseList ? "grid gap-1" : "flex flex-wrap gap-2 sm:gap-1.5",
)}
>
{group.optionSections
? group.optionSections.map((section) => {
const sectionOptions = visibleOptions.filter((option) => section.optionValues.includes(option.value));
if (sectionOptions.length === 0) return null;
const sectionDense =
(section.optionValues.length >= 6 && section.optionValues.length <= 20) ||
(sectionOptions.length >= 6 && sectionOptions.length <= 20);
return (
<section key={section.id} className="grid gap-1.5 rounded-lg bg-[color:var(--surface-subtle)] p-2.5">
<div className="min-w-0">
Expand All @@ -599,11 +618,13 @@ export function ResultFilterFacetChips({
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2 sm:gap-1.5">{renderOptions(sectionOptions)}</div>
<div className={cn(sectionDense ? "grid gap-1" : "flex flex-wrap gap-2 sm:gap-1.5")}>
{renderOptions(sectionOptions, sectionDense)}
</div>
</section>
);
})
: renderOptions(visibleOptions)}
: renderOptions(visibleOptions, isDenseList)}
</div>
</section>
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/document-viewer/document-rail-panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export function DocumentViewerRail({
data-testid="high-yield-summary"
className={cn(
panel,
"group min-w-0 scroll-mt-[var(--document-anchor-offset,6rem)] source-print md:col-span-2 lg:col-span-1",
"group min-w-0 max-sm:hidden print:block scroll-mt-[var(--document-anchor-offset,6rem)] source-print md:col-span-2 lg:col-span-1",
)}
>
<DocumentSectionSummary
Expand Down
4 changes: 4 additions & 0 deletions src/components/document-viewer/use-section-spy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import type { DocumentSection } from "@/components/document-viewer/section-index
*/
const sectionAnchorAliases: Record<string, string[]> = {
"source-evidence": ["source-evidence-rail"],
// The rail's document-profile disclosure owns the canonical anchor (the spy
// treats it as an exclusive-accordion member), while the in-flow clinical
// summary card above the PDF is its phone/tablet copy.
"source-summary": ["source-summary-card"],
};

/**
Expand Down
Loading
Loading