# 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

```ts
import { openSheet, closeSheet } from '$lib/shell/sheets/sheet-manager.svelte';

openSheet('shell.aiModelTestReport', { model }, {
  side: 'right',                         // default
  contentClass: 'w-3/4 sm:max-w-xl p-0', // Sheet.Content classes (width!)
  modal: true,                           // default
});

closeSheet(); // programmatic close
```

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:

<Mermaid chart={`block
  columns 1
  HEAD["HEAD — title + actions (default close button)"]
  TOOLBAR["TOOLBAR — optional strip (search, tabs, chips)"]
  CONTENT["CONTENT — the only scrollable region"]
  FOOT["FOOT — optional fixed bottom strip (actions, inputs)"]
  HEAD --> TOOLBAR --> CONTENT --> FOOT`} />

- **HEAD** — `SheetHeader`: mandatory `icon` snippet + `title` snippet on
  the left, `actions` snippet on the right. The icon MUST be the same icon
  as the CTA that opens the sheet. When `actions` is 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.
- **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 padding `p-2`, override with `contentClass`. 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: a `Button` with 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 `$effect`
  in `BadgeValuePanel` produced an `effect_update_depth_exceeded` loop.)
- 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.

```svelte
<SheetPanelLayout contentClass="px-4 py-3">
  {#snippet title()}
    <span>Panel title</span>
  {/snippet}

  <p>Scrollable content…</p>

  {#snippet footer()}
    <div class="p-3">
      <Button class="w-full">{$t('app.common.apply')}</Button>
    </div>
  {/snippet}
</SheetPanelLayout>
```

## Registering a panel

1. Add the id to `SheetPanelId` in `sheet-manager.svelte.ts`
   (`'shell.*'` for shell panels, `'entity.*'` for entity-list, `'config.*'`
   for config-driven panels).
2. Add its props to `SheetPanelPropsMap`.
3. Create the component under `src/lib/shell/sheets/panels/` using
   `SheetPanelLayout`.
4. Register the component in the `registry` inside `SheetHost.svelte`.
5. 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.Root` outside `SheetHost`.
- Do not re-create the anatomy inside a panel — `SheetPanelLayout` owns it.

## Next steps

- [UI patterns](/frontend/dev/ui-patterns) — popover vs dialog vs sheet
- [App shell](/frontend/dev/app-shell) — where `SheetHost` is mounted
