Sprinter Docs

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:

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?: Record<string, unknown> | null; // Reserved: theme, CTA, analytics
}

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:

FunctionDescription
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.

// 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 Data Sources

In addition to entity-backed data sources, views support two pull-based external source types defined in features/external-data/:

TypeConfigHow it works
pollPollConfigpollUrl, pollInterval, pollTransform, pollHeadersInngest cron job fetches the URL on schedule, applies a JSONPath transform, and stores results as external_data_points. Good for APIs without webhooks.
apiApiConfigapiUrl, apiHeaders, apiTransform, cacheTtlFetched live during view resolution with a short-TTL cache (default 5 min). Good for real-time dashboards.

The existing webhook type (where external systems POST via POST /api/webhooks/inbound) is unchanged. All three source types produce uniform metric rows that blocks can bind to via dataSourceId.

Page Types

Page TypePurposeLayout Options
listEntity type list pagesstack, grid-2, grid-3, bento, single
workspaceStandalone dashboards and optional record-page custom layoutsstack, 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.

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.

type ViewTheme = "dashboard" | "page" | "minimal" | "embed";
ThemeChromeUse case
dashboardPassthrough — blocks render their own Card chromeAdmin dashboards, analytics pages (default)
page<section> with optional <h3> labelEntity detail pages, content pages
minimalBare passthrough, no decorationEmbedded views, print layouts
embedBare passthrough, optimized for iframePublic 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.

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.

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.

// 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:

TemplateLayoutBlocksdataSources
kpi-dashboardbentostat-cards, chart, ranking, activity
entity-overviewbentosummary, connection-list, activity
pipeline-trackerstackstat-cards, kanban
data-explorerstackdata-table
activity-feedstackstat-cards, entity-feed
executive-overviewbentostat-cards, chart, activityprimary
pipeline-boardstackstat-cards, kanbanprimary
content-operationsbentostat-cards, entity-feed, activityprimary

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:

<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)

FunctionSignatureDescription
buildEmbedSnippet(token, hostUrl)(string, string) => stringReturns the copy-paste HTML with <div> + <script> tags.
getEmbedPreviewUrl(token, hostUrl)(string, string) => stringReturns 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 the view_responses table in real time so users can resume if they close the tab.

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. A GET /api/embed/v/[token]/respond?sessionId=… fetch restores any previously saved data and injects it as surfaceConfig.initialData.
  3. Every onSave callback from SurfaceRenderer accumulates block data in a ref and triggers a debounced POST /api/embed/v/[token]/respond (300 ms delay).
  4. When a form-flow block with a FormFlowConfig.output config completes, the client calls POST /api/form-flow/complete to create a platform entity from the collected values.

API surface:

MethodPathAuthDescription
GET/api/embed/v/[token]/respond?sessionId=NoneRestore previous session data
POST/api/embed/v/[token]/respondNoneUpsert response data for a session
POST/api/form-flow/completeNone (validated by publishToken)Create an entity from completed form values

Both read/write endpoints use the admin client and validate the publishToken against views.publish_status. The view_responses table uses a UNIQUE(session_id, publish_token) constraint to enable safe upserts.

view_responses table:

ColumnTypeNotes
iduuidPrimary key
tenant_iduuid FK tenantsTenant scoping
view_iduuid FK viewsThe published view
publish_tokentextMatches views.publish_token
session_idtextClient-generated UUID, stable per browser
user_iduuid FK auth.usersOptional — for authenticated users
emailtextOptional — for invite flows
datajsonbAccumulated block data keyed by block ID
completedbooleanSet to true when form-flow marks complete

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).

Fullscreen Presentation Mode

The /present/[id] route renders any view in a fullscreen, app-chrome-free layout. It is designed for two 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).

Route Architecture

FilePurpose
app/present/layout.tsxMinimal shell: full-height flex container, "Powered by Sprinter" footer, no nav. Redirects unauthenticated users to /login.
app/present/[id]/page.tsxServer component: fetches view via admin client, validates tenant ownership, resolves blocks through resolveView().
app/present/[id]/present-view-client.tsxClient 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:

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:

RoleBehaviour
displayRead-only in both modes (charts, activity, documents)
inputAlways shows as an input form (form-flow, response-form)
bothRenders 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:

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.
  • 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:

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:

