Side Panels
Define a slide-out panel with definePanel, open it by typed handle, read its params, and gate it by permission.
A side panel is the slide-out pane that appears beside the main content — the staff profile that opens when you click a row, the role editor that slides in to manage permissions. Rather than scatter that machinery across the app, the Education Hub gives every panel one home: a single *.panel.tsx file that declares what it needs, renders itself, and registers under a typed handle.
The mental model is small. You write a component, you describe its inputs and its permission, and definePanel hands you back a handle. Everywhere else in the app you open that panel by passing the handle — never a string, never raw props on the window. The plumbing in _registry/ does the rest: it tracks which panel is open, validates the data you passed, and renders a denied view if the user isn't allowed in.
One file, one panel
Each panel lives in its own kebab-case.panel.tsx under src/components/side-panels/. The folder's _registry/ is infrastructure — you almost never touch it. You write your file, the codegen picks it up, and the rest is automatic.
Defining a panel
The whole contract lives in the definePanel call at the bottom of the file. The real staff-profile.panel.tsx ends like this:
export const StaffProfile = definePanel('staff-profile', {
component: StaffProfilePanel,
contextParams: z.object({ staffId: z.string() }),
permission: {
action: Action.READ,
resource: Resource.STAFF,
subject: ({ data }) => ({ id: data.staffId as string }),
},
})Read it as four answers. What renders? component. What data does it need from the caller? contextParams, a Zod schema. What in the URL does it depend on? routeParams. Who may see it? permission. The exported StaffProfile is a typed handle — that's the thing you pass around, and TypeScript reads the schema off it.
Two kinds of params
A panel can take input two ways, and they're easy to tell apart:
contextParamsare arguments you pass explicitly at open time —staffId, aroleId. They're a Zod schema, so they're typed for IntelliSense and validated at runtime.routeParamsare read automatically from the URL — list the names, like['campusId'], and the registry pulls them off the current route. You never pass these by hand.
The simplest panels need only context params. The ManageRole panel is exactly that — a roleId and nothing else:
export const ManageRole = definePanel('manage-role', {
component: ManageRolePanel,
contextParams: z.object({ roleId: z.string() }),
})Opening a panel
You open a panel with the useSidePanel hook and the Panel handle — never a bare string. Reach for both off the barrel, then call openPanel with the handle, a size, and the context params:
const { openPanel } = useSidePanel()
openPanel(Panel.StaffProfile, { size: 'wide' }, { staffId })The call is fully type-safe. Because StaffProfile carries its schema, TypeScript demands the third argument match { staffId: string } — pass the wrong shape and it won't compile. If the panel declared routeParams instead, you'd skip that third argument entirely; the registry reads them from the URL for you.
Pass the handle, not the id
Always open with Panel.StaffProfile, the object — not the string 'staff-profile'. The handle is what gives you autocomplete on the params and a compile error when you forget one. The raw id is an internal detail of the registry.
Reading params inside
Inside the panel component you read the context params back with usePanelParams, handing it the same handle. That's what makes the result typed — the hook parses the live store value through the panel's schema and returns it:
function StaffProfilePanel() {
const { staffId } = usePanelParams(StaffProfile)
const { staffProfile, loadingStaffProfile } = useStaffProfile(staffId)
// ... render the profile
}Because the schema lives on the handle, staffId comes back as a string with no casting and no any. The component never reaches into the Zustand store directly — usePanelParams is the one doorway, and it guarantees the shape.
Gating by permission
The permission block is where a panel protects itself. The container resolves the open panel's config, runs the check through useMeetsRequirement, and — when it fails — renders a PermissionDenied view in place of your component. Your panel code never has to ask "is this allowed?"; declaring the requirement is enough.
For instance-level checks, the subject resolver turns the panel's params into the object CASL evaluates against — here, the specific staff record being opened:
permission: {
action: Action.READ,
resource: Resource.STAFF,
subject: ({ data }) => ({ id: data.staffId as string }),
}No permission means open
The permission key is optional. A panel like ManageRole that omits it is always allowed to render — gating is opt-in. When you do add it, the denied view comes for free; you don't render it yourself. See Permissions for how action, resource, and subject resolve.
Sizes and persisted state
The size you pass at open time picks how the screen splits between the main content and the side pane. They run smallest to widest — smallest gives the panel the least room, widest the most:
| Size | Main | Side |
|---|---|---|
smallest | 80% | 20% |
small | 75% | 25% |
normal | 70% | 30% |
wide | 65% | 35% |
widest | 60% | 40% |
Which panel is open, its size, and the params you passed all live in a persisted Zustand store under the key sidepanel-state. Because it's persisted to localStorage, an open panel survives a page reload — a useful default, and one more reason the store is the single source of truth rather than component state. The store is part of the broader client state story.
Step 1: Create the empty file
Add a new file named for your feature. Leave it blank — the codegen plugin watches the folder and notices it:
touch src/components/side-panels/student-attendance.panel.tsxStep 2: Let codegen scaffold it
On save, the Vite plugin scaffolds the empty file with a working definePanel skeleton and regenerates the barrel. You don't edit panel-definitions.gen.ts — it's auto-generated, and your new handle appears on Panel once the file exports one.
Step 3: Write the component
Fill in the component using the SidePanelNavBar and SidePanelBody containers, reading params at the top via usePanelParams. Keep it focused — a panel is a detail view, not a page.
Step 4: Register with definePanel
Export the handle at the bottom, declaring its params and (if needed) its permission. This is the line that puts it on the Panel map:
export const StudentAttendance = definePanel('student-attendance', {
component: StudentAttendancePanel,
contextParams: z.object({ studentId: z.string() }),
})Step 5: Open it by handle
Anywhere in the app, pull openPanel off useSidePanel and open your new panel with Panel.StudentAttendance. The third argument is type-checked against the schema you just wrote:
openPanel(Panel.StudentAttendance, { size: 'normal' }, { studentId })That's the full loop — file, codegen, component, register, open. Every panel in the app follows it.