
# Galleries

> The gallery component — a responsive card grid over records, in a uniform grid, a masonry wall or a carousel.

A gallery draws one card per record. It binds through the shared `dataSource` module and has no `pagination` key of its own: filtering, sorting and paging all belong to the binding.

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `layout` | enum | `grid`, `masonry`, `carousel` |  | Gallery layout mode: grid \| masonry \| carousel. `carousel` lays the same cards on one horizontal track paged by its own controls, so a set too wide to grid is still walkable. |
| `emptyMessage` | string |  |  | Message displayed when no records match the data source query |

### `dataSource`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `dataSource` | object |  |  | DB-table binding (DataSource) OR a system read-endpoint binding |
| `dataSource.table` | string |  |  | Table to bind to: a declared name (validated against app.tables), or a $param.<name> route reference declared by the page path |
| `dataSource.fields` | array |  |  | Specific fields to fetch from the table |
| `dataSource.fields[]` | string |  |  | One field name, spelled as the bound table declares it |
| `dataSource.mode` | enum | `list`, `single`, `search` |  | Data fetching mode: 'list' (multiple), 'single' (one record), 'search' (interactive) |
| `dataSource.filter` | array |  |  | Filter conditions applied with AND logic |
| `dataSource.filter[]` | object (truncated) |  |  | Single filter condition for data source queries |
| `dataSource.sort` | array |  |  | Sort rules applied in order |
| `dataSource.sort[]` | object (truncated) |  |  | Single sort rule for data source queries |
| `dataSource.pagination` | object |  |  | Pagination configuration for data source |
| `dataSource.pagination.pageSize` | number |  |  | Number of records per page |
| `dataSource.pagination.style` | enum | `numbered`, `loadMore`, `infinite` |  | How pagination controls are displayed (default: numbered). `infinite` is accepted but not implemented and pages as `numbered`. |
| `dataSource.param` | string |  |  | Route parameter name for single mode (e.g., slug, id) |
| `dataSource.searchEngine` | enum | `client`, `fts`, `trigram`, `hybrid` |  | Search backend for this data source (default: 'client'). Only 'client' is dispatched today; the other three validate and search as 'client' does. |
| `dataSource.searchFields` | array |  |  | Fields to search across in search mode |
| `dataSource.searchFields[]` | string |  |  | One field name the search term is matched against |
| `dataSource.debounceMs` | number |  |  | Debounce delay for search input (ms) |
| `dataSource.limit` | number |  |  | Maximum number of results to return |
| `dataSource.targetId` | string |  |  | Publisher-side identifier for cross-component references — addressable by a FilterAction (targetDataSource) and by a sibling subscriber's bindTo (shared filter/period state) |
| `dataSource.bindTo` | string |  |  | ID of a publisher component whose value drives this data source (cross-component binding). By default a `search-input` whose query string drives the search; when sharedFilter is also set, a shared filter/period selector whose published params are merged into every request |
| `dataSource.sharedFilter` | object |  |  | Companion to bindTo: the bound publisher is a shared filter/period selector whose published params are merged into every request this data source issues. One selector can drive many sibling subscribers. Inert without bindTo. |
| `dataSource.sharedFilter.params` | array (truncated) |  |  | Request-param keys this subscriber consumes from the shared publisher's value bag (omit to merge the full bag verbatim) |
| `dataSource.refreshMode` | enum | `none`, `poll`, `realtime` |  | Data refresh strategy for this binding (default: 'none'). 'poll' uses pollIntervalMs; 'realtime' subscribes to live change events. |
| `dataSource.pollIntervalMs` | number |  | 30000 | Polling interval in milliseconds for refreshMode: poll (min 1000, max 300000). Defaults to 30000 when omitted, and is ignored unless refreshMode is 'poll'. |
| `dataSource.system` | object |  |  | Read-endpoint binding: feed a data-bound component from a system endpoint instead of a DB table |
| `dataSource.system.endpoint` | string |  |  | Read endpoint path to fetch rows from (e.g. /api/admin/automations/runs) |
| `dataSource.system.rowsKey` | string |  |  | Key of the rows array in the response envelope (default: 'items') |
| `dataSource.system.param` | string |  |  | Route parameter substituted into the endpoint's :param placeholder. Must be declared by the host page's path. |
| `dataSource.system.idKey` | string |  |  | Key of each row's unique id (default: 'id') |
| `dataSource.system.totalKey` | string |  |  | Key of the total-count in the envelope; falls back to rows length if absent |
| `dataSource.system.query` | object |  |  | Static query params merged into every request to the endpoint |
| `dataSource.system.bindTo` | string |  |  | ID of a sibling shared filter/period selector whose published params are merged into every request to endpoint (the dynamic counterpart to the static query) |
| `dataSource.system.sharedFilter` | object (truncated) |  |  | Companion to bindTo: the shared selector params merged (dynamically) into every request to endpoint, alongside the static query. Inert without bindTo. |

