Skip to content

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

  1. Activity-Based Views: Tools are grouped by theatrical activity (Rehearsal Room, Sound Booth, etc.) rather than role names
  2. Multi-Panel Layouts: Workspaces can define side-by-side tool panels with resizable dividers
  3. Context Switching: Users can switch workspaces without changing permissions
  4. Permission Integration: Workspaces and tools filter based on RBAC permissions
  5. Responsive Design: Vertical sidebar (desktop) with collapsible icon-only mode; mobile sidebar drawer (<768px) with hamburger trigger; split view desktop-only (≥1024px)
  6. Device Gating: Tools declare a minWidth — viewports below that threshold show a DeviceGateBanner instead of the tool, directing users to a larger screen

Architecture

Configuration Source

All workspace and tool definitions live in:

src/features/layout/workspace-config.ts

Type Definitions

typescript
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

typescript
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.

typescript
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
}
WorkspaceTool → Suggested Companion
Rehearsal RoomPrompt Book → Notes, Blocking → Notes, Notes → Prompt Book
Sound BoothSound → Scenes, Scenes → Sound
Light LabLighting → Prompt Book, Scenes → Lighting
Creative SuiteBlocking → Notes, Notes → Blocking
Production OfficeProduction → 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 DeviceGateBanner

Key Components

ComponentPathPurpose
WorkspaceSidebarsrc/features/layout/components/WorkspaceSidebar.tsxMain sidebar with workspace-filtered tools
WorkspaceSelectorsrc/features/layout/components/WorkspaceSelector.tsxModal for switching workspaces
SidebarTool(in WorkspaceSidebar)Individual sidebar button
MainLayoutsrc/features/layout/components/MainLayout.tsxRoot layout component
SplitLayoutsrc/features/layout/components/SplitLayout.tsxTwo-panel split renderer
PanelToolPickersrc/features/layout/components/PanelToolPicker.tsxTool selection grid for right panel
GlassPanelsrc/components/common/GlassSurface/GlassPanel.tsxFocus-tracking panel container
PanelSeparatorsrc/components/common/GlassSurface/PanelSeparator.tsxInvisible-reveal resize handle
ToolActionBarsrc/components/common/ToolActionBar.tsxPer-tool action bar with split toggle
DeviceGateBannersrc/components/common/DeviceGateBanner.tsxViewport-too-small warning with bypass option
useDeviceGatesrc/hooks/useDeviceGate.tsHook: compares viewport width to tool's minWidth

Permission Integration

Workspace Visibility

Workspaces are hidden if the user lacks ANY of their requiredPermissions:

typescript
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:

typescript
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:

typescript
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

typescript
// In MainLayout.tsx
const deviceGate = useDeviceGate(activeToolConfig?.minWidth);

const gatedComponent = deviceGate.isGated
    ? <DeviceGateBanner toolName={toolLabel} minimumDevice="tablet" onBypass={() => deviceGate.bypass()} />
    : activeComponent;

useDeviceGate Hook

typescript
// 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 minWidthPx is undefined, the tool is never gated
  • Bypass state resets on tool change or page refresh

Currently Gated Tools (minWidth: 768)

ToolReason
SchedulerComplex grid layout
Set BuilderKonva canvas
BlockingKonva canvas
LightingMulti-column cue list
Prompt BookScript + cue palette
Run SheetWide data table
BudgetSpreadsheet layout
ProductionMulti-panel dashboard
ProjectionsMedia management

Adding Device Gating to a New Tool

Add minWidth to the tool's config in workspace-config.ts:

typescript
{ 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

ShortcutAction
Ctrl + 1Open Feed (always first)
Ctrl + 2-9Jump to tools 2-9 in sidebar
Ctrl + SpaceOpen Workspace Selector
Ctrl + BCollapse/expand sidebar
Ctrl + ZUndo (scoped to active tool)
Ctrl + Shift + ZRedo

Implementation Notes

The useKeyboardNavigation hook handles global shortcuts:

  • Focus Guard: Shortcuts are disabled when user is typing in <input>, <textarea>, or contenteditable elements
  • Dynamic Indexing: Tool indices recalculate when sidebar collapses (maps to visible tools only)
  • Order Preservation: Uses .map() on primaryTools to 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 tool

The 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

  1. Add the WorkspaceId type:

    typescript
    export type WorkspaceId = 
        | 'stage-management'
        | 'your-new-workspace'  // Add here
        | ...
  2. Add the workspace configuration:

    typescript
    export 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],
        },
    ];
  3. Update default workspace logic (if needed):

    typescript
    export function getDefaultWorkspace(permissions: Permission[]): WorkspaceId {
        // Add check for your workspace
    }

