diff --git a/docs/shell-design.md b/docs/shell-design.md index 60f359dfb..912b7f74d 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -29,7 +29,7 @@ semantic tokens, UI authoring rules and the local component reference. Channels is presented as Messages. Legacy tone props are retained for compatibility; all pages share the supplied gradient and repeating CSS dots. Add recognized page presentation here without changing plugin contracts. -- `AppShell.tsx` owns the 56px header, vertical page navigation, contributed panel +- `AppShell.tsx` owns the 48px header, vertical page navigation, contributed panel launchers, Settings access, community rail, and page frames. Page navigation sits above the channel list outside Settings, using its saved sidebar width and resize behavior. Settings replaces that card with `SettingsSidebar.tsx`, @@ -45,7 +45,9 @@ semantic tokens, UI authoring rules and the local component reference. 220px disclosure overlays content, supports Escape, and keeps sidebar state mounted. A navigation selection closes the phone drawer and hands focus to the main content; this includes conversation and Settings-section selections. - Desktop layouts retain the visible sidebar and saved width. + Messages, Agents, and desktop Settings share an animated header toggle; hiding + the sidebar preserves its mounted state and saved width. Reduced motion disables + the transition. Other desktop pages retain the visible sidebar. The header keeps history and account/search actions, with no second navigation row. Full-height pages get a 16px outer gutter (8px on narrow screens) and own their card surfaces. The shell adds no white backing behind them. Document pages diff --git a/src/app/entity-navigation.test.tsx b/src/app/entity-navigation.test.tsx index 8a111cc84..1852b980d 100644 --- a/src/app/entity-navigation.test.tsx +++ b/src/app/entity-navigation.test.tsx @@ -11,7 +11,7 @@ import { within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { finalizeEvent, getPublicKey } from "nostr-tools"; import { App } from "./App"; import { createServices, type AppServices } from "./services"; @@ -55,6 +55,15 @@ const project = finalizeEvent( }, key, ); +// jsdom has no media queries; responsive geometry is covered in browser tests. +beforeEach(() => { + vi.stubGlobal("matchMedia", (media: string) => ({ + media, + matches: false, + addEventListener() {}, + removeEventListener() {}, + })); +}); let services: AppServices | undefined; let stop = () => {}; afterEach(async () => { diff --git a/src/app/navigation.test.tsx b/src/app/navigation.test.tsx index 03c174a12..81537ee75 100644 --- a/src/app/navigation.test.tsx +++ b/src/app/navigation.test.tsx @@ -8,7 +8,7 @@ import { screen, waitFor, } from "@testing-library/react"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { keypair, signed } from "../features/relay/testing"; import { stubAvatarBrowserApis } from "../features/agents/avatar-testing"; import type { RelayEvent } from "../features/relay/events"; @@ -46,6 +46,15 @@ vi.mock("../bundled", async () => ({ }, ], })); +// jsdom has no media queries; responsive geometry is covered in browser tests. +beforeEach(() => { + vi.stubGlobal("matchMedia", (media: string) => ({ + media, + matches: false, + addEventListener() {}, + removeEventListener() {}, + })); +}); let services: AppServices | undefined; afterEach(async () => { activation.release(); diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index a2870585b..58d43841e 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -51,9 +51,31 @@ export function AppShell({ children: ReactNode; }) { const fillsWorkspace = workspace || selected === "settings"; + const channelsNavigation = + selected === "buzz.channels/channels" || selected === "buzz.agents/agents"; + const [narrow, setNarrow] = useState( + () => window.matchMedia("(max-width: 650px)").matches, + ); + useEffect(() => { + const query = window.matchMedia("(max-width: 650px)"); + const update = () => setNarrow(query.matches); + update(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + const collapsibleSidebar = channelsNavigation || selected === "settings"; + const [sidebarOpen, setSidebarOpen] = useState(true); const [navigationOpen, setNavigationOpen] = useState(false); const navigationToggle = useRef(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: Only a new navigation attempt closes the drawer; opening the disclosure must not retrigger this effect. + const visibleSidebar = narrow ? navigationOpen : sidebarOpen; + const toggleLabel = narrow + ? navigationOpen + ? "Hide navigation" + : "Show navigation" + : sidebarOpen + ? "Hide Channel sidebar" + : "Show Channel sidebar"; + // biome-ignore lint/correctness/useExhaustiveDependencies: Only a navigation attempt closes the drawer. useEffect(() => { if (navigationOpen && navigationToggle.current?.getClientRects().length) { document.getElementById("main-content")?.focus({ preventScroll: true }); @@ -109,19 +131,25 @@ export function AppShell({ data-tauri-drag-region={macDesktop ? undefined : true} {...titleBarDragProps} > - {navigationControls} - + {(collapsibleSidebar || narrow) && ( setNavigationOpen((open) => !open)} - icon={ + )} + {navigationControls}
- {/* biome-ignore lint/a11y/noStaticElementInteractions: Delegated Escape from descendant controls closes the disclosure; the layout wrapper is not itself interactive. */}
{ if ( diff --git a/src/app/shell/NavigationControls.tsx b/src/app/shell/NavigationControls.tsx index 2c8977f40..07747e9cc 100644 --- a/src/app/shell/NavigationControls.tsx +++ b/src/app/shell/NavigationControls.tsx @@ -9,7 +9,7 @@ export function NavigationControls({ navigation }: { navigation: Navigation }) { const state = useSyncExternalStore(navigation.subscribe, navigation.snapshot); return ( ); diff --git a/src/app/shell/PageSearch.tsx b/src/app/shell/PageSearch.tsx index 019e5a013..cdc1f4c17 100644 --- a/src/app/shell/PageSearch.tsx +++ b/src/app/shell/PageSearch.tsx @@ -153,13 +153,7 @@ export function PageSearch({ trigger.current?.focus(); begin(); }} - icon={ -