diff --git a/docs/architecture.md b/docs/architecture.md index e40bd2b..83cb8e9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -229,3 +229,23 @@ The separator supports 16px Left/Right steps; double-click or Enter resets 224px This is a layout preference and does not navigate, save content or remount actors. The design source is revision 95bd47e899600c0f038a7d4622a40cfc61d063e2, Notes `sidebar-dragging` and Components `window-title-row`. + + +## Native title-row surfaces + +The native 44px caption row is transparent over full-height sidebar/content +surfaces and carries no centered window title. Windows caption buttons retain +46x44px hit areas and the existing readiness, drag, system-menu and close guards. +The first sidebar control starts 56px from the window top; sheet surface reaches +the top while interactive sheet content begins below the 44px caption area. +Workspace publishes only `DesktopLayout {sidebarWidth, sidebarVisible}` through +the shell context in a layout effect after its initial load. A null layout is a +single-pane account/loading window with no sidebar toggle. Width metadata has +no account identity or content and does not remount editors or module actors. +The existing editor action portal remains mounted in the row. Its bounded area +scrolls horizontally when actions cannot fit, keeping the protected caption +buttons outside that scroll area. Buttons and scrollbar interaction do not drag +the window. Back/Forward history and the account-avatar sidebar header remain +separate work; no decorative history controls are added. +Reference: design revision 95bd47e899600c0f038a7d4622a40cfc61d063e2, +Components `window-title-row` and Notes `layout-desktop`/`layout-narrow`. diff --git a/web/src/desktop/DesktopShell.tsx b/web/src/desktop/DesktopShell.tsx index 2bc9ef3..a8c5eb1 100644 --- a/web/src/desktop/DesktopShell.tsx +++ b/web/src/desktop/DesktopShell.tsx @@ -1,15 +1,18 @@ -import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react"; +import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject, type CSSProperties } from "react"; import { PanelLeft, Minus, Square, Copy, X } from "lucide-react"; import { checkClose, lockClosing } from "./closeGuard"; import { freezeWindow } from "./freeze"; import { connectWindow, isDesktop, type DesktopWindow } from "./window"; import "./desktop.css"; +export type DesktopLayout = { sidebarWidth: number; sidebarVisible: boolean }; + type Navigation = { expanded: boolean; toggle: () => void }; type DesktopContextValue = { toolbar: HTMLElement | null; navigationRef: RefObject; setNavigation: (navigation: Navigation | null) => void; + setLayout: (layout: DesktopLayout | null) => void; }; const DesktopContext = createContext(null); export const useDesktop = () => useContext(DesktopContext); @@ -19,6 +22,23 @@ export function DesktopShell({ children }: { children: ReactNode }) { return {children}; } +// Preserve an existing focused action when its scrollport shrinks. Fixed menu +// items are outside the strip and must not change the trigger strip's position. +function revealToolbarFocus(toolbar: HTMLElement | null) { + const focused = document.activeElement; + if (!toolbar || !(focused instanceof HTMLElement) || + !toolbar.contains(focused) || focused.closest('[role="menu"]')) return; + const viewport = toolbar.getBoundingClientRect(); + const control = focused.getBoundingClientRect(); + const left = viewport.left + toolbar.clientLeft; + const right = left + toolbar.clientWidth; + const delta = control.left < left ? control.left - left + : control.right > right ? control.right - right : 0; + if (delta) toolbar.scrollLeft = Math.max(0, Math.min( + toolbar.scrollWidth - toolbar.clientWidth, toolbar.scrollLeft + delta, + )); +} + function NativeShell({ children }: { children: ReactNode }) { const [ready, setReady] = useState(false); const [started, setStarted] = useState(false); @@ -26,6 +46,7 @@ function NativeShell({ children }: { children: ReactNode }) { const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [maximized, setMaximized] = useState(false); + const [layout, setLayout] = useState(null); const [navigation, setNavigation] = useState(null); const [toolbar, setToolbar] = useState(null); const navigationRef = useRef(null); @@ -35,6 +56,12 @@ function NativeShell({ children }: { children: ReactNode }) { const bridge = useRef(null); const closing = useRef(false); const failure = () => setError("Window controls aren’t available. Try again."); + useLayoutEffect(() => { + if (!toolbar || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => revealToolbarFocus(toolbar)); + observer.observe(toolbar); + return () => observer.disconnect(); + }, [toolbar]); useLayoutEffect(() => { document.documentElement.dataset.desktop = "true"; return () => { delete document.documentElement.dataset.desktop; }; @@ -87,23 +114,24 @@ function NativeShell({ children }: { children: ReactNode }) { function run(action: (window: DesktopWindow) => Promise) { if (bridge.current) void action(bridge.current).catch(failure); } - return -
+ return +
{ - if (event.button !== 0 || !ready || (event.target as HTMLElement).closest("button, a, input, textarea, select, [role='menu']")) return; + if (event.button !== 0 || !ready || (event.target as HTMLElement).closest("button, a, input, textarea, select, [role='menu'], [data-window-interactive]")) return; if (event.detail === 2) run(window => window.toggleMaximize()); else run(window => window.startDragging()); }} onContextMenu={event => { - if ((event.target as HTMLElement).closest("button, a, input, textarea, select, [role='menu']")) return; + if ((event.target as HTMLElement).closest("button, a, input, textarea, select, [role='menu'], [data-window-interactive]")) return; event.preventDefault(); run(window => window.showSystemMenu()); }}> {navigation && } -
- Orbyn Notes -
-
+
+
revealToolbarFocus(toolbar)} />
diff --git a/web/src/desktop/desktop.css b/web/src/desktop/desktop.css index 39ea24a..a946e97 100644 --- a/web/src/desktop/desktop.css +++ b/web/src/desktop/desktop.css @@ -1,4 +1,5 @@ .desktop-shell { + position: relative; height: 100dvh; display: flex; flex-direction: column; @@ -6,28 +7,20 @@ background: var(--main); } .desktop-titlebar { - position: relative; + position: absolute; + top: 0; + left: 0; + right: 0; height: 44px; flex: 0 0 44px; display: flex; align-items: center; user-select: none; - background: var(--main); + background: transparent; z-index: 20; } .desktop-drag-area { flex: 1; height: 100%; min-width: 28px; } -.desktop-window-title { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - font-size: 12.5px; - font-weight: 500; - pointer-events: none; - white-space: nowrap; -} -.desktop-titlebar:has(.desktop-editor-actions) .desktop-window-title { display: none; } -.desktop-titlebar:has(.desktop-editor-actions) .desktop-drag-area { flex: 0 1 24px; } +.desktop-titlebar:has(.desktop-editor-actions) .desktop-drag-area { flex: 0 0 28px; } .desktop-sidebar-toggle { width: 28px; height: 28px; @@ -52,20 +45,28 @@ .desktop-caption-controls .desktop-close:hover { background: #c42b1c; color: #fff; } .desktop-caption-controls .desktop-close:active { background: #a5261a; color: #fff; } .desktop-caption-controls button:focus-visible { outline-offset: -4px; } -.desktop-editor-toolbar { min-width: 0; flex: 1; } +.desktop-editor-toolbar { min-width: 0; flex: 1; height: 44px; overflow-x: auto; scrollbar-width: thin; } .desktop-editor-toolbar:empty { display: none; } -.desktop-editor-actions { display: flex; align-items: center; gap: 12px; padding-right: 8px; min-width: 0; } +.desktop-editor-actions { display: flex; align-items: center; gap: 12px; padding-right: 8px; min-width: max-content; min-height: 36px; } .desktop-editor-actions > span { margin-left: auto; color: var(--t3); font-size: 11px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } -.desktop-editor-actions > button { white-space: nowrap; background: transparent; } +.desktop-editor-actions > button { white-space: nowrap; background: transparent; flex-shrink: 0; } +.desktop-editor-actions > .menu-anchor { flex-shrink: 0; } .desktop-content { flex: 1; min-height: 0; overflow: hidden; } .desktop-content > .app { height: 100%; min-height: 0; } .desktop-content .mobile-header { display: none; } -.desktop-content .sidebar { padding-top: 2px; } -.desktop-content .sheet { margin: 0; border: 0; border-radius: 0; } +.desktop-content .sidebar { padding-top: 56px; } +.desktop-sheet-content { display: contents; } +.desktop-content .sheet { margin: 0; border: 0; border-radius: 0; overflow: hidden; } +.desktop-content .desktop-sheet-content { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; margin-top: 44px; overflow: auto; } +.desktop-shell[data-layout="single-pane"] .desktop-content { padding-top: 44px; } +.desktop-shell[data-sidebar-visible="true"] { background: linear-gradient(to right, var(--bg) 0 var(--desktop-sidebar-width), var(--main) var(--desktop-sidebar-width)); } +.desktop-shell > .loading { flex: 1; min-height: 0; padding-top: 44px; } +.desktop-content .sidebar-resize { z-index: 21; } +.desktop-titlebar:has([role="menu"]) { z-index: 22; } .desktop-content .account-page, .desktop-content .loading { min-height: 100%; height: 100%; overflow: auto; } .desktop-content .desktop-sidebar-hidden { grid-template-columns: minmax(0, 1fr); } .desktop-sidebar-hidden > .sidebar { display: none; } -.desktop-window-notice { display: flex; align-items: center; gap: 12px; padding: 6px 16px; font-size: 12px; background: var(--amber-bg); color: var(--t2); } +.desktop-window-notice { position: relative; z-index: 23; margin-top: 44px; display: flex; align-items: center; gap: 12px; padding: 6px 16px; font-size: 12px; background: var(--amber-bg); color: var(--t2); } .desktop-window-notice button { margin-left: auto; } @media (max-width: 899px) { .desktop-content .sidebar.open { top: 44px; padding-top: 12px; } diff --git a/web/src/ui/Workspace.tsx b/web/src/ui/Workspace.tsx index b7fd42b..c815571 100644 --- a/web/src/ui/Workspace.tsx +++ b/web/src/ui/Workspace.tsx @@ -486,8 +486,16 @@ export function Workspace({ query.addEventListener("change", update); return () => query.removeEventListener("change", update); }, []); - useEffect(() => { - if (!desktop) return; + useLayoutEffect(() => { + if (!desktop || !loaded) return; + desktop.setLayout({ + sidebarWidth, + sidebarVisible: wide && !sidebarHidden, + }); + return () => desktop.setLayout(null); + }, [desktop?.setLayout, loaded, sidebarWidth, wide, sidebarHidden]); + useLayoutEffect(() => { + if (!desktop || !loaded) return; desktop.setNavigation({ expanded: wide ? !sidebarHidden : drawer, toggle: () => { @@ -496,7 +504,7 @@ export function Workspace({ }, }); return () => desktop.setNavigation(null); - }, [desktop?.setNavigation, wide, sidebarHidden, drawer]); + }, [desktop?.setNavigation, loaded, wide, sidebarHidden, drawer]); useEffect(() => { if (loaded) return; const timer = setTimeout(() => setShowLoadingSkeleton(true), 400); @@ -1801,6 +1809,7 @@ export function Workspace({ disabled={!wide || !!(desktop && sidebarHidden)} />
+
void readFiles(e.target.files)} /> +
{linkRecoveryOpen && links.service && ( errors.push(error.message)); +await context.route(/\/src\/desktop\/window\.ts(?:\?|$)/, (route) => + route.fulfill({ + contentType: "application/javascript", + body: `export const isDesktop = () => true; + export async function connectWindow(onClose,onResize) { + const state = window.__desktopPreview = {calls:[],failMinimize:false,maximized:false}; + return {minimize:async()=>{state.calls.push('minimize');if(state.failMinimize)throw Error('preview failure')}, + toggleMaximize:async()=>{state.calls.push('maximize');state.maximized=!state.maximized;onResize()}, + isMaximized:async()=>state.maximized,close:async()=>{state.calls.push('close');onClose(1)}, + startDragging:async()=>{state.calls.push('drag')},showSystemMenu:async()=>{state.calls.push('system-menu')}, + finishClose:async()=>{state.calls.push('finish-close')},dispose:()=>{state.calls.push('dispose')}}; + }`, + }), +); +await context.route( + /\/tests\/integration\/library-browser\.html\?desktop-shell/, + async (route) => { + const response = await route.fetch(); + await route.fulfill({ + response, + body: (await response.text()) + .replace( + '
', + '
Simulated native bridge; ', + ) + .replace( + "No account-server qualification.", + "No native OS or account-server qualification.", + ), + }); + }, +); +async function geometry(selector) { + return page.locator(selector).evaluate((element) => { + const r = element.getBoundingClientRect(), + css = getComputedStyle(element); + return { + x: r.x, + y: r.y, + width: r.width, + height: r.height, + bg: css.backgroundColor, + border: css.borderTopWidth, + radius: css.borderTopLeftRadius, + }; + }); +} +async function captions() { + for (const name of ["Minimize window", "Maximize window", "Close window"]) { + const button = page.getByRole("button", { name, exact: true }); + const r = await button.boundingBox(); + expect(Math.round(r.width)).toBe(46); + expect(Math.round(r.height)).toBe(44); + expect(Math.round(r.y)).toBe(0); + await expect(button).toBeInViewport(); + } +} +const samples = []; +try { + await page.goto( + `${base}/tests/integration/library-browser.html?desktop-shell=1`, + ); + const setup = page.getByRole("dialog", { name: "Set up Orbyn Notes" }); + await expect(setup).toBeVisible(); + await setup.getByRole("button", { name: "Skip", exact: true }).click(); + await setup.getByRole("button", { name: "Skip", exact: true }).click(); + await expect(setup).toHaveCount(0); + await expect( + page.getByRole("heading", { name: "All notes", exact: true }), + ).toBeVisible(); + const rail = page.getByRole("separator", { name: "Resize sidebar" }); + await expect(page.locator(".desktop-titlebar")).not.toContainText( + "Orbyn Notes", + ); + for (const theme of ["light", "dark", "dim"]) { + await page.evaluate((theme) => { + document.documentElement.dataset.theme = theme; + }, theme); + for (const width of [180, 224, 420]) { + await rail.focus(); + await page.keyboard.press("Enter"); + if (width === 180) + for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowLeft"); + if (width === 420) + for (let i = 0; i < 13; i++) await page.keyboard.press("ArrowRight"); + await expect(rail).toHaveAttribute("aria-valuenow", String(width)); + const title = await geometry(".desktop-titlebar"), + sheet = await geometry(".sheet"), + identity = await geometry(".identity"), + scroll = await geometry(".desktop-sheet-content"); + expect(title.height).toBe(44); + expect(title.bg).toBe("rgba(0, 0, 0, 0)"); + expect(sheet.y).toBe(0); + expect(sheet.x).toBe(width); + expect(sheet.border).toBe("0px"); + expect(sheet.radius).toBe("0px"); + expect(identity.y).toBe(56); + expect(scroll.y).toBe(44); + await captions(); + samples.push({ theme, width, title, sheet, identity, scroll }); + await page.screenshot({ path: `${output}/${theme}-${width}.png` }); + } + } + await page.getByRole("button", { name: "Hide sidebar" }).click(); + expect((await geometry(".sheet")).x).toBe(0); + await expect(rail).toHaveCount(0); + await page.getByRole("button", { name: "Show sidebar" }).click(); + expect((await geometry(".sheet")).x).toBe(420); + await rail.focus(); + await page.keyboard.press("Enter"); + await page + .getByRole("button", { name: "New note", exact: true }) + .first() + .click(); + await page + .getByLabel("Note title", { exact: true }) + .fill("Native layout retained note"); + await page + .getByLabel("Note content", { exact: true }) + .fill("Retained encrypted writing\n".repeat(80)); + await expect( + page.getByText("Saved in this browser", { exact: true }), + ).toBeVisible(); + const editor = await page + .getByLabel("Note content", { exact: true }) + .elementHandle(); + const portal = await page.locator(".desktop-editor-actions").elementHandle(); + await page.getByRole("button", { name: "Hide sidebar" }).click(); + await page.getByRole("button", { name: "Show sidebar" }).click(); + expect( + await page + .getByLabel("Note content", { exact: true }) + .evaluate((node, before) => node === before, editor), + ).toBe(true); + expect( + await page + .locator(".desktop-editor-actions") + .evaluate((node, before) => node === before, portal), + ).toBe(true); + await page.locator(".desktop-sheet-content").evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + expect((await geometry(".desktop-sheet-content")).y).toBe(44); + await page.getByLabel("Note content", { exact: true }).focus(); + expect((await geometry(".desktop-sheet-content")).y).toBe(44); + for (const width of [900, 899, 641, 640, 390]) { + await page.setViewportSize({ width, height: 850 }); + if (width < 900) await expect(rail).toHaveCount(0); + await captions(); + if (width > 640) + await expect( + page.locator(".desktop-editor-actions .note-links-toggle"), + ).toBeVisible(); + const menu = page.getByRole("button", { name: "Note menu", exact: true }); + await menu.focus(); + await expect(menu).toBeInViewport(); + const menuBox = await menu.boundingBox(), + cap = await page + .getByRole("button", { name: "Minimize window" }) + .boundingBox(); + expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(cap.x + 1); + await menu.click(); + await expect( + page.getByRole("menuitem", { name: "Rename", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Rename", exact: true }), + ).toBeInViewport(); + const popup = page.getByRole("menu", { name: "Note menu", exact: true }); + await popup.evaluate((element) => + Promise.all( + element + .getAnimations({ subtree: true }) + .filter( + (animation) => + animation.effect?.getTiming().iterations !== Infinity, + ) + .map((animation) => animation.finished.catch(() => {})), + ), + ); + await expect(popup).toHaveCSS("opacity", "1"); + await page.screenshot({ path: `${output}/editor-menu-${width}.png` }); + await page.keyboard.press("Escape"); + } + await page.setViewportSize({ width: 1280, height: 850 }); + await page.getByRole("button", { name: "Minimize window" }).click(); + expect( + await page.evaluate(() => + window.__desktopPreview.calls.includes("minimize"), + ), + ).toBe(true); + expect( + await page.evaluate(() => window.__desktopPreview.calls.includes("drag")), + ).toBe(false); + await page.evaluate(() => { + window.__desktopPreview.failMinimize = true; + }); + await page.getByRole("button", { name: "Minimize window" }).click(); + await expect( + page.getByText("Window controls aren’t available. Try again."), + ).toBeVisible(); + const retry = page.getByRole("button", { name: "Retry", exact: true }); + expect((await retry.boundingBox()).y).toBeGreaterThanOrEqual(44); + await page.screenshot({ path: `${output}/error-notice.png` }); + await retry.click(); + await expect(page.getByLabel("Note title", { exact: true })).toHaveValue( + "Native layout retained note", + ); + await page.goto( + `${base}/tests/integration/library-browser.html?desktop-shell=1&pane=account`, + ); + await expect(page.locator(".account-card")).toBeVisible(); + await expect(page.locator(".desktop-shell")).toHaveAttribute( + "data-layout", + "single-pane", + ); + await expect(page.getByRole("button", { name: /sidebar/ })).toHaveCount(0); + expect((await geometry(".account-card")).y).toBeGreaterThanOrEqual(44); + await captions(); + await page.screenshot({ path: `${output}/single-pane-account.png` }); + expect(errors).toEqual([]); + await writeFile( + `${output}/result.json`, + JSON.stringify( + { + qualification: + "Production shell/Workspace with explicitly simulated native bridge and synthetic account; genuine WASM/IndexedDB. No native OS input, authentication or FPS qualification.", + samples, + pageErrors: errors, + }, + null, + 2, + ), + ); + console.log("Desktop layout browser checks passed"); +} catch (error) { + await page.screenshot({ path: `${output}/failed-state.png` }); + await writeFile( + `${output}/failed-state.json`, + JSON.stringify( + await page.evaluate(() => ({ + width: innerWidth, + height: innerHeight, + active: document.activeElement?.outerHTML, + nodes: [ + ...document.querySelectorAll( + ".desktop-titlebar,.desktop-editor-toolbar,.desktop-editor-actions,.menu-anchor,.desktop-caption-controls", + ), + ].map((element) => ({ + name: element.className, + rect: element.getBoundingClientRect().toJSON(), + scrollLeft: element.scrollLeft, + scrollWidth: element.scrollWidth, + clientWidth: element.clientWidth, + overflow: getComputedStyle(element).overflow, + })), + })), + null, + 2, + ), + ); + throw error; +} finally { + await context.close(); + await browser.close(); +} diff --git a/web/tests/integration/library-browser.tsx b/web/tests/integration/library-browser.tsx index 08d48af..e8cab04 100644 --- a/web/tests/integration/library-browser.tsx +++ b/web/tests/integration/library-browser.tsx @@ -1,6 +1,9 @@ // Test-only entry. Never imported by the production application. import { createRoot } from "react-dom/client"; import { Workspace } from "../../src/ui/Workspace"; +import { DesktopShell } from "../../src/desktop/DesktopShell"; +import { AccountView } from "../../src/ui/AccountView"; +import { createAccountService } from "../../src/account"; import { applyInitialPreferences } from "../../src/ui/primitives"; import { openNotesStore } from "../../src/data"; import { establish } from "../../src/account/session"; @@ -43,15 +46,38 @@ async function start() { } establish(identity); const notes = await openNotesStore(identity); - createRoot(document.getElementById("root")!).render( + const workspace = ( { notes.close(); }} - />, + /> + ); + createRoot(document.getElementById("root")!).render( + new URLSearchParams(location.search).has("desktop-shell") ? ( + + {new URLSearchParams(location.search).get("pane") === "account" ? ( + { + throw new Error("Preview does not submit authentication"); + }} + /> + ) : ( + workspace + )} + + ) : ( + workspace + ), ); } void start().catch((error) => { diff --git a/web/tests/ui/desktop-layout.test.tsx b/web/tests/ui/desktop-layout.test.tsx new file mode 100644 index 0000000..0a770c0 --- /dev/null +++ b/web/tests/ui/desktop-layout.test.tsx @@ -0,0 +1,195 @@ +import { useLayoutEffect, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { DesktopShell, useDesktop } from "../../src/desktop/DesktopShell"; +import { connectWindow, isDesktop } from "../../src/desktop/window"; + +vi.mock("../../src/desktop/window", () => ({ + isDesktop: vi.fn(), + connectWindow: vi.fn(), +})); +afterEach(() => vi.unstubAllGlobals()); +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(isDesktop).mockReturnValue(true); + vi.mocked(connectWindow).mockResolvedValue({ + minimize: vi.fn(async () => {}), + toggleMaximize: vi.fn(async () => {}), + isMaximized: async () => false, + close: vi.fn(async () => {}), + startDragging: vi.fn(async () => {}), + showSystemMenu: vi.fn(async () => {}), + finishClose: vi.fn(async () => {}), + dispose: vi.fn(), + }); +}); +it("leaves single-pane caption space free of invented title and navigation", async () => { + render( + +
Account entry
+
, + ); + await screen.findByText("Account entry"); + const toolbar = screen.getByRole("banner", { name: "Window toolbar" }); + expect(toolbar).not.toHaveTextContent("Orbyn Notes"); + expect( + screen.queryByRole("button", { name: /sidebar/ }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Minimize window" })).toBeEnabled(); + expect(document.querySelector(".desktop-shell")).toHaveAttribute( + "data-layout", + "single-pane", + ); +}); +it("updates content-free layout without remounting its child or editor portal", async () => { + let mounts = 0, + unmounts = 0; + let update!: (value: number) => void; + let hide!: () => void; + function Content() { + const desktop = useDesktop(); + const [width, setWidth] = useState(224), + [visible, setVisible] = useState(true); + update = setWidth; + hide = () => setVisible(false); + useEffect(() => { + mounts++; + return () => { + unmounts++; + }; + }, []); + useLayoutEffect(() => { + desktop?.setLayout({ sidebarWidth: width, sidebarVisible: visible }); + return () => desktop?.setLayout(null); + }, [desktop?.setLayout, width, visible]); + return ( +
+ + {desktop?.toolbar && + createPortal(, desktop.toolbar)} +
+ ); + } + const view = render( + + + , + ); + const action = await screen.findByRole("button", { name: "Portaled action" }); + const input = screen.getByRole("textbox", { name: "Retained draft" }); + fireEvent.change(input, { target: { value: "Retained unsaved draft" } }); + const toolbarNode = action.parentElement; + expect(document.querySelector(".desktop-shell")).toHaveAttribute( + "data-layout", + "workspace", + ); + act(() => update(420)); + act(hide); + expect(screen.getByRole("button", { name: "Portaled action" })).toBe(action); + expect(action.parentElement).toBe(toolbarNode); + expect(screen.getByRole("textbox")).toBe(input); + expect(input).toHaveValue("Retained unsaved draft"); + expect(mounts).toBe(1); + expect(unmounts).toBe(0); + view.unmount(); + expect(unmounts).toBe(1); +}); +it("returns to single-pane geometry when Workspace leaves without recreating the bridge", async () => { + function WorkspaceLayout() { + const desktop = useDesktop(); + useLayoutEffect(() => { + desktop?.setLayout({ sidebarWidth: 300, sidebarVisible: true }); + return () => desktop?.setLayout(null); + }, [desktop?.setLayout]); + return
Library
; + } + const view = render( + + + , + ); + await screen.findByText("Library"); + expect(document.querySelector(".desktop-shell")).toHaveAttribute( + "data-layout", + "workspace", + ); + view.rerender( + +
Account entry
+
, + ); + expect(document.querySelector(".desktop-shell")).toHaveAttribute( + "data-layout", + "single-pane", + ); + expect(connectWindow).toHaveBeenCalledOnce(); +}); +it("reveals an already focused toolbar action on resize without scrolling fixed menu items or stealing focus", async () => { + let notify = () => {}; + const disconnect = vi.fn(); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: () => void) { + notify = callback; + } + observe = vi.fn(); + disconnect = disconnect; + }, + ); + function Content() { + const desktop = useDesktop(); + return ( +
+ + {desktop?.toolbar && + createPortal( + <> + +
+ +
+ , + desktop.toolbar, + )} +
+ ); + } + const view = render( + + + , + ); + const action = await screen.findByRole("button", { name: "Portaled action" }); + const toolbar = action.parentElement!; + Object.defineProperties(toolbar, { + clientWidth: { value: 180 }, + scrollWidth: { value: 600 }, + clientLeft: { value: 0 }, + }); + vi.spyOn(toolbar, "getBoundingClientRect").mockReturnValue( + new DOMRect(70, 0, 180, 44), + ); + vi.spyOn(action, "getBoundingClientRect").mockReturnValue( + new DOMRect(70, 0, 40, 32), + ); + action.focus(); + expect(toolbar.scrollLeft).toBe(0); + vi.mocked(action.getBoundingClientRect).mockReturnValue( + new DOMRect(400, 0, 40, 32), + ); + act(notify); + expect(toolbar.scrollLeft).toBe(190); + expect(action).toHaveFocus(); + toolbar.scrollLeft = 0; + screen.getByRole("button", { name: "Fixed menu action" }).focus(); + act(notify); + expect(toolbar.scrollLeft).toBe(0); + screen.getByRole("textbox", { name: "Outside toolbar" }).focus(); + act(notify); + expect(screen.getByRole("textbox")).toHaveFocus(); + expect(toolbar.scrollLeft).toBe(0); + view.unmount(); + expect(disconnect).toHaveBeenCalledOnce(); +}); diff --git a/web/tests/ui/desktop.test.tsx b/web/tests/ui/desktop.test.tsx index b4511ef..551a92c 100644 --- a/web/tests/ui/desktop.test.tsx +++ b/web/tests/ui/desktop.test.tsx @@ -69,11 +69,12 @@ describe("desktop window", () => { vi.mocked(connectWindow).mockResolvedValue(window); render(Content); await screen.findByText("Content"); - fireEvent.mouseDown(screen.getByText("Orbyn Notes"), { button: 0, detail: 1 }); + const toolbar = screen.getByRole("banner", { name: "Window toolbar" }); + fireEvent.mouseDown(toolbar, { button: 0, detail: 1 }); expect(window.startDragging).toHaveBeenCalledOnce(); fireEvent.mouseDown(screen.getByRole("button", { name: "Close window" }), { button: 0, detail: 1 }); expect(window.startDragging).toHaveBeenCalledOnce(); - fireEvent.mouseDown(screen.getByText("Orbyn Notes"), { button: 0, detail: 2 }); + fireEvent.mouseDown(toolbar, { button: 0, detail: 2 }); expect(window.toggleMaximize).toHaveBeenCalledOnce(); }); it("blocks guarded and pending work without replaying the close", async () => {