diff --git a/apps/agent-forge/src/__tests__/polishCss.test.ts b/apps/agent-forge/src/__tests__/polishCss.test.ts index 2857c89ea..0b5d90c52 100644 --- a/apps/agent-forge/src/__tests__/polishCss.test.ts +++ b/apps/agent-forge/src/__tests__/polishCss.test.ts @@ -69,3 +69,24 @@ describe('U01 tokens', () => { expect(theme).toMatch(/--nav-bg:\s*light-dark\(#efeff2,\s*#060608\);/); }); }); + +describe('U02 floating content card', () => { + const css = sheet('shell/shell.css'); + it('frames the shell on --nav-bg', () => { + expect(rulesFor(css, '.app-shell')).toMatch(/background:\s*var\(--nav-bg\)/); + expect(rulesFor(css, '.nav-rail')).toMatch(/background:\s*var\(--nav-bg\)/); + }); + it('floats the screen as a card when docked', () => { + const main = rulesFor(css, '.app-shell-main'); + expect(main).toMatch(/margin:\s*var\(--space-2\) var\(--space-2\) var\(--space-2\) 0/); + expect(main).toMatch(/border-radius:\s*var\(--radius-lg\)/); + expect(main).toMatch(/box-shadow:\s*var\(--shadow-sm\)/); + expect(main).toMatch(/overflow:\s*hidden/); + }); + it('drops the card below 860px', () => { + const narrow = rulesFor(css, '.app-shell-narrow .app-shell-main'); + expect(narrow).toMatch(/margin:\s*0/); + expect(narrow).toMatch(/border-radius:\s*0/); + expect(narrow).toMatch(/box-shadow:\s*none/); + }); +}); diff --git a/apps/agent-forge/src/shell/AppShell.tsx b/apps/agent-forge/src/shell/AppShell.tsx index 5bc7c7c4f..9e413719f 100644 --- a/apps/agent-forge/src/shell/AppShell.tsx +++ b/apps/agent-forge/src/shell/AppShell.tsx @@ -1,9 +1,10 @@ import './shell.css'; import { Menu } from 'lucide-react'; -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { createContext, useCallback, useContext, useEffect, useState, type CSSProperties, type ReactNode } from 'react'; import { NARROW_QUERY, useMediaQuery } from '../components/useMediaQuery'; import { useAppState } from '../state/AppState'; import { CommandPalette } from '../ui/CommandPalette'; +import { Dock, DockProvider, useDock, type DockApi } from './Dock'; import { LogoMark } from './NavBrand'; import { NavRail } from './NavRail'; import { useNavCollapsed } from './navCollapsed'; @@ -24,13 +25,11 @@ function ConnectionBanner() { } /** - * Left navigation around every screen. Docked, it collapses to a 56px icon rail - * (remembered in localStorage); below 860px it is a slide-over drawer (scrim / Escape close it). + * The narrow-mode nav drawer: open state, Escape close, and exclusivity with + * the dock sheet (opening the drawer closes the dock). */ -export function AppShell({ children }: { children: ReactNode }) { - const narrow = useMediaQuery(NARROW_QUERY); +function useNavDrawer(narrow: boolean, dock: DockApi): { navOpen: boolean; toggleNav: () => void; closeNav: () => void } { const [open, setOpen] = useState(false); - const [collapsed, toggleCollapsed] = useNavCollapsed(); useEffect(() => { if (!narrow) setOpen(false); @@ -44,36 +43,83 @@ export function AppShell({ children }: { children: ReactNode }) { }, [open]); const navOpen = narrow && open; + useEffect(() => { + if (navOpen) dock.setOpen(false); + }, [navOpen, dock]); + + const toggleNav = useCallback(() => setOpen((o) => !o), []); + const closeNav = useCallback(() => setOpen(false), []); + return { navOpen, toggleNav, closeNav }; +} + +/** The narrow-mode top bar: hamburger button, logo and app name. Only rendered below 860px. */ +function NarrowBar({ navOpen, onToggle }: { navOpen: boolean; onToggle: () => void }) { + return ( +
+ + + Agent Forge +
+ ); +} + +/** + * Left navigation around every screen. Docked, it collapses to a 56px icon rail + * (remembered in localStorage); below 860px it is a slide-over drawer (scrim / Escape close it). + * `dock` is the optional right-hand panel column (the Agent Author's content); + * when it is undefined the dock never renders, even if an open state is stored. + */ +export function AppShell({ children, dock }: { children: ReactNode; dock?: ReactNode }) { + return ( + + {children} + + ); +} + +/** Attributes of the `.app-shell` grid: rail 232/56px, flexible screen, optional dock column. */ +function gridAttrs(narrow: boolean, collapsed: boolean, docked: boolean, dockWidth: number) { + return { + className: `app-shell${narrow ? ' app-shell-narrow' : ''}`, + 'data-nav': !narrow && collapsed ? ('collapsed' as const) : undefined, + 'data-dock': docked ? ('open' as const) : undefined, + style: docked ? ({ '--dock-width': `${dockWidth}px` } as CSSProperties) : undefined, + }; +} + +/** The shell markup; lives inside DockProvider so the dock API can drive its grid. */ +function ShellLayout({ children, dock }: { children: ReactNode; dock?: ReactNode }) { + const narrow = useMediaQuery(NARROW_QUERY); + const [collapsed, toggleCollapsed] = useNavCollapsed(); + const dockApi = useDock(); + const { navOpen, toggleNav, closeNav } = useNavDrawer(narrow, dockApi); + const docked = dock !== undefined && dockApi.open && !narrow; + return ( -
- {narrow && ( -
- - - Agent Forge -
- )} +
+ {narrow && } setOpen(false)} + onNavigate={closeNav} /> - {navOpen &&