A drawer is the fast path to one row: it opens over the list without losing the
operator's place, their filters or their scroll position. Declare it on a
resource and set `rowClick: "drawer"` to open it by clicking the row.

```ts excerpt
resource(orders, {
  columns: ["id", "customerEmail", "status"],
  rowClick: "drawer",
  drawer: {
    width: "xl",
    header: (row) => `Order ${row.id}`,
    tabs: [
      { key: "summary", label: "Summary", fields: "*" },
      { key: "items", label: "Items", resource: "orderItems", filter: (row) => ({ orderId: row.id }) },
    ],
    actions: [
      {
        key: "refund",
        label: "Refund",
        variant: "destructive",
        requireRole: "admin",
        confirm: "Refund this order?",
        form: [{ name: "reason", type: "textarea", required: true }],
        run: async (row, formData, ctx) => {
          await refund(ctx.db, row.id, formData.reason as string);
          return { ok: true, message: "Refunded", refresh: true };
        },
      },
    ],
  },
});
```

## DrawerConfig

**DrawerConfig** — Side panel opened for a single row.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `width` | `DrawerWidth` | no | Panel width. |
| `header` | `function` | no | Panel title. Defaults to the row's key. |
| `fields` | `DrawerFieldList<Row>` | no | Fields shown when no `tabs` are declared. `"*"` shows every column. |
| `tabs` | `array` | no | Tabs to render instead of a flat field list. |
| `actions` | `array` | no | Buttons rendered in the panel footer. |

`DrawerWidth` is `"sm" | "md" | "lg" | "xl" | "2xl" | "full"`, defaulting to
`"lg"`.

Without `tabs`, the panel renders `fields` as a flat key/value list. Field
labels come from the matching column's `label`, and any column with a `render`
function is rendered with it — so a status badge looks the same in the drawer as
in the table.

`fields` is `DrawerFieldList<Row>` — the same shape as `detail.fields`:
`(keyof Row | FieldDef<Row>)[] | "*"`. A key your row does not have is a compile
error, not an empty row in the panel.

## Tabs

`DrawerTab` is a union of three shapes, discriminated by which key you set.

### DrawerTabFields

The row's own fields.

**DrawerTabFields** — Drawer tab showing the row's own fields as a key/value list.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | `string` | yes | Stable identifier for the tab. |
| `label` | `string` | yes |  |
| `fields` | `DrawerFieldList<Row>` | yes | Fields to show. `"*"` shows every declared column. |

### DrawerTabResource

Rows of a related resource, filtered by the open row.

**DrawerTabResource** — Drawer tab listing rows of a related resource.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | `string` | yes |  |
| `label` | `string` | yes |  |
| `resource` | `string` | yes | Related resource to list. Must be registered on the same admin. |
| `filter` | `function` | no | Filter applied to the related resource, derived from the open row. |

The related resource's own `requireRole` still applies. A tab the operator may
not read comes back empty rather than failing the whole panel.

<Callout type="warn">
A resource tab shows the **first 20 rows** and nothing more. There is no
pagination, no "load more", and no count of what was left out — the drawer
route reads one page of 20 and serializes it. For a relation that can exceed
that, link to the target resource's own list (filtered) instead of leaning on
the tab.
</Callout>

### DrawerTabWidgets

Dashboard widgets, scoped to the open row.

**DrawerTabWidgets** — Drawer tab rendering dashboard widgets scoped to the open row.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | `string` | yes |  |
| `label` | `string` | yes |  |
| `widgets` | `array` | yes | `custom()` widgets are not renderable here — the drawer serializes over the wire. |

`custom()` widgets are rejected here at compile time. The drawer payload is
serialized over the wire, and a React component cannot cross it — use a widget
tab of metrics/charts/tables, or a `detail` tab, which renders on the server.

Each widget in the tab is flattened to a `SerializedWidget` before it crosses:
a discriminated union over `kind`, one variant per widget type.

```ts excerpt
type SerializedWidget =
  | { kind: "metric"; label: string; value: number | string; /* format, sublabel, tone, span, realtime */ }
  | { kind: "table"; rows: Record<string, unknown>[]; columns: { field: string; label?: string }[]; /* … */ }
  | { kind: "statGroup"; stats: { label: string; value: unknown; /* … */ }[]; /* … */ }
  | { kind: "chart"; subkind: "area" | "bar" | "line" | "pie"; label: string; dataPoints: number; /* … */ }
  | { kind: "unsupported"; label?: string; reason: string; span?: number };
```

The queries have already run by then — a metric carries its resolved `value`,
a chart carries only how many points it produced. A widget whose query throws
comes back as `{ kind: "unsupported", reason }` with a client-safe message, so
one failing widget does not take the panel down with it.

## Actions

Buttons in the panel footer. See [Actions](/docs/reference/actions#draweraction)
for `DrawerAction` and its form spec.

Drawer actions go through the same guard pipeline as every other write: the
admin-wide role gate, the resource gate, the action's own `requireRole`, then
input validation — all before `run`. An action the current operator cannot run
is omitted from the drawer payload as well as blocked server-side. Setting
`palette: true` also surfaces an authorized action in ⌘K.

**DrawerAction** — Button rendered in the drawer footer for the open row.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | `string` | yes | Stable identifier, used in the action's URL. |
| `label` | `string` | yes |  |
| `variant` | `"destructive" \| "default"` | no | `"destructive"` styles the button as dangerous. |
| `confirm` | `string` | no | Ask for confirmation with this message before running. |
| `form` | `array` | no | Inputs collected before `run`, passed to it as `formData`. |
| `palette` | `boolean` | no | Also offer the action in the command palette. |
| `access` | `AccessRule` | no | Roles allowed to see and execute this action. |
| `when` | `function` | no | Server-enforced row condition evaluated after the scoped row load. |
| `unsafe` | `array` | no | Explicitly opt trusted code into raw database access. |
| `requireRole` | `array \| string` | no |  |
| `run` | `function` | yes | Server-side handler. Runs only after every guard has passed. |

## The payload

What `GET /api/flowpanel/drawer/<resource>/<id>` answers with — worth knowing if
you render the drawer yourself.

**DrawerPayload**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `row` | `Record<string, unknown>` | yes |  |
| `header` | `string` | yes |  |
| `resourceLabel` | `string` | yes | The resource's display label, so the drawer never shows the raw registry name. |
| `width` | `"full" \| "2xl" \| "xl" \| "lg" \| "md" \| "sm"` | yes |  |
| `fields` | `array \| "*"` | yes |  |
| `tabs` | `array \| null` | yes |  |
| `actions` | `array` | yes |  |
| `prerendered` | `Record<string, string>` | yes | Field → HTML for fields whose column declares a `render`. |
| `labels` | `Record<string, string>` | yes | Field → column label, so drawer rows read like their table headers. |
| `formats` | `Record<string, ColumnFormat>` | yes | Field → column `format`. Plain data, rendered client-side exactly as the table does. |

## What crosses to the client

The drawer payload carries only the resource's **declared** surface — the
columns and fields you listed, never the raw adapter row. Columns with a
`render` function are pre-rendered to HTML on the server, so your renderer can
touch server-only data without shipping it.
