diff --git a/src/components/clinical-dashboard/use-sidebar-collapsed.ts b/src/components/clinical-dashboard/use-sidebar-collapsed.ts index ff419375b4..bcc5834089 100644 --- a/src/components/clinical-dashboard/use-sidebar-collapsed.ts +++ b/src/components/clinical-dashboard/use-sidebar-collapsed.ts @@ -3,9 +3,23 @@ import { useCallback } from "react"; import { createBrowserStore } from "@/lib/client-store-factory"; -const storageKey = "clinical-kb-sidebar-collapsed"; +/** localStorage key for an explicit expanded/collapsed pin. */ +export const SIDEBAR_COLLAPSED_STORAGE_KEY = "clinical-kb-sidebar-collapsed"; + const changeEvent = "clinical-kb-sidebar-collapsed-change"; +/** + * Maps a raw stored value to the collapsed preference. Absent key (`null` / + * `undefined`) resolves to collapsed so first-run and never-toggled browsers + * match the closed-by-default product choice. When a value is present, + * collapsed is true only for the explicit `"1"` pin (same ternary the store + * used before this helper was extracted). Storage errors are handled by + * callers (they pass null / use their own catch path). + */ +export function readSidebarCollapsedPreference(storedValue: string | null | undefined): boolean { + return storedValue === null || storedValue === undefined ? true : storedValue === "1"; +} + // 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; @@ -17,12 +31,15 @@ function getSnapshot() { return inMemoryFallback; } try { - const storedValue = window.localStorage.getItem(storageKey); - // 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"; + const storedValue = window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY); + // Collapsed is the default whenever the key is absent. The key is only + // written after an explicit toggle, so browsers that never touched the + // control (returning users included) also land on collapsed — matching + // the already-closed mobile drawer. Explicit "0"/"1" preferences still + // win; expanding remains a remembered choice. + return readSidebarCollapsedPreference(storedValue); } catch { - return false; + return true; } } @@ -35,7 +52,7 @@ function subscribe(onChange: () => void) { }; } -const useSidebarCollapsedStore = createBrowserStore(subscribe, getSnapshot, false); +const useSidebarCollapsedStore = createBrowserStore(subscribe, getSnapshot, true); /** * Desktop sidebar collapse state shared across shells and persisted per @@ -46,7 +63,7 @@ export function useSidebarCollapsed() { const collapsed = useSidebarCollapsedStore(); const setCollapsed = useCallback((next: boolean) => { try { - window.localStorage.setItem(storageKey, next ? "1" : "0"); + window.localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, next ? "1" : "0"); // Storage write succeeded; clear the in-memory fallback so persisted // storage remains the source of truth. inMemoryFallback = null; diff --git a/tests/sidebar-collapsed.test.ts b/tests/sidebar-collapsed.test.ts new file mode 100644 index 0000000000..d144d4994a --- /dev/null +++ b/tests/sidebar-collapsed.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + readSidebarCollapsedPreference, + SIDEBAR_COLLAPSED_STORAGE_KEY, +} from "../src/components/clinical-dashboard/use-sidebar-collapsed"; + +describe("readSidebarCollapsedPreference", () => { + it("defaults to collapsed when the preference key is absent", () => { + expect(readSidebarCollapsedPreference(null)).toBe(true); + expect(readSidebarCollapsedPreference(undefined)).toBe(true); + }); + + it("honours an explicit expanded pin", () => { + expect(readSidebarCollapsedPreference("0")).toBe(false); + }); + + it("honours an explicit collapsed pin", () => { + expect(readSidebarCollapsedPreference("1")).toBe(true); + }); + + it('only treats an explicit "1" as collapsed when a value is present', () => { + // Preserves the pre-existing ternary: absent → collapsed; otherwise + // collapsed iff the stored pin is exactly "1". Stale/garbage pins expand. + expect(readSidebarCollapsedPreference("")).toBe(false); + expect(readSidebarCollapsedPreference("true")).toBe(false); + expect(readSidebarCollapsedPreference("collapsed")).toBe(false); + }); + + it("pins the storage key used by Playwright seeds and the browser store", () => { + expect(SIDEBAR_COLLAPSED_STORAGE_KEY).toBe("clinical-kb-sidebar-collapsed"); + }); +}); diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index 677d19e9b5..d915ff8891 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -233,12 +233,18 @@ test.describe("Clinical KB accessibility coverage", () => { await page.setViewportSize({ width: 1280, height: 800 }); await mockMinimalDashboardApi(page); + // New chat lives on both the collapsed rail and the expanded panel. With + // collapsed-by-default the expanded panel is unmounted, so scope to the + // rail rather than relying on .first() (same hazard the forced-colors + // journey below guards against). + const railNewChat = page.getByLabel("Clinical Guide collapsed sidebar").getByRole("button", { name: "New chat" }); + // Reduced motion → every scripted scroll must be an instant "auto" jump. await page.emulateMedia({ reducedMotion: "reduce" }); await gotoApp(page); await expectDashboardUsable(page); await resetBehaviours(); - await page.getByRole("button", { name: "New chat" }).first().click(); + await railNewChat.click(); await expect.poll(readBehaviours).not.toHaveLength(0); const reduced = await readBehaviours(); expect(reduced, "reduced motion must not animate scripted scrolls").not.toContain("smooth"); @@ -247,7 +253,7 @@ test.describe("Clinical KB accessibility coverage", () => { // No preference → the same action animates smoothly. await page.emulateMedia({ reducedMotion: "no-preference" }); await resetBehaviours(); - await page.getByRole("button", { name: "New chat" }).first().click(); + await railNewChat.click(); await expect.poll(readBehaviours).not.toHaveLength(0); expect(await readBehaviours(), "no-preference should animate scripted scrolls").toContain("smooth"); }); @@ -374,10 +380,14 @@ test.describe("Clinical KB accessibility coverage", () => { await page.emulateMedia({ forcedColors: "active" }); await page.setViewportSize({ width: 1440, height: 1000 }); await mockMinimalDashboardApi(page); + // Target the expanded sidebar's solid --command "New chat" button. With the + // collapsed-by-default rail, getByRole("New chat").first() would hit the + // icon-rail control (text-muted), not the solid label this regression guards. + await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "0")); await gotoApp(page); await expectDashboardUsable(page); - const newChat = page.getByRole("button", { name: "New chat" }).first(); + const newChat = page.locator("#clinical-tools-sidebar").getByRole("button", { name: "New chat" }); await expect(newChat).toBeVisible(); const { canvas, buttonLabelColor, tokenColors } = await newChat.evaluate((button) => { const probe = document.createElement("span"); diff --git a/tests/ui-hydration.spec.ts b/tests/ui-hydration.spec.ts index c9c97b1754..66485701cc 100644 --- a/tests/ui-hydration.spec.ts +++ b/tests/ui-hydration.spec.ts @@ -4,9 +4,12 @@ test.describe("React Hydration Safety", () => { const scenarios = [ { name: "dashboard defaults", route: "/", storage: {} }, { + // Seed expanded ("0") so the client preference differs from the + // collapsed SSR/server snapshot after the closed-by-default flip — + // that is the mismatch/re-render path useSyncExternalStore must handle. name: "dashboard persisted theme and sidebar", route: "/", - storage: { "clinical-kb-theme": "dark", "clinical-kb-sidebar-collapsed": "1" }, + storage: { "clinical-kb-theme": "dark", "clinical-kb-sidebar-collapsed": "0" }, themeCookie: "dark", }, { diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index f3b6f99b16..7e1cc43544 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1164,24 +1164,27 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); - test("desktop sidebar defaults to the labelled state for new users", async ({ page }) => { + test("desktop sidebar defaults to the collapsed 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): the labelled navigation remains the default, - // 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); + // No stored preference (PT-10): the collapsed icon rail is the default, + // so first-run desktop shows the collapsed rail, not the labelled panel; + // expanding is remembered. #clinical-tools-sidebar only mounts when + // expanded, so its absence (not just hidden) is the collapsed signal. + await expect(page.getByLabel("Clinical Guide collapsed sidebar")).toBeVisible(); + await expect(page.locator("#clinical-tools-sidebar")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Expand sidebar" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Collapse 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. + // This journey starts from the collapsed rail (now the default for new + // users too) and exercises expanding/collapsing it. await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "1")); await gotoApp(page, "/?mode=tools"); @@ -1226,13 +1229,16 @@ test.describe("Clinical KB UI smoke coverage", () => { test("tablet shows icon rail without drawer trigger or expand control @critical", async ({ page }) => { await page.setViewportSize({ width: 768, height: 1024 }); await mockDemoApi(page); + // Seed expanded preference so #clinical-tools-sidebar mounts. Without this + // seed the panel is absent (count 0) and toBeHidden() would pass vacuously; + // we need the remembered-expanded path where the panel exists but stays + // display:none below lg while tablet still only presents the icon rail. + await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "0")); await gotoApp(page, "/?mode=answer"); await waitForDemoDashboardReady(page); await expect(page.getByRole("button", { name: "Open Clinical Guide menu" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Expand 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(); @@ -1336,8 +1342,8 @@ 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. + // Exercises both collapsed and expanded account affordances; seed collapsed + // explicitly (also the new-user default) so the journey starts on the rail. await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "1")); await gotoApp(page, "/"); await waitForDemoDashboardReady(page); @@ -1379,6 +1385,11 @@ test.describe("Clinical KB UI smoke coverage", () => { await gotoApp(page, "/"); await waitForDemoDashboardReady(page); + // Sidebar defaults to collapsed for new users; expand so the in-rail Settings + // control this journey asserts is reachable (same as the account-setup case). + await page.getByRole("button", { name: "Expand sidebar" }).click(); + await expect(page.locator("#clinical-tools-sidebar")).toBeVisible(); + const settings = accountSettingsDialog(page); await page.locator("#clinical-tools-sidebar").getByRole("button", { name: "Settings", exact: true }).click(); await expect(settings).toBeVisible(); diff --git a/tests/ui-tools-collapse.spec.ts b/tests/ui-tools-collapse.spec.ts index 6a1aae73b4..eac1f0056d 100644 --- a/tests/ui-tools-collapse.spec.ts +++ b/tests/ui-tools-collapse.spec.ts @@ -9,9 +9,10 @@ async function goto(page: Page, path: string) { await expect(page.locator("#main-content").first()).toBeVisible({ timeout: 15_000 }); } -// 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. +// The shell's sidebar (expanded when a remembered preference restores it) +// 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(); } diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 57bb338ce5..23c93c06c4 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -556,8 +556,8 @@ 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. + // Asserts the collapsed rail affordance below; explicit for clarity even + // though collapsed is now the default for new users too. await page.addInitScript(() => window.localStorage.setItem("clinical-kb-sidebar-collapsed", "1")); await gotoLauncher(page, "/?mode=answer");