Adding a New Tool

  1. Add the ToolId type:

    typescript
    export type ToolId = 
        | 'existing-tools'
        | 'your-new-tool'  // Add here
        | ...
  2. Add the tool configuration:

    typescript
    export const TOOLS: ToolConfig[] = [
        // ... existing
        { 
            id: 'your-new-tool', 
            label: 'Your Tool', 
            icon: 'icon_name', 
            route: '/your-tool',
            requiredPermissions: [Permission.SOME_PERMISSION],
        },
    ];
  3. Add the route in src/main.tsx:

    typescript
    <Route path="/your-tool" element={<YourToolComponent />} />
  4. Add to workspace(s):

    typescript
    {
        id: 'stage-management',
        primaryTools: ['existing', 'your-new-tool'],  // Add here
    }

Multi-Panel Split View

How It Works

  1. Activation: The "Split" toggle button is always visible in the ToolActionBar (desktop only, ≥1024px)
  2. Left Panel = Active Tool: When the user clicks "Split," their current tool stays in the left panel
  3. Right Panel = User's Choice: The right panel shows a PanelToolPicker grid, or a suggested companion tool from the workspace's defaultCompanion map
  4. Companion Persistence: The last-used right-panel tool is saved per active tool to localStorage (onbook-split-companion:{toolId})
  5. Focus Tracking: Clicking a panel calls onPanelFocus(side), which updates the active tab and ToolActionBar title
  6. 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:

  1. localStorageonbook-split-companion:{activeToolId} (user's last choice)
  2. Workspace defaultcurrentWorkspace.defaultCompanion[activeToolId]
  3. null — show PanelToolPicker (user picks manually)

SplitLayout Architecture

typescript
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 PanelToolPicker

Persistence

Panel sizes are persisted to localStorage with the key onbook-split-sizes:{workspaceId}:{leftToolId}:{rightToolId}:

typescript
// 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:

typescript
// Key: onbook-split-companion:{activeToolId}
// Value: rightToolId (e.g., 'notes')

Glass Components (Liquid Glass Design System)

ComponentPurpose
GlassRegularFrosted glass material for navigation surfaces
GlassClearHigh-transparency material for media-rich overlays
GlassPanelSplit-view panel with focus/inactive states, materialization animation
PanelSeparatorInvisible-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-transparency
  • prefers-reduced-motion
  • prefers-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

typescript
// src/hooks/useMotionConfig.ts
const { overlayTransition, subtleTransition, morphTransition, reducedMotion } = useMotionConfig();
PresetTypeUse Case
overlaytween (250ms ease)Modal/Toast enter/exit
subtletween (180ms ease-out)Sidebar accordion, dropdown reveals
morphspring (stiffness 200, damping 18)Barry FAB ↔ panel layoutId morph

All presets return { duration: 0 } when prefers-reduced-motion is active.

Animated Components

ComponentAnimationMechanism
ModalScale + fade enter/exitAnimatePresence + motion.div
ToastContextSlide-up enter, fade exitAnimatePresence + motion.div
WorkspaceSelectorScale + fade overlayAnimatePresence + motion.div
WorkspaceSidebarAccordion staggermotion.div with layout prop
BarryPanelFAB → panel morphinglayoutId for positional morph
BarryPanel contentMaterialization cascadeEntry: 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:

tsx
// 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:

tsx
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:):

tsx
{/* ❌ 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

ComponentFileBreakpoints
PersonnelGridcast/components/PersonnelGrid.tsx@sm @lg @xl
SoundPadGridsound/components/SoundPadGrid.tsx@md @xl
ProductionDashboardproduction/components/ProductionDashboard.tsx@md @lg
FileExplorerfiles/components/FileExplorer.tsx@sm @md @lg @xl
TrashViewfiles/components/TrashView.tsx@sm @md @lg @xl
DashboardPage (main)dashboard/pages/DashboardPage.tsx@md @lg
BudgetDashboardPagebudget/pages/BudgetDashboardPage.tsx@md
BudgetTabbudget/components/BudgetTab.tsx@md
BudgetSetupTabbudget/components/BudgetSetupTab.tsx@md
MeasurementEditorcostumes/components/MeasurementEditor.tsx@sm @md @lg
MetaBarlayout/components/MetaBar.tsx@md
PanelToolPickerlayout/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, exempt
  • NotesSidebar.tsx — deferred (complex layout logic)

Testing Considerations

Permission Matrix

Test each workspace with different permission levels:

User TypeExpected Behavior
OwnerAll workspaces visible
Stage ManagerSM, Director workspaces
DesignerDepartment-specific workspace
Cast MemberCast Member workspace only
No permissions"No access" fallback message
  • [ ] 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 primaryTools config

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)