diff --git a/tools/web-ux/browser-tests/console_history_shell_test.ts b/tools/web-ux/browser-tests/console_history_shell_test.ts index 9d2192627..36e4c3cbc 100644 --- a/tools/web-ux/browser-tests/console_history_shell_test.ts +++ b/tools/web-ux/browser-tests/console_history_shell_test.ts @@ -42,8 +42,8 @@ async function historyRequestCount(baseUrl: string): Promise { } async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): Promise { - const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-fold-button"); - const result = await toggle.evaluate(async (button, mobile) => { + const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-frame"); + const result = await toggle.evaluate(async (button, { mobile, opening }) => { const frame = document.querySelector(".sidebar-frame")!; const content = frame.querySelector(".sidebar-frame-content")!; const main = document.querySelector(".app-shell__main")!; @@ -52,11 +52,17 @@ async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): x: moving.getBoundingClientRect().x, width: moving.getBoundingClientRect().width, frameWidth: frame.getBoundingClientRect().width, + panelWidth: frame.querySelector(".sidebar-frame__bevel")!.getBoundingClientRect().width, mainWidth: main.getBoundingClientRect().width, mainHeight: main.getBoundingClientRect().height, }); const before = sample(); - (button as HTMLButtonElement).click(); + if (mobile) { + (button as HTMLButtonElement).click(); + } else { + button.dispatchEvent(new PointerEvent(opening ? "pointerenter" : "pointerleave", { pointerType: "mouse" })); + if (!opening) await new Promise((resolve) => setTimeout(resolve, 190)); + } await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); const animations = frame.getAnimations({ subtree: true }); const slides = animations.filter((animation) => animation instanceof CSSTransition && @@ -70,8 +76,8 @@ async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): for (const animation of animations) animation.finish(); await Promise.all(animations.map((animation) => animation.finished)); return { before, middle, after: sample(), durations, contentInert: content.inert, mainInert: main.inert }; - }, mobile); - assert(result.durations.length >= (mobile ? 1 : 2)); + }, { mobile, opening }); + assert(result.durations.length >= (mobile ? 1 : 2), `missing sidebar motion: ${JSON.stringify({ mobile, opening, result })}`); assert(result.durations.every((duration) => duration === 220)); const { before, middle, after } = result; assert(opening ? before.x < middle.x && middle.x < after.x : before.x > middle.x && middle.x > after.x, @@ -79,13 +85,13 @@ async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): assertEquals(before.width, after.width, "sidebar contents must slide without reflowing"); assertEquals(result.contentInert, !opening); assertEquals(result.mainInert, mobile && opening); - if (mobile) { - assertEquals(before.mainWidth, after.mainWidth); - assertEquals(before.mainHeight, after.mainHeight); - assertEquals(after.frameWidth, before.frameWidth); - } else { - assert(opening ? before.frameWidth < middle.frameWidth && middle.frameWidth < after.frameWidth - : before.frameWidth > middle.frameWidth && middle.frameWidth > after.frameWidth); + assertEquals(before.mainWidth, after.mainWidth, "hover/touch previews must not resize the main content"); + assertEquals(before.mainHeight, after.mainHeight); + assertEquals(after.frameWidth, before.frameWidth, "hover mode keeps a fixed rail width"); + if (!mobile) { + assert(opening ? before.panelWidth < middle.panelWidth && middle.panelWidth < after.panelWidth + : before.panelWidth > middle.panelWidth && middle.panelWidth > after.panelWidth, + "the overlay panel must expand past the rail and contract again"); } } @@ -384,24 +390,55 @@ async function checkConsoleHistory(viewportHeight: number): Promise { assertEquals(new Set(rowIds).size, rowIds.length); await page.getByRole("button", { name: "Overview", exact: true }).click(); assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12); - await checkSidebarSlide(page, false, false); - await checkSidebarSlide(page, false, true); - await checkSidebarSlide(page, false, false); - assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "true"); - await page.reload(); - await page.getByRole("button", { name: "Unfold sidebar", exact: true }).waitFor(); - assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0); - await page.setViewportSize({ width: 600, height: viewportHeight }); - await page.locator(".sidebar-frame").evaluate(async (element) => { + const sidebar = page.locator(".sidebar-frame"); + const settleSidebar = () => sidebar.evaluate(async (element) => { await Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished)); }); + await page.getByRole("button", { name: "Unpin sidebar", exact: true }).click(); + await page.mouse.move(1000, 30); + await page.locator(".sidebar-frame.folded").waitFor(); + await settleSidebar(); + const mainBeforeHover = await page.locator(".app-shell__main").boundingBox(); + await checkSidebarSlide(page, false, true); + await checkSidebarSlide(page, false, false); + // Use real mouse and keyboard events as well as deterministic motion sampling. + await sidebar.hover({ position: { x: 10, y: 40 } }); + await page.locator(".sidebar-frame:not(.folded)").waitFor(); + await settleSidebar(); + assertEquals(await page.locator(".app-shell__main").boundingBox(), mainBeforeHover); + const sidebarLink = sidebar.locator(".sidebar-frame-content a").first(); + await sidebarLink.focus(); + await page.mouse.move(1000, 30); + await page.waitForTimeout(250); + assertEquals(await page.locator(".sidebar-frame.folded").count(), 0, "keyboard focus must hold the preview open"); + await page.getByRole("link", { name: "Open Account", exact: true }).focus(); + await page.locator(".sidebar-frame.folded").waitFor(); + await settleSidebar(); + await sidebar.hover({ position: { x: 10, y: 40 } }); + await page.getByRole("button", { name: "Pin sidebar", exact: true }).click(); + await page.mouse.move(1000, 30); + await page.waitForTimeout(250); + assertEquals(await page.locator(".sidebar-frame.folded").count(), 0); + assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned"); + await page.getByRole("button", { name: "Unpin sidebar", exact: true }).click(); + // Hold a preview open across reload; only the mode should be restored. + await sidebarLink.focus(); + await page.mouse.move(1000, 30); + assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover"); + await page.reload(); + await page.getByRole("button", { name: "Pin sidebar", exact: true }).waitFor(); + assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0); + await page.setViewportSize({ width: 600, height: viewportHeight }); + await settleSidebar(); await checkSidebarSlide(page, true, true); await checkSidebarSlide(page, true, false); await checkSidebarSlide(page, true, true); - assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "false"); + assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover"); await page.reload(); - await page.getByRole("button", { name: "Hide sidebar", exact: true }).waitFor(); - assertEquals(await page.locator(".app-shell.sidebar-open").count(), 1); + await page.getByRole("button", { name: "Show sidebar", exact: true }).waitFor(); + assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0); + await page.getByRole("button", { name: "Show sidebar", exact: true }).click(); + await settleSidebar(); await page.emulateMedia({ reducedMotion: "reduce" }); await page.getByRole("button", { name: "Hide sidebar", exact: true }).click(); const reducedSidebar = await page.locator(".sidebar-frame").evaluate((element) => ({ @@ -411,6 +448,31 @@ async function checkConsoleHistory(viewportHeight: number): Promise { })); assertEquals(reducedSidebar, { animations: 0, right: 0, visibility: "hidden" }); assertEquals(await page.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false); + if (viewportHeight === 600) { + const touchContext = await browser.newContext({ + viewport: { width: 600, height: 600 }, hasTouch: true, isMobile: true, + storageState: await context.storageState(), + }); + const touchPage = await touchContext.newPage(); + await touchPage.goto(page.url()); + await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap(); + await touchPage.getByRole("button", { name: "Pin sidebar", exact: true }).tap(); + assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned"); + await touchPage.getByRole("button", { name: "Hide sidebar", exact: true }).tap(); + await touchPage.reload(); + await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor(); + assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned"); + // A wide touchscreen still has a temporary-open control in hover mode. + await touchPage.setViewportSize({ width: 1024, height: 600 }); + await touchPage.getByRole("button", { name: "Unpin sidebar", exact: true }).tap(); + await touchPage.locator(".sidebar-frame.folded").waitFor(); + await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap(); + await touchPage.locator(".sidebar-frame:not(.folded)").waitFor(); + await touchPage.getByRole("button", { name: "Hide sidebar", exact: true }).tap(); + await touchPage.locator(".sidebar-frame.folded").waitFor(); + assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover"); + await touchContext.close(); + } assertEquals(errors, []); await context.close(); } finally { diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 4c0640485..f69f9b4a3 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -1325,12 +1325,12 @@ Deno.test("Account UI owns browser passkey session state without workspace autho "Workspace layout should load workspace data, register with the parent slot, and provide the same slot contract to children", ); assert( - sidebarFrame.includes("folded = $bindable(false)") && + sidebarFrame.includes("mode: 'pinned' | 'hover'") && sidebarFrame.includes("sidebar-fold-button") && - sidebarFrame.includes("Fold sidebar") && - sidebarFrame.includes("Unfold sidebar") && - rootLayout.includes("let sidebarFolded = $state(loadSidebarFolded())") && - rootLayout.includes("") && + sidebarFrame.includes("Pin sidebar") && + sidebarFrame.includes("Unpin sidebar") && + rootLayout.includes("let sidebarMode = $state(loadSidebarMode())") && + rootLayout.includes("mode={sidebarMode}") && !workspaceLayout.includes("sidebarFolded") && !workspaceLayout.includes("onToggleFold") && !sidebar.includes("folded?: boolean") && diff --git a/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte b/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte index 6bfc9e323..364bfceed 100644 --- a/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte +++ b/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte @@ -1,24 +1,95 @@ - + diff --git a/web/workspace/src/lib/workspace/sidebar/sidebar.css b/web/workspace/src/lib/workspace/sidebar/sidebar.css index cfd0875e0..f641cfd28 100644 --- a/web/workspace/src/lib/workspace/sidebar/sidebar.css +++ b/web/workspace/src/lib/workspace/sidebar/sidebar.css @@ -5,6 +5,7 @@ --sidebar-item-hover: oklch(96% 0 0); --sidebar-item-active: oklch(92.5% 0 0); --sidebar-width: clamp(220px, 20vw, 280px); + --sidebar-rail-width: calc(32px + var(--space-2) + var(--space-2) + 1px); --sidebar-motion-duration: 220ms; --sidebar-motion-easing: cubic-bezier(0.2, 0, 0, 1); @@ -38,7 +39,7 @@ } } .sidebar-frame.folded { - width: calc(32px + var(--space-2) + var(--space-2) + 1px); + width: var(--sidebar-rail-width); overflow: hidden; } .sidebar-frame-content { @@ -58,6 +59,25 @@ visibility: hidden; transition-delay: 0s, var(--sidebar-motion-duration); } + .sidebar-frame.hover-mode { + position: relative; + z-index: 40; + width: var(--sidebar-rail-width); + overflow: visible; + } + .sidebar-frame.hover-mode .bevel.sidebar-frame__bevel { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--sidebar-width); + overflow: hidden; + background: var(--bg); + transition: width var(--sidebar-motion-duration) var(--sidebar-motion-easing); + } + .sidebar-frame.hover-mode.folded .bevel.sidebar-frame__bevel { + width: var(--sidebar-rail-width); + } .sidebar-frame-content, .global-sidebar, .workspace-sidebar, @@ -647,7 +667,8 @@ } @media (max-width: 760px) { - .sidebar-frame { + .sidebar-frame, + .sidebar-frame.hover-mode { --sidebar-width: 100%; z-index: 20; grid-column: 1; @@ -660,7 +681,8 @@ transition: transform var(--sidebar-motion-duration) var(--sidebar-motion-easing), visibility 0s; } - .sidebar-frame.folded { + .sidebar-frame.folded, + .sidebar-frame.hover-mode.folded { width: 100%; transform: translateX(-100%); visibility: hidden; @@ -673,8 +695,11 @@ visibility: inherit; transition: none; } - .sidebar-control-row { - display: none; + .sidebar-frame.hover-mode .bevel.sidebar-frame__bevel, + .sidebar-frame.hover-mode.folded .bevel.sidebar-frame__bevel { + position: static; + width: 100%; + transition: none; } } diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 00f1bc367..d9e088ee3 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -25,31 +25,42 @@ const headerOverrides = createOverrideStack((activeHeader) => { header = activeHeader; }); - // Browser-wide layout preference; independent of authentication and Workspace. - const sidebarFoldStorageKey = 'yoi.sidebar.folded.v1'; - function loadSidebarFolded(): boolean { - if (typeof window === 'undefined') return false; + // Only the display mode persists; hover/touch previews never do. + const sidebarModeStorageKey = 'yoi.sidebar.mode.v1'; + const legacySidebarStorageKey = 'yoi.sidebar.folded.v1'; + function loadSidebarMode(): 'pinned' | 'hover' { + if (typeof window === 'undefined') return 'pinned'; try { - return window.localStorage.getItem(sidebarFoldStorageKey) === 'true'; + const mode = window.localStorage.getItem(sidebarModeStorageKey); + if (mode === 'pinned' || mode === 'hover') return mode; + return window.localStorage.getItem(legacySidebarStorageKey) === 'true' ? 'hover' : 'pinned'; } catch { - return false; + return 'pinned'; } } - let sidebarFolded = $state(loadSidebarFolded()); + let sidebarMode = $state(loadSidebarMode()); + let sidebarTransientOpen = $state(false); const mobileLayout = new MediaQuery('(max-width: 760px)'); + const sidebarOpen = $derived((!mobileLayout.current && sidebarMode === 'pinned') || sidebarTransientOpen); $effect(() => { - const folded = sidebarFolded; + const mode = sidebarMode; try { - window.localStorage.setItem(sidebarFoldStorageKey, String(folded)); + window.localStorage.setItem(sidebarModeStorageKey, mode); + window.localStorage.removeItem(legacySidebarStorageKey); } catch { - // Storage can be blocked or full; folding must still work for this page. + // Blocked/full storage must not prevent mode changes in this page. } }); + $effect(() => { + mobileLayout.current; + sidebarTransientOpen = false; + }); + function toggleSidebar() { - sidebarFolded = !sidebarFolded; + sidebarTransientOpen = !sidebarOpen; } provideHeaderController({ @@ -62,8 +73,14 @@ -
- +
+ { sidebarMode = mode; }} + onOpenChange={(open) => { sidebarTransientOpen = open; }} + >
@@ -203,6 +220,12 @@ padding: var(--space-4); } + @media (hover: none) { + .sidebar-hover-mode .app-shell__mobile-sidebar-toggle { + display: inline-flex; + } + } + @media (max-width: 760px) { .app-shell { grid-template-columns: minmax(0, 1fr); diff --git a/web/workspace/src/routes/sidebar-fold.browser.spec.ts b/web/workspace/src/routes/sidebar-fold.browser.spec.ts index ac4ceff48..4a3bcbae2 100644 --- a/web/workspace/src/routes/sidebar-fold.browser.spec.ts +++ b/web/workspace/src/routes/sidebar-fold.browser.spec.ts @@ -2,88 +2,172 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; -import { createRawSnippet } from "svelte"; +import { createRawSnippet, tick } from "svelte"; import { logout } from "$lib/workspace/auth/api"; import Layout from "./+layout.svelte"; vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } })); - -const storageKey = "yoi.sidebar.folded.v1"; +const storageKey = "yoi.sidebar.mode.v1"; +const legacyKey = "yoi.sidebar.folded.v1"; const props = { params: {}, children: createRawSnippet(() => ({ render: () => "

Page content

" })), data: { accessibleWorkspaces: [], workspaceCatalogError: null }, }; +const frame = () => screen.getByRole("complementary", { name: "Sidebar" }); +const folded = () => frame().classList.contains("folded"); +const enter = () => fireEvent.pointerEnter(frame(), { pointerType: "mouse" }); +const leave = () => fireEvent.pointerLeave(frame(), { pointerType: "mouse" }); +const delay = () => vi.advanceTimersByTimeAsync(200); -beforeEach(() => localStorage.clear()); +beforeEach(() => { localStorage.clear(); vi.useFakeTimers(); }); afterEach(() => { cleanup(); + vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); localStorage.clear(); }); -test("sidebar starts unfolded and persists both desktop and mobile toggles", async () => { - const view = render(Layout, { props }); - expect(screen.getByRole("button", { name: "Fold sidebar" }).getAttribute("aria-expanded")).toBe("true"); - const content = view.container.querySelector(".sidebar-frame-content")!; +test("unpinning changes mode, and pointer departure closes the preview", async () => { + render(Layout, { props }); + expect(screen.getByRole("button", { name: "Unpin sidebar" }).getAttribute("aria-pressed")).toBe("true"); + await enter(); + screen.getByRole("button", { name: "Unpin sidebar" }).focus(); + await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 }); + expect(localStorage.getItem(storageKey)).toBe("hover"); + expect(folded()).toBe(false); + await leave(); + await delay(); + expect(folded()).toBe(true); +}); + +test.each(["pinned", "hover"])("restores %s mode without persisting a transient open state", (mode) => { + localStorage.setItem(storageKey, mode); + render(Layout, { props }); + expect(folded()).toBe(mode === "hover"); + expect(localStorage.getItem(storageKey)).toBe(mode); +}); + +test.each([true, false])("migrates legacy folded=%s to a mode", (value) => { + localStorage.setItem(legacyKey, String(value)); + render(Layout, { props }); + expect(localStorage.getItem(storageKey)).toBe(value ? "hover" : "pinned"); + expect(localStorage.getItem(legacyKey)).toBeNull(); + expect(folded()).toBe(value); +}); + +test("a saved mode takes precedence over the legacy preference", () => { + localStorage.setItem(legacyKey, "true"); + localStorage.setItem(storageKey, "pinned"); + render(Layout, { props }); + expect(folded()).toBe(false); +}); + +test("hover keeps the same content node, delays closing, and cancels closing on reentry", async () => { + localStorage.setItem(storageKey, "hover"); + render(Layout, { props }); + const content = frame().querySelector(".sidebar-frame-content")!; + expect(content.inert).toBe(true); + await enter(); + expect(folded()).toBe(false); expect(content.inert).toBe(false); - await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); - expect(view.container.querySelector(".sidebar-frame-content")).toBe(content); + await leave(); + await vi.advanceTimersByTimeAsync(100); + expect(folded()).toBe(false); + await enter(); + await delay(); + expect(folded()).toBe(false); + await leave(); + await delay(); + expect(folded()).toBe(true); + expect(frame().querySelector(".sidebar-frame-content")).toBe(content); expect(content.inert).toBe(true); expect(content.getAttribute("aria-hidden")).toBe("true"); - expect(localStorage.getItem(storageKey)).toBe("true"); - expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(false); - await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" })); - expect(content.inert).toBe(false); - expect(content.getAttribute("aria-hidden")).toBe("false"); - expect(localStorage.getItem(storageKey)).toBe("false"); - expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); - await fireEvent.click(screen.getByRole("button", { name: "Hide sidebar" })); - expect(localStorage.getItem(storageKey)).toBe("true"); - await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" })); - expect(localStorage.getItem(storageKey)).toBe("false"); + expect(localStorage.getItem(storageKey)).toBe("hover"); }); -test.each([true, false])("restores stored folded=%s without overwriting it with the default", (folded) => { - localStorage.setItem(storageKey, String(folded)); - const writes = vi.spyOn(Storage.prototype, "setItem"); - const view = render(Layout, { props }); - expect(screen.getByRole("button", { name: folded ? "Unfold sidebar" : "Fold sidebar" }).getAttribute("aria-expanded")).toBe(String(!folded)); - expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(!folded); - expect(writes.mock.calls.every(([key, value]) => key !== storageKey || value === String(folded))).toBe(true); +test("keyboard focus opens the preview and holds it until focus leaves", async () => { + localStorage.setItem(storageKey, "hover"); + render(Layout, { props }); + screen.getByRole("button", { name: "Pin sidebar" }).focus(); + await tick(); + expect(folded()).toBe(false); + await enter(); + await leave(); + await delay(); + expect(folded()).toBe(false); + screen.getByRole("link", { name: "Open Account" }).focus(); + await delay(); + expect(folded()).toBe(true); }); -test("fold preference survives logout and a fresh app mount", async () => { +test("pinning a preview keeps it open after pointer and focus leave", async () => { + localStorage.setItem(storageKey, "hover"); + render(Layout, { props }); + await enter(); + await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }), { detail: 1 }); + await leave(); + await delay(); + expect(folded()).toBe(false); + expect(localStorage.getItem(storageKey)).toBe("pinned"); +}); + +test("mode survives logout and remount, but an open preview does not", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" }))); + localStorage.setItem(storageKey, "hover"); const view = render(Layout, { props }); - await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); + await enter(); await logout(); view.unmount(); render(Layout, { props }); - expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull(); - expect(localStorage.getItem(storageKey)).toBe("true"); + expect(folded()).toBe(true); + expect(localStorage.getItem(storageKey)).toBe("hover"); }); -test("invalid storage falls back to unfolded", () => { - localStorage.setItem(storageKey, "not-a-boolean"); +test("touch does not hover; mobile header opens temporarily without changing mode", async () => { + const matchMedia = window.matchMedia.bind(window); + vi.spyOn(window, "matchMedia").mockImplementation((query) => { + const result = matchMedia(query); + Object.defineProperty(result, "matches", { value: query === "(max-width: 760px)" }); + return result; + }); + localStorage.setItem(storageKey, "hover"); + const view = render(Layout, { props }); + await fireEvent.pointerEnter(frame(), { pointerType: "touch" }); + expect(folded()).toBe(true); + await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" })); + expect(folded()).toBe(false); + expect(view.container.querySelector("main")!.inert).toBe(true); + await fireEvent.pointerLeave(frame(), { pointerType: "touch" }); + await delay(); + expect(folded()).toBe(false); + await fireEvent.click(screen.getByRole("button", { name: "Hide sidebar" })); + expect(folded()).toBe(true); + expect(view.container.querySelector("main")!.inert).toBe(false); + expect(localStorage.getItem(storageKey)).toBe("hover"); +}); + +test("invalid storage falls back to pinned", () => { + localStorage.setItem(storageKey, "not-a-mode"); render(Layout, { props }); - expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); + expect(folded()).toBe(false); }); -test("unavailable storage does not prevent folding", async () => { +test("unavailable storage does not prevent mode switching", async () => { vi.spyOn(window, "localStorage", "get").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); }); render(Layout, { props }); - await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); - expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull(); - await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" })); - expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" })); + await delay(); + expect(folded()).toBe(true); + await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" })); + expect(folded()).toBe(false); }); -test("full storage still restores the saved value and allows toggles", async () => { - localStorage.setItem(storageKey, "true"); +test("full storage still restores the mode and allows changes", async () => { + localStorage.setItem(storageKey, "hover"); vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); }); render(Layout, { props }); - await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" })); - expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" })); + expect(folded()).toBe(false); }); diff --git a/web/workspace/tests/bevel-line-contract.test.ts b/web/workspace/tests/bevel-line-contract.test.ts index f433d9412..15451cd8c 100644 --- a/web/workspace/tests/bevel-line-contract.test.ts +++ b/web/workspace/tests/bevel-line-contract.test.ts @@ -93,18 +93,18 @@ Deno.test("Shell regions use selected Bevel edges while open separators use Beve assert( rootLayout.includes('class="app-shell__topbar-bevel"') && rootLayout.includes("top={false} right={false} left={false}") && - rootLayout.includes("bind:folded={sidebarFolded}") && + rootLayout.includes("open={sidebarOpen}") && rootLayout.includes("app-shell__mobile-sidebar-toggle") && - rootLayout.includes("class:sidebar-open={!sidebarFolded}") && + rootLayout.includes("class:sidebar-open={sidebarOpen}") && !rootLayout.includes("depth=") && !rootLayout.includes("BevelLine") && sidebarFrame.includes('class="sidebar-frame__bevel"') && sidebarFrame.includes("top={false} bottom={false} left={false}") && - sidebarFrame.includes("$bindable(false)") && + sidebarFrame.includes("class:folded={!open}") && !sidebarFrame.includes("depth=") && !sidebarFrame.includes("BevelLine") && sidebarCss.includes(".sidebar-frame.folded") && - sidebarCss.includes("display: none") && + sidebarCss.includes("transform: translateX(-100%)") && sidebarCss.includes("display: grid"), "Desktop shell boundaries must use one selected border edge and Mobile must share Header fold state", );