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:
| 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.
// 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/:
| Type | Config | How it works |
|---|---|---|
poll | PollConfig — pollUrl, pollInterval, pollTransform, pollHeaders | Inngest cron job fetches the URL on schedule, applies a JSONPath transform, and stores results as external_data_points. Good for APIs without webhooks. |
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 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 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:
blocksstores the canonical block map.blockOrdercontrols display order.layoutcontrols 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";| 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.
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 touserand 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:
useViewPreview()hook posts the current draft state (blocks, layout, data sources, optional entity context) toPOST /api/views/preview, debounced 400ms.- The endpoint builds a temporary in-memory
ViewRecordand calls the existingresolveView()server resolver — nothing is persisted. - Resolved blocks are returned keyed by block id and passed down to
EditorBlockLive, which dispatches toBlockRendererwhen data is present or falls back toEditorBlockPreview(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)
- The entity type list page queries views with
page_type='list'for the entity type. - If views exist,
ViewTabsrenders tab navigation (with create/edit/delete buttons) andUnifiedViewRendererrenders the active view. - If no views exist, the page falls back to
EntityListSplitWrapper(the built-in table/grid/kanban view). - If there are no persisted views but legacy
config.dashboard.sectionsstill exist, the page renders a deterministic synthetic dashboard view without mutating the database during page load.
Entity Detail Page Flow
- The detail page always renders the schema-driven default surface first (
EntityBento, notes, sidebar, responses). - The page queries optional
page_type='workspace'views withgetViewsForEntity()for record-specific customization. - The server resolves detail-view selection once, then passes the selected workspace view state and resolved blocks to the client.
- Workspace views are only resolved when explicitly selected; the default record request path remains schema-first.
- 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
- Query --
getViews()orgetViewsForEntity()fetches views from DB, filtered by tenant, entity type, page type, and access scope. Raw DB rows are normalised throughparseViewRecord(), which promotes legacy array-format blocks to the flat map format and canonicalises older block config shapes. - Block resolution -- Call sites pass the
ViewRecordtoresolveView(). The unified resolver classifies blocks bydataRequirement, applies anydataSourcesconfig, and delegates to the appropriate sub-resolver. ReturnsResolvedBlock[]in original block order. - Rendering --
UnifiedViewRendererrenders the already-resolved flat block sequence viaBlockGrid.
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:
| 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:
- Replaces
"__CONTEXT__"indataSourceswith the actualentityTypeSlug - For blocks without a
dataSourceId, injectsentityTypeSlugdirectly into block config - If no
typeSlugis provided (standalone views), stripsdataSourceIdreferences
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
- A tenant admin calls
publishView(viewId)(or uses thePublishViewDialogcomponent on a tool page). The server generates a 40-character hex token, setspublish_status = 'published', and stores the token inpublish_token. - 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. publishToolAsView(toolSlug, toolName)is a convenience action that creates a new standalone view with a singletoolblock 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:
- Fetches the view by token via
getPublishedView(token)— uses the admin client to bypass RLS (the RLS anon policy also permits direct access). - Resolves blocks through
resolveView()withtenantIdfrom the view record andmode: "view". - Renders the resolved blocks inside
EmbedViewClientwhich 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
srcattribute (works on any deployment). - Creates an
<iframe>pointing to/embed/v/[token]. - Listens for
sprinter-embed-resizepostMessageevents from the iframe'scontentWindowand updatesiframe.style.heightaccordingly.
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 the view_responses table in real time so users can resume if they close the tab.
How it works:
- On mount, the client derives a stable
sessionIdfromlocalStorage(key:embed-session-{token}). A fresh UUID is generated if none exists. - A
GET /api/embed/v/[token]/respond?sessionId=…fetch restores any previously saved data and injects it assurfaceConfig.initialData. - Every
onSavecallback fromSurfaceRendereraccumulates block data in a ref and triggers a debouncedPOST /api/embed/v/[token]/respond(300 ms delay). - When a
form-flowblock with aFormFlowConfig.outputconfig completes, the client callsPOST /api/form-flow/completeto create a platform entity from the collected values.
API surface:
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/embed/v/[token]/respond?sessionId= | None | Restore previous session data |
POST | /api/embed/v/[token]/respond | None | Upsert response data for a session |
POST | /api/form-flow/complete | None (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:
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key |
tenant_id | uuid FK tenants | Tenant scoping |
view_id | uuid FK views | The published view |
publish_token | text | Matches views.publish_token |
session_id | text | Client-generated UUID, stable per browser |
user_id | uuid FK auth.users | Optional — for authenticated users |
email | text | Optional — for invite flows |
data | jsonb | Accumulated block data keyed by block ID |
completed | boolean | Set 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:
- Distraction-free presentation — Any authenticated user can navigate to
/present/{viewId}to see a view without the sidebar, navigation, or other app chrome. - Guest view confinement — Invited guest users with an
assigned_view_idin their JWTapp_metadataare automatically redirected to/present/{assignedViewId}for every request that is not an explicitly allowed path (see Guest View Isolation).
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:
- The
viewIdis stored in the created user'sapp_metadata.assigned_view_id. - 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}. - After login, the
?next=redirect lands the user on/present/{viewId}. - On subsequent requests,
proxy.tsreadsassigned_view_idfrom 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:
| 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
ViewFilterProviderwraps the view renderer, providing a React context for filter state keyed bydataSourceId.- When the user applies filters in an
entity-filterblock, the block callssetFilters(dataSourceId, rules)on the context. - Data blocks that share the same
dataSourceIduseuseFilteredEntities(). This hook watches the filter context — when active filters exist, it fires a client-side fetch to/api/entitieswith the updated rules. When no filters are active, blocks use their SSR-resolvedinitialData.
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
rectSortingStrategyfor 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
tableblock can be swapped tokanban,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 throughparent_view_idfor 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:
useViewEditorhook (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).WorkspaceEditor(features/views/components/workspace-editor/index.tsx) -- Top-level editor shell that wires block data, picker data, and save flow together.EditorCanvas(features/views/components/workspace-editor/editor-canvas.tsx) -- Wraps the standardBlockGridwith dnd-kit sorting so blocks can be rearranged inline.BlockConfigPanel(features/views/components/workspace-editor/block-config-panel.tsx) -- Side panel for individual block configuration, type swapping, and removal.BlockPalette(features/views/components/workspace-editor/block-palette.tsx) -- Categorized picker for adding new blocks.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/translatefetches the entity type'sjson_schema, injects the field whitelist into a Vercel AI SDKgenerateObject()prompt, and re-validates everyfilters[].fieldandsort.fieldagainst the whitelist before returning (hallucinated fields → 422). Uses the single-call helpertranslateNlToDataSource()infeatures/views/server/nl-to-data-source.ts. - JSON — raw editable JSON validated against
DataSourceConfigSchemaon 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
usageswith the view ID, view title, and source name for every occurrence. Imports flow throughprepareSourceImport()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 ExperienceThe 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:
| 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:
{
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)
| 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. |
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:
surfaceDefinitions.get(type); // → SurfaceDefinition | undefined
surfaceDefinitions.listAll(); // → SurfaceDefinition[]
surfaceDefinitions.listByCategory(cat); // → SurfaceDefinition[]
surfaceDefinitions.introspect(type); // → SurfaceIntrospection | undefined
surfaceDefinitions.introspectAll(); // → SurfaceIntrospection[] — for AISurfaceIntrospection 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
- Define — Create
features/views/surfaces/definitions/my-surface.tsusingdefineSurface()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);- 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>;
}- Register — Add one line to
features/views/surfaces/register.ts:
registerSurface("my-surface", { component: MySurface });- 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-cardblock showing"Revenue: $1.2M"renders in every surface type. - A
chartblock 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:SequenceSurfaceputs input blocks into edit mode per step;FormSurfaceidentifies 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:
swipesurface — each swipe creates anentity_responsevia the existing response system (submitResponsetool /createEntityResponse()). The scoring math, criteria sets, and aggregation pipeline are unchanged.ranksurface — dragging cards into order saves arankfield value on each entity.sequencesurface withresponse-formblocks — multi-step assessment where each step scores a different dimension. All values feed the same response and criteria pipeline.formsurface — submits collect viaonSave("__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. |
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.
| 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.
Related Modules
- 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
Obsidian Interop
Bidirectional sync between the Sprinter Platform and Obsidian vaults. Covers the body field, TipTap rich editor with wikilinks, slash commands, vault import/export, and the TypeSpec runtime API.
Block System
The universal rendering primitive. Every visual surface in the platform -- entity details, dashboards, chat messages, list views -- renders through the same BlockConfig to ResolvedBlock to component pipeline.