Workspace Navigation Architecture
Activity-based workspace system with multi-panel layouts for production management.
Overview
On Book Pro uses a workspace-based navigation architecture that surfaces relevant tools based on the user's role while keeping all permitted tools accessible. Workspaces are named after theatrical spaces (Rehearsal Room, Sound Booth, Light Lab) rather than roles to promote activity-based thinking. This replaced the previous flat 12+ tab horizontal navigation that reached its cognitive scaling limit.
As of February 2026, workspaces support multi-panel split layouts — users can view two tools side-by-side with resizable panels, powered by react-resizable-panels.
Design Principles
- Activity-Based Views: Tools are grouped by theatrical activity (Rehearsal Room, Sound Booth, etc.) rather than role names
- Multi-Panel Layouts: Workspaces can define side-by-side tool panels with resizable dividers
- Context Switching: Users can switch workspaces without changing permissions
- Permission Integration: Workspaces and tools filter based on RBAC permissions
- Responsive Design: Vertical sidebar (desktop) with collapsible icon-only mode; mobile sidebar drawer (<768px) with hamburger trigger; split view desktop-only (≥1024px)
- Device Gating: Tools declare a
minWidth— viewports below that threshold show aDeviceGateBannerinstead of the tool, directing users to a larger screen
Architecture
Configuration Source
All workspace and tool definitions live in:
src/features/layout/workspace-config.tsType Definitions
export type WorkspaceId =
| 'stage-management' // "Rehearsal Room"
| 'costumes' // "Wardrobe"
| 'props' // "Workshop"
| 'sound' // "Sound Booth"
| 'lighting' // "Light Lab"
| 'director' // "Creative Suite"
| 'production-manager' // "Production Office"
| 'cast-member' // "Green Room"
| 'all-tools'; // "Backstage"
export type ToolId =
| 'show-structure'
| 'cast'
| 'scheduler'
| 'calendar'
| 'notes'
| 'set-builder'
| 'blocking'
| 'blocking-snapshot'
| 'props'
| 'costumes'
| 'sound'
| 'lighting'
| 'feed'
| 'files'
| 'scenes'
| 'budget'
| 'production'
| 'settings'
| 'dev';Configuration Interfaces
export interface WorkspaceConfig {
id: WorkspaceId;
label: string;
icon: string; // Material Symbols icon name
description: string;
primaryTools: ToolId[]; // Ordered tool list
requiredPermissions?: Permission[]; // If user lacks these, workspace is hidden
/** Suggested companion tool when splitting from a given active tool */
defaultCompanion?: Partial<Record<ToolId, ToolId>>;
}
export interface ToolConfig {
id: ToolId;
label: string;
icon: string;
route: string; // URL path (e.g., '/scheduler')
requiredPermissions?: Permission[];
minWidth?: number; // Minimum viewport width in px (device gate threshold)
}Default Companion Suggestions
Workspaces can define a defaultCompanion map that suggests a companion tool when the user activates split view from a specific tool. The active tool always stays in the left panel.
defaultCompanion: {
'prompt-book': 'notes', // Split from Prompt Book → suggest Notes on right
'blocking': 'notes', // Split from Blocking → suggest Notes on right
'notes': 'prompt-book', // Split from Notes → suggest Prompt Book on right
}| Workspace | Tool → Suggested Companion |
|---|---|
| Rehearsal Room | Prompt Book → Notes, Blocking → Notes, Notes → Prompt Book |
| Sound Booth | Sound → Scenes, Scenes → Sound |
| Light Lab | Lighting → Prompt Book, Scenes → Lighting |
| Creative Suite | Blocking → Notes, Notes → Blocking |
| Production Office | Production → Budget, Budget → Production |
Component Hierarchy
Desktop (≥768px)
MainLayout
├── Header
│ ├── Logo
│ ├── Presence Indicators
│ └── User Avatar → My Profile Modal
│
├── WorkspaceSidebar (floating, collapsible)
│ ├── Workspace Selector Button
│ ├── Global Tools (Feed)
│ ├── Primary Tools (per workspace)
│ ├── Secondary Tools (collapsed "All Tools")
│ └── Collapse Toggle
│
├── ToolActionBar
│ ├── Title (panel-aware: "Workspace — Tool" in split view)
│ ├── Left Actions
│ ├── Split Toggle Button (visible when workspace has layout + desktop)
│ └── Right Actions (Undo/Redo, Print, etc.)
│
├── Content Area (single-panel mode)
│ └── <Outlet /> (React Router) OR DeviceGateBanner
│
└── Content Area (split-view mode, ≥1024px only)
└── SplitLayout
├── Group (react-resizable-panels, orientation: horizontal)
│ ├── Panel (id="split-left") → GlassPanel → Active Tool
│ ├── Separator (resize handle)
│ └── Panel (id="split-right") → GlassPanel → Companion Tool or PanelToolPicker
└── PanelToolPicker (shown when no companion selected)Mobile (<768px)
MainLayout
├── Header
│ ├── Hamburger Button (aria-label="Open navigation")
│ ├── Title (centered, truncated)
│ └── Cloud Status · User Avatar (right-aligned)
│
├── Mobile Sidebar Drawer (AnimatePresence overlay)
│ ├── Backdrop (bg-black/40, click to dismiss)
│ └── Drawer (w-64, slides in from left)
│ └── WorkspaceSidebar (full-featured)
│
└── Content Area (full width, no sidebar offset)
└── <Outlet /> OR DeviceGateBannerKey Components
| Component | Path | Purpose |
|---|---|---|
WorkspaceSidebar | src/features/layout/components/WorkspaceSidebar.tsx | Main sidebar with workspace-filtered tools |
WorkspaceSelector | src/features/layout/components/WorkspaceSelector.tsx | Modal for switching workspaces |
SidebarTool | (in WorkspaceSidebar) | Individual sidebar button |
MainLayout | src/features/layout/components/MainLayout.tsx | Root layout component |
SplitLayout | src/features/layout/components/SplitLayout.tsx | Two-panel split renderer |
PanelToolPicker | src/features/layout/components/PanelToolPicker.tsx | Tool selection grid for right panel |
GlassPanel | src/components/common/GlassSurface/GlassPanel.tsx | Focus-tracking panel container |
PanelSeparator | src/components/common/GlassSurface/PanelSeparator.tsx | Invisible-reveal resize handle |
ToolActionBar | src/components/common/ToolActionBar.tsx | Per-tool action bar with split toggle |
DeviceGateBanner | src/components/common/DeviceGateBanner.tsx | Viewport-too-small warning with bypass option |
useDeviceGate | src/hooks/useDeviceGate.ts | Hook: compares viewport width to tool's minWidth |
Permission Integration
Workspace Visibility
Workspaces are hidden if the user lacks ANY of their requiredPermissions:
export function getAccessibleWorkspaces(userPermissions: Permission[]): WorkspaceConfig[] {
return WORKSPACES.filter(workspace => {
if (!workspace.requiredPermissions) return true;
return workspace.requiredPermissions.some(p => userPermissions.includes(p));
});
}Tool Visibility
Tools are hidden if the user lacks ANY of their requiredPermissions:
export function getAccessibleTools(userPermissions: Permission[]): ToolConfig[] {
return TOOLS.filter(tool => {
if (!tool.requiredPermissions) return true;
return tool.requiredPermissions.some(p => userPermissions.includes(p));
});
}Default Workspace Selection
New users are assigned a default workspace based on their permissions:
export function getDefaultWorkspace(userPermissions: Permission[]): WorkspaceId {
if (userPermissions.includes(Permission.SELF_CHECK_IN) && userPermissions.length <= 3) {
return 'cast-member'; // Minimal permissions = likely actor
}
if (userPermissions.includes(Permission.EDIT_RUNSHEET)) {
return 'stage-management';
}
if (userPermissions.includes(Permission.MANAGE_USERS)) {
return 'production-manager';
}
// ... additional checks
return 'all-tools'; // Fallback
}Device Gate System
Tools can declare a minimum viewport width via minWidth in their ToolConfig. When the active viewport is narrower than that threshold, MainLayout renders a DeviceGateBanner instead of the tool component.
How It Works
// In MainLayout.tsx
const deviceGate = useDeviceGate(activeToolConfig?.minWidth);
const gatedComponent = deviceGate.isGated
? <DeviceGateBanner toolName={toolLabel} minimumDevice="tablet" onBypass={() => deviceGate.bypass()} />
: activeComponent;useDeviceGate Hook
// src/hooks/useDeviceGate.ts
function useDeviceGate(minWidthPx?: number): {
isGated: boolean; // true when viewport < minWidthPx (and not bypassed)
bypass: () => void; // user override — dismisses banner for this session
}- If
minWidthPxisundefined, the tool is never gated - Bypass state resets on tool change or page refresh
Currently Gated Tools (minWidth: 768)
| Tool | Reason |
|---|---|
| Scheduler | Complex grid layout |
| Set Builder | Konva canvas |
| Blocking | Konva canvas |
| Lighting | Multi-column cue list |
| Prompt Book | Script + cue palette |
| Run Sheet | Wide data table |
| Budget | Spreadsheet layout |
| Production | Multi-panel dashboard |
| Projections | Media management |
Adding Device Gating to a New Tool
Add minWidth to the tool's config in workspace-config.ts:
{ id: 'your-tool', label: 'Your Tool', icon: 'icon', route: '/your-tool',
requiredPermissions: [Permission.SOME_PERMISSION],
minWidth: 768, // Gate on viewports narrower than 768px
},No other changes needed — MainLayout handles the rest automatically.
Keyboard Navigation
Shortcut Bindings
| Shortcut | Action |
|---|---|
Ctrl + 1 | Open Feed (always first) |
Ctrl + 2-9 | Jump to tools 2-9 in sidebar |
Ctrl + Space | Open Workspace Selector |
Ctrl + B | Collapse/expand sidebar |
Ctrl + Z | Undo (scoped to active tool) |
Ctrl + Shift + Z | Redo |
Implementation Notes
The useKeyboardNavigation hook handles global shortcuts:
- Focus Guard: Shortcuts are disabled when user is typing in
<input>,<textarea>, orcontenteditableelements - Dynamic Indexing: Tool indices recalculate when sidebar collapses (maps to visible tools only)
- Order Preservation: Uses
.map()onprimaryToolsto maintain workspace-specific order
URL Strategy
Navigation uses flat URLs for backward compatibility and deep linking:
/run-sheet → Run Sheet tool
/scheduler → Scheduler tool
/costumes → Costumes toolThe active workspace is a UI filter, not a routing layer. Changing workspaces does not change the URL—it only changes which tools appear in the sidebar.
Adding a New Workspace
Add the WorkspaceId type:
typescriptexport type WorkspaceId = | 'stage-management' | 'your-new-workspace' // Add here | ...Add the workspace configuration:
typescriptexport const WORKSPACES: WorkspaceConfig[] = [ // ... existing { id: 'your-new-workspace', label: 'Your Workspace', icon: 'your_icon', description: 'Description for tooltips', primaryTools: ['tool1', 'tool2', 'tool3'], requiredPermissions: [Permission.SOME_PERMISSION], }, ];Update default workspace logic (if needed):
typescriptexport function getDefaultWorkspace(permissions: Permission[]): WorkspaceId { // Add check for your workspace }
Adding a New Tool
Add the ToolId type:
typescriptexport type ToolId = | 'existing-tools' | 'your-new-tool' // Add here | ...Add the tool configuration:
typescriptexport const TOOLS: ToolConfig[] = [ // ... existing { id: 'your-new-tool', label: 'Your Tool', icon: 'icon_name', route: '/your-tool', requiredPermissions: [Permission.SOME_PERMISSION], }, ];Add the route in
src/main.tsx:typescript<Route path="/your-tool" element={<YourToolComponent />} />Add to workspace(s):
typescript{ id: 'stage-management', primaryTools: ['existing', 'your-new-tool'], // Add here }
Multi-Panel Split View
How It Works
- Activation: The "Split" toggle button is always visible in the ToolActionBar (desktop only, ≥1024px)
- Left Panel = Active Tool: When the user clicks "Split," their current tool stays in the left panel
- Right Panel = User's Choice: The right panel shows a
PanelToolPickergrid, or a suggested companion tool from the workspace'sdefaultCompanionmap - Companion Persistence: The last-used right-panel tool is saved per active tool to localStorage (
onbook-split-companion:{toolId}) - Focus Tracking: Clicking a panel calls
onPanelFocus(side), which updates the active tab and ToolActionBar title - Panel-Aware Title: In split view, the ToolActionBar title shows
"Workspace — Focused Tool"
Resolution Order for Right Panel
When split view activates, the right panel tool is resolved in this order:
- localStorage —
onbook-split-companion:{activeToolId}(user's last choice) - Workspace default —
currentWorkspace.defaultCompanion[activeToolId] - null — show
PanelToolPicker(user picks manually)
SplitLayout Architecture
Group (orientation: "horizontal", onLayoutChanged)
├── Panel (id="split-left", defaultSize: 60%)
│ └── GlassPanel (focused, onPanelFocus)
│ └── Active Tool Component
├── Separator (resize handle)
└── Panel (id="split-right", defaultSize: 40%)
└── GlassPanel (focused, onPanelFocus)
└── Companion Tool Component OR PanelToolPickerPersistence
Panel sizes are persisted to localStorage with the key onbook-split-sizes:{workspaceId}:{leftToolId}:{rightToolId}:
// Read: on mount
const persisted = readPersistedSizes(sizeKey);
// Returns: { 'split-left': 60, 'split-right': 40 }
// Write: on resize via onLayoutChanged callback
handleLayoutChanged(layout) → localStorage.setItem(...)Companion tool is persisted per active tool:
// Key: onbook-split-companion:{activeToolId}
// Value: rightToolId (e.g., 'notes')Glass Components (Liquid Glass Design System)
| Component | Purpose |
|---|---|
GlassRegular | Frosted glass material for navigation surfaces |
GlassClear | High-transparency material for media-rich overlays |
GlassPanel | Split-view panel with focus/inactive states, materialization animation |
PanelSeparator | Invisible-reveal resize handle (4px hit target, 44px touch) |
All glass components use CSS modules (glass.module.css) with GlassKit design tokens and accessibility fallbacks for:
prefers-reduced-transparencyprefers-reduced-motionprefers-contrast: more
Motion System (Framer Motion)
On Book Pro uses the motion package (MIT, ~32KB gzip) for declarative enter/exit animations, layout morphing, and spring physics. This replaced fragmented CSS @keyframes with a unified, accessible motion system.
Core Hook
// src/hooks/useMotionConfig.ts
const { overlayTransition, subtleTransition, morphTransition, reducedMotion } = useMotionConfig();| Preset | Type | Use Case |
|---|---|---|
overlay | tween (250ms ease) | Modal/Toast enter/exit |
subtle | tween (180ms ease-out) | Sidebar accordion, dropdown reveals |
morph | spring (stiffness 200, damping 18) | Barry FAB ↔ panel layoutId morph |
All presets return { duration: 0 } when prefers-reduced-motion is active.
Animated Components
| Component | Animation | Mechanism |
|---|---|---|
Modal | Scale + fade enter/exit | AnimatePresence + motion.div |
ToastContext | Slide-up enter, fade exit | AnimatePresence + motion.div |
WorkspaceSelector | Scale + fade overlay | AnimatePresence + motion.div |
WorkspaceSidebar | Accordion stagger | motion.div with layout prop |
BarryPanel | FAB → panel morphing | layoutId for positional morph |
BarryPanel content | Materialization cascade | Entry: staggerChildren 60ms; Exit: instant (100ms fade) |
Pattern: layoutId Morph
The Barry FAB and panel share a layoutId, allowing Framer Motion to animate the positional morph between the two states:
// When closed — renders as FAB circle
<motion.div layoutId="barry-surface" transition={morphTransition} />
// When open — renders as chat panel
<motion.div layoutId="barry-surface" transition={morphTransition} />Pattern: Entry-Only Stagger
Barry's content uses staggered entry but instant exit to avoid competing with the layoutId morph:
const container = {
visible: { transition: { staggerChildren: 0.06 } },
exit: { opacity: 0, transition: { duration: 0.1 } },
};Container Query Responsive Grids
Components rendered inside split panels must not use viewport-based Tailwind breakpoints (sm:, md:, lg:, xl:) for their grid layouts. In a split panel, the component's container may be 400–600px wide even on a 1920px display. Viewport breakpoints would apply the wide layout despite the narrow actual width, causing columns to squeeze instead of reflow.
Pattern
Wrap the grid element in a @container div and use container query breakpoints (@sm:, @md:, @lg:, @xl:):
{/* ❌ WRONG — viewport breakpoints ignore panel width */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{/* ✅ CORRECT — container query breakpoints respond to actual container width */}
<div className="@container">
<div className="grid grid-cols-2 @sm:grid-cols-3 @md:grid-cols-4 gap-4">
...
</div>
</div>Converted Components
| Component | File | Breakpoints |
|---|---|---|
PersonnelGrid | cast/components/PersonnelGrid.tsx | @sm @lg @xl |
SoundPadGrid | sound/components/SoundPadGrid.tsx | @md @xl |
ProductionDashboard | production/components/ProductionDashboard.tsx | @md @lg |
FileExplorer | files/components/FileExplorer.tsx | @sm @md @lg @xl |
TrashView | files/components/TrashView.tsx | @sm @md @lg @xl |
DashboardPage (main) | dashboard/pages/DashboardPage.tsx | @md @lg |
BudgetDashboardPage | budget/pages/BudgetDashboardPage.tsx | @md |
BudgetTab | budget/components/BudgetTab.tsx | @md |
BudgetSetupTab | budget/components/BudgetSetupTab.tsx | @md |
MeasurementEditor | costumes/components/MeasurementEditor.tsx | @sm @md @lg |
MetaBar | layout/components/MetaBar.tsx | @md |
PanelToolPicker | layout/components/PanelToolPicker.tsx | @sm |
Scope
- In-panel components (tools, dashboards, grids) → use
@container/@sm:etc. - Admin pages (own route, never in split panels) → keep viewport breakpoints
- Modals/overlays → viewport breakpoints are acceptable (they float above layout)
KitchenSink.tsx— dev-only, exemptNotesSidebar.tsx— deferred (complex layout logic)
Testing Considerations
Permission Matrix
Test each workspace with different permission levels:
| User Type | Expected Behavior |
|---|---|
| Owner | All workspaces visible |
| Stage Manager | SM, Director workspaces |
| Designer | Department-specific workspace |
| Cast Member | Cast Member workspace only |
| No permissions | "No access" fallback message |
Navigation Testing
- [ ] Keyboard shortcuts work with sidebar expanded
- [ ] Keyboard shortcuts work with sidebar collapsed
- [ ] Shortcuts disabled when typing in inputs
- [ ] Workspace persists across page refresh
- [ ] Tool order matches
primaryToolsconfig
Split View Testing
- [ ] Split toggle only visible when workspace has layout + desktop viewport
- [ ] Split toggle hidden on mobile (<1024px)
- [ ] Panel focus updates ToolActionBar title
- [ ] Panel sizes persist across page refresh
- [ ] All tool components render correctly as panels
- [ ] PanelSeparator invisible until hover
Mobile / iPad Testing
- [ ] Sidebar hidden at <768px; hamburger button visible
- [ ] Hamburger opens drawer overlay; backdrop tap dismisses
- [ ] Content fills full viewport width on mobile (no sidebar offset)
- [ ] No horizontal overflow at 375px (iPhone SE)
- [ ] No horizontal overflow at 507×768 (iPad Split View)
- [ ] Device gate banner shown for tools with
minWidth> viewport - [ ] Ungated tools render normally on mobile
- [ ] All interactive elements meet 44×44px touch target minimum
- [ ] Resize from mobile → desktop restores sidebar; hamburger disappears
Playwright suites: e2e/ipad-layout.spec.ts (5 tests), e2e/mobile-device-gate.spec.ts (16 tests)
Further Reading
- State Management — Zustand store architecture
- Cloud Sync — Real-time synchronization patterns
workspace_ux_upgrade_strategy.md— Design strategy document
Last updated: March 22, 2026 (Mobile responsiveness, device gating, touch targets — Sprint B2)