From 02672320db5a0b5b57b589e8d19d19c8c66483d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 07:36:50 +0000 Subject: [PATCH 1/2] feat(favourites): search-led workspace, no ModeHome (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ledger #164: /favourites becomes one dashboard + search page. Direction B from the comps — persistent search, sets as chips, Continue + Recent + sets on an empty query, in-place filter on a typed one. Product had already rejected a ModeHome treatment for this route, and none is reintroduced. - Retire the marketing lockup. The heart icon tile, the "Favourites command library" H1 and a sentence explaining the page to someone already standing on it cost roughly 90px of fold and said nothing the nav had not. The heading is now "Favourites" with the item count beside it as plain text, never a heading. - Real in-place filtering. The page reads the shared composer's live draft through useSearchCommand, seeded from the route's submitted ?q= so hard loads still server-render the exact list. Typing now filters the table without navigating and without a second input — the same pattern the tools results page already uses, so the one-composer contract in docs/search-chrome-behaviour.md is untouched and the route keeps the shell's hero composer. - Collapse the duplicated filter. filteredItems and the table's own tableRows were computed independently from identical inputs, so the band's match count and the table's count were two answers to one question. The page now derives the rows once and passes them down. - One chip rail replaces three navigation surfaces: FavouritesSidebar, FavouritesMobileQuickViews and FavouritesMobileBrowseRail. Sets, quick views and types are chips with counts, so browse reads identically at every width instead of three components disagreeing about what it means. Each chip still toggles its own dimension, so a set and a type compose as the sidebar allowed; "All" is the only chip that clears everything. favourites-library-nav had no remaining callers afterwards and is deleted rather than left dead. - Empty query shows Continue, then Recent and Your sets side by side. A typed query demotes that band to a collapsed disclosure and filters the table in place beneath it, with the table header switching to "N matches for …". Continue and Recent are rendered from the existing derivation and no new store was added. Worth stating plainly: lastUsedByItemId and pinnedItemIds are still hard-coded five-entry literals for demo slugs, and real registry items fall back to the string "Saved". Direction B leads with both surfaces, so they want a genuine per-item last-opened timestamp — that is a data-layer change, not a layout one, and inventing it here would have quietly changed the scope of this row. Captured as its own ledger row instead. Test updates land with the change rather than after it: the retired heading was asserted in six places, and two source-text pins needed adjusting for reasons unrelated to their intent — one sliced the mobile card's source to "wherever FavouritesTable happens to be" and so swallowed any component added between them, the other pinned the also-matches query expression when the test is about which surface owns that block. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XYphQZmsBBeqnidpSnAtjE --- .../favourites-command-library-page.tsx | 502 +++++++++++++---- .../favourites-library-nav.tsx | 529 ------------------ .../audit-navigation-auth-regressions.test.ts | 6 +- tests/favourites-auth-gate.dom.test.tsx | 4 +- tests/favourites-demo-boundary.test.ts | 13 +- tests/ui-smoke.spec.ts | 8 +- 6 files changed, 422 insertions(+), 640 deletions(-) delete mode 100644 src/components/clinical-dashboard/favourites-library-nav.tsx diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 517d1f57d2..bb7c10199c 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -5,10 +5,12 @@ import { useRouter } from "next/navigation"; import { ChevronDown, ChevronsRight, + Clock, Copy, ExternalLink, FileText, Folder, + FolderPlus, Heart, MoreVertical, Pill, @@ -20,15 +22,9 @@ import { X, type LucideIcon, } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { - FavouritesMobileBrowseRail, - FavouritesMobileQuickViews, - FavouritesSidebar, - useFavouritesNavCollapsed, - type FavouritesViewMode, -} from "@/components/clinical-dashboard/favourites-library-nav"; +import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { cn, EmptyState, ignoreUnavailableActivation } from "@/components/ui-primitives"; @@ -53,7 +49,9 @@ import { useAuthSession } from "@/lib/supabase/client"; type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form" | "Differential"; -type ViewMode = FavouritesViewMode; +// Previously imported from `favourites-library-nav`, which this redesign +// retired along with the sidebar and the two phone rails it exported. +type ViewMode = "all" | "source-backed" | "pinned" | "recent"; type SortMode = "last-used" | "title" | "type"; type FavouriteItem = { @@ -81,6 +79,15 @@ type FavouriteSet = { const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; +// How many rows the Recent card shows before "View all" takes over. Small on +// purpose: this is a glance surface sitting above the real table, not a second +// copy of it. +const recentPreviewLimit = 3; + +function subscribeNoop() { + return () => {}; +} + const typeAppearance: Record = { Medication: { kind: "information", tone: "accent" }, Document: { kind: "category", tone: "document" }, @@ -601,11 +608,252 @@ function FavouritesEmptyMatches() { ); } +/** + * One horizontal rail replacing three separate navigation surfaces: the + * desktop `FavouritesSidebar`, the phone `FavouritesMobileQuickViews`, and the + * phone `FavouritesMobileBrowseRail`. Direction B (ledger #164) collapses sets, + * quick views and types into a single row of chips, so the same control reads + * identically at every width instead of three components disagreeing about + * what "browse" means. + * + * Each chip toggles its own dimension, so a set and a type still compose the + * way the sidebar allowed. "All" is the only chip that clears every dimension, + * and it reads as pressed exactly when nothing else is. + */ +function FavouritesFilterRail({ + items, + sets, + selectedSetId, + selectedTypeId, + viewMode, + onSelectSet, + onSelectType, + onSelectViewMode, + onClearAll, +}: { + items: FavouriteItem[]; + sets: FavouriteSet[]; + selectedSetId: string | null; + selectedTypeId: string; + viewMode: ViewMode; + onSelectSet: (id: string | null) => void; + onSelectType: (id: string) => void; + onSelectViewMode: (mode: ViewMode) => void; + onClearAll: () => void; +}) { + const typeChips = favouriteTabs + .filter((tab) => tab.id !== "all" && tab.id !== "sets") + .map((tab) => ({ id: tab.id, label: tab.label, count: items.filter((item) => item.tabId === tab.id).length })) + .filter((tab) => tab.count > 0); + const pinnedCount = items.filter((item) => item.pinned === true).length; + const sourceBackedCount = items.filter((item) => isSourceBacked(item)).length; + const nothingActive = selectedSetId === null && selectedTypeId === "all" && viewMode === "all"; + + return ( + + ); +} + +function FilterRailChip({ + label, + count, + pressed, + onClick, +}: { + label: string; + count: number | null; + pressed: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +/** + * The empty-query dashboard band. Both cards read from the same derived data + * the table does — no separate store — so they cannot drift from it. + */ +function FavouritesDashboardBand({ + recentItems, + sets, + onSelectSet, + onShowRecent, +}: { + recentItems: FavouriteItem[]; + sets: FavouriteSet[]; + onSelectSet: (id: string) => void; + onShowRecent: () => void; +}) { + return ( +
+
+
+

+ + Recent +

+ +
+
    + {recentItems.map((item) => ( +
  • + + {item.type} + + + {item.title} + + {item.set} · {item.lastUsed} + + + + Open + +
  • + ))} +
+
+ +
+
+

+ + Your sets +

+
+ {sets.length === 0 ? ( +

+ Saved items group into sets as you add them. +

+ ) : ( +
    + {sets.map((set) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} + function FavouritesTable({ items, + rows: tableRows, searchTerm, - selectedTypeId, - selectedSet, viewMode, sortMode, selectedItemId, @@ -613,26 +861,18 @@ function FavouritesTable({ onSelectItem, }: { items: FavouriteItem[]; + // The filtered/sorted rows are computed once by the page and passed in. + // They used to be derived here as well, from the same inputs, so the band's + // match count and the table's own count were two independent answers to one + // question — the redundant half of the "dual search" ledger #164 names. + rows: FavouriteItem[]; searchTerm: string; - selectedTypeId: string; - selectedSet: FavouriteSet | null; viewMode: ViewMode; sortMode: SortMode; selectedItemId: string | null; onSortModeChange: (value: SortMode) => void; onSelectItem: (id: string) => void; }) { - const tableRows = useMemo(() => { - const rows = filterAndSortItems(items, { - searchTerm, - selectedTypeId, - selectedSet, - viewMode, - sortMode, - }); - return rows; - }, [items, searchTerm, selectedSet, selectedTypeId, viewMode, sortMode]); - // With the item workspace open (only at 2xl), the middle column narrows sharply. // Drop the leading icon and the secondary Evidence column there so titles keep // room instead of collapsing to a couple of characters. @@ -644,11 +884,24 @@ function FavouritesTable({ return (
-

- - {tableRows.length} - {tableRows.length === 1 ? "item" : "items"} - {tableRows.length !== items.length ? ` of ${items.length}` : ""} +

+ {searchTerm.trim() ? ( + <> + + {tableRows.length} + {tableRows.length === 1 ? "match for" : "matches for"} + + “{searchTerm.trim()}” + + + ) : ( + <> + + {tableRows.length} + {tableRows.length === 1 ? "item" : "items"} + {tableRows.length !== items.length ? ` of ${items.length}` : ""} + + )}