### `autoSave`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `autoSave` | object |  |  | Configuration for automatic persistence of edits. Applies to table, form, kanban, and calendar components. |
| `autoSave.saveMode` | enum | `auto`, `onBlur`, `manual` |  | Save trigger strategy: 'auto' (debounced), 'onBlur' (field blur), 'manual' (button). Default: 'manual'. |
| `autoSave.autoSaveDebounceMs` | number |  |  | Debounce delay for auto-save in milliseconds (default: 500, min: 100) |
| `autoSave.showSaveIndicator` | boolean |  |  | Display a save status indicator (Saving... / Saved / Error). Default: true when saveMode is auto or onBlur. |
| `autoSave.saveIndicatorPosition` | enum | `inline`, `toast`, `toolbar` |  | Where the save status indicator appears |

### `search`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `search` | object |  |  | Shared search bar configuration for data-bound components (table, kanban, calendar) |
| `search.enabled` | boolean |  |  | Enable search bar (default: true) |
| `search.placeholder` | string |  |  | Search input placeholder text |
| `search.debounceMs` | number |  |  | Debounce delay for search input in ms (default: 300) |
| `search.highlight` | boolean |  |  | Highlight matched search terms in results (default: false) |

### `gridColumns`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `gridColumns` | object |  |  | Responsive column counts per breakpoint for the gallery grid layout |
| `gridColumns.mobile` | number |  |  | Number of columns on mobile (default: 1) |
| `gridColumns.sm` | number |  |  | Number of columns on small screens (>= 640px) |
| `gridColumns.md` | number |  |  | Number of columns on medium screens (>= 768px) |
| `gridColumns.lg` | number |  |  | Number of columns on large screens (>= 1024px) |
| `gridColumns.xl` | number |  |  | Number of columns on extra-large screens (>= 1280px) |

