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