Documentation source
View System
Config-driven views that compose flat block layouts. Persisted list and workspace views, plus a schema-first record detail surface with optional workspace overlays.
## Overview
The view system provides a config-driven way to compose blocks into page layouts. Rather than hardcoding every page structure in React components, the platform stores persisted view configurations in the database and renders them through a unified resolver/renderer pipeline. This makes views portable, shareable, forkable, and authorable by AI agents.
Entity types can have multiple persisted views for their list page plus optional workspace overlays that can be opened from a record page or as standalone dashboards. The record detail default path is intentionally schema-first and does not require a persisted view. Views are stored in the `views` table and scoped to a tenant with optional entity-type binding. The current runtime contract is intentionally flat: one block map, one block order, one layout. Earlier region/tab workspace experiments were flattened by migration and are no longer part of the live rendering/editor contract.
## Key Concepts
### View Record
A view is a row in the `views` table with this shape:
```typescript
interface ViewRecord {
id: string;
tenant_id: string;
entity_type_id: string | null; // null = standalone view
entity_id?: string | null; // entity-specific override
parent_view_id?: string | null; // fork/copy lineage
title: string;
description: string | null;
scope: "default" | "shared" | "user";
position: number;
page_type: ViewPageType;
created_by: string | null;
// Surface type — HOW blocks are arranged and interacted with
surface_type: SurfaceType; // default: "grid"
// Block storage — flat map + ordering (see Flat Node Map below)
blocks: Record<string, BlockConfig>; // keyed by block id
blockOrder: string[]; // block ids in display order
layout: ViewLayout;
// Rendering theme — controls block chrome
theme?: ViewTheme; // "dashboard" | "page" | "minimal" | "embed" — default: "dashboard"
// Named entity queries referenced by blocks via dataSourceId
dataSources: Record<string, DataSourceConfig>;
// Publishing fields
publish_token?: string | null; // Unique public access token
publish_status?: string; // "draft" | "published" | "disabled"
publish_config?: {
gate?: PublishGate;
presentation?: "framed" | "standalone"; // default: "framed"
} | null;
}
```
### Flat Node Map
Blocks are stored as a flat `Record<string, BlockConfig>` keyed by block ID, with a separate `blockOrder: string[]` controlling display order. This replaces the earlier `BlockConfig[]` array.
**Why:** O(1) block lookup by ID, which the workspace editor needs for drag-to-reorder without re-indexing the whole array. It also makes block-level updates (patching a single block config) straightforward.
**Backward compatibility:** The DB column is `blocks` JSONB and a new `block_order` text[] column stores the order. `parseViewBlocks()` auto-converts legacy array data on read and canonicalizes older block config shapes like `field-card.config.fieldName` and `stat-cards.config.stats`. All write paths use the new format.
**Helpers in `features/views/types.ts`:**
| Function | Description |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `parseViewBlocks(rawBlocks, rawBlockOrder)` | Normalises raw DB data to `{ blocks, blockOrder }`. Handles legacy array format, new map format, and canonical config migration for older block shapes. |
| `parseViewRecord(raw)` | Converts a raw `Tables<"views">` DB row to a typed `ViewRecord`. |
| `orderedBlocks(view)` | Returns `BlockConfig[]` in display order — convenience for iteration and rendering. |
| `blockCount(view)` | Number of blocks in the view. |
### Data Sources
`dataSources` is a `Record<string, DataSourceConfig>` on `ViewRecord`, stored in the `data_sources` JSONB column. A data source defines a named entity query (type, filters, sort, limit) that one or more blocks can reference via `block.dataSourceId`.
```typescript
// Example: two blocks sharing one data source
const view = {
dataSources: {
pipeline: {
type: "entity",
entityTypeSlug: "opportunity",
filters: [{ field: "status", operator: "eq", value: "active" }],
sort: { field: "created_at", order: "desc" },
limit: 50,
},
},
blocks: {
"blk-1": { id: "blk-1", type: "table", dataSourceId: "pipeline" },
"blk-2": {
id: "blk-2",
type: "chart",
dataSourceId: "pipeline",
config: { groupBy: "stage" },
},
},
blockOrder: ["blk-1", "blk-2"],
};
```
When `resolveView()` encounters a block with a `dataSourceId`, it merges the data source's `entityTypeSlug`, `filters`, `limit`, `sort`, and relation-column config into the block config before resolution. Block-level config values override data source defaults. `data-table` blocks receive `limit` as their `pageSize` so named source limits behave consistently across collection renderers.
The workspace editor can create sources directly in the Data Sources panel or
import reusable sources from the Library. Saved views contribute source assets
automatically. First-party product modules can also register named source
presets through `features/views/lib/data-source-presets.ts`; those presets stay
platform-typed (`DataSourceConfig`) while their product-specific registration
lives outside `features/views/`. Standalone workspace views (`entity_type_id =
null`) receive all available presets for the tenant's entity types, while
entity-bound views only receive presets for their bound entity type.
### Workspace Editor Status
The workspace editor renders a compact status strip directly below the toolbar
so authors can see whether a draft is saved, dirty, or saving while they work.
The strip also summarizes block count, data-source count, surface type, layout,
preview mode, and selected block context. These labels are derived from the
existing `useViewEditor` draft state and surface registry metadata; the strip
does not introduce a new persistence contract.
Selected block labels may be user-authored, so the selected-context item is
min-width-aware and truncates long text instead of widening the editor on small
viewports.
### Task View Fixtures
`features/views/lib/task-design-fixtures.ts` defines first-party task view
fixtures used to exercise the task UX across real block contracts. The fixtures
are surfaced through the create-view template picker only when the current
entity type slug is `task`.
The shipped fixtures are:
- `task-queue-command` — summary cards, compact data table, priority mix, and
delegation mix.
- `task-status-board` — kanban board and row table bound to open Task entities.
- `task-delegation-board` — delegation-state board and row table for
agent-assisted tasks.
- `task-workspace-pulse` — workspace page with task metrics, status/delegation
charts, planner, and activity.
Each fixture stores blocks as the current flat block map plus `block_order`, and
each block with `dataSourceId` references a named `data_sources` entry.
### Reusable Artifact Catalog
The generative UI artifact registry adds stable identity and versioning above
the existing runtime tables. `ui_artifacts` stores the catalog row for reusable
views, blocks, data sources, tools, renderers, and forms. `ui_artifact_versions`
stores immutable manifest snapshots.
Artifacts do not replace `views`, `views.data_sources`,
`external_data_sources`, `actions`, or `ui_renderer_plugins`. Instead, each
artifact can point at a materialized runtime row while preserving authoring
provenance, current version, review status, and interop metadata. This keeps
the view renderer and block resolver on their existing storage contracts while
giving agents and libraries stable artifact IDs to reference.
The view library can expose artifact-backed source and block assets alongside
saved-view-derived assets. Saved-view assets remain useful discovery results;
artifact-backed assets are the durable reusable identities.
### External Metric Data Sources
In addition to entity-backed data sources, views can bind external metric rows
from `features/external-data/` through named `kind: "metric"` sources:
| Type | Config | How it works |
| -------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| `metric` | `sourceKey`, optional `metricKeys`, `mode: "latest" \| "history"`, optional `window` | Resolves a tenant-scoped `external_data_sources.source_key` and reads `external_data_points` for bound blocks. |
| `api` | `ApiConfig` — `apiUrl`, `apiHeaders`, `apiTransform`, `cacheTtl` | Fetched live during view resolution with a short-TTL cache (default 5 min). Good for real-time dashboards. |
The existing `webhook` source type (where external systems POST via
`POST /api/webhooks/inbound`) is unchanged. Connector schedules plus metrics
targets are the polling path for external APIs; the old external-data poll
schema was never shipped.
### Page Types
| Page Type | Purpose | Layout Options |
| ----------- | ------------------------------------------------------------- | ------------------------------------ |
| `list` | Entity type list pages | stack, grid-2, grid-3, bento, single |
| `workspace` | Standalone dashboards and optional record-page custom layouts | stack, grid-2, grid-3, bento, single |
There is no persisted `detail` page type in the current implementation. Record detail defaults to the schema-driven page and can optionally activate a `workspace` view when a specific `?view=` is selected.
### View Layouts
Five layout options control how blocks are arranged:
- **stack** -- Single-column vertical stack
- **grid-2** -- Two-column grid
- **grid-3** -- Three-column grid
- **bento** -- Responsive bento grid where blocks declare their own size (full, half, third)
- **single** -- Single block fills the entire area
### Flattened Workspace Model
The current workspace model is the same flat structure used by list views:
- `blocks` stores the canonical block map.
- `blockOrder` controls display order.
- `layout` controls presentation.
Earlier region/tab/preset experiments were flattened into standalone views by migration `20260401000007_flatten_views_and_normalize_blocks.sql`. The editor, renderer, and resolver all standardize on the flattened model to reduce surface area and avoid split-brain rendering paths.
### View Scope
- **default** -- The primary view for this entity type / page type combination
- **shared** -- Visible to all tenant members
- **user** -- Private to the user who created it
User-scoped views are only visible to their creator. The `canAccessView()` helper enforces this check.
### Workspace-Scoped Views
`views` is a scope-aware table under ADR-0013. The `workspace_id` nullable column controls which tier a view belongs to:
- `workspace_id IS NULL` — tenant-scoped (visible to all workspace URLs in the tenant)
- `workspace_id = X` — workspace-scoped (visible only inside `/t/<slug>/w/<ws>/...` URLs)
The admin view inventory at `/admin/views` renders all tiers with an `<InheritanceBadge />` showing "Tenant" or the workspace name. Tenant-scoped rows show an **"Override in this workspace"** button that calls the views override handler to clone the view into the active workspace scope. The clone clears bundle provenance columns (`installed_by_workspace_id`, `installed_by_bundle`, `bundle_version`) so an `uninstall_bundle()` call does not sweep the user-created override.
Workspace-scoped views use `ON DELETE CASCADE` — deleting a workspace removes its view overrides. Tenant-scoped views are unaffected.
### V2 View Service
The canonical server-side view action implementation now lives in
`lib/ui-registry/server/view-service.ts`. The legacy
`features/views/server/actions.ts` module remains as a `"use server"` re-export
shim so existing imports keep the same names, signatures, and return shapes:
`getViews`, `getViewsKeyed`, `getAllViews`, `getAccessibleViewInventory`,
`getViewInventory`, `getViewById`, `getViewByIdKeyed`, `getViewBySlug`,
`getViewsForEntity`, `createView`, `updateView`, `deleteView`,
`forkViewForEntity`, `copyView`, `saveFiltersAsView`, and `repairView`.
The service resolves request scope through `resolveViewScope()` in
`lib/ui-registry/server/view-service-scope.ts`, which combines tenant context,
user id, and the strict active workspace id into the shared ADR-0013 `Scope`.
List reads thread that scope through `applyScopeFilter(...,
SCOPE_AWARE_TABLES.views)`, so tenant-default rows and active-workspace rows are
visible while foreign-workspace rows are excluded. Single-slug reads fetch
candidate rows and choose the most specific row, which lets an active-workspace
view override a tenant-default view with the same slug.
Writes preserve the V1 own/team permission split: user-scoped views created by
the caller use `views.own.*`; every other mutation uses `views.team.*`.
Entity-bound creates stamp the active `workspace_id` when one is present.
Tenant-spanning standalone creates and `copyView()` stay tenant-level.
### Definition-First Persistence
Persisted views are read definition-first. When a row has `views.definition`,
`parseViewRecord()` derives the legacy `ViewRecord` fields through
`viewRecordFromDefinition()`; legacy `blocks`, `block_order`, `surface_type`,
and `surface_config` columns remain the fallback path. This keeps current
renderers on the unchanged `ViewRecord` shape while moving the source of truth
toward the UI registry definition contract.
Writes follow the inverse path. `createView()`, `updateView()`, and
`repairView()` fold block layout, surface type, surface config, data sources,
and theme into `views.definition` through `surfaceToDefinition()`. `updateView()`
also accepts a persisted public `PublicAuthorView` definition directly and
normalizes a runtime `View` input to that public shape before writing.
The legacy `surface_config` column is intentionally written only for canvas
state. `canvas_state` is still stored there until the deferred canvas-state to
definition migration lands; non-canvas surface config rides the persisted
definition instead.
#### Legacy surface type → V2 container mapping
`surfaceToDefinition` (`lib/ui-registry/lib/surface-to-definition.ts`) is the
single source of truth for this projection — used by both live view writes and
the `scripts/migrate-views-to-definition.ts` batch migrator.
| Legacy `surface_type` | V2 root container | Notes |
| ---------------------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `grid` | `grid-block` | `layout` column → `columns`: `single`/`stack`=1, `grid-2`=2 (default), `grid-3`=3 |
| `dashboard` | `dashboard-grid` | |
| `page`, `scroll`, `pdf`, `email`, `embed-card`, `terminal`, `notebook` | `page-block` | `config.surface` discriminates; V1 `sectionSpacing=spacious` → `spacing=relaxed`; CSS-string `maxWidth` parsed to number |
| `form` | `form-block` | `mode: "input"` |
| `slides`, `carousel` | `slides-block` | |
| `story`, `focus` | `stack-block` | `story` → `darkMode: true`; `focus` → `darkMode: false` |
| `swipe` | `swipe-block` | `mode: "input"` |
| `rank` | `ranking` | `mode: "input"` |
| `sequence`, `wizard` | `sequence-block` | `mode: "input"` |
| `split` | `split-block` | |
| `table`, `timeline`, `gallery`, `inbox`, `compare`, `chat`, `kanban` | `list-block` | |
| `map`, `graph`, `theater`, `flow-overview`, `artifact` | `page-block` | Spatial/agent fallback — blocks stay readable stacked |
| `canvas` | `grid-block` | Inert (no children); `canvas_state` still in `surface_config` until canvas migration lands |
`viewRecordFromDefinition` inverts this table on read so a round-trip
`surfaceToDefinition` → `viewRecordFromDefinition` preserves the original
`surface_type`. Re-running `scripts/migrate-views-to-definition.ts` post-deploy
is required to re-project rows that were incorrectly collapsed before this fix.
#### Migration runbook notes
The migrator was run against prod on **2026-06-10** twice (PR #2311 activation — 66 views, then PR #2333 tool input-mode default — 64 views). Known issue: the migrator is not checksum-idempotent. Approximately 58 rows project different checksums on each run (suspected nondeterministic block IDs), defeating the skip-by-checksum optimization and causing those rows to be re-written on every invocation. This is benign but wasteful; a fix is tracked as a follow-up. Until resolved, treat every migrator run as a full rewrite rather than an incremental patch — verify row counts in the script output rather than relying on "0 rows changed" as a sign of a clean state.
#### Tenant-sync definition passthrough (PR #2392)
Tenant-declared views (`defineViews` in tenant module declarations) can now carry a pre-computed `definition` field. When the tenant-sync `toRow` writer encounters a record with `definition` set, it writes that value directly rather than re-deriving it from legacy surface columns via `surfaceToDefinition`. This is necessary for quiz-root views (`exercise-block` roots) where the canonical quiz config lives inside `definition.blocks` and cannot be round-tripped through the legacy column projection without data loss. Views without a `definition` field continue to derive one from surface columns, preserving the Phase-4C dual-write contract.
#### Deck (slides-block) authoring shape rule
`views.definition` for a `slides-block` view is the **authoring shape** (`PublicAuthorView` with named `block` keys). The runtime normalized shape — where literal bindings are materialized and block arrays are resolved — is computed at render time inside `normalize-view.ts` and `definition-to-view.ts`. Saving with the normalized shape instead of the authoring shape causes literal bindings to be double-materialized on subsequent renders. Always persist the authoring shape.
### View Themes
The `theme` field on `ViewRecord` controls how block chrome is rendered. The `BlockFrame` component wraps each block with chrome appropriate to the theme.
```typescript
type ViewTheme = "dashboard" | "page" | "minimal" | "embed";
```
| Theme | Chrome | Use case |
| ----------- | ------------------------------------------------- | ------------------------------------------- |
| `dashboard` | Passthrough — blocks render their own Card chrome | Admin dashboards, analytics pages (default) |
| `page` | `<section>` with optional `<h3>` label | Entity detail pages, content pages |
| `minimal` | Bare passthrough, no decoration | Embedded views, print layouts |
| `embed` | Bare passthrough, optimized for iframe | Public embeds in external sites |
`BlockFrame` is in `features/blocks/components/block-frame.tsx`. Existing block components that render their own `<Card>` are unaffected by the `dashboard` passthrough.
### Spec Render Parity
ViewSpec and BlockSpec renders — the paths used by agent-generated and ephemeral views — now use the same chrome composition as persisted views rendered through `BlockGrid`.
**The problem before this change:** The default `BlockSlot` in `ViewSpecRenderer` and the entirety of `BlockSpecRenderer` called `createElement(component, props)` directly, with no error boundary, no `Suspense` skeleton, and no `BlockFrame`. A crash in one block brought down the whole spec. A slow-resolving block produced no loading UI. Unregistered block types emitted a bare `<div data-slot-failure>` with no user-facing message.
**The shared chrome — `BlockSpecChrome`:**
`features/blocks/components/block-spec-chrome.tsx` is a thin composition helper shared by both renderers:
```tsx
<BlockFrame theme={theme} label={block.label} description={block.description}>
<BlockErrorBoundary
blockType={block.type}
blockLabel={block.label}
resetKey={resetKey}
>
<Suspense
fallback={<BlockSkeleton type={block.type} label={block.label} />}
>
{children}
</Suspense>
</BlockErrorBoundary>
</BlockFrame>
```
The `resetKey` is derived from `type + id + JSON.stringify(config)` via the existing `getBlockErrorBoundaryResetKey` helper — the same logic `BlockGrid` uses for persisted views.
**What each render path does now:**
| Render path | Error boundary | Suspense skeleton | BlockFrame | Unknown type |
| -------------------------------------- | -------------------- | ----------------- | ------------ | ------------------------- |
| Persisted view (`BlockGrid`) | `BlockErrorBoundary` | `BlockSkeleton` | `BlockFrame` | `UnknownBlockPlaceholder` |
| `ViewSpecRenderer` default `BlockSlot` | `BlockErrorBoundary` | `BlockSkeleton` | `BlockFrame` | `UnknownBlockPlaceholder` |
| `BlockSpecRenderer` | `BlockErrorBoundary` | `BlockSkeleton` | `BlockFrame` | `UnknownBlockPlaceholder` |
**The `renderBlock` override is unchanged.** Callers that pass a `renderBlock` prop to `ViewSpecRenderer` receive the resolved block and render it however they choose. The chrome wrapping only applies to the default path.
**Accessibility:** `UnknownBlockPlaceholder` gained `role="alert"` and `aria-live="polite"`. Screen readers now announce missing-block failures instead of silently skipping them.
### Fork and Copy Lineage
Views track their origin via `parent_view_id`:
- **Copy** (`copyView`) -- Creates an independent duplicate with a new title, linked to the source for lineage.
- **Fork** (`forkViewForEntity`) -- Creates an entity-specific override of a type-level view. The fork is scoped to `user` and records the source view as its parent.
### View Management UX
Views are managed directly from the tab bar and the workspace editor — no drill-down required.
**Per-view overflow menu (tab bar):** Each active view tab has a `…` dropdown with Edit layout, Rename, Duplicate, and Delete actions. Rename opens a small inline dialog that PATCHes `/api/views/[id]`. Duplicate calls `POST /api/views/[id] { action: "copy" }` and navigates to the new view. Delete is no longer buried inside the edit panel. Implemented in `features/views/components/view-tabs-uncontrolled.tsx` and wired through `view-tabs.tsx`.
**Scope-based tab grouping:** View tabs are sorted by scope (team/shared first, then personal/user), with a visual divider between groups and a scope icon (Users vs User) on each tab. The pure grouping helper lives in `features/views/lib/sort-views.ts`.
**Save as new view (editor toolbar):** The workspace editor toolbar has a `Save as…` button that forks the current unsaved draft (blocks, layout, data sources, surface config) into a brand-new view via `useViewEditor.saveAs()`. Users pick a new title and visibility scope in the `SaveAsNewViewDialog`. No separate workflow — it's a single button click mid-edit. Original view is untouched.
**Admin list duplicate:** The admin data-types views list (`ViewListItem`) has a Copy button in the hover actions that calls `copyView()` directly and refreshes the list.
**Interactive badge + Type filter (standalone gallery):** The `/views` gallery distinguishes _experiences_ (interactive views that collect input) from read-only dashboards without renaming anything — every interface is still a View (ADR-0046 D2). `isInteractiveView()` (`lib/ui-registry/lib/is-interactive-view.ts`) classifies a view as interactive when any block is in `renderMode: "input"` or is an `exercise-block` / `form-block`. Interactive-view cards carry a `Zap` "Interactive" badge, and a "Type" filter (All / Interactive / Dashboards) narrows the grid. The gallery keeps the name **"Views"** — read-only dashboards are Views, not experiences.
### Live Block Previews in Workspace Editor
The workspace editor canvas renders **actual block components** by default, not placeholder cards. When a block is added or configured, the editor fetches resolved block data from `/api/views/preview` and renders the real component via `BlockRenderer`, wrapped in a `pointer-events-none` layer so clicks still route to the `SortableBlock` wrapper for drag/select/resize.
**Data flow:**
1. `useViewPreview()` hook posts the current draft state (blocks, layout, data sources, optional entity context) to `POST /api/views/preview`, debounced 400ms.
2. The endpoint builds a temporary in-memory `ViewRecord` and calls the existing `resolveView()` server resolver — nothing is persisted.
3. Resolved blocks are returned keyed by block id and passed down to `EditorBlockLive`, which dispatches to `BlockRenderer` when data is present or falls back to `EditorBlockPreview` (icon + label) for unsupported types or during loading.
**Mode toggle:** The editor toolbar has a Live ↔ Outline toggle (Eye / Shapes icons). Live mode (default) renders real components; Outline mode shows lightweight placeholder cards for fast drag manipulation on large views.
## How It Works
### Entity Type Page Flow (List Views)
1. The entity type list page queries views with `page_type='list'` for the entity type.
2. If views exist, `ViewTabs` renders tab navigation (with create/edit/delete buttons) and `UnifiedViewRenderer` renders the active view.
3. If no views exist, the page falls back to `EntityListSplitWrapper` (the built-in table/grid/kanban view).
4. If there are no persisted views but legacy `config.dashboard.sections` still exist, the page renders a deterministic synthetic dashboard view without mutating the database during page load.
### Entity Detail Page Flow
1. The detail page always renders the schema-driven default surface first (`EntityBento`, notes, sidebar, responses).
2. The page queries optional `page_type='workspace'` views with `getViewsForEntity()` for record-specific customization.
3. The server resolves detail-view selection once, then passes the selected workspace view state and resolved blocks to the client.
4. Workspace views are only resolved when explicitly selected; the default record request path remains schema-first.
5. Entity-specific overrides take priority over entity-type-level views when a workspace view is selected.
### Standalone View Pages
The `/view/[id]` route renders any persisted view by ID outside entity type context. These views are shareable, forkable, and can be embedded. Standalone views have `entity_type_id = null`.
### View Resolution Pipeline
1. **Query** -- `getViews()` or `getViewsForEntity()` fetches views from DB, filtered by tenant, entity type, page type, and access scope. Raw DB rows are normalised through `parseViewRecord()`, which promotes legacy array-format blocks to the flat map format and canonicalises older block config shapes.
2. **Block resolution** -- Call sites pass the `ViewRecord` to `resolveView()`. The unified resolver classifies blocks by `dataRequirement`, applies any `dataSources` config, and delegates to the appropriate sub-resolver. Returns `ResolvedBlock[]` in original block order.
3. **Rendering** -- `UnifiedViewRenderer` renders the already-resolved flat block sequence via `BlockGrid`.
#### Relation Column Resolution in data-table Blocks
When a `data-table` block config includes a `relationColumns` map, `resolveDataTableBlock()` calls `resolveRelationColumns(entityIds, relationColumns, tenantId)` after fetching the base entity set. The resolved cross-entity values are merged into each entity's `content` before the block is returned. This means relation-aware computed columns (e.g., a company's most recent deal stage) are available in the resolved block's `data.entities` without any additional client-side fetching.
```typescript
// Example data-table block config with relation columns
{
type: "data-table",
entityTypeSlug: "contact",
config: {
relationColumns: {
latest_deal_stage: {
relationshipType: "has_deal",
targetField: "stage",
aggregation: "latest",
},
},
},
}
```
The resolved block's `data.relationColumnDefs` carries the original `relationColumns` config so the client-side renderer can build the correct column definitions.
### Auto-Generated Default Blocks
When creating a detail view, `features/views/lib/default-blocks.ts` can auto-generate `field-card` blocks from the entity type's JSON schema. This provides a sensible starting point that users can customize.
### Realtime Subscriptions
The `useViewRealtime(viewId)` hook subscribes to Supabase Realtime UPDATE events on a specific view record. When an AI agent iterates on a view via the `manageView` tool, the subscription fires and invalidates the React Query cache, causing the UI to re-render with the updated view configuration.
### Templates
`features/views/lib/templates.ts` provides predefined view templates for fast scaffolding:
| Template | Layout | Blocks | dataSources |
| -------------------- | ------ | ------------------------------------ | ----------- |
| `kpi-dashboard` | bento | stat-cards, chart, ranking, activity | — |
| `entity-overview` | bento | summary, connection-list, activity | — |
| `pipeline-tracker` | stack | stat-cards, kanban | — |
| `data-explorer` | stack | data-table | — |
| `activity-feed` | stack | stat-cards, entity-feed | — |
| `executive-overview` | bento | stat-cards, chart, activity | primary |
| `pipeline-board` | stack | stat-cards, kanban | primary |
| `content-operations` | bento | stat-cards, entity-feed, activity | primary |
#### Template dataSources and Hydration
Role-based templates (`executive-overview`, `pipeline-board`, `content-operations`) include a `dataSources` map with a `primary` data source. The entity type slug in the data source uses the placeholder `"__CONTEXT__"` (`TEMPLATE_CONTEXT_SLUG` constant), which is replaced at creation time.
When a user creates a view from a template, `hydrateTemplate(template, typeSlug)` in the create dialog:
1. Replaces `"__CONTEXT__"` in `dataSources` with the actual `entityTypeSlug`
2. For blocks without a `dataSourceId`, injects `entityTypeSlug` directly into block config
3. If no `typeSlug` is provided (standalone views), strips `dataSourceId` references
The hydrated `dataSources` are included in the `POST /api/views` payload and persisted with the view. This allows all blocks in a template-created view to share a single entity query rather than each block independently configuring its own data source.
## View Publishing and Embeds
Any view can be published with a public access token, making it renderable without authentication in an iframe on an external website.
### Publishing Flow
1. A tenant admin calls `publishView(viewId)` (or uses the `PublishViewDialog` component on a tool page). The server generates a 40-character hex token, sets `publish_status = 'published'`, and stores the token in `publish_token`.
2. The token is stable — unpublishing sets `publish_status = 'disabled'` but preserves the token. Re-publishing reuses the same token and the same embed code continues to work.
3. `publishToolAsView(toolSlug, toolName)` is a convenience action that creates a new standalone view with a single `tool` block and publishes it in one call, returning the view record, token, and ready-to-paste embed snippet.
### Public Embed Route
`/embed/v/[token]` is a Next.js route outside the authenticated app shell. It:
1. Fetches the view by token via `getPublishedView(token)` — uses the admin client to bypass RLS (the RLS anon policy also permits direct access).
2. Resolves blocks through `resolveView()` with `tenantId` from the view record and `mode: "view"`.
3. Renders the resolved blocks inside `EmbedViewClient` which imports the full block registry and custom tool UIs.
The embed layout (`app/embed/v/[token]/layout.tsx`) has no app shell, no auth middleware, and adds an inline `ResizeObserver` script that posts `document.documentElement.scrollHeight` to the parent frame as `{ type: "sprinter-embed-resize", height: number }`.
### Embed Loader Script
`/embed/sprinter-embed.js` is a static script that external pages include via a `<script>` tag:
```html
<div id="my-embed"></div>
<script
src="https://app.sprinter.ai/embed/sprinter-embed.js"
data-token="TOKEN"
data-target="#my-embed"
></script>
```
The loader:
- Derives the host from its own `src` attribute (works on any deployment).
- Creates an `<iframe>` pointing to `/embed/v/[token]`.
- Listens for `sprinter-embed-resize` `postMessage` events from the iframe's `contentWindow` and updates `iframe.style.height` accordingly.
### Embed Utilities (`features/views/lib/embed-code.ts`)
| Function | Signature | Description |
| ------------------------------------ | ---------------------------- | ------------------------------------------------------------ |
| `buildEmbedSnippet(token, hostUrl)` | `(string, string) => string` | Returns the copy-paste HTML with `<div>` + `<script>` tags. |
| `getEmbedPreviewUrl(token, hostUrl)` | `(string, string) => string` | Returns the direct URL to the public embed page for preview. |
### RLS and Security
A dedicated RLS policy grants the `anon` Supabase role SELECT access to `views` rows where `publish_status = 'published'` and `publish_token IS NOT NULL`. No other tables are opened to anonymous access. The route itself also validates `publish_status` server-side before rendering. Unpublished views return a 404 even if the token is known.
### Embed Response Persistence
`EmbedViewClient` persists form progress to `sessions.draft_values` in real time so users can resume if they close the tab. Per [ADR-0017](/docs/adr/0017), `sessions` is the sole source of truth for embed drafts — the legacy `view_responses` mirror was dropped on 2026-05-22.
**How it works:**
1. On mount, the client derives a stable `sessionId` from `localStorage` (key: `embed-session-{token}`). A fresh UUID is generated if none exists.
2. The client calls `POST /api/embed/v/[token]/session` to bootstrap (or reattach to) a server-issued `response` session — the returned `session_id` becomes the `responseSessionId` for subsequent requests.
3. If the session already has `draft_values`, those are injected as `surfaceConfig.initialData`. Otherwise a `GET /api/embed/v/[token]/respond?sessionId=…&responseSessionId=…` fetch restores any previously saved data.
4. Every `onSave` callback from `SurfaceRenderer` accumulates block data in a ref and triggers a debounced `POST /api/embed/v/[token]/respond` (300 ms delay) which updates `sessions.draft_values` via the admin client.
5. View-level submit calls `POST /api/embed/v/[token]/submit`, which materializes the draft through `bundleContentThroughResponses` per ADR-0017.
**API surface:**
| Method | Path | Auth | Description |
| ------ | ------------------------------------------------------------ | ---------------------------------- | ---------------------------------------------------- |
| `POST` | `/api/embed/v/[token]/session` | None | Bootstrap or reattach to a response session |
| `GET` | `/api/embed/v/[token]/respond?sessionId=&responseSessionId=` | None | Restore session draft data |
| `POST` | `/api/embed/v/[token]/respond` | None | Update `sessions.draft_values` (requires session id) |
| `POST` | `/api/embed/v/[token]/submit` | None (validated by `publishToken`) | Materialize the session into entities via responses |
Endpoints use the admin client and validate the `publishToken` against `views.publish_status`. The response session row enforces `view_id` binding so a caller holding any publish_token cannot probe a session belonging to a different view.
### PublishViewDialog
`features/views/components/publish-view-dialog.tsx` provides the publish/unpublish UI:
- **Unpublished state** — Shows a description and a "Publish" button.
- **Published state** — Shows the full embed snippet in a scrollable `<pre>` block, a "Copy Embed Code" button, and a "Preview" link opening the public route in a new tab. A ghost "Unpublish" button is available at the bottom.
The dialog is controlled by `onPublish` and `onUnpublish` callbacks so it can be wired to either `publishView` / `unpublishView` (for existing views) or `publishToolAsView` (for first-time tool publishing).
### Publish Presentation (`framed` vs `standalone`)
`publish_config.presentation` controls how `/embed/v/[token]` renders the view:
| Value | Behavior |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"framed"` (default) | Renders inside the standard embed layout with a ResizeObserver that reports height to the parent frame. Suited for embedding in external sites via the loader script. |
| `"standalone"` | Renders full-bleed (`min-h-[100dvh]`) with no Sprinter chrome. Suited for mini-apps, games, interactive tools, and landing pages hosted at the public URL directly. |
`resolvePublishPresentation(publishConfig)` in `lib/ui-registry/server/publish-config.ts` reads the field from any `publish_config` JSONB shape and fails safe to `"framed"` for missing or malformed values.
When `presentation: "standalone"` is set **and** a `gate` is present, the page suppresses the HTML `<meta name="description">` tag to avoid leaking gated content to search crawlers.
`publish_config` updates are merged rather than replaced — setting `presentation` does not clear an existing `gate` and vice versa.
**Auto-publish from `publish_view`:** when the tool persists a view and a presentation is in play — `presentation` passed explicitly, or implied because `kind: "html"` defaults to `"standalone"` — the view row is written with `publish_status: "published"` and a `publish_token`, and the tool result includes the durable `publicUrl` (`/embed/v/[token]`). A persisted view with **no** presentation input keeps the historical draft behavior (publish remains an explicit UI action). `updateViewId` re-publishes in place and never rotates an existing token, so a shared mini-app URL keeps working across iterations. The Studio agent surfaces this loop directly: the **Mini-app** creation intent seeds a single-file HTML build, and the canvas header exposes **Open** / **Copy public link** for any artifact carrying a `publicUrl`.
## Fullscreen Presentation Mode
The `/present/[id]` route renders any view in a fullscreen, app-chrome-free layout. It is designed for three use cases:
1. **Distraction-free presentation** — Any authenticated user can navigate to `/present/{viewId}` to see a view without the sidebar, navigation, or other app chrome.
2. **Guest view confinement** — Invited guest users with an `assigned_view_id` in their JWT `app_metadata` are automatically redirected to `/present/{assignedViewId}` for every request that is not an explicitly allowed path (see [Guest View Isolation](/docs/features/auth-permissions#guest-view-isolation)).
3. **Per-data-type deck templates ("Present as deck")** — When an entity type's configuration declares a `deckTemplate` view ID, a "Present as deck" option appears on matching records and opens `/present/{templateViewId}?entityId={recordId}`. The template is a `slides-block` view whose blocks bind field values from the record at render time. Agents can push a deck live by calling `publish_view({ viewId, attachToEntityId })` to pin it to the record's share site.
### Route Architecture
| File | Purpose |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `app/present/layout.tsx` | Minimal shell: full-height flex container, "Powered by Sprinter" footer, no nav. Redirects unauthenticated users to `/login`. |
| `app/present/[id]/page.tsx` | Server component: fetches view via admin client, validates tenant ownership, resolves blocks through `resolveView()`. |
| `app/present/[id]/present-view-client.tsx` | Client component: imports block registry and custom tool UIs, delegates to `SurfaceRenderer`. |
### Security
The page enforces tenant ownership before rendering: `rawView.tenant_id !== ctx.tenantId` → 404. This prevents cross-tenant view access even if the view ID is known. The layout itself requires authentication via `getUserId()` and redirects to `/login` for unauthenticated requests.
### Guest Invite Flow
Admins can invite users with a specific view assignment via `POST /api/auth/invite` with the optional `viewId` parameter:
1. The `viewId` is stored in the created user's `app_metadata.assigned_view_id`.
2. The invite email is customized: subject and heading reference the view name; the CTA button changes from "Sign In" to "Get Started"; the login URL becomes `/login?next=/present/{viewId}`.
3. After login, the `?next=` redirect lands the user on `/present/{viewId}`.
4. On subsequent requests, `proxy.ts` reads `assigned_view_id` from the JWT claims and redirects the user to `/present/{assignedViewId}` for any path outside `/present/`, `/api/`, `/login`, `/auth/`, and `/reset-password`.
## Response Mode
On entity detail pages, a view can flip from display mode to response/input mode. When the user triggers "Submit Response," every block with `meta.role: "both"` renders its `editComponent` in place — the layout stays identical, but fields become inline inputs.
### ViewResponseState
`use-view-response.ts` provides the response context:
```typescript
interface ViewResponseState {
mode: "view" | "respond";
values: Record<string, unknown>; // collected values keyed by field name
touched: Set<string>; // block IDs that have been modified
enterResponseMode: () => void;
exitResponseMode: () => void;
setValue: (key: string, value: unknown) => void;
submit: () => Promise<void>; // submits via the submitResponse tool
}
```
Wrap a view with `<ViewResponseProvider>` to enable response mode. The `ResponseToolbar` component (`features/views/components/response-toolbar.tsx`) renders a sticky bottom bar showing the modified field count and Submit / Cancel controls — it renders nothing outside a `ViewResponseProvider` or when `mode` is `"view"`.
### Block Roles
Block `meta.role` controls behaviour in response mode:
| Role | Behaviour |
| --------- | -------------------------------------------------------------------------------------------- |
| `display` | Read-only in both modes (charts, activity, documents) |
| `input` | Always shows as an input form (form-flow, response-form) |
| `both` | Renders as display normally; flips to `editComponent` in response mode (field-card, summary) |
The key UX insight: the view layout doesn't change. A field-card showing `"Revenue: $1.2M"` becomes an inline input showing `$1.2M` with a cursor. A criteria score `"4/5"` becomes a slider. All values collect in `ViewResponseState.values` and are submitted together via the `submitResponse` tool.
## Cross-Block Filtering
When a view contains an `entity-filter` block alongside data blocks (table, chart, kanban, etc.), those blocks can share interactive filter state through `ViewFilterProvider`.
### How It Works
1. `ViewFilterProvider` wraps the view renderer, providing a React context for filter state keyed by `dataSourceId`.
2. When the user applies filters in an `entity-filter` block, the block calls `setFilters(dataSourceId, rules)` on the context.
3. Data blocks that share the same `dataSourceId` use `useFilteredEntities()`. This hook watches the filter context — when active filters exist, it fires a client-side fetch to `/api/entities` with the updated rules. When no filters are active, blocks use their SSR-resolved `initialData`.
### Setup
The `entity-filter` block and the data blocks it should control must all reference the same `dataSourceId`:
```typescript
const view = {
dataSources: {
deals: { entityTypeSlug: "opportunity", limit: 50 },
},
blocks: {
"filter-1": {
id: "filter-1",
type: "entity-filter",
dataSourceId: "deals",
},
"table-1": { id: "table-1", type: "table", dataSourceId: "deals" },
"chart-1": {
id: "chart-1",
type: "chart",
dataSourceId: "deals",
config: { groupBy: "stage" },
},
},
blockOrder: ["filter-1", "table-1", "chart-1"],
};
```
The `UnifiedViewRenderer` wraps all blocks in `ViewFilterProvider`. No additional wiring is needed.
## Visual Editor
The visual editor is an inline drag-and-drop editing mode for views. It lets users rearrange blocks, configure block settings, swap between compatible block types, add new blocks from a palette, and remove blocks -- all without leaving the page. The editor is an overlay on top of the existing renderer, so it adds zero JS overhead when not in use.
### Entering Edit Mode
Click the **Customize** button in the `ViewToolbar` on entity detail pages or standalone view pages. This toggles the view into edit mode, where blocks become draggable and a floating toolbar appears with save/cancel controls.
### What You Can Do
- **Drag to reorder** -- Blocks become sortable within the flat block list. Drag handles appear on hover. The editor uses dnd-kit's `rectSortingStrategy` for 2D grid reordering that respects the bento layout.
- **Configure blocks** -- Click the settings icon on any block to open a popover sheet. You can change the block label, size (full/half/third or span 1-12), and type-specific configuration.
- **Swap block types** -- Within the settings popover, compatible block types are offered as swap targets. For example, a `table` block can be swapped to `kanban`, `data-table`, `entity-feed`, or other collection-group types. Shared config keys (entityTypeSlug, limit, filters) are preserved; renderer-specific config is reset. See [Block Compatibility Groups](/docs/features/block-system#block-compatibility-groups).
- **Add blocks** -- The block palette groups all 32 types by category (Data, Entity, Content, Forms, Activity, Interactive, Tools) with a search box that filters across all categories. Selecting a type appends a new block to the current block list.
- **Remove blocks** -- Delete a block from the settings popover.
- **Undo / Redo** -- Every add, remove, reorder, resize, and config change is pushed to an undo stack. Use Ctrl+Z / Ctrl+Shift+Z (or the toolbar buttons) to step backward and forward through changes.
### Save Flow
The editor supports two save modes:
- **Save** (fork-on-write) -- On entity detail pages, saving creates an entity-specific fork of the view via `forkViewForEntity()`. The fork is linked to the source view through `parent_view_id` for lineage tracking. If the view is already a fork, saving updates the fork in place.
- **Save as default** -- Saves changes back to the template view, affecting all entities of that type that have not forked their own override.
A dirty-state indicator in the toolbar shows whether unsaved changes exist.
### Architecture
The visual editor is composed of these layers:
1. **`useViewEditor` hook** (`features/views/hooks/use-view-editor.ts`) -- Pure state management: draft tracking, dirty detection, block operations (reorder, add, remove, update), and save orchestration (fork-on-write vs save-as-default).
2. **`WorkspaceEditor`** (`features/views/components/workspace-editor/index.tsx`) -- Top-level editor shell that wires block data, picker data, and save flow together.
3. **`EditorCanvas`** (`features/views/components/workspace-editor/editor-canvas.tsx`) -- Wraps the standard `BlockGrid` with dnd-kit sorting so blocks can be rearranged inline.
4. **`BlockConfigPanel`** (`features/views/components/workspace-editor/block-config-panel.tsx`) -- Side panel for individual block configuration, type swapping, and removal.
5. **`BlockPalette`** (`features/views/components/workspace-editor/block-palette.tsx`) -- Categorized picker for adding new blocks.
6. **`EditorToolbar`** (`features/views/components/workspace-editor/editor-toolbar.tsx`) -- Save, save-as-default, cancel, and dirty-state controls.
The editor mounts separately from `UnifiedViewRenderer`; the renderer remains focused on showing resolved flat blocks, while `WorkspaceEditor` owns draft state and block editing behavior.
Dependencies: `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` (~16KB gzip total, only loaded when the editor is active).
### Data source authoring (Visual / NL / JSON)
Each entity data source in the editor exposes a `DataSourceModeToggle` segmented control so authors can choose between three ways of editing the same underlying `DataSourceConfig`:
- **Visual** — today's filter / sort / limit form (`EntityFilterControls`).
- **NL** — a textarea + Translate button. On submit, `POST /api/views/data-source/translate` fetches the entity type's `json_schema`, injects the field whitelist into a Vercel AI SDK `generateObject()` prompt, and re-validates every `filters[].field` and `sort.field` against the whitelist before returning (hallucinated fields → 422). Uses the single-call helper `translateNlToDataSource()` in `features/views/server/nl-to-data-source.ts`.
- **JSON** — raw editable JSON validated against `DataSourceConfigSchema` on blur / ⌘⏎. Invalid payloads surface the first Zod issue's path + message in a destructive Alert and disable Apply until fixed.
All three modes commit back to the same data source, so switching between them never loses edits.
### Data source preview chip
Each data source is annotated with `<DataSourcePreviewChip>` showing `<count> rows · live` (entity) or `<count> rows · inline` (static). Entity sources hit a new lightweight `GET /api/entities?count=true&typeSlug=…&filters=…` branch that uses Supabase `count: 'exact', head: true` to short-circuit through the index without materialising rows. 30s stale time via React Query. Powered by `useDataSourcePreview(config)`.
### Views hub source manager
The Views hub includes `ViewDataSourceManager`, which uses `useViewLibrary()` to list deduped source assets across saved views. Selecting a source posts its `DataSourceConfig` to `POST /api/views/data-source/preview`, which returns real preview rows, the total count, schema-derived filter controls, sort options, and unsupported-filter notes. Edits are made as a local draft and saved back to the source asset's canonical `sourceViewId`/source name through `updateView({ dataSources })`.
This is a management layer over view-local `data_sources`, not a separate workspace data-source table. `ViewLibrarySourceAsset.usages` exposes every view/name occurrence behind a deduped config so authors can see matching usages before changing the canonical copy.
### Bottom block palette dock + `/` shortcut
`BlockPaletteDock` renders a horizontal strip above the editor's bottom edge with 6 featured block chips (from `FEATURED_BLOCK_TYPES` in `features/blocks/definition.ts`) plus the author's most recently added blocks from `useRecentBlocks()` (localStorage, capped at 5, dedup'd against featured). A `/` kbd hint + "More…" chip open the full `BlockPalette` Sheet.
A document-level `keydown` listener gated by `shouldFireSlashHotkey(event)` fires `/` as an open-palette hotkey. The helper returns `false` for non-`/`, modifier keys, input / textarea / contentEditable targets, and — critically — when a Radix popper portal (Select, Popover, DropdownMenu) or an open Radix Dialog / Sheet is mounted. This prevents the hotkey from preempting Radix's internal typeahead. The listener also requires `document.activeElement?.closest('[data-editor-root]')` before firing, so `/` only opens the palette when focus is inside the editor.
When the Sheet opens, the search input auto-focuses after 50 ms (past the Radix open transition) so `/` → type feels like Notion.
### Reusable library (views, blocks, data sources)
The workspace editor now exposes a third right-rail tab, `Library`, alongside `Data Sources` and `Surface`. The panel is driven by `useViewLibrary()` (`features/views/hooks/use-view-library.ts`) and a new authenticated `GET /api/views/library` route backed by `getViewLibraryPayload()` (`features/views/server/library.ts`).
`buildViewLibraryPayload()` (`features/views/lib/view-library.ts`) derives three reusable asset collections from saved views:
- **Views** — saved standalone or entity-scoped views in the current scope. Authors can append all blocks from a source view into the active draft.
- **Blocks** — deduplicated reusable block patterns keyed by block type, config, and dependent data sources. The panel shows usage counts and the source view title, then imports through `prepareBlockImport()`.
- **Sources** — deduplicated data source configs keyed by stable serialized config. Each asset includes `usages` with the view ID, view title, and source name for every occurrence. Imports flow through `prepareSourceImport()` so name collisions reuse equivalent sources and rename non-equivalent ones.
All three import paths collapse into a single `editor.batchImport()` mutation in `useViewEditor()`, which keeps the action undoable as one history step and remaps `block.dataSourceId` references before inserting imported blocks.
### Ask about this view
`AskAboutViewButton` is a floating `fixed bottom-20 right-4` pill rendered by `UnifiedViewRenderer` when `editable === false` (viewer mode only). Its only job is `setOpen(true)` on the agent sidebar — the canonical `<ViewContextBridge>` already owns the `viewContext` lifecycle, so the button does not call `setViewContext`. If no bridge ancestor has populated a `viewContext` the button renders `null`. Positioned above the global Chat Dock (which sits at `bottom-4`) to avoid overlap.
## Surface Types
Surface types determine HOW blocks are arranged and interacted with inside a view. They are orthogonal to blocks: any block works in any surface, and any surface renders any block. Adding a new block type makes it immediately available in every surface; adding a new surface makes all existing blocks available in it.
```
Entity (data) → View (blocks + surface_type + data binding) → Rendered Experience
```
The `surface_type` field on `ViewRecord` defaults to `"grid"` — all existing views are unaffected.
### The SurfaceProps Contract
Every surface component receives the same uniform props:
```typescript
interface SurfaceProps {
/** Resolved blocks in view order, ready for rendering */
blocks: ResolvedBlock[];
/** Grid layout preset (used by GridSurface, ignored by most others) */
layout?: ViewLayout;
/** Theme controlling block chrome */
theme?: ViewTheme;
/** Whether blocks are editable */
editable?: boolean;
/** Callback when a block saves data: (blockId, data) */
onSave?: (blockId: string, data: unknown) => void;
/** Callback when block settings are opened */
onBlockSettings?: (block: ResolvedBlock) => void;
/** Surface-specific configuration from view.surface_config */
surfaceConfig?: Record<string, unknown>;
/** Additional CSS class */
className?: string;
}
```
The resolved blocks are already data-populated before the surface component is invoked. The surface decides how to arrange and sequence them — it does not re-fetch data.
### Core Surfaces (shipped)
Nine surfaces are available now:
| Surface | Type string | Interaction model | Use case |
| ------------ | ----------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Grid** | `grid` | Spatial, scroll | Blocks in a responsive 12-column grid. The default. Wraps `BlockGrid` with zero behavior change. |
| **Sequence** | `sequence` | One block at a time, step-forward/back | Typeform-style stepper with progress bar, back navigation, review step, and completion state. Keyboard-navigable (Arrow keys, Enter). |
| **Slides** | `slides` | Full-screen, keyboard/swipe nav | One block per slide. Slide counter, arrow navigation, dot indicators, fullscreen toggle, optional auto-advance timer. |
| **Page** | `page` | Vertical scroll, full-width sections | Blocks render as page sections with generous spacing. First block optionally renders as a hero. Optimized for public pages and SEO. |
| **Form** | `form` | All inputs visible, single submit | Traditional structured form. Display blocks render normally between inputs. Submit collects all values via `onSave("__form_submit", values)`. Stacked or two-column layout. |
| **Compare** | `compare` | Side-by-side columns | The same blocks rendered in 2–4 columns, each bound to a different entity. Optional difference highlighting with a border ring. Equal-width columns by default. |
| **Focus** | `focus` | One block at a time, keyboard nav | Distraction-free single-block view. Minimal chrome, fullscreen-capable, configurable fade/slide/none transition. Auto-hides controls after inactivity. |
| **Carousel** | `carousel` | Horizontal scroll, arrow/swipe nav | Blocks laid out horizontally with a configurable peek amount showing the adjacent block. Arrow buttons, dot indicators, optional auto-play timer. |
| **Swipe** | `swipe` | Swipe gestures = scoring actions | Tinder-style card stack. Right swipe = approve, left = reject, up = bookmark. Action buttons shown as gesture alternatives. Card stack depth configurable. |
### Surface Configuration
Surfaces accept configuration through the opaque `surfaceConfig` bag on `ViewRecord`. Each core surface's schema is validated by its Zod definition:
**Sequence config:**
```typescript
{
autoSave?: boolean; // default true
showReview?: boolean; // default true — show a review step before submit
showProgress?: boolean; // default true
allowBack?: boolean; // default true
submitLabel?: string; // default "Submit"
}
```
**Slides config:**
```typescript
{
showCounter?: boolean; // default true — "3 / 12" counter
showNav?: boolean; // default true — left/right arrow buttons
darkMode?: boolean; // default false — dark backdrop
autoAdvance?: number; // default 0 — seconds between auto-advance (0 = disabled)
}
```
**Page config:**
```typescript
{
heroFirst?: boolean; // default true — first block renders full-bleed
maxWidth?: string; // default "1024px" — content section max-width
sectionSpacing?: "compact" | "normal" | "spacious"; // default "normal"
}
```
**Form config:**
```typescript
{
layout?: "stacked" | "two-column"; // default "stacked"
submitLabel?: string; // default "Submit"
showReset?: boolean; // default false
validateOnSubmit?: boolean; // default true
}
```
**Compare config:**
```typescript
{
columns?: number; // default 2 — 2 to 4 side-by-side columns
highlightDifferences?: boolean; // default false — border ring on differing values
equalWidth?: boolean; // default true — all columns share equal width
}
```
**Focus config:**
```typescript
{
showBlockLabel?: boolean; // default false — label/description above block
transitionStyle?: "fade" | "slide" | "none"; // default "fade"
autoHideControls?: boolean; // default true — hide nav after 3s inactivity
}
```
**Carousel config:**
```typescript
{
showArrows?: boolean; // default true — left/right arrow buttons
showDots?: boolean; // default true — dot indicators
peekAmount?: number; // default 40 — pixels of adjacent block visible (0–100)
gap?: number; // default 16 — gap between blocks in pixels
autoPlay?: number; // default 0 — seconds between auto-advance (0 = disabled)
}
```
**Swipe config:**
```typescript
{
approveLabel?: string; // default "Approve" — label for right-swipe action
rejectLabel?: string; // default "Reject" — label for left-swipe action
showActions?: boolean; // default true — show action buttons as gesture alternatives
stackDepth?: number; // default 3 — cards visible in the stack behind the top (1–5)
}
```
### Surface Config UI
The view editor renders a schema-driven config panel for each surface type. When you select a surface from the `SurfaceTypeSelect` dropdown, the panel reads the surface's Zod `configSchema` and renders per-field controls automatically. Changing a config value updates `view.surface_config` and triggers a live re-render via the realtime subscription.
`SurfaceTypeSelect` only shows surfaces that have been registered via `registerSurface()`. Unregistered catalog entries (the 21 future surfaces in `SURFACE_TYPE_META`) are not shown until their component is implemented.
### Full Surface Catalog (30 types)
The platform catalogs 30 surface types across 7 categories. 9 surfaces are implemented (marked ✓); the remaining 21 are defined in `SURFACE_TYPE_META` and `SurfaceDefinitionRegistry` as targets for future implementation. Any of them can be added by writing one `*Surface` component and calling `registerSurface()`.
**Core** (all 5 implemented)
| Type | Description |
| ---------- | ------------------------------------------------- |
| `grid` | Responsive spatial grid. Default layout. |
| `sequence` | One block at a time. Typeform-style stepper. |
| `slides` | Full-screen presentation mode. |
| `page` | Full-width scrollable sections. Public-optimized. |
| `form` | All inputs visible with a single submit. |
**Data browsing**
| Type | Description |
| ---------- | ------------------------------------------------------------- |
| `split` | Master-detail two-pane layout. List on left, detail on right. |
| `kanban` | Drag-and-drop board. Columns are field values. |
| `timeline` | Blocks arranged along a time axis. |
| `calendar` | Entities placed on a month/week/day calendar grid. |
| `gallery` | Visual masonry layout optimized for images and cards. |
| `table` | Inline-editable spreadsheet grid. Airtable-style. |
**Interactive**
| Type | Description |
| ----------- | ------------------------------------------------------------------ |
| `swipe` ✓ | Tinder-style card stack. Swipe to score, rank, or triage. |
| `wizard` | Conditional branching steps. Next step depends on previous answer. |
| `chat` | Conversational interface. Blocks render inline in a conversation. |
| `compare` ✓ | Side-by-side columns for 2–4 entities. |
| `rank` | Drag-to-reorder list for priority setting. |
**Spatial**
| Type | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `canvas` ✓ | Freeform 2D placement with connectors. Whiteboard-style. |
| `map` | Blocks pinned to geographic coordinates. |
| `graph` | Force-directed node layout for relationship exploration. |
| `theater` | System-level data flow with sources / brain / outputs and live ribbons. |
| `flow-overview` ✓ | System-wide Records ↔ Actions ↔ Records graph with live counts, HITL bottlenecks, and "unblocks N downstream" hover hints. |
**Narrative**
| Type | Description |
| ------------ | --------------------------------------------------------------- |
| `story` | Vertical swipe, full-screen cards. Instagram Stories style. |
| `scroll` | Parallax long-form scroll with animated transitions. |
| `carousel` ✓ | Horizontal swipe/arrow navigation with peek at adjacent blocks. |
**Output**
| Type | Description |
| ------------ | --------------------------------------------------- |
| `pdf` | Paginated, print-optimized layout with page breaks. |
| `email` | Email-safe HTML render for newsletters and digests. |
| `embed-card` | Compact embeddable card for external sites. |
**Power user**
| Type | Description |
| ----------- | ------------------------------------------------- |
| `terminal` | Text command interface. |
| `notebook` | Code cells + output cells. Jupyter-style. |
| `inbox` | Priority-sorted action queue with inline actions. |
| `dashboard` | KPI-forward layout with alerts and sparklines. |
| `focus` ✓ | Distraction-free single entity view. |
### Surface Rendering Architecture
```
ViewRecord
→ resolveView() // unchanged — resolves data for all blocks
→ ResolvedBlock[] // same output as today
→ SurfaceRenderer // top-level dispatcher
surfaceType === "grid" → GridSurface (fast path, no registry lookup)
surfaceType === other → getSurface(type).component (registry lookup)
unknown type → GridSurface (safe fallback)
```
`SurfaceRenderer` (`features/views/components/surface-renderer.tsx`) is a client component that imports the surface barrel (`features/views/surfaces/`) to trigger all `registerSurface()` calls.
### SurfaceDefinitionRegistry
A server-safe singleton (`surfaceDefinitions`) that mirrors `BlockDefinitionRegistry`:
```typescript
surfaceDefinitions.get(type); // → SurfaceDefinition | undefined
surfaceDefinitions.listAll(); // → SurfaceDefinition[]
surfaceDefinitions.listByCategory(cat); // → SurfaceDefinition[]
surfaceDefinitions.introspect(type); // → SurfaceIntrospection | undefined
surfaceDefinitions.introspectAll(); // → SurfaceIntrospection[] — for AI
```
`SurfaceIntrospection` is the machine-readable shape used by agents and tools:
```typescript
interface SurfaceIntrospection {
type: SurfaceType;
category: SurfaceCategory;
label: string;
description: string;
interactive: boolean; // accepts user input
fullscreen: boolean; // takes over the viewport
supportsPublishing: boolean;
}
```
### Adding a New Surface Type
1. **Define** — Create `features/views/surfaces/definitions/my-surface.ts` using `defineSurface()` and register it:
```typescript
import { z } from "zod/v4";
import { defineSurface, surfaceDefinitions } from "../../surface-definition";
export const MySurfaceConfigSchema = z.object({
myOption: z.boolean().default(false),
});
export const mySurfaceDefinition = defineSurface({
type: "my-surface", // must be a value in SURFACE_TYPES
meta: {
label: "My Surface",
description: "...",
category: "interactive",
icon: "Star",
},
capabilities: {
interactive: true,
fullscreen: false,
supportsPublishing: true,
requiresClientSide: true,
},
configSchema: MySurfaceConfigSchema,
});
surfaceDefinitions.register(mySurfaceDefinition);
```
2. **Implement** — Create `features/views/surfaces/my-surface.tsx`:
```typescript
"use client";
import type { SurfaceProps } from "../surface-registry";
export function MySurface({ blocks, theme, onSave, surfaceConfig = {} }: SurfaceProps) {
const { myOption = false } = surfaceConfig as { myOption?: boolean };
// Render blocks[] using BlockRenderer — surface controls arrangement
return <div>...</div>;
}
```
3. **Register** — Add one line to `features/views/surfaces/register.ts`:
```typescript
registerSurface("my-surface", { component: MySurface });
```
4. **Import** — Add the definition import to `features/views/surfaces/definitions/index.ts`.
That is the entire contract. The new surface type is immediately available in every view, every published URL, and every agent tool that accepts `surface_type`.
### Flow Overview Surface
`flow-overview` renders the action registry as an interactive Records ↔ Actions ↔ Records graph with live theory-of-constraints overlay. The surface answers the system-level question that the routines list and per-entity pipeline cannot: **where does data cascade from any starting point, and where is the system stuck right now?**
**Data flow:**
```
views.surface_type="flow-overview" → <FlowOverviewSurface/>
→ useTenantFlowGraph (React Query, 30s poll)
→ POST /api/flow-overview (Zod-validated)
→ getTenantFlowGraph() (unstable_cache, 30s, scope-aware)
→ buildFlowGraph({entityTypes, actions, sessionCounts, seed}) ← pure
→ { nodes, edges, bottlenecks, hasCycle }
→ layoutFlowGraph (dagre, lazy) → @xyflow/react canvas
```
**Nodes:** every active entity type becomes a Record node; every in-scope action becomes an Action node. Edges encode the action contract: `record → action` labeled with the trigger (`manual`, `on create`, `field: status → published`, `cron`, `webhook`); `action → record` labeled with the output verb (`creates Customer`, `populates name, email +2`); `action → action` dashed `unlocks` edges for every `depends_on`.
**Theory-of-constraints overlay.** Each action node carries live counts (waiting / running / failed in last 24h). HITL actions (`assigned_to` set, no `agent_slug`) glow with `border-warning` + ring. Hovering a bottleneck surfaces "Unblocks N downstream" — the count of reachable action nodes through `depends_on` transitive closure. Cycle detection runs via `compileActionsToWorkflow()` and fires a single `sonner` toast.
**Seeds.** `surfaceConfig.seed` is one of `{ kind: "tenant" }` (default — entire workspace scope), `{ kind: "entity-type", ids: [...] }` (BFS-reachable subgraph from those types), or `{ kind: "entity", ids: [...] }` (per-instance flow — extends the same data model as `useEntityActionReadiness`).
**Mobile.** Below 640px the surface falls back to a vertical list with a top-3 bottleneck summary — xyflow + dagre never load on mobile.
**Reusability.** `RecordNode`, `ActionNode`, `FlowCountsChip`, and `layoutFlowGraph` accept a normalized `FlowGraph` payload and are not coupled to action-registry shape. The sibling `relation-overview` surface (logged as `_backlog/idea-entity-relation-flow-visualizer.md`) is designed to reuse this rendering layer with a different builder.
**Click handlers.** Record node → `/admin/data-types/<slug>`. Action node → `/actions/<id>`. Keyboard-accessible (Enter / Space).
### How Surfaces Compose with Blocks
Surfaces do not change block data or block configuration. The same `ResolvedBlock[]` produced by `resolveView()` are handed to the surface component — the surface only controls layout and interaction. This means:
- A `field-card` block showing `"Revenue: $1.2M"` renders in every surface type.
- A `chart` block renders its chart in a grid, as a slide, as a page section, or as a form step.
- Block `meta.role` (`display` / `input` / `both`) is respected by interactive surfaces: `SequenceSurface` puts input blocks into edit mode per step; `FormSurface` identifies input blocks to wire them to the submit action.
The `FormSurface` uses a heuristic to classify input blocks:
```typescript
const isInput =
block.source === "field" ||
block.source === "fields" ||
block.source === "responses" ||
block.type === "field-card" ||
block.type === "form-flow" ||
block.type === "response-form";
```
### Scoring and Workshop Exercises
Interactive surfaces map directly to existing data primitives — no new APIs are required:
- **`swipe` surface** — each swipe creates an `entity_response` via the existing response system (`submitResponse` tool / `createEntityResponse()`). The scoring math, criteria sets, and aggregation pipeline are unchanged.
- **`rank` surface** — dragging cards into order saves a `rank` field value on each entity.
- **`sequence` surface with `response-form` blocks** — multi-step assessment where each step scores a different dimension. All values feed the same response and criteria pipeline.
- **`form` surface** — submits collect via `onSave("__form_submit", values)`, which the caller wires to entity writes or response submissions.
The surface is only the UX wrapper. All writes flow through existing APIs.
## API Reference
### Server Actions (`features/views/server/actions.ts`)
| Function | Signature | Description |
| ------------------------------------ | ----------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `getViews(entityTypeId?, pageType?)` | Returns `ViewRecord[]` | Fetch views for an entity type or standalone. Defaults to `page_type='list'`. |
| `getViewById(viewId)` | Returns `ViewRecord \| null` | Fetch a single view with access check. |
| `getViewsForEntity(params)` | `{entityTypeId, entityId, pageType?}` -> `ViewRecord[]` | Entity-specific overrides take priority over type-level views. |
| `createView(input)` | Returns `ViewRecord` | Create with auto-positioned ordering and canonical flat block storage. |
| `updateView(viewId, input)` | Returns `ViewRecord` | Partial update with ownership verification. |
| `deleteView(viewId)` | Returns `void` | Delete with ownership verification. |
| `forkViewForEntity(params)` | `{sourceViewId, entityId, blockOverrides?}` -> `ViewRecord` | Fork a type-level view for a specific entity. |
| `copyView(sourceViewId, newTitle?)` | Returns `ViewRecord` | Independent duplicate with lineage tracking. |
### Publish Actions (`features/views/server/publish-actions.ts`)
| Function | Signature | Description |
| --------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `publishView(viewId)` | Returns `ViewRecord` | Sets `publish_status = 'published'`, generates token if not already present. Requires admin role. |
| `unpublishView(viewId)` | Returns `ViewRecord` | Sets `publish_status = 'disabled'`, preserves token for future re-use. Requires admin role. |
| `getPublishedView(token)` | Returns `ViewRecord \| null` | Fetches a published view by token. Uses admin client to bypass RLS. Safe to call without auth. |
| `publishToolAsView(toolSlug, toolName)` | Returns `{ view, token, embedCode }` | Creates a standalone view with a single tool block and publishes it in one call. |
| `generatePublishToken()` | Returns `string` | Generates a 40-character hex token (two UUIDs concatenated, dashes stripped). |
### API Routes
| Method | Path | Description |
| -------- | ---------------------- | ------------------------------------------------------------------------------------- |
| `GET` | `/api/views` | List views (query params: entityTypeId, pageType) |
| `GET` | `/api/views/library` | Derive reusable library assets for the active editor scope (views, blocks, sources) |
| `POST` | `/api/views` | Create a new view |
| `PATCH` | `/api/views/[id]` | Update a view |
| `DELETE` | `/api/views/[id]` | Delete a view |
| `POST` | `/api/views/[id]/copy` | Duplicate a view |
| `POST` | `/api/views/[id]/fork` | Fork a view with parent lineage |
| `GET` | `/embed/v/[token]` | Public embed page — renders published view without auth (Next.js page route, not API) |
### Components
| Component | Location | Description |
| ---------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `SurfaceRenderer` | `features/views/components/surface-renderer.tsx` | Top-level surface dispatcher. Resolves surface component from registry, fast-paths grid, falls back to GridSurface for unknown types. |
| `UnifiedViewRenderer` | `features/views/components/unified-view-renderer.tsx` | Single renderer for all view types. Renders resolved flat blocks through `SurfaceRenderer`. |
| `ViewHeader` | `features/views/components/view-header.tsx` | Title, description, share/edit/copy/fork/pin actions. |
| `ViewTabs` | `features/views/components/view-tabs.tsx` | Tab navigation with create/edit/delete view UI. |
| `WorkspaceEditor` | `features/views/components/workspace-editor/` | Flat-block editor used in record views, standalone views, and admin entity type editor. |
| `ViewEmptyState` | `features/views/components/view-empty-state.tsx` | Shown when no blocks are configured. |
| `PublishViewDialog` | `features/views/components/publish-view-dialog.tsx` | Publish / unpublish dialog with copy-paste embed code and preview link. |
| `HorizontalScrollFade` | `components/ui/horizontal-scroll-fade.tsx` | Wraps any horizontally-scrollable container and shows gradient edge fades only when content overflows on that side. Uses IntersectionObserver rooted to the scroll container (not the viewport) with `threshold: 1.0`. `useLayoutEffect` seeds initial overflow state before first paint, surviving React Strict Mode. Apply via `contentClassName` for scroll-snap utilities. |
## For Agents
Agents interact with views through these tools:
| Tool | Description |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `listBlockTypes` | Returns all `BlockDefinition` introspections: type, category, configSchema fields, metadata. Filter by `category`. Use before composing a view so block config is correct. |
| `manageView` | Create, update, or delete views with full block config control including `surface_type`. Part of the admin tool group. |
| `generateView` | Create ephemeral `TransientViewSpec` with inline data in chat (no DB storage). |
| `saveTransientView` | Promote a transient view spec to a persisted workspace view. |
| `inspectViews` | Read-only view inspection for understanding current layout configurations. |
| `publish_view` | Publish a view and return a live public URL. Accepts a full `PublicAuthorView` or one of three shorthands (`markdown`, `html`, `blocks`). Part of the `"view"` tool group (requires the view-building group to be enabled for the agent). |
When composing a view, call `surfaceDefinitions.introspectAll()` (or use the equivalent tool response) to discover available surface types and their capabilities before setting `surface_type`. The machine-readable `SurfaceIntrospection` shape includes the config options each surface accepts. Use `listBlockTypes` to understand which blocks make sense in each surface — e.g., input-role blocks are most useful in `sequence` and `form` surfaces; display-only blocks work in any surface.
The `manageView` tool combined with `useViewRealtime` creates a live authoring loop: the agent writes view config including `surface_type`, the realtime subscription fires, and the user sees the updated layout without refreshing.
### Publishing a mini-app in one call
To host a fully interactive mini-app (game, landing page, interactive tool) at a standalone public URL:
```json
{
"kind": "html",
"html": "<h1>Hello</h1><button onclick=\"amble.submit({clicked:true})\">Submit</button>",
"title": "My Mini-App",
"presentation": "standalone"
}
```
The `kind: "html"` shorthand wraps the HTML string in an `html-embed` block, persists the view, and returns:
- `renderUrl` — the public `/embed/v/[token]` URL (immediately live, no auth required).
- `envelope` + `hostResource` — the host-neutral IR for transport to MCP Apps or ChatGPT.
Inside the iframe, `window.amble.data` carries any structured data the agent passed; `window.amble.submit(payload)` lands a submission through the existing anonymous-write pipeline. See [html-embed Block](/docs/features/block-system#html-embed-block) for the full bridge contract.
## Responsive Primitives
The view system ships with a canonical set of responsive tools in `lib/responsive/`. All new surfaces and block layouts should draw from these primitives rather than rolling their own.
| Export | Purpose |
| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `BREAKPOINT_PX`, `BREAKPOINT_REM` | Named breakpoint constants (`sm`, `md`, `lg`, `xl`, `2xl`). Source of truth for media-query string construction. |
| `MOBILE_MAX_PX`, `MOBILE_MEDIA_QUERY` | The 767px mobile ceiling, expressed as a pixel value and as a ready-to-use media query string. |
| `CONTAINER_INLINE_SCOPE`, `containerStyle()` | Container-query scope name and inline style helper. Apply to the slot wrapper so child blocks can use `@container` rules for density decisions. |
| `FAB_CLEARANCE_CLASS`, `FAB_CLEARANCE_VAR_DECL`, `FAB_CLEARANCE_DESKTOP_CLASS` | Bottom-padding utilities that consume `env(safe-area-inset-bottom)` via the `--fab-clearance` CSS var. Prevents the floating action button from overlapping the iOS home bar. |
**Rule: media queries for shell, container queries for slots.** Use `useIsMobile()` from `@/hooks/use-mobile` (or `useMediaQuery()`) for viewport-class decisions — sidebar mode, FAB visibility, page shell layout. Use container queries (`CONTAINER_INLINE_SCOPE` + `containerStyle()`) for decisions that depend on the slot the block renders into — field density, chip count, card layout. Never infer slot width from the viewport.
**`useIsMobile()` and `useMediaQuery()` are the only approved viewport hooks.** Adding a third implementation silently diverges from the canonical SSR-safe hydration contract. See `lib/responsive/index.ts` for the rationale.
**`100dvh` not `100vh` on full-bleed surfaces.** Mobile browsers subtract the browser chrome from `100dvh` dynamically. Pages that use `100vh` clip behind the address bar on Safari iOS. The layout root uses `100dvh` and falls back gracefully on browsers that predate dynamic viewport units (Safari 15.4+).
## Design Decisions
**Surfaces are orthogonal to blocks.** Any block works in any surface. A new surface type does not need to know about specific block types; a new block type does not need to know about surfaces. The `SurfaceProps` contract keeps them decoupled — the surface receives `ResolvedBlock[]` and can render them however it chooses via `BlockRenderer`. This multiplicative composability is intentional: 32 blocks × 30 surfaces = 960 combinations with no per-combination code.
**Definitions and components are kept in separate registries.** `SurfaceDefinitionRegistry` (`surface-definition.ts`) is server-safe — it stores metadata and Zod schemas but no React. `surfaceRegistry` (`surface-registry.ts`) stores React components and is client-only. This allows server components to call `surfaceDefinitions.introspectAll()` for AI context without importing React.
**Grid fast-path in `SurfaceRenderer`.** The most common surface type (`grid`) is resolved without a registry lookup — `if (surfaceType === "grid") return <GridSurface />` — because it is the vast majority of renders. Unknown types also fall back to `GridSurface` to prevent blank pages from unregistered future surface types.
**`surfaceConfig` is an opaque bag, not typed props.** Rather than adding surface-specific props to `SurfaceProps`, surface-specific configuration flows through `surfaceConfig: Record<string, unknown>`. Each surface casts and validates the fields it cares about. This keeps the `SurfaceProps` interface stable as new surfaces are added and avoids union-type explosion on the shared contract.
**Two persisted page types plus a schema-first record default.** List and workspace views are the only persisted view types in the runtime. Record detail uses the schema-driven page by default and only activates a workspace view when explicitly selected. This keeps the core record path aligned with the platform north star while still allowing optional overlays.
**One flattened storage/rendering model.** Views now standardize on flat block maps plus `blockOrder` and `layout`. This avoids carrying separate renderer/editor logic for regions, tab overrides, and preset-only layouts that no longer exist in the runtime contract.
**Fork-on-write for entity-specific overrides.** Rather than storing view overrides inline on entities, the system creates a new `ViewRecord` with `entity_id` set and `parent_view_id` linking back to the source. This keeps the schema clean and makes lineage queryable.
**Scope-based access rather than ACLs.** Views use a simple three-tier scope (default, shared, user) rather than per-user access control lists. This matches the typical usage pattern where views are either team-wide or personal.
**Auto-migration from legacy dashboards.** Entity types that still have `config.dashboard.sections` get their dashboard config automatically converted to views on first page load. This was a one-time migration path that avoids the need for a manual data migration script.
**Admin pages use `requireAdmin`, not `requirePermission`.** The Admin Views page (`/admin/views`) and `getViewInventory()` server action are guarded with `requireAdmin()` rather than `requirePermission("views.all.read")`. This is consistent with every other admin route and avoids role-mapping edge cases that can cause false-negative permission checks for tenant owners. The rule: use `requirePermission()` for granular per-resource gates inside the app; use `requireAdmin()` for all pages under `/admin`.
**Publishing lives on the `views` table, not a separate `published_surfaces` table.** The original design called for a generic `PublishedSurface` model that could point at tools, views, or entity views. In practice, all current publish targets are already view-backed: a `publishToolAsView()` call creates a view containing a `tool` block before publishing it. Adding three columns to `views` avoids a join on every embed lookup, keeps the RLS policy simple (one table, one condition), and defers schema complexity until a use case genuinely requires multi-kind surfaces. The `publish_config` JSONB column is reserved for future theme, CTA, and analytics attribution without requiring another migration.
**Unpublishing preserves the token.** Once an embed code is distributed to external sites, revoking the token would silently break those embeds. Keeping the token through disable/re-publish cycles means re-publishing restores the same embed code. Site owners that intentionally want to rotate the URL can delete the view and create a new one.
## Designer (companion editor)
Amble ships two view-editing surfaces. They share **the same `useViewEditor` hook, block registry, surface registry, and server actions** — only the chrome differs.
| Surface | Route | Use when |
| ------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Inline editor** (`WorkspaceEditor`) | `/view/[id]` with edit mode toggled, `/admin/views`, inline on entity detail pages | Default editing experience; tabbed sidebar (Data Sources / Surface / Library) + right config panel |
| **Designer** (`ViewDesigner`) | `/view/[id]/designer` | Full-screen authoring with a three-pane direct-manipulation canvas; better for views with many blocks or rapid surface/data exploration |
### Entry: the "Open in Designer" button
The inline editor's toolbar carries a single "Open in Designer" button. If the editor has unsaved changes, clicking it opens an `AlertDialog` with **Cancel / Discard & Open / Save & Open**. The save path is fail-safe: `useViewEditor.save()` returns `Promise<boolean>`; on save failure (error toasted by the hook) the dialog stays open so the in-memory draft is never silently destroyed.
### Designer layout
Three panes on `≥1024px` viewports; below that, the rails collapse into top-anchored sheets triggered by toolbar buttons.
```
┌─ Top toolbar ──────────────────────────────────────────────┐
│ [Blocks] [Inspector] (mobile triggers) Save / Undo │
├─ Left rail (260px) ──┬─ Canvas (flex) ─┬─ Right rail (300) │
│ Blocks | Data │ Block list with │ View inspector or │
│ tab toggle │ live preview │ Block inspector │
│ │ + per-block │ (selection-aware) │
│ Block palette │ floating toolbar│ + surface chip │
│ (search + categories)│ [↑ ↓] [3-12] [×]│ (popover) │
└──────────────────────┴─────────────────┴───────────────────┘
Data-source drawer slides in
from right on any DS-chip click
```
### Patterns
- **Single-selection model** — `editor.selectedBlockId` is the only source of truth. Click block → select; click background → clear; `Esc` → clear. Right rail switches view↔block inspector based on the same value.
- **Per-block floating toolbar** — appears on the selected block with `[↑ ↓] [3 4 6 8 12] [×]`. Span buttons are direct-manipulation (replaces a number input in the inline editor).
- **Surface picker popover** — chip in the View inspector opens a categorized grid keyed off `surfaceDefinitions.listVisible()`, with `interactive`/`fullscreen` badges.
- **Data-source drawer** — clicking any data-source chip slides in a `<Sheet>` showing the JSON config plus a 5-row × 3-field preview. The preview is powered by a synthetic single-block `useViewPreview` call with `pageSize: 5` — read-only in v1; edits still happen in the inline editor.
- **Block gallery modal** (ADR-0038) — the "Gallery" toolbar button and the left-rail "Browse gallery" footer open the full block gallery as a modal, with live previews + config schema per block and an "Add to view" affordance. It is the **same** `BlockGalleryClient` rendered at `/admin/blocks` (relocated to `features/blocks/components/gallery/`) — one component, two mount points; passing `onAdd` switches each card into add-to-view mode.
- **Drag-and-drop reorder** (ADR-0038) — canvas blocks reorder by dragging a per-block grip handle (`@dnd-kit/sortable`). The handle is separate from the click-select surface, and the floating toolbar's `[↑ ↓]` chevrons remain the keyboard/a11y fallback.
- **Block render-mode + writes** (ADR-0038, the input/display convergence) — see below.
- **Reuse, not duplication** — block rendering reuses `EditorBlockLive`; block config form reuses `BlockConfigForm` (extracted from `BlockConfigPanel` with chrome stripped). Save / undo / redo / history flow through the same `useViewEditor` hook used by the inline editor.
- **Inspector label compaction** — `FieldInput` inside the block config inspector suppresses the duplicate label that appeared when the field already rendered a visible heading, keeping the panel scannable.
- **`toolSlug` renders as a `ToolPicker` combobox** — the inspector replaces the raw text field for `toolSlug` with a searchable combobox (`lib/ui-registry/components/editor/tool-picker.tsx`) that lists registered tools by label. While the tool list loads the combobox shows the current slug so a configured inspector never appears blank.
- **`defaultInputs` caption** — the JSON field for default inputs shows a placeholder and a caption explaining the expected shape.
- **Input binding under "Advanced"** — the raw JSON binding editor is collapsed under an "Advanced" `<Collapsible>` to reduce visual noise for the common case.
- **"Canvas (nested)" label** — `canvas-leaf` is now labeled "Canvas (nested)" in the palette and inspector, eliminating the two identical "Canvas" entries that caused authoring confusion.
- **New tool blocks default to `mode: "input"`** — when the designer adds a `tool` block, its initial config carries `mode: "input"` so the tool form is interactive immediately without a manual mode toggle.
### Block render-mode + entity-graph writes (ADR-0038)
A block **instance** carries an optional `renderMode` (`"display" | "input" | "print"`, the ui-registry `BlockMode` axis; absent ⇒ `"display"`, so every existing view is unchanged). It is named `renderMode` — not `mode` — to avoid collision with `ResolvedBlock.mode` (`"view" | "edit"`, the canvas chrome state, a different axis).
The block inspector surfaces a **Mode toggle** gated on the block's declared input role (`getBlockMetadata(type).role` of `"input" | "both"`) — display-only blocks can't be switched. In input mode, a **"Writes to" editor** binds the input to where its value lands in the entity graph: `writes: { entityType, field, materializer?, criteriaSet? }`.
This is the authoring side of the convergence — _"a body map, a slider, and a signature are all just Blocks in input mode"_. A view whose blocks are `input`-renderMode on a runner surface (`form`/`sequence`) **is an InteractionSpec**; on submit it lands an entity + N `entity_responses` (+ materializer fan-out) through the existing response-session pipeline (ADR-0017). The runtime submit + the consolidation of the legacy exercise kinds is Phase 5 (tracked separately).
### Quiz authoring mode (`exercise-block` root)
When the view's root block is an `exercise-block` (a quiz / interactive experience), the designer swaps
its generic three-pane body for a dedicated **QuizAuthoringMode** (`view-editor-shell.tsx` branches on
`isExerciseRoot`). This is the unified successor to the standalone `/exercises/builder` — one authoring
surface, not two (ADR-0046).
- It owns the moved `StepRail` / `StepInspector` / `starter` modules under
`quiz-authoring-mode/shared/`, so per-step authoring (all 12 step types,
option editor, per-step rules JSON, graph-mapping) stays at parity without a
second builder surface.
- The authoritative `Quiz` lives at `exercise-block.config.sourceQuiz`; the runtime
`children` / `branchTargets` / `branchRules` / `theme` are a **derived** render of it. Each edit emits
ONE atomic `block.update` on the root, so the derived tree never drifts from `sourceQuiz`.
- The rail Export action serializes the same canonical `sourceQuiz` as a typed
code seed module, copies it to the clipboard when possible, and falls back to a
`.quiz-seed.ts` download. That replaces the old builder's clipboard export
without reintroducing a second authoring route.
- Agents author quizzes through the **same** public contract humans use: `quizToViewDefinition(quiz)`
emits a `PublicAuthorView`, and `publicViewToRuntimeView` / `runtimeViewToPublicView` round-trip it
losslessly (the `sourceQuiz` survives — regression test in
`features/blocks/modules/exercise/quiz-to-view.test.ts`).
- Create View includes designer-first starts for blank quizzes, DOC360-style
intake/protocol flows, agent feedback, ROI calculators, and lead-magnet
assessments. The calculator and lead-magnet starts use the same quiz step,
scoring, graph-mapping, reveal, and lead-capture contracts rather than a
parallel calculator system. ROI calculator starts also include evaluated
low/base/high formulas in `quiz.scoring.calculations`; the reveal inspector
edits result cards, numeric input step ids, scenario multipliers, ranges, and
output formats.
- Admin generated experiences use the same path. The data-type rich-experience tab converts generated
`ExperienceManifest` drafts into `Quiz` configs, saves them as canonical designer views, and opens
`/view/<id>/designer` directly — no localStorage handoff or `experience_builder` surface row is required.
- **Run** for exercise roots creates a real quiz response session from the saved
`sourceQuiz`, stamps `sessions.metadata.quiz_config`, and opens
`/exercises/[sessionId]`, so autosave/finalize/scoring use the same path as
seeded quizzes.
- **Publish** for definition-backed exercise roots redirects from `/embed/v` to
`/quiz/<tenant>/v/<token>`, resolves `sourceQuiz` server-side, and sends a
sanitized quiz to the browser. The legacy `/exercises/builder` route now
redirects to the Views hub; the remaining cleanup is proving or migrating old
`experience_builder` rows.
### What the designer deliberately does NOT do
- Replace the inline editor (both surfaces ship; "Open in Designer" is opt-in).
- Add columns to `views` or `entity_responses` (no migration; `renderMode` / `writes` ride in the `blocks` jsonb, and `view.dataSources` shapes are derived at render time).
- Register tenant-specific blocks (platform palette ignores `features/custom/blocks/definitions`; venture blocks bootstrap separately).
- URL-sync of selection state (P1 follow-up: `?block=blk-…`).
### Sunset policy
Two editors is a deliberate v1 trade-off. The spec commits to retiring the inline editor's UI chrome (keeping the hook + `EditorBlockLive`) once `view_designer.session_started` exceeds 50% of view-edit sessions over 60 days. Tracked in `documents/work/2026-05-15-view-designer-companion/followups.md`.
## Related Modules
- [Block System](/docs/features/block-system) -- Views compose blocks; `BlockConfig[]` is the core data structure
- [Entity System](/docs/features/entity-system) -- Views are scoped to entity types and render entity data
- [Agent System](/docs/features/agent-system) -- Agents author views via admin tools with realtime feedback
- [Navigation](/docs/features/navigation) -- Saved views can appear as sidebar navigation items