SurfaceType stringInteraction modelUse case
GridgridSpatial, scrollBlocks in a responsive 12-column grid. The default. Wraps BlockGrid with zero behavior change.
SequencesequenceOne block at a time, step-forward/backTypeform-style stepper with progress bar, back navigation, review step, and completion state. Keyboard-navigable (Arrow keys, Enter).
SlidesslidesFull-screen, keyboard/swipe navOne block per slide. Slide counter, arrow navigation, dot indicators, fullscreen toggle, optional auto-advance timer.
PagepageVertical scroll, full-width sectionsBlocks render as page sections with generous spacing. First block optionally renders as a hero. Optimized for public pages and SEO.
FormformAll inputs visible, single submitTraditional structured form. Display blocks render normally between inputs. Submit collects all values via onSave("__form_submit", values). Stacked or two-column layout.
ComparecompareSide-by-side columnsThe 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.
FocusfocusOne block at a time, keyboard navDistraction-free single-block view. Minimal chrome, fullscreen-capable, configurable fade/slide/none transition. Auto-hides controls after inactivity.
CarouselcarouselHorizontal scroll, arrow/swipe navBlocks laid out horizontally with a configurable peek amount showing the adjacent block. Arrow buttons, dot indicators, optional auto-play timer.
SwipeswipeSwipe gestures = scoring actionsTinder-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:

