Naalya Handbook
Panels & Surfaces

Panels & Surfaces

The two codegen-backed overlay systems — slide-out panels and responsive modal surfaces — and when to use each.

Almost every screen in the Education Hub eventually needs to show you more without throwing away where you were. You click a staff member and want their full profile; you delete a class and need to confirm; you create a department and a quick form should appear. The app answers all of these with two overlay systems — and the first skill to learn here is simply telling them apart.

  • A Side Panel slides out alongside the main content, splitting the screen into a main pane and a side pane. You can still see and use the rest of the UI. It's the home for inspectors, detail views, and in-place editing.
  • A Surface is a modal overlay. It renders as a centered Dialog on desktop and a bottom Drawer on mobile, and it interrupts the workflow — perfect for confirmations, alerts, and short forms.

Both are codegen-backed registries: you drop a file in a folder, and a Vite plugin discovers it, scaffolds it if it's empty, and exposes it to the rest of the app through a typed handle. You never wire up imports or routing by hand.

One decision, every time

Supplementary information you want to work alongside — keep the main UI visible — is a panel. Something that needs immediate action, or must block the task until it's resolved, is a surface. When in doubt, ask whether the user should still be able to touch the page behind it. Yes means panel; no means surface.

How both registries work

The two systems are near-mirror images, so once you understand one you understand both. Each has a Vite plugin in .vite-plugins/ that watches a folder of files named by convention:

  • Panels live in src/components/side-panels/ as *.panel.tsx, each exporting a definePanel(...).
  • Surfaces live in src/components/surfaces/ as *.surface.tsx, each exporting a defineSurface(...).

The plugin walks the folder, finds the exported constant in every file with a regex, and writes a generated barrel into the _registry subfolder. That barrel is the single thing the rest of the app imports.

.vite-plugins/sidepanels-codegen.ts
const PANEL_FILE_PATTERN = /\.panel\.tsx$/
const DEFINE_PANEL_REGEX = /export\s+const\s+(\w+)\s*=\s*definePanel\s*\(/
const GEN_FILENAME = 'panel-definitions.gen.ts'

The plugin runs on buildStart and again on every hotUpdate, so adding, renaming, or deleting a panel updates the registry live while the dev server runs — no restart needed.

The generated barrel

The output is one typed object. Every panel becomes a named member, so you open one by a handle the editor can autocomplete rather than a stringly-typed name:

src/components/side-panels/_registry/panel-definitions.gen.ts
// AUTO-GENERATED by sidepanels-codegen — DO NOT EDIT
import { ManageRole } from '../manage-role.panel'
import { StaffProfile } from '../staff-profile.panel'
// ...
export const Panel = {
  ManageRole,
  StaffProfile,
  // ...
} as const

Surfaces generate the exact same shape under a Surface object — Surface.Alert, Surface.CreateDepartment, Surface.EditStaff, and so on. Reach for Panel.StaffProfile to open a panel, Surface.Alert to open a surface, and TypeScript tells you immediately if the handle doesn't exist.

Never edit the .gen.ts files

The barrel carries a // DO NOT EDIT banner because the plugin overwrites it on every change. Edit a *.panel.tsx or *.surface.tsx file instead and let codegen regenerate. To keep HMR sane, the plugin only rewrites the barrel when the content actually changed — so a no-op save won't trigger an infinite reload loop.

Empty files scaffold themselves

There's a small touch that makes adding a new overlay almost free. If the plugin finds a *.panel.tsx or *.surface.tsx file that's completely empty, it fills it in with working boilerplate — a component, a definePanel/defineSurface call with a zod context schema, and a commented-out permission gate to copy from.

.vite-plugins/sidepanels-codegen.ts (scaffold)
if (content.trim().length === 0) {
  const panelName = getPanelName(path.basename(relPath))
  fs.writeFileSync(filepath, buildScaffold(panelName, relPath), 'utf-8')
}

So the real "create a panel" workflow is just two steps: make an empty file named for what it does — create-term.surface.tsx, staff-profile.panel.tsx — then save it. The kebab-case filename becomes the PascalCase handle (create-term to CreateTerm) and a Title Case heading, and you start editing from a scaffold rather than a blank page.

Side by side

Both overlays gate on permissions and both are summoned by a typed handle, so the choice really does come down to how much they interrupt.

Side PanelSurface
LayoutSplit pane, beside the contentModal over the content
On mobileSlides overBottom drawer
Main UIStill visible and usableBlocked until dismissed
Best forInspectors, detail views, editingConfirms, alerts, quick forms
HandlePanel.StaffProfileSurface.Alert

Once you've placed your overlay on the right side of that line, the two sub-pages below cover how to build and drive each one — panel sizing, context params, and the responsive SurfaceContainer that swaps Dialog for Drawer.

Where to go next

On this page