Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/agent-forge/src/__tests__/polishCss.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
96 changes: 71 additions & 25 deletions apps/agent-forge/src/shell/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand All @@ -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 (
<div className="app-shell-bar">
<button
type="button"
className="btn btn-ghost nav-toggle"
aria-label="Navigation"
aria-expanded={navOpen}
aria-controls="primary-nav"
onClick={onToggle}
>
<Menu size={16} strokeWidth={1.75} aria-hidden="true" />
</button>
<LogoMark />
<span className="nav-bar-title">Agent Forge</span>
</div>
);
}

/**
* 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 (
<DockProvider>
<ShellLayout dock={dock}>{children}</ShellLayout>
</DockProvider>
);
}

/** 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 (
<ShellContext.Provider value={{ navOpen }}>
<div className={`app-shell${narrow ? ' app-shell-narrow' : ''}`} data-nav={!narrow && collapsed ? 'collapsed' : undefined}>
{narrow && (
<div className="app-shell-bar">
<button
type="button"
className="btn btn-ghost nav-toggle"
aria-label="Navigation"
aria-expanded={navOpen}
aria-controls="primary-nav"
onClick={() => setOpen((o) => !o)}
>
<Menu size={16} strokeWidth={1.75} aria-hidden="true" />
</button>
<LogoMark />
<span className="nav-bar-title">Agent Forge</span>
</div>
)}
<div {...gridAttrs(narrow, collapsed, docked, dockApi.width)}>
{narrow && <NarrowBar navOpen={navOpen} onToggle={toggleNav} />}
<NavRail
drawer={narrow ? (navOpen ? 'open' : 'closed') : undefined}
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
onNavigate={() => setOpen(false)}
onNavigate={closeNav}
/>
{navOpen && <div className="app-shell-scrim" aria-hidden="true" onClick={() => setOpen(false)} />}
{navOpen && <div className="app-shell-scrim" aria-hidden="true" onClick={closeNav} />}
<div className="app-shell-main">
<ConnectionBanner />
{children}
</div>
{dock !== undefined && <Dock label="Agent Author">{dock}</Dock>}
<CommandPalette />
</div>
</ShellContext.Provider>
Expand Down
Loading