### `galleryCard`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `galleryCard` | object |  |  | Template configuration for how records render as gallery cards |
| `galleryCard.coverImage` | string |  |  | Image URL or $record.* variable for card cover image |
| `galleryCard.aspectRatio` | string |  |  | Cover image aspect ratio (e.g. 4:3, 16:9, 1:1) |
| `galleryCard.children` | array |  |  | Child component definitions for the card body |
| `galleryCard.children[]` | object |  |  | One child component definition, rendered inside the card |
| `galleryCard.onClick` | object |  |  | Component action. Discriminated by type: auth (authentication), crud (data operations), automation (invoke workflow), filter (cross-component filtering), navigate (pure URL navigation), toast (transient notification), fetch (client-side HTTP with toast response). Open-drawer is discriminated by `action: openDrawer` instead of `type` (PG-04 quick-edit drawer pattern). |
| `galleryCard.onClick.type` | enum | `auth`, `crud`, `automation`, `filter`, `navigate`, `toast`, `fetch` |  | Which kind of action this is. It decides which of the other keys apply. |
| `galleryCard.onClick.method` | enum | `login`, `signup`, `logout`, `resetPassword`, `setNewPassword`, `verifyEmail`, `GET`, `POST`, `PUT`, `PATCH`, `DELETE` |  | What the action performs: the authentication operation under `type: auth`, or the HTTP verb under `type: fetch` (default GET). |
| `galleryCard.onClick.strategy` | enum | `email`, `magicLink`, `oauth` |  | Authentication strategy to use |
| `galleryCard.onClick.provider` | string |  |  | OAuth provider name (e.g., google, github) |
| `galleryCard.onClick.submitLabel` | string |  |  | Submit-button label for the form this action embeds. Defaults to the localized built-in label for the method or operation. Supports $t:key translation references. |
| `galleryCard.onClick.pendingLabel` | string |  |  | Custom in-flight (pending) submit-button label for the auth form. Defaults to the localized built-in pending label for the method. Supports $t:key translation references. |
| `galleryCard.onClick.fields` | array (truncated) |  |  | Per-field label/placeholder overrides for the form this action embeds. Targets fields by name. Additive — the default fields are used when omitted. |
| `galleryCard.onClick.onSuccess` | object (truncated) |  |  |  |
| `galleryCard.onClick.onError` | object (truncated) |  |  |  |
| `galleryCard.onClick.operation` | enum | `create`, `update`, `delete` |  | Data operation to perform |
| `galleryCard.onClick.table` | string |  |  | Table to perform the operation on |
| `galleryCard.onClick.confirm` | boolean |  |  | If true, shows a confirmation prompt before executing the action |
| `galleryCard.onClick.confirmMessage` | string |  |  | Custom confirmation message. Defaults to a generic confirmation prompt. |
| `galleryCard.onClick.data` | object |  |  | Field values to apply in bulk update operations |
| `galleryCard.onClick.name` | string |  |  | Automation name (must match an automation defined in app.automations) |
| `galleryCard.onClick.inputData` | object |  |  | Key-value pairs passed to the automation as input. Supports $variable references. |
| `galleryCard.onClick.await` | boolean |  |  | Wait for completion before triggering response (default: false = fire-and-forget) |
| `galleryCard.onClick.targetDataSource` | string |  |  | ID of the data source to filter (matches dataSource.targetId) |
| `galleryCard.onClick.field` | string |  |  | Field name to apply the filter to |
| `galleryCard.onClick.operator` | enum | `eq`, `neq`, `contains`, `gt`, `lt`, `gte`, `lte` |  | Comparison operator (defaults to eq) |
| `galleryCard.onClick.path` | string |  |  | Destination URL path (supports $record.X substitution) |
| `galleryCard.onClick.message` | string |  |  | Toast notification message. Supports $variable references. |
| `galleryCard.onClick.variant` | enum | `success`, `error`, `warning`, `info` |  | Visual style of the toast notification |
| `galleryCard.onClick.duration` | number |  |  | Auto-dismiss duration in milliseconds (default: 5000) |
| `galleryCard.onClick.url` | string |  |  | Target URL (any absolute path or fully-qualified URL; not prefix-restricted). e.g. /api/tables/contacts/records, /api/auth/admin/ban-user, /api/buckets/default/files/<key> |
| `galleryCard.onClick.mode` | enum | `fetch`, `navigate`, `download`, `oauth` |  | Dispatch mode: fetch (default, client fetch + toast), navigate (browser navigation, e.g. ?format=csv export), download (native file download), oauth (authorize → provider → callback round-trip) |
| `galleryCard.onClick.headers` | object |  |  | Request headers. Content-Type defaults to application/json when body is set. |
| `galleryCard.onClick.body` | object |  |  | JSON request body (serialized with JSON.stringify). String values support $record.<field> and the $session.<field> token (resolved client-side from the caller session, e.g. { confirm: "$session.email" }). |
| `galleryCard.onClick.filename` | string |  |  | Suggested download filename. Only meaningful when mode is "download". |
| `galleryCard.onClick.redirectKey` | string |  |  | Response field holding the OAuth provider redirect URL (default "url"). Only meaningful when mode is "oauth". |
| `galleryCard.onClick.callbackPath` | string |  |  | OAuth provider return path (the registered redirect_uri). Only meaningful when mode is "oauth". |
| `galleryCard.onClick.responseEnvelope` | enum | `sovrium`, `better-auth`, `raw` | sovrium | Response-envelope interpretation: sovrium (default), better-auth (always-200 enumeration-safe envelope at /api/auth/admin/*), raw (status-only, no body assumptions) |
| `galleryCard.onClick.action` | enum | `openDrawer` |  | Set to `openDrawer` to open a sibling drawer instead of running one of the `type` actions. |
| `galleryCard.onClick.component` | string |  |  | ID of the drawer page-component to open (matches a sibling `{ type: 'drawer', id }`) |
| `galleryCard.onClick.props` | object (truncated) |  |  | Per-trigger overrides applied to the referenced drawer component |
| `galleryCard.hoverOverlay` | object |  |  | Overlay content displayed on card hover |
| `galleryCard.hoverOverlay.children` | array (truncated) |  |  | Components rendered in the hover overlay |

`gridColumns` sets the column count per breakpoint — `{ mobile, sm, md, lg, xl }`, each 1 to 6. `layout` is `grid` for uniform rows, `masonry` for variable heights, or `carousel` for one walkable track.

```yaml
tables:
  - name: products
    fields:
      - { name: name, type: single-line-text }
      - { name: photo, type: single-attachment }
pages:
  - name: Products
    path: /products
    components:
      - type: gallery
        dataSource: { table: products }
        gridColumns: { mobile: 1, md: 2, lg: 3 }
        layout: masonry
        galleryCard:
          coverImage: '$record.photo'
          aspectRatio: '4:3'
          children:
            - { type: text, element: h3, content: '$record.name' }
```

## The card

`galleryCard` is where a card's own shape is declared. `coverImage` is usually a `$record.*` reference and `aspectRatio` takes a ratio such as `4:3`, `16:9` or `1:1`. `children` are the components rendered in the card body, and `hoverOverlay: { children }` renders over the card on hover. `onClick` is the action a click on the card runs.

Because `children` is an ordinary component tree, a card is composed from the same types a page is: a `text` for the title, a `badge` for a status, a `description-list` for a run of facts. `$record.<field>` resolves inside all of them.

## Behaviour

### Data Gallery

- `type: 'gallery'` is a valid component type in page components
- `dataSource.table` binds the gallery to a table
- `card.children` supports component children with `$record.*` vars
- `gridColumns` configures responsive grid columns per breakpoint
- `card.coverImage` renders a cover image from a record field
- `card.onClick` triggers a navigation or modal action on card click
- `layout: 'grid'` renders equal-height cards
- `layout: 'masonry'` renders variable-height cards
- `dataSource.pagination` controls pagination style
- `card.aspectRatio` sets the cover image aspect ratio
- `card.hoverOverlay` renders action buttons on card hover
- `layout: carousel` lays the same cards on one walkable track, keyboard-reachable
- `style: numbered` renders a numbered pager reaching every record
- `style: infinite` pages as `numbered` does, losing no record
- An omitted `pagination` draws every record and no pager
- User can complete full gallery workflow (regression)

### System read-endpoint data source

- A system-source gallery whose `galleryCard` fields are NOT app.tables fields boots successfully (field cross-validation is skipped)
- With `dataSource.system`, the gallery fetches `system.endpoint` and never falls through to `/api/tables/:t/records`
- The `{ items: [...] }` envelope is normalized to gallery cards; `total` falls back to rows length when `totalKey` is absent
- The `galleryCard` template renders against endpoint rows (config drives rendering, not a table schema)
- `dataSource.system.query` static params are merged into the endpoint request
- A system-source gallery offers no record-write affordances (no create/edit/delete)
- A system-source gallery offers no saved/user views and no user-preferences (read-only, endpoint-shaped)
- Pagination (`loadMore`/`numbered`/`infinite`) stays ON for a system source — a read-side control that re-reads the endpoint, never the records API
- The existing `dataSource.table` binding still renders table rows as cards unchanged (discriminated-union regression)
- A system-source gallery validates, fetches its endpoint, normalizes the envelope into cards, gates writes off, keeps pagination, leaves DB intact