{
  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:

{
  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:

{
  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:

{
  layout?: "stacked" | "two-column"; // default "stacked"
  submitLabel?: string;              // default "Submit"
  showReset?: boolean;               // default false
  validateOnSubmit?: boolean;        // default true
}

Compare config:

{
  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:

{
  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:

{
  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:

{
  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)

TypeDescription
gridResponsive spatial grid. Default layout.
sequenceOne block at a time. Typeform-style stepper.
slidesFull-screen presentation mode.
pageFull-width scrollable sections. Public-optimized.
formAll inputs visible with a single submit.

Data browsing

TypeDescription
splitMaster-detail two-pane layout. List on left, detail on right.
kanbanDrag-and-drop board. Columns are field values.
timelineBlocks arranged along a time axis.
calendarEntities placed on a month/week/day calendar grid.
galleryVisual masonry layout optimized for images and cards.
tableInline-editable spreadsheet grid. Airtable-style.

Interactive

TypeDescription
swipeTinder-style card stack. Swipe to score, rank, or triage.
wizardConditional branching steps. Next step depends on previous answer.
chatConversational interface. Blocks render inline in a conversation.
compareSide-by-side columns for 2–4 entities.
rankDrag-to-reorder list for priority setting.

Spatial

TypeDescription
canvasFreeform 2D placement with connectors. Whiteboard-style.
mapBlocks pinned to geographic coordinates.
graphForce-directed node layout for relationship exploration.

Narrative

TypeDescription
storyVertical swipe, full-screen cards. Instagram Stories style.
scrollParallax long-form scroll with animated transitions.
carouselHorizontal swipe/arrow navigation with peek at adjacent blocks.

Output

TypeDescription
pdfPaginated, print-optimized layout with page breaks.
emailEmail-safe HTML render for newsletters and digests.
embed-cardCompact embeddable card for external sites.

Power user

TypeDescription
terminalText command interface.
notebookCode cells + output cells. Jupyter-style.
inboxPriority-sorted action queue with inline actions.
dashboardKPI-forward layout with alerts and sparklines.
focusDistraction-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:

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:

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:
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);
  1. Implement — Create features/views/surfaces/my-surface.tsx:
"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>;
}
  1. Register — Add one line to features/views/surfaces/register.ts:
registerSurface("my-surface", { component: MySurface });
  1. 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.

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:

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)

FunctionSignatureDescription
getViews(entityTypeId?, pageType?)Returns ViewRecord[]Fetch views for an entity type or standalone. Defaults to page_type='list'.
getViewById(viewId)Returns ViewRecord | nullFetch a single view with access check.
getViewsForEntity(params){entityTypeId, entityId, pageType?} -> ViewRecord[]Entity-specific overrides take priority over type-level views.
createView(input)Returns ViewRecordCreate with auto-positioned ordering and canonical flat block storage.
updateView(viewId, input)Returns ViewRecordPartial update with ownership verification.
deleteView(viewId)Returns voidDelete with ownership verification.
forkViewForEntity(params){sourceViewId, entityId, blockOverrides?} -> ViewRecordFork a type-level view for a specific entity.
copyView(sourceViewId, newTitle?)Returns ViewRecordIndependent duplicate with lineage tracking.

Publish Actions (features/views/server/publish-actions.ts)

FunctionSignatureDescription
publishView(viewId)Returns ViewRecordSets publish_status = 'published', generates token if not already present. Requires admin role.
unpublishView(viewId)Returns ViewRecordSets publish_status = 'disabled', preserves token for future re-use. Requires admin role.
getPublishedView(token)Returns ViewRecord | nullFetches 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 stringGenerates a 40-character hex token (two UUIDs concatenated, dashes stripped).

API Routes

MethodPathDescription
GET/api/viewsList views (query params: entityTypeId, pageType)
GET/api/views/libraryDerive reusable library assets for the active editor scope (views, blocks, sources)
POST/api/viewsCreate a new view
PATCH/api/views/[id]Update a view
DELETE/api/views/[id]Delete a view
POST/api/views/[id]/copyDuplicate a view
POST/api/views/[id]/forkFork a view with parent lineage
GET/embed/v/[token]Public embed page — renders published view without auth (Next.js page route, not API)

Components

ComponentLocationDescription
SurfaceRendererfeatures/views/components/surface-renderer.tsxTop-level surface dispatcher. Resolves surface component from registry, fast-paths grid, falls back to GridSurface for unknown types.
UnifiedViewRendererfeatures/views/components/unified-view-renderer.tsxSingle renderer for all view types. Renders resolved flat blocks through SurfaceRenderer.
ViewHeaderfeatures/views/components/view-header.tsxTitle, description, share/edit/copy/fork/pin actions.
ViewTabsfeatures/views/components/view-tabs.tsxTab navigation with create/edit/delete view UI.
WorkspaceEditorfeatures/views/components/workspace-editor/Flat-block editor used in record views, standalone views, and admin entity type editor.
ViewEmptyStatefeatures/views/components/view-empty-state.tsxShown when no blocks are configured.
PublishViewDialogfeatures/views/components/publish-view-dialog.tsxPublish / unpublish dialog with copy-paste embed code and preview link.
HorizontalScrollFadecomponents/ui/horizontal-scroll-fade.tsxWraps 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:

ToolDescription
listBlockTypesReturns all BlockDefinition introspections: type, category, configSchema fields, metadata. Filter by category. Use before composing a view so block config is correct.
manageViewCreate, update, or delete views with full block config control including surface_type. Part of the admin tool group.
generateViewCreate ephemeral TransientViewSpec with inline data in chat (no DB storage).
saveTransientViewPromote a transient view spec to a persisted workspace view.
inspectViewsRead-only view inspection for understanding current layout configurations.

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.

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.

ExportPurpose
BREAKPOINT_PX, BREAKPOINT_REMNamed breakpoint constants (sm, md, lg, xl, 2xl). Source of truth for media-query string construction.
MOBILE_MAX_PX, MOBILE_MEDIA_QUERYThe 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_CLASSBottom-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.

  • Block System -- Views compose blocks; BlockConfig[] is the core data structure
  • Entity System -- Views are scoped to entity types and render entity data
  • Agent System -- Agents author views via admin tools with realtime feedback
  • Navigation -- Saved views can appear as sidebar navigation items

On this page

OverviewKey ConceptsView RecordFlat Node MapData SourcesWorkspace Editor StatusTask View FixturesReusable Artifact CatalogExternal Data SourcesPage TypesView LayoutsFlattened Workspace ModelView ScopeWorkspace-Scoped ViewsView ThemesFork and Copy LineageView Management UXLive Block Previews in Workspace EditorHow It WorksEntity Type Page Flow (List Views)Entity Detail Page FlowStandalone View PagesView Resolution PipelineRelation Column Resolution in data-table BlocksAuto-Generated Default BlocksRealtime SubscriptionsTemplatesTemplate dataSources and HydrationView Publishing and EmbedsPublishing FlowPublic Embed RouteEmbed Loader ScriptEmbed Utilities (features/views/lib/embed-code.ts)RLS and SecurityEmbed Response PersistencePublishViewDialogFullscreen Presentation ModeRoute ArchitectureSecurityGuest Invite FlowResponse ModeViewResponseStateBlock RolesCross-Block FilteringHow It WorksSetupVisual EditorEntering Edit ModeWhat You Can DoSave FlowArchitectureData source authoring (Visual / NL / JSON)Data source preview chipViews hub source managerBottom block palette dock + / shortcutReusable library (views, blocks, data sources)Ask about this viewSurface TypesThe SurfaceProps ContractCore Surfaces (shipped)Surface ConfigurationSurface Config UIFull Surface Catalog (30 types)Surface Rendering ArchitectureSurfaceDefinitionRegistryAdding a New Surface TypeHow Surfaces Compose with BlocksScoring and Workshop ExercisesAPI ReferenceServer Actions (features/views/server/actions.ts)Publish Actions (features/views/server/publish-actions.ts)API RoutesComponentsFor AgentsResponsive PrimitivesDesign DecisionsRelated Modules