diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index ca207e0c1..995ac23c4 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -71,13 +71,18 @@ const sidebarToolItems = [ { id: "answer", label: "Answer", icon: Sparkles, href: "/?mode=answer" }, { id: "documents", label: "Documents", icon: FileText, href: "/?mode=documents" }, { id: "services", label: "Services", icon: appModeIcons.services, href: "/services" }, - { id: "forms", label: "Forms", icon: ClipboardPen, href: "/forms" }, + // badge = catalogue-maturity pill: the Forms registry is a small starter set. + { id: "forms", label: "Forms", icon: ClipboardPen, href: "/forms", badge: "Early access" }, { id: "favourites", label: "Favourites", icon: Heart, href: "/favourites" }, { id: "differentials", label: "Differentials", icon: BrainCircuit, href: "/differentials" }, { id: "prescribing", label: "Medications", icon: Pill, href: "/?mode=prescribing" }, { id: "tools", label: "Tools", icon: Wrench, href: "/?mode=tools" }, ] as const; +function sidebarItemBadge(item: (typeof sidebarToolItems)[number]): string | undefined { + return "badge" in item ? item.badge : undefined; +} + // Display-free base so callers can compose `grid` / `hidden lg:grid` without // conflicting display utilities (cn does not de-duplicate classes). const collapsedSidebarControl = @@ -251,6 +256,11 @@ export function ClinicalSidebarContent({ )} /> {item.label} + {sidebarItemBadge(item) ? ( + + {sidebarItemBadge(item)} + + ) : null} ); })} @@ -420,8 +430,8 @@ function ClinicalCollapsedRail({ onFocus={item.id === "tools" ? onPrefetchApplications : undefined} onPointerEnter={item.id === "tools" ? onPrefetchApplications : undefined} className={cn(collapsedSidebarButton, active && collapsedSidebarActiveButton)} - aria-label={item.label} - title={item.label} + aria-label={sidebarItemBadge(item) ? `${item.label} (${sidebarItemBadge(item)})` : item.label} + title={sidebarItemBadge(item) ? `${item.label} (${sidebarItemBadge(item)})` : item.label} aria-current={active ? "page" : undefined} > diff --git a/src/components/clinical-dashboard/use-sidebar-collapsed.ts b/src/components/clinical-dashboard/use-sidebar-collapsed.ts index daf22d938..574a82c31 100644 --- a/src/components/clinical-dashboard/use-sidebar-collapsed.ts +++ b/src/components/clinical-dashboard/use-sidebar-collapsed.ts @@ -5,17 +5,28 @@ import { useCallback, useSyncExternalStore } from "react"; const storageKey = "clinical-kb-sidebar-collapsed"; const changeEvent = "clinical-kb-sidebar-collapsed-change"; +// In-memory fallback when localStorage writes fail (e.g. private browsing mode). +// Null means no fallback needed; storage is the source of truth. +let inMemoryFallback: boolean | null = null; + function getSnapshot() { + // When storage writes have failed, the in-memory fallback takes precedence + // so the UI reflects the user's toggle even when persistence is unavailable. + if (inMemoryFallback !== null) { + return inMemoryFallback; + } try { const storedValue = window.localStorage.getItem(storageKey); - return storedValue === null ? true : storedValue === "1"; + // New users get the labelled (expanded) sidebar: eight icon-only + // destinations demand recall/hover; collapsing stays a remembered choice. + return storedValue === null ? false : storedValue === "1"; } catch { - return true; + return false; } } function getServerSnapshot() { - return true; + return false; } function subscribe(onChange: () => void) { @@ -37,8 +48,13 @@ export function useSidebarCollapsed() { const setCollapsed = useCallback((next: boolean) => { try { window.localStorage.setItem(storageKey, next ? "1" : "0"); + // Storage write succeeded; clear the in-memory fallback so persisted + // storage remains the source of truth. + inMemoryFallback = null; } catch { - // Ignore storage failures (private mode); state simply won't persist. + // Storage write failed (private mode, quota exceeded, etc.); remember + // the requested state in memory so the UI can reflect the toggle. + inMemoryFallback = next; } window.dispatchEvent(new Event(changeEvent)); }, []); diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index a5a6be5e7..7a20634d1 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -114,7 +114,7 @@ export function FormsHomePage() { { await expect(page.getByTestId("global-search-input")).toBeEnabled(); }); + test("desktop sidebar defaults to the labelled state for new users", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockDemoApi(page); + await gotoApp(page, "/?mode=answer"); + await waitForDemoDashboardReady(page); + + // No stored preference (PT-10): eight icon-only destinations demand recall, + // so first-run desktop shows the labelled sidebar; collapse is remembered. + await expect(page.locator("#clinical-tools-sidebar")).toBeVisible(); + await expect(page.getByRole("button", { name: "Collapse sidebar" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Expand sidebar" })).toHaveCount(0); + }); + test("desktop sidebar mode sync and accessibility affordances stay coherent", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockDemoApi(page); + // This journey exercises the remembered-collapsed rail; new users now + // default to the labelled sidebar, so seed the stored preference. + await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "1")); await gotoApp(page, "/?mode=tools"); const sidebar = page.locator("#clinical-tools-sidebar"); @@ -936,14 +952,17 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByRole("button", { name: "Open Clinical Guide menu" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Expand sidebar" })).toHaveCount(0); - await expect(page.locator("#clinical-tools-sidebar")).toHaveCount(0); + // With the labelled default the expanded panel exists in the DOM but stays + // display:none below lg; tablet must still only present the icon rail. + await expect(page.locator("#clinical-tools-sidebar")).toBeHidden(); await expect(page.getByLabel("Clinical Guide collapsed sidebar")).toBeVisible(); for (const tool of [ { name: "Answer", href: "/?mode=answer" }, { name: "Documents", href: "/?mode=documents" }, { name: "Services", href: "/services" }, - { name: "Forms", href: "/forms" }, + // The rail speaks the catalogue-maturity badge as part of the Forms name. + { name: "Forms (Early access)", href: "/forms" }, { name: "Favourites", href: "/favourites" }, { name: "Differentials", href: "/differentials" }, { name: "Medications", href: "/?mode=prescribing" }, @@ -1018,6 +1037,9 @@ test.describe("Clinical KB UI smoke coverage", () => { }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockDemoApi(page); + // Exercises both collapsed and expanded account affordances; seed the + // remembered-collapsed preference now that new users default to labelled. + await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "1")); await gotoApp(page, "/"); await waitForDemoDashboardReady(page); diff --git a/tests/ui-tools-collapse.spec.ts b/tests/ui-tools-collapse.spec.ts index b174aea09..10bb33693 100644 --- a/tests/ui-tools-collapse.spec.ts +++ b/tests/ui-tools-collapse.spec.ts @@ -9,6 +9,13 @@ async function goto(page: Page, path: string) { await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined); } +// The shell's expanded sidebar (now the desktop default) contributes its own +// "Search recent chats" searchbox, so mockup searches must be scoped to the +// page content instead of grabbing the first searchbox on the page. +function mockupSearch(page: Page) { + return page.locator("#main-content").getByRole("searchbox").first(); +} + test.describe("Tools mockups collapse the primary region when filtering", () => { test.describe.configure({ timeout: 60_000 }); @@ -18,7 +25,7 @@ test.describe("Tools mockups collapse the primary region when filtering", () => await expect(page.getByRole("heading", { name: "Start here" })).toBeVisible(); - const search = page.getByRole("searchbox").first(); + const search = mockupSearch(page); await search.fill("medication"); await expect(page.getByRole("heading", { name: "Start here" })).toHaveCount(0); await expect(page.getByLabel("Open Medication Prescribing")).toBeVisible(); @@ -36,7 +43,7 @@ test.describe("Tools mockups collapse the primary region when filtering", () => await expect(page.getByRole("heading", { name: "Resume" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Assess" })).toBeVisible(); - await page.getByRole("searchbox").first().fill("medication"); + await mockupSearch(page).fill("medication"); await expect(page.getByRole("heading", { name: "Assess" })).toHaveCount(0); await expect(page.getByLabel("Open Medication Prescribing")).toBeVisible(); }); @@ -47,7 +54,7 @@ test.describe("Tools mockups collapse the primary region when filtering", () => await expect(page.getByRole("heading", { name: "Launcher overview" })).toBeVisible(); - await page.getByRole("searchbox").first().fill("medication"); + await mockupSearch(page).fill("medication"); await expect(page.getByRole("heading", { name: "Launcher overview" })).toHaveCount(0); await expect(page.getByRole("heading", { name: "Results" })).toBeVisible(); // The split-pane card is a button; it must expose an explicit accessible diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 55801e9e3..d7e54176c 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -376,6 +376,9 @@ test.describe("Clinical KB tools launcher", () => { test("mode toggle stays global on the services home route", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); + // Asserts the collapsed rail affordance below; seed the remembered + // preference now that new users default to the labelled sidebar. + await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "1")); await gotoLauncher(page, "/?mode=answer"); // Re-open + re-click on each retry: a single click can be swallowed while the