Sheet panel
src/lib/shell/sheets/
The application has one global side sheet. Pages and components never
mount their own Sheet.Root — they call openSheet() and the single
SheetHost (mounted in AppShell) renders the requested panel.
Architecture
| Piece | File | Role |
|---|---|---|
| Manager | src/lib/shell/sheets/sheet-manager.svelte.ts | sheetState, openSheet, replaceSheet, closeSheet, typed SheetPanelId + SheetPanelPropsMap |
| Host | src/lib/shell/sheets/SheetHost.svelte | The only Sheet.Root/Sheet.Content; resolves the panel component from a registry |
| Layout | src/lib/shell/sheets/SheetPanelLayout.svelte | Canonical panel anatomy — HEAD, optional TOOLBAR, scrollable CONTENT, optional FOOT |
| Header | src/lib/shell/sheets/SheetHeader.svelte | HEAD chrome (title + actions snippets) |
| Panels | src/lib/shell/sheets/panels/, src/lib/entity-list/sheets/panels/ | Registered panel components |
Opening a sheet
Code
Props are typed per panel id via SheetPanelPropsMap — TypeScript rejects
unknown ids or mismatched props.
Panel anatomy
Every panel uses SheetPanelLayout, which enforces a fixed anatomy:
- HEAD —
SheetHeader: mandatoryiconsnippet +titlesnippet on the left,actionssnippet on the right. The icon MUST be the same icon as the CTA that opens the sheet. Whenactionsis omitted a standard close button renders. AI-assistant sheets share one convention:AiIcon- "AI Assistant" + a topic qualifier (
global,guide,regex,json). HEAD carries secondary icon-only utilities (reset, clear, new) plus the close ✕ — never the panel's primary action.
- "AI Assistant" + a topic qualifier (
- TOOLBAR — optional below-head strip (
border-b px-3 py-2) for tools only: search inputs, tab bars, sort segmented controls. No commit CTAs. - CONTENT —
min-h-0 flex-1 overflow-auto; the only region that scrolls. Default paddingp-2, override withcontentClass. How the content is sectioned (labels, dividers, accordions, tabs) is the panel's own domain — no enforced standard. Per-row actions and inline sub-form CTAs (e.g. "Add filter") belong here. - FOOT — optional fixed bottom strip (
border-t); never scrolls. This is where a FORM panel's primary CTA lives: aButtonwith a text label,class="w-full"for single actions (no Cancel — the close ✕ and outside-click already dismiss). Pickers and viewers have no footer.
Header action styles
Every CTA in the header uses one shared chrome — the same as close:
inline-flex size-8 icon button, text-muted-foreground opacity-70,
hover:bg-accent hover:opacity-100, icon size-4. Close renders it via
Sheet.Close; other CTAs render it via SheetHeaderAction. No Button
component variants, no tinted pills — the header is symmetric by rule.
HEAD actions are secondary utilities only; the primary action of a form
panel always sits in FOOT as a labeled Button.
Panel behaviors — three archetypes
Every panel fits one of three commit behaviors:
| Archetype | Commit | CTA placement | Examples |
|---|---|---|---|
| Picker | Immediate — click a row/toggle → commit → close | The row itself is the CTA; no footer | CurrencySelectPanel, PhonePrefixSelectPanel, ProtocolSelectPanel, ColumnsPanel |
| Form | Explicit — draft fields, primary CTA commits (and usually closes) | FOOT: Button w-full (or flex justify-end for multi-action) | BadgeValuePanel (add & edit), FiltersPanel (Apply), AiCerebellumPanel (Save), AiModelImportPanel (Add) |
| Viewer | None — read-only or per-row actions | CONTENT row buttons; HEAD may carry a destructive reset (e.g. ErrorsPanel clear) | ErrorsPanel, VersionsPanel, VersionHistoryPanel, AiModelCachePanel |
Rules of thumb:
- Form panels never live-commit while the user edits — they stage a
draft and write once on the footer CTA. (A previous live-commit
$effectinBadgeValuePanelproduced aneffect_update_depth_exceededloop.) - HEAD is never the home of a primary action — icon-only buttons carry no label, so a primary CTA there is invisible. If it commits, it goes in FOOT.
- A single-CTA footer uses
class="w-full"; do not add a Cancel button — the standard close ✕ and outside-click dismissal already cover "abort".
Sheet width
Every sheet renders at the same width — 384px (w-3/4 sm:max-w-sm
on the right/left variant). Do not pass width classes in
contentClass: sm:max-w-sm caps them silently. A wider sheet is a
central change to Sheet.Content, not a per-call override.
Code
Registering a panel
- Add the id to
SheetPanelIdinsheet-manager.svelte.ts('shell.*'for shell panels,'entity.*'for entity-list,'config.*'for config-driven panels). - Add its props to
SheetPanelPropsMap. - Create the component under
src/lib/shell/sheets/panels/usingSheetPanelLayout. - Register the component in the
registryinsideSheetHost.svelte. - Open it from a click handler with
openSheet().
Guidelines
- Open sheets from discrete events (clicks), never from effects that watch a bindable "open" flag — that produces a close/re-open loop.
- Do not mount
Sheet.RootoutsideSheetHost. - Do not re-create the anatomy inside a panel —
SheetPanelLayoutowns it.
Next steps
- UI patterns — popover vs dialog vs sheet
- App shell — where
SheetHostis mounted