Documentation source
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.
## Overview
The block system is the rendering layer of the Sprinter Platform. Rather than building bespoke components for each page type, every visual surface renders through a unified pipeline: `BlockConfig[]` (serializable configuration) is resolved server-side into `ResolvedBlock[]` (config + data), which are then rendered by registered block components on the client.
This architecture means the same block types work everywhere: in entity detail pages, on dashboard views, inside chat messages, in tool output displays, and on standalone workspace pages. Adding a new block type -- say, a Gantt chart -- makes it immediately available across all these surfaces.
Blocks are orthogonal to surface types. A `ResolvedBlock[]` array is handed to whichever surface component the view declares (`grid`, `sequence`, `slides`, `form`, etc.) — the surface controls arrangement, the block controls rendering. See [Surface Types](/docs/features/view-system#surface-types) in the View System doc for how blocks compose with surfaces.
The block system lives in `features/blocks/` and has zero domain knowledge. It does not know about opportunities, people, or any specific entity type. It renders whatever data is resolved for it.
## Block Primitive Contract (ADR-0036)
ADR-0036 introduces a unified `BlockDefinition` contract in `lib/ui-registry/block-contract.ts`. This is a **second, parallel surface** — it does not replace the existing `features/blocks/` pipeline yet, but it is the canonical authoring target for all new Blocks going forward. Platform Blocks (the 35+ types in `features/blocks/definitions/`) will migrate to this contract in later phases.
### Why a new contract
The existing pipeline accumulated ~10 slot kinds and ~70 per-adapter files over time. The ADR-0036 contract collapses these into one self-describing object with typed I/O, lazy render maps, permission gating, sandbox routing, and tree-shake enforcement — all in a single `defineBlock()` call.
### BlockDefinition shape
```typescript
import { z } from "zod"
interface BlockDefinition<TInput, TOutput, TConfig> {
name: string // unique slug, e.g. "body-map"
tenantSlug?: string // set for tenant-scoped blocks, omit for platform blocks
version: string // semver, e.g. "1.0.0"
inputSchema: z.ZodType<TInput> // Zod schema for inbound data
outputSchema: z.ZodType<TOutput> // Zod schema for emitted values
configSchema: z.ZodType<TConfig> // Zod schema for editor config
modes: BlockMode[] // at least one of "display" | "input" | "print"
security: BlockSecurity // "host-render" (default) | "sandbox"
requiredPermission?: AppPermission // gates mount; falls through to permission-denied fallback
overridePolicy?: BlockOverridePolicy // default "code-locked"
// Reserved — not yet consumed by <MountBlock>
cacheable?: boolean
cacheKey?: (input: TInput, config: TConfig, ctx: BlockContext) => string
render: BlockRenderMap<TInput, TOutput, TConfig> // lazy loader per mode
sandboxSource?: string // required when security === "sandbox"
}
```
**`BlockMode`** — `"display" | "input" | "print"`. Each mode maps to a separate lazy render loader in `render`. A Block that declares `modes: ["display", "input"]` must have both `render.display` and `render.input`.
**`BlockSecurity`** — `"host-render"` renders in the same React tree as the host. `"sandbox"` routes through `<ArtifactHost>` — the same srcdoc iframe used by agent-authored module-kind components (ADR-0020). Sandbox Blocks are subject to the `@/runtime` palette restrictions and the `ACTION_ALLOWLIST` from `lib/runtime/protocol.ts`.
**`BlockOverridePolicy`** — mirrors ADR-0018 slot-resolution tiers:
- `"code-locked"` (default) — code wins; plugin tier is consulted only when no code slot is registered.
- `"allow-db-override"` — DB-resident (agent-authored) binding wins over code.
- `"code-only"` — plugin tier is never consulted. Use for security-sensitive surfaces.
**`cacheable` / `cacheKey`** — reserved; `<MountBlock>` does not yet consume these fields. A Block that sets `cacheable: true` without a `cacheKey` will fail `defineBlock()` validation.
### defineBlock() — the authoring gateway
`defineBlock<TInput, TOutput, TConfig>(definition)` is the sole entry point for registering a new Block. It runs module-load validation and throws on misconfiguration:
- `name`, `version`, and `modes` must be non-empty.
- Every key in `render` must be a declared mode; undeclared render keys are rejected.
- Every declared mode must have a corresponding `render` entry.
- `requiredPermission` must be a valid `AppPermission` enum value.
- `cacheable: true` without `cacheKey` is rejected.
- `cacheable: true` on a Block that only has `input` mode is rejected (input Blocks are write-path; caching write-path data is almost always wrong).
`defineBlock()` returns the definition unchanged — it is a validation gateway, not a factory.
After calling `defineBlock()`, pass the result to `registerBlock()` from `lib/ui-registry/resolve-block.ts` to wire it into the slot registry.
```typescript
import { defineBlock } from "@/lib/ui-registry/block-contract"
import { registerBlock } from "@/lib/ui-registry/resolve-block"
import { BodyMapValueSchema } from "./body-map-value"
const bodyMapBlock = defineBlock({
name: "body-map",
tenantSlug: "docs", // tenant-scoped: only resolves for the "docs" tenant
version: "1.0.0",
inputSchema: BodyMapValueSchema,
outputSchema: BodyMapValueSchema,
configSchema: BodyMapConfigSchema,
modes: ["display", "input"],
security: "host-render",
render: {
display: () => import("./body-map-display"),
input: () => import("./body-map-input"),
},
})
registerBlock(bodyMapBlock)
```
### MountBlock — the single mount surface
`<MountBlock>` in `lib/ui-registry/mount-block.tsx` is the only component that should render a Block. It drives the full pipeline:
1. **Resolve** — `resolveBlock(name, { tenantSlug: context.tenantSlug ?? null })` looks up the registered `BlockDefinition` from the slot registry. Passes `tenantSlug` (the human slug like `"docs"`), **never** `tenantId` (UUID).
2. **Mode check** — if the requested mode is not in `definition.modes`, renders `<BlockFallback reason="mode-not-supported" />`.
3. **Schema validation** (dev only) — validates `input` against `definition.inputSchema` and logs a warning on mismatch.
4. **Permission gate** — if `definition.requiredPermission` is set, checks it against the caller's permissions. Renders `<BlockFallback reason="permission-denied" />` on failure.
5. **Sandbox routing** — if `definition.security === "sandbox"`, renders through `<ArtifactHost>` with the Block's `sandboxSource`.
6. **Lazy render** — calls `definition.render[mode]()` to dynamically import the render component, then mounts it.
```tsx
import { MountBlock } from "@/lib/ui-registry/mount-block"
<MountBlock
name="body-map"
mode="display"
input={patientPainSymptoms}
context={{ tenantSlug: "docs", tenantId: tenant.id }}
onOutput={(value) => handleBodyMapChange(value)}
fallback={<Skeleton className="h-48 w-full" />}
/>
```
**CRITICAL — tenant-scoping rule (P0 finding from multi-model review):**
Pass `context.tenantSlug` (the human slug, e.g. `"docs"`), **never** `context.tenantId` (the UUID). The slot registry encodes tenant-scoped keys as `block:name|t=<slug>`. Passing the UUID causes `resolveBlock` to miss all tenant-scoped registrations and silently fall back to the platform-level slot (or `undefined`), producing either wrong output or a blank fallback with no error.
### resolveBlock() and registerBlock()
`lib/ui-registry/resolve-block.ts` exports two thin typed wrappers around `getSlot()`/`registerSlot()`:
```typescript
// Register a block definition into the slot registry
function registerBlock(
definition: BlockDefinition<unknown, unknown, unknown>,
options?: RegisterBlockOptions
): void
interface RegisterBlockOptions {
/**
* Pass true when intentionally replacing a legacy features/blocks/registry.ts
* registration. Without this flag, registerBlock() throws if the slot is
* already occupied — the collision guard prevents accidental double-registration.
*/
replaceLegacy?: boolean
}
// Look up a block definition by name + optional tenant scope
function resolveBlock(
name: string,
opts?: { tenantSlug?: string | null }
): BlockDefinition<unknown, unknown, unknown> | undefined
```
The **collision guard** in `registerBlock()` throws if the slot is already occupied by another registration, unless `{ replaceLegacy: true }` is explicitly passed. This prevents silent shadowing when platform Blocks begin migrating from `features/blocks/registry.ts` to this contract.
### Tree-shake gate
`scripts/check-block-tree-shake.mjs` (run via `pnpm check:block-tree-shake`) statically verifies that no Block render implementation is imported at module-load time — every entry in a `BlockDefinition.render` map must be a dynamic import `() => import("./...")`. The gate is wired into the `release-floor` job in `.github/workflows/ci-pr-ready.yml` and blocks merges. This forecloses the ADR-0028 cold-start incident class for Blocks — heavy renderers (SVG diagrams, specialized editors) never load in Lambda cold-starts that don't actually render those Blocks.
### BlockFallbackProps
When `<MountBlock>` cannot render, it calls `props.fallback` (if provided) or mounts a platform default fallback. The `reason` discriminant is one of:
| Reason | When |
|--------|------|
| `"block-not-found"` | `resolveBlock()` returned `undefined` |
| `"mode-not-supported"` | Requested mode is not in `definition.modes` |
| `"permission-denied"` | `requiredPermission` check failed |
| `"schema-mismatch"` | `input` failed `inputSchema` validation (dev only) |
| `"sandbox-not-implemented"` | `security === "sandbox"` but ArtifactHost is not available in this render context |
### body-map — canonical reference Block
`features/custom/tenants/docs/blocks/body-map.ts` is the canonical reference implementation for the ADR-0036 contract. It demonstrates:
- Tenant-scoped registration (`tenantSlug: "docs"`)
- Symmetric input+output schemas (`BodyMapValueSchema` for both)
- Typed config (`BodyMapConfigSchema` with `maxMarkers`, `initializeFromPatient`, `viewport`)
- Lazy display and input render loaders
- `security: "host-render"`
Read this file before authoring a new tenant Block.
### Design decisions
**Why a second `BlockDefinition` type?** The existing `BlockDefinition` in `features/blocks/definition.ts` is tightly coupled to the `BlockConfig → ResolvedBlock` server-resolution pipeline. The new contract in `lib/ui-registry/block-contract.ts` decouples authoring from server resolution entirely — a Block is a self-describing render unit, not a config schema. The two types coexist during the migration; the old type will be removed once all 35+ platform Blocks migrate.
**Why require `tenantSlug` for tenant Blocks?** The slot registry keys tenant registrations as `block:name|t=<slug>`. A Block registered without `tenantSlug` is platform-global and resolves for all tenants. A Block registered with `tenantSlug` only resolves when `resolveBlock()` is called with a matching slug. This mirrors the same scoping rule applied to entity-type slots (see `.claude/rules/tenant-modules.md`).
**Why validate at `defineBlock()` call time?** Module-load validation makes misconfiguration fail at server start (or test run), not at the first user render. The alternatives — runtime validation in `<MountBlock>` or CI lint rules — both produce silent production failures.
See `documents/adr/0036-block-primitive-unification.md` for the full decision record. Related: [ADR-0018 (slot resolution tiers)](/docs/architecture#adr-0018), [ADR-0020 (module-runtime components)](/docs/features/components-runtime).
## Key Concepts
### Block Types
The platform ships with 35 block types:
| Block Type | Description | Default Size |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
| `stat-cards` | KPI cards showing counts and metrics | full |
| `table` | Sortable data table with rows | full |
| `chart` | Bar, pie, donut, line, area, or stacked-bar chart (Recharts) | half |
| `radar` | Radar/spider chart with interactive scoring | half |
| `ranking` | Score-based ranking bars | half |
| `kanban` | Kanban board grouped by an enum field | full |
| `summary` | Key-value pairs rendered as a summary card | full |
| `field-card` | Single entity field with smart display | varies |
| `connection-list` | Linked entities from a connection field | half |
| `data-table` | Full data table with pagination and filters | full |
| `activity` | Recent activity timeline | full |
| `entity-card` | Compact entity card | half |
| `entity-feed` | Ranked entity feed with filtering | full |
| `entity-graph` | Entity relationship graph (@xyflow/react) | full |
| `rich-text` | Markdown/rich text content | full |
| `status-banner` | Status message banner | full |
| `child-entity-list` | Children of a container entity | full |
| `custom` | Passthrough for custom renderers | varies |
| `image` | Image display | half |
| `video` | Video embed | full |
| `excalidraw` | Excalidraw whiteboard | full |
| `react-flow` | React Flow diagram | full |
| `entity-pipeline` | Visual data-flow diagram for the entity-pipes DAG — entity → actions → downstream unlocks (xyflow, lazy) | full |
| `bubble-chart` | Bubble chart visualization | half |
| `form-flow` | Multi-step form wizard with validation and progress tracking | full |
| `canvas` | Interactive node-based canvas with drag-and-drop (@xyflow/react) | full |
| `entity-filter` | Schema-driven filter controls with inline search results | full |
| `tool` | Interactive tool with input form and output rendered inline | full |
| `notes` | Editable rich-text notes attached to an entity | full |
| `documents` | Uploaded documents list for an entity | full |
| `pdf-viewer` | Inline PDF document viewer | full |
| `response-form` | Criteria scoring form that submits a scored response | full |
| `menu` | Navigation menu or action list | full |
| `resource-download` | Download CTA shown after form completion | full |
| `task-tree` | Nested drag-to-reorder task list with inline add and checkbox toggle | full |
| `checklist` | Flat checkbox list with inline add and Cmd+Enter keyboard flow | full |
| `planner` | Day-planner composite: unscheduled task list + vertical hour-rail timeline with drag-to-schedule, drag-to-resize, and drag-to-unschedule | full |
| `html-embed` | Agent-authored HTML rendered in a sandboxed iframe with the `window.amble` bridge | full |
### Landing pages — `buildLandingPageView`
`features/blocks/lib/landing-page-view.ts` is the reusable helper for shipping a
polished public landing page. Given typed content — a hero (eyebrow, headline,
subhead, up to two CTAs), optional social proof (stats / logos / testimonials),
an offer band, and an optional FAQ — it returns a canonical `PublicAuthorView`
that renders through the standard `BlockHost` path:
```
page-block → landing-hero → social-proof → offer-cta → faq
```
The design quality lives in the shared block renderers (≥44px tap targets,
perceptible primary-CTA hover, token-only color, surface elevation, spacing
rhythm), so every landing built this way inherits the same bar. The builder owns
only the composition: the page-block surface config, the section order, and
dropping empty optional sections. It is **answer-key-safe by construction** — it
composes only the four display blocks and never an `exercise-block`, so a landing
can publish to the anonymous embed/site routes without leaking a quiz answer key
(link the eligibility quiz from a hero CTA instead). ReclaimDuty's landing
(`features/custom/tenants/reclaimduty/declarations/landing-view.ts`) is the
reference consumer.
### Task Blocks
Task-oriented blocks are designed to render inside saved views, not only on the
standalone task pages:
- `checklist` renders a compact checkbox list with inline add and hover states.
- `task-tree` renders a nested task hierarchy with drag/drop, keyboard
indent/outdent, checkbox completion, and inline add.
- `planner` renders a day planner with a backlog column, scheduled task list,
hour rail, drag-to-schedule, drag-to-unschedule, and drag-to-resize.
These blocks use semantic work-model tokens (`human`, `agent`, and related
background tokens) so task views inherit tenant theme changes instead of
hardcoding product colors.
### html-embed Block
`features/blocks/modules/html-embed/` is the platform block for agent-authored interactive experiences — games, landing pages, calculators, data explorers, or any freeform HTML/JS surface that agents build and publish.
#### Sandbox model
The block renders agent-authored HTML inside a `<iframe srcdoc="...">`. The sandbox attribute set is `allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox`. `allow-same-origin` is **never** set — this is the security guarantee: agent code executes in a unique opaque origin and cannot read the host app's cookies, localStorage, or DOM. The security comes from the sandbox attribute, not HTML sanitization, which would require an allowlist that cannot be maintained securely for arbitrary interactive content.
#### window.amble bridge contract
The host injects a bootstrap `<script>` at the top of the `srcdoc` document before the agent's HTML. This script exposes:
```typescript
interface AmbleBridge {
/**
* The data the mini-app renders (read-only). When a View binds a live
* record/recordList DataSource onto this block, `data` is the resolved live
* data — a record, or an array of records for a list source — baked in at
* render time. With no binding it falls back to the static `config.data`
* record. See "Live Data Binding" below.
*/
data: unknown;
/**
* Submit a payload to the platform. One-shot — subsequent calls are no-ops.
* Payload is capped at 64 KB. On the host side, the message emits a
* `request.complete` BlockEvent with `audit` durability through the existing
* anon-write boundary.
*/
submit(payload: unknown): void;
/**
* Request a height change. The host clamps to [160, 4000] px and throttles
* rapid calls. Useful when content height changes after initial load.
*/
resize(heightPx: number): void;
/** Signal that the iframe content is ready to be shown (removes a skeleton). */
ready(): void;
}
declare const window: { amble: AmbleBridge };
```
All messages pass over `postMessage`. The host validates every inbound message against a per-render nonce (injected into the bootstrap script as a JSON-escaped literal) **and** the `MessageEvent.source` reference. Messages from any other frame, or with a mismatched nonce, are silently dropped.
#### Config schema
```typescript
interface HtmlEmbedConfig {
html: string; // required, max 600 000 chars
title?: string; // max 200 chars
heightPx?: number; // clamped to [160, 4000], default 640
data?: Record<string, unknown>; // static window.amble.data; a live binding overrides it (see Live Data Binding)
}
```
#### Live Data Binding
By default `window.amble.data` is the static `config.data` record. To feed a mini-app **live workspace data**, bind a `record` / `recordList` DataSource onto the html-embed block through a View `bind` input. At render time the platform resolves the source (tenant-scoped) and bakes the result onto the block's render channel; the bootstrap script then exposes it as `window.amble.data`.
This is authored with **`publish_view` using `kind: "view"`**. The `kind: "html"` shorthand and `manageView` only produce static mini-apps — they carry `config.dataSourceId`, **not** the `{ kind: "bind" }` input that actually feeds the iframe, so `kind: "view"` is the path for live data:
```typescript
publish_view({
kind: "view",
view: {
root: {
block: "html-embed",
mode: "display",
input: { kind: "bind", dataSourceId: "rows" },
config: { html: "<!doctype html>…" },
},
dataSources: {
rows: { kind: "entity", entityTypeSlug: "company", cardinality: "many", limit: 50 },
},
},
});
```
Inside the mini-app:
- `window.amble.data` is the **resolved** data — a single record for `cardinality: "one"`, an array of records for `"many"`. It is a snapshot baked at render time, not a live stream.
- Entity rows expose their fields under **`.content`**, with `id` at the top level: `window.amble.data.content.name` (one) or `window.amble.data.map((r) => r.content.name)` (many).
- **Empty results:** a `cardinality: "one"` source that matches nothing resolves to `{}` (so `window.amble.data.content?.name` is always safe to optional-chain — never `null`); a `"many"` source that matches nothing resolves to `[]`. Guard for both.
- **Keep bound lists modest.** The resolved data is serialized into the iframe document at render time, so set a sensible DataSource `limit` (tens, not thousands) — a large `recordList` inflates the rendered document and the render-thread serialization cost.
- Live data **wins** over `config.data`; an unbound block falls back to the static `config.data`.
- The source is resolved through the same tenant-scoped resolver as every other bound block, so a mini-app only ever sees records the viewer is allowed to read.
#### Module layout
```
features/blocks/modules/html-embed/
bridge.ts # pure: message types, nonce validation, srcdoc composition
bridge.test.ts # unit tests for the bridge contract
schemas.ts # Zod config + value schemas
schemas.test.ts # schema validation tests
definition.ts # defineBlock(...) registration
manifest.ts # pure metadata — what the gallery + discover-block reads
render.client.tsx # "use client" iframe renderer
render.client.test.tsx # render tests
sample-data.ts # gallery examples
```
#### Design decisions
**`srcdoc` over `src`.** There is no server round-trip — the agent's HTML string is injected inline. This avoids creating a new URL surface, keeps the request count at zero, and means the iframe content is always exactly what the agent authored with no caching ambiguity.
**One-shot submit.** The `submit` method becomes a no-op after the first call. This prevents double-submissions from user double-clicks or retry logic inside agent-authored scripts from reaching the anon-write path twice.
**Nonce + source validation, not origin checking.** `srcdoc` iframes have an opaque origin — `window.location.origin` inside them returns `"null"` — so origin-based `postMessage` filtering cannot distinguish "our iframe" from any other `null`-origin frame (such as a sandboxed third-party embed on the same page). The per-render nonce combined with the `MessageEvent.source` reference check provides equivalent guarantees without depending on origin strings.
### BlockDefinition Interface
Every block type is described by a `BlockDefinition` — a single self-contained object that replaces the previous pattern of editing 5+ files to add a block type. Definitions live in `features/blocks/definitions/` (one file per type) and are registered into `blockDefinitions` (`BlockDefinitionRegistry`) on import.
```typescript
interface BlockDefinition<TConfig = Record<string, unknown>, TData = unknown> {
type: BlockType;
meta: BlockMeta;
configSchema: z.ZodType<TConfig>; // machine-readable contract — drives agents + editor
dataRequirement: DataRequirement;
resolve: DataResolver<TConfig, TData>;
component?: ComponentType<{ block: ResolvedBlock }>;
editComponent?: ComponentType<{
block: ResolvedBlock;
onSave: (data: unknown) => void;
}>;
configPanel?: ComponentType<unknown>;
validate?: (config: TConfig) => { valid: boolean; errors?: string[] };
}
```
**`BlockMeta`** carries static metadata:
```typescript
interface BlockMeta {
label: string;
description: string;
category: BlockCategory; // "data" | "entity" | "content" | "forms" | "activity" | "interactive" | "tools"
defaultSpan: number; // 1-12 column span
minSpan?: number;
responsiveStack?: boolean;
source: BlockSource;
display: BlockDisplay;
role?: "display" | "input" | "both"; // drives response mode behaviour
emitsFilters?: boolean; // can emit cross-view filters (e.g. chart clicks)
acceptsFilters?: boolean; // responds to cross-view filters
}
```
Use `defineBlock()` to create a frozen definition:
```typescript
import { defineBlock } from "@/features/blocks/definition";
export const chartDefinition = defineBlock({
type: "chart",
meta: { label: "Chart", category: "data", defaultSpan: 6, ... },
configSchema: z.object({ chartType: z.enum(["bar", "pie", ...]), groupBy: z.string(), ... }),
dataRequirement: "entity-list",
resolve: async (ctx, config) => { /* pure resolver using ctx */ },
});
```
The `configSchema` is the single source of truth for what an agent can configure and what the editor auto-generates as a config panel.
### BlockDefinitionRegistry
`blockDefinitions` is the global registry, exported from `features/blocks/definition.ts`:
```typescript
blockDefinitions.get(type); // → BlockDefinition | undefined
blockDefinitions.listAll(); // → BlockDefinition[]
blockDefinitions.listByCategory(cat); // → BlockDefinition[]
blockDefinitions.introspect(type); // → BlockIntrospection (simplified for agents)
blockDefinitions.introspectAll(); // → BlockIntrospection[]
```
`resolveView()` reads from this registry; so does the `listBlockTypes` agent tool and the block palette in the view editor. All 35 block definitions are registered at module init via the `features/blocks/definitions/index.ts` barrel.
### FilterEngine
`FilterEngine` (`features/blocks/server/filter-engine.ts`) centralises all filter logic that was previously duplicated across individual block resolvers.
```typescript
class FilterEngine {
// Apply AND-chained filter rules to in-memory rows
apply(rows: DataRow[], filters: FilterRule[]): DataRow[];
// Extract FilterRule[] from block config.filters (handles {field: value} shorthand)
parseFromBlockConfig(
config: Record<string, unknown>,
schemaProps: Record<string, unknown>,
): FilterRule[];
}
export const filterEngine = new FilterEngine(); // singleton
```
All resolvers that previously re-implemented filter parsing (chart, data-table, kanban, stat-cards, entity-filter) now call `filterEngine.apply()` on their resolved rows.
### BlockConfig
The serializable configuration stored in `views.blocks` JSONB:
```typescript
interface BlockConfig {
id?: string;
type: BlockType;
label?: string;
config?: Record<string, any>;
size?: "full" | "half" | "third"; // legacy size preset
span?: number; // 12-column grid span (1-12); preferred over size
source?: BlockSource;
display?: BlockDisplay;
workflow?: BlockWorkflow;
dataSourceId?: string; // references a named data source on the view
}
```
The `config` object is type-specific. For example, a `chart` block might have `{ chartType: "area", groupBy: "status", series: ["count", "value"] }`, while a `table` block might have `{ limit: 20, sortBy: "created_at" }`.
Legacy config keys such as `field-card.config.fieldName` and `stat-cards.config.stats` are canonicalized to the modern `fields: string[]` shape during normalization and view parsing. Runtime resolvers assume the canonical shape.
### Data Sources
Views carry a named `dataSources` map (`Record<string, DataSourceConfig>`) that defines reusable entity queries. Blocks reference a data source by setting `dataSourceId`. This avoids repeating the same entity type, filter, sort, and limit config across multiple blocks on the same view.
```typescript
interface DataSourceConfig {
entityTypeSlug: string;
filters?: FilterRule[];
sort?: { field: string; order: "asc" | "desc" };
limit?: number;
}
interface FilterRule {
field: string;
operator: "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "in" | "contains";
value: unknown;
}
```
When `resolveView()` processes a block with a `dataSourceId`, it looks up the data source and merges its `entityTypeSlug`, `filters`, `limit`, `sort`, and relation-column config into the block config before delegating to the resolver. Block-level config values take precedence over data source defaults. For `data-table`, the source `limit` is also treated as `pageSize`.
### Data Requirement
Every block type declares a `dataRequirement` in `features/blocks/lib/block-metadata.ts`. The unified resolver uses this to classify blocks into resolution buckets:
| Requirement | Description | Example types |
| --------------- | -------------------------------------------- | --------------------------------------------------- |
| `entity-list` | Needs a collection of entity rows | `table`, `chart`, `kanban`, `entity-feed` |
| `entity-single` | Needs one entity's full data | `field-card`, `summary`, `radar`, `connection-list` |
| `aggregation` | Needs computed counts or stats | `stat-cards` |
| `activity` | Needs the activity log | `activity` |
| `none` | Carries its own data in config (passthrough) | `rich-text`, `tool`, `form-flow`, `image` |
### Block Compatibility Groups
Block types are organized into four compatibility groups. Types within the same group share a data source shape and can be swapped without losing semantic meaning. This powers the visual editor's "swap type" feature.
| Group | Block Types | Data Shape |
| ------------ | ----------------------------------------------------------------------------------- | ----------------------- |
| `collection` | table, data-table, kanban, entity-feed, ranking, child-entity-list, connection-list | Entity rows with fields |
| `chart` | chart, radar, bubble-chart | Numeric series / axes |
| `single` | entity-card, field-card, summary | Single entity or field |
| `content` | rich-text, status-banner | Static text / markup |
### Chart Block Config
The `chart` block type accepts a richer config object for both inline datasets and entity-backed aggregations:
```typescript
interface ChartData {
items: Array<{
name: string;
value: number;
key?: string;
[key: string]: unknown;
}>;
chartType?: "bar" | "pie" | "donut" | "line" | "area" | "stacked-bar";
orientation?: "vertical" | "horizontal"; // bar only; vertical = horizontal bars
colorThresholds?: Array<{ min: number; max: number; color: string }>;
footerStats?: Array<{ label: string; value: string }>;
series?: string[]; // stacked-bar only: data keys to stack
}
```
| `chartType` | Description |
| ------------- | ------------------------------------------------------------------------------ |
| `bar` | Vertical bars (default). `orientation: "horizontal"` flips to horizontal bars. |
| `pie` | Pie chart with percentage labels on each slice. |
| `donut` | Pie chart with a 50% inner radius cutout. |
| `line` | Line chart with dots at each data point. |
| `area` | Area chart with a gradient fill beneath the line. |
| `stacked-bar` | Stacked bar chart. Pass `series` to name each stack layer. |
`colorThresholds` drives per-bar color in `bar` mode: bars whose value falls in a threshold range get that threshold's color instead of the default palette. This is useful for RAG (red/amber/green) status bars.
`footerStats` renders a small grid of label/value pairs below the chart — useful for showing summary totals alongside a distribution chart.
When a chart is entity-backed, the block config can derive `items` from records instead of storing them inline:
```typescript
interface ChartBlockConfig {
entityTypeSlug?: string;
groupBy?: string;
dataSourceField?: string;
aggregation?: "count" | "sum" | "avg" | "min" | "max";
sortBy?: "value" | "label";
sortOrder?: "asc" | "desc";
maxGroups?: number;
labelMap?: Record<string, string>; // custom display label per group key
items?: Array<Record<string, unknown>>; // optional inline data override
footerStats?: Array<{ label: string; value: string }> | "auto";
clickAction?: "list" | "detail";
clickFilterField?: string;
clickEntityTypeSlug?: string;
// Display options (see ChartDisplayOptions below)
paletteId?: string;
xAxisLabel?: string;
yAxisLabel?: string;
showLegend?: boolean;
showDataLabels?: boolean;
showGrid?: boolean;
valueFormat?: "number" | "currency" | "percent" | "compact";
valuePrefix?: string;
valueSuffix?: string;
legendPosition?: "top" | "bottom" | "left" | "right";
xAxisTickAngle?: number;
yAxisMin?: number;
yAxisMax?: number;
chartHeight?: number;
referenceLines?: Array<{
value: number;
label?: string;
color?: string;
style?: "solid" | "dashed";
}>;
}
```
- `aggregation` + `dataSourceField` let the same block power count charts, value rollups, and grouped KPI visuals from any record type.
- `labelMap` maps raw group key values to custom display labels rendered in the chart and legend. For example `{ "under_3_months": "< 3 mo" }` without changing the underlying data. Sort-by-label uses the display label.
- `items` is respected when present, so agent-generated or saved transient views can keep custom chart datasets.
- `footerStats: "auto"` generates footer stat cards automatically from the resolved chart items — one card per group with a non-zero count, formatted as `"<value> · <count>"`. Pass an explicit array to override with hand-crafted stat pairs.
- `clickAction: "list"` turns grouped charts into drill-down entry points that navigate to filtered list views with exact-match URL filters.
### Chart Display Options
`ChartDisplayOptions` controls visual presentation and is read from `block.config` at render time:
```typescript
interface ChartDisplayOptions {
xAxisLabel?: string; // Label rendered below the X axis
yAxisLabel?: string; // Label rendered to the left of the Y axis (rotated)
showLegend?: boolean; // Default: true for pie/donut/stacked-bar; true for all types
showDataLabels?: boolean; // Default: true for bar; false for line/area/pie
showGrid?: boolean; // Default: true for Cartesian charts
valueFormat?: "number" | "currency" | "percent" | "compact";
valuePrefix?: string; // Prepended to formatted value (e.g. "$")
valueSuffix?: string; // Appended to formatted value (e.g. "%")
legendPosition?: "top" | "bottom" | "left" | "right"; // Default: "bottom"
xAxisTickAngle?: number; // Rotation in degrees (e.g. -45 for angled labels)
yAxisMin?: number; // Override Y-axis minimum (useful for non-zero baselines)
yAxisMax?: number; // Override Y-axis maximum
paletteId?: string; // Named color palette (see Color Palettes section)
}
```
Y-axis tick values auto-format to compact notation (e.g. `42K`, `1.2M`) when values exceed 1,000. This is applied automatically regardless of `valueFormat` to prevent unreadable axis labels.
**Value formatting** is handled by `formatChartValue()` in `features/blocks/lib/chart-format.ts`, shared between chart renderers and export utilities.
### Chart defaults (2026-04-17)
`ChartDisplayOptions` above lists every available knob, but callers rarely set them all. `resolveDisplayOptions()` in `features/blocks/lib/chart-defaults.ts` merges user config over sensible defaults so `{chartType: "bar", groupBy: "stage"}` alone produces a production-quality chart.
- **`xAxisLabel` / `yAxisLabel`** — Auto-derived from `groupBy` (via `groupByLabel()`) and `aggregation` + `dataSourceField` (via `metricLabel()`). For horizontal bar charts the axes are swapped. `groupByLabel` strips common field suffixes: `owner_id → "Owner"`, `priority_slug → "Priority"`, `created_at_month → "Month"`, `closed_at_quarter → "Quarter"`. `metricLabel` handles aggregation modes: `count → "Count"`; `sum + amount → "Total amount"`; `avg + score → "Average score"`; `min/max + field → "Minimum/Maximum {field}"`.
- **`showLegend`** — `true` by default for pie/donut and multi-series stacked-bar; `false` for single-series bar/line/area. Explicit `false` always wins.
- **`tooltipLabel`** — Auto-populated from `metricLabel(aggregation, dataSourceField)` so the tooltip never reads a misleading "Count" when the chart is actually summing amounts.
- **`showGrid`** — `true` for Cartesian charts; skipped for pie/donut.
Every default is override-able. Passing an explicit value (even `""`, `false`, `0`) suppresses the corresponding default. The merge is key-wise with `undefined` as the "unset" sentinel.
### Grouping dimension in tooltips
`ChartTooltipContent` now accepts a `dimensionLabel` prop threaded through `SharedProps` from `ChartBlock`. When set, the tooltip renders the dimension as an uppercase subtitle row above the category label, so readers can tell what's being grouped without having to read the title. Pie/donut, bar, stacked-bar, line, and area all show this row.
### Single-color bars by default
For single-series bar charts with no `colorMap` and no `colorThresholds`, every bar now shares `palette[0]` via a single `fill` prop on `<Bar>` — no per-item `<Cell>` rendering. Rainbow-per-bar (previous default) was the reported "looks AI-generated" complaint. Users who want per-category color distinction opt in via `colorMap: { "Closed Won": "…", "Closed Lost": "…" }` or via value-banded `colorThresholds`. Pie/donut keep palette rotation (their whole purpose is categorical distinction).
### Default item caps + truncation footnote
`buildGroupedChartItemsWithMeta()` in `features/blocks/lib/chart-data.ts` returns `{items, totalGroups, truncatedCount}`. When `config.maxGroups` is unset, `defaultMaxGroups(chartType, orientation)` applies a sensible cap:
| Chart type | Orientation | Default `maxGroups` |
| --------------------- | ------------ | ----------------------------- |
| `bar` / `stacked-bar` | `horizontal` | 20 |
| `bar` / `stacked-bar` | `vertical` | 50 |
| `line` / `area` | — | 50 |
| `pie` / `donut` | — | _none_ (user-configured only) |
When the cap trims groups, `ChartBlock` renders a "Showing top N of M" footnote below the chart so readers know they're looking at a truncated view. Scroll-within-card was rejected as a known trackpad scroll-trap UX anti-pattern.
### Stacked-bar diagnostic
Setting `chartType: "stacked-bar"` without populating `series: string[]` previously produced a silent fake-stacked bar (internally `series ?? ["value"]`). Now:
1. `diagnoseChartBlock()` emits a `level: "warning"` diagnostic with message `"Stacked bar needs series; showing a plain bar chart. Add series fields or switch chart type to 'bar'."`.
2. `ChartBlock.renderChart()` detects the empty-series case and calls `renderBarChart()` instead.
3. The warning appears as a banner above the fallback chart, not hidden behind the previous `items.length === 0` gate.
This applies to both supervised (chat-driven) and autonomous (heartbeat-driven) chart generation via `manageView` / `manageTasks`.
### Color Palettes
Six named palettes are available via `CHART_PALETTES` in `lib/chart-colors.ts`:
| ID | Label | Description |
| ------------ | ---------- | -------------------------------------------------------- |
| `default` | Default | Platform CSS variables (`--chart-1` through `--chart-8`) |
| `blue-scale` | Blue Scale | Monochromatic blue progression |
| `warm` | Warm | Orange/red/amber tones |
| `cool` | Cool | Teal/cyan/green tones |
| `earth` | Earth | Brown/tan/olive tones |
| `vivid` | Vivid | High-chroma mixed hues |
All non-default palettes use `oklch()` values for perceptual uniformity. Use `getPaletteColors(paletteId)` from `lib/chart-colors.ts` to resolve a palette to a `string[]` of color values, with automatic fallback to the default palette for unknown IDs.
The config panel renders a palette picker with a live color swatch preview (5 swatches per palette).
### Reference Lines
Bar, line, area, and stacked-bar charts support horizontal reference lines for targets, benchmarks, and thresholds:
```typescript
referenceLines?: Array<{
value: number; // Y-axis position
label?: string; // Text displayed alongside the line (e.g. "Target")
color?: string; // CSS color string; defaults to muted-foreground
style?: "solid" | "dashed"; // Default: "dashed"
}>
```
Reference lines are added and removed via the chart config panel. They are rendered using Recharts' `ReferenceLine` component inside the same coordinate system as the chart data. Reference lines are not supported on pie or donut charts.
### Chart Schema Healing
The healing system detects misconfigured chart blocks and provides auto-fix suggestions. It lives in `features/blocks/lib/chart-healing.ts`.
```typescript
// Analyze a chart block config against the entity type's schema
const report = diagnoseChartBlock(block, schemaProperties);
// report.isHealthy — false when any error-level diagnostic exists
// report.diagnostics — array of { level, message, field, suggestion }
// Apply all auto-fix suggestions to produce a corrected config
const fixedConfig = applyChartHealing(block.config, report);
```
**Diagnostic levels:**
| Level | Meaning | Example |
| --------- | ----------------------------------- | --------------------------------------------------------- |
| `error` | Chart cannot render | No data type selected, group-by field missing from schema |
| `warning` | Chart may produce misleading output | Aggregation set to `sum` but no measure field selected |
| `info` | Improvement suggestion | No axis labels configured |
**Surfaces:**
- **Empty state** — When a chart block has no data items, the empty state runs `diagnoseChartBlock()` and renders each diagnostic message with its level icon. Warnings and errors include the suggested fix value.
- **Config panel** — A diagnostics banner appears at the top of the chart config panel when any error or warning exists. A one-click "Auto-fix" button calls `applyChartHealing()` and applies the result.
**Field inference** — The healing engine uses pattern matching against known category-field names (`status`, `stage`, `category`, `type`, `priority`, etc.) and numeric-field names (`value`, `amount`, `cost`, `score`, etc.) to suggest sensible `groupBy` and `dataSourceField` values when fields are missing or renamed.
### Bubble Chart Block Config
`bubble-chart` is now a first-class configurable block for opportunity-map style views:
```typescript
interface BubbleChartBlockConfig {
entityTypeSlug?: string;
labelField?: string;
xField?: string;
yField?: string;
zField?: string;
xLabel?: string;
yLabel?: string;
zLabel?: string;
tooltipFields?: string[];
limit?: number;
sortBy?: string;
sortOrder?: "asc" | "desc";
height?: number;
items?: Array<Record<string, unknown>>; // inline data override
clickAction?: "list" | "detail";
clickFilterField?: string;
clickEntityTypeSlug?: string;
}
```
When `xField`/`yField` are configured, the server block resolver loads records for the selected type, maps the chosen fields into bubble coordinates, attaches `entityId`, and passes through any requested tooltip fields. This makes bubble charts reusable across any view, not just hardcoded dashboards.
Block types not in any group (stat-cards, activity, image, video, excalidraw, react-flow, entity-graph, custom) are standalone and cannot be swapped.
**Swapping preserves shared config.** When swapping between compatible types, the `swapBlockType()` function copies shared config keys (`entityTypeSlug`, `limit`, `filters`, `relationshipType`, `mode`) from the old block to the new one and resets renderer-specific config. This means swapping a `table` to `kanban` keeps the entity type and filter settings but drops table-specific column config.
```typescript
import {
swapBlockType,
getCompatibleTypes,
} from "@/features/blocks/lib/compatibility";
// Find what a table block can be swapped to
getCompatibleTypes("table");
// → ["data-table", "kanban", "entity-feed", "ranking", "child-entity-list", "connection-list"]
// Swap a table block to kanban, preserving shared config
const kanbanBlock = swapBlockType(tableBlock, "kanban");
```
The compatibility module is at `features/blocks/lib/compatibility.ts`.
### ResolvedBlock
A `BlockConfig` augmented with server-fetched data, ready for rendering:
```typescript
interface ResolvedBlock extends BlockConfig {
data: unknown;
mode: "view" | "edit";
status?: BlockStatus;
}
```
The `data` field contains whatever the block type needs to render. For a `chart` block, this is `{ items: [{name, value}], chartType }`. For a `field-card`, it includes the field value, display type, and schema metadata.
### BlockStatus
Blocks can carry a status that reflects their position in a workflow:
```typescript
type BlockStatus =
| "empty" // No data yet
| "pending" // Waiting to be populated
| "blocked" // Depends on another block
| "in-progress" // Currently being worked on
| "waiting-human" // Needs human input
| "failed" // Population failed
| "complete"; // Successfully populated
```
### Block Sizes and the 12-Column Grid
Every block controls its width through one of two systems:
**Legacy size presets** (backward-compatible):
- **full** -- Spans the entire row
- **half** -- Takes half the row (two blocks per row)
- **third** -- Takes one-third of the row (three blocks per row)
**Numeric span** (preferred for new views):
Set `span` to any integer from 1 to 12. When any block in a view has a `span` value, `BlockGrid` switches to a native 12-column CSS grid (`grid grid-cols-12 gap-4`) and assigns each block a `col-span-{n}` class.
```typescript
// Examples
{
span: 12;
} // full width (equivalent to size: "full")
{
span: 6;
} // half width (equivalent to size: "half")
{
span: 4;
} // one-third (equivalent to size: "third")
{
span: 3;
} // one-quarter
{
span: 8;
} // two-thirds
```
`resolveSpan()` in `features/blocks/lib/layout.ts` maps a block to its effective column count — it prefers `span`, falls back to the `size` preset mapping (`full→12`, `half→6`, `third→4`), and defaults to 12. `hasSpanBlocks()` returns `true` when at least one block in the array uses numeric span, which is the condition that activates the 12-column grid.
### ViewLayout
The `BlockGrid` component arranges blocks according to a layout option:
```typescript
const VIEW_LAYOUTS = ["stack", "grid-2", "grid-3", "bento", "single"] as const;
```
- **stack** -- Single column, every block is full width
- **grid-2** / **grid-3** -- Fixed-column grids ignoring block size hints
- **bento** -- Responsive grid respecting each block's `size` property
- **single** -- Only renders the first block, full width
### Block Workflow Metadata
Blocks can carry workflow metadata that drives orchestration when blocks are used as work units:
```typescript
interface BlockWorkflow {
instructions?: string;
assignee?: {
type: "human" | "agent";
agentSlug?: string;
};
dependsOn?: string[]; // IDs of blocks that must complete first
required?: boolean;
output?: {
type:
| "response"
| "entity"
| "entities"
| "relation-entity"
| "document"
| "status"
| "none";
fields?: string[];
entityTypeSlug?: string;
key?: string;
};
}
```
This metadata is handed to the action/session runtime for action-enabled detail blocks.
## How It Works
### The Rendering Pipeline
```
ViewRecord (view.blocks + view.dataSources)
|
v
resolveView() -- unified server-side resolver
| classifies blocks by dataRequirement, applies dataSources
| delegates to entity-single / entity-list / aggregation / activity resolvers
v
ResolvedBlock[] -- config + data, in original block order
|
v
BlockGrid / BlockRenderer -- client-side rendering
|
v
Registered Components -- type-specific React components
```
### Unified Server-Side Resolution
`resolveView()` in `features/blocks/server/resolve-view.ts` is the single entry point for all view resolution. It accepts a `ViewRecord` and a `ResolveViewContext`, builds a `ResolveContext` (tenant ID, entity context, shared `FilterEngine`), then dispatches to each block's `BlockDefinition.resolve()` function from the `BlockDefinitionRegistry`. All blocks resolve in parallel.
```
ViewRecord (view.blocks + view.dataSources)
│
▼
buildResolveContext() — entity data, FilterEngine, mode
│
▼ (per block, parallel)
BlockDefinitionRegistry.get(block.type).resolve(ctx, config)
│
▼
ResolvedBlock[] — config + data, in original block order
```
Blocks with `dataRequirement: "none"` (rich-text, tool, form-flow, image, etc.) have passthrough resolvers that return `block.config` directly as data. Collection and aggregation resolvers delegate to Supabase queries using the shared `FilterEngine` to apply filters. Entity-single resolvers use the entity context from `ResolveContext`.
When a block has a `dataSourceId`, `resolveView()` merges the data source's `entityTypeSlug`, `filters`, `limit`, `sort`, and relation-column config into the block config before delegation. Block-level config values override data source defaults.
Data is pre-fetched once per entity type when multiple blocks need it, avoiding N+1 queries.
### Schema-Driven Config Panels
`SchemaConfigPanel` in `features/blocks/components/schema-config-panel.tsx` generates a config form automatically from a Zod schema. Any Zod object schema with `.describe("Label")` annotations on its fields produces the appropriate form control without writing bespoke form UI.
```typescript
// Define a config schema with describe() labels
const myBlockConfigSchema = z.object({
entityTypeSlug: z.string().optional().describe("Record Type"),
limit: z.number().optional().describe("Max Items"),
showFooter: z.boolean().optional().describe("Show Footer"),
});
// SchemaConfigPanel renders the right control for each field type:
// string → StringField, number → NumberField, boolean → BooleanField, enum → EnumField
<SchemaConfigPanel schema={myBlockConfigSchema} value={config} onChange={setConfig} />
```
Ten field widgets live in `schema-config-fields.tsx`: `StringField`, `NumberField`, `BooleanField`, `EnumField`, `FieldSelectorField`, `FieldMultiSelectField`, `EntityTypeSelectField`, `EntityTypeMultiSelectField`, `RelationTypeMultiSelectField`, `CriteriaSetSelectField`, `JsonField`.
### Block Registration (Plugin Pattern)
> **Deprecated path.** The `registerBlock()` from `features/blocks/registry.ts` described below is the legacy registration surface. New Blocks MUST use `defineBlock()` from `lib/ui-registry/block-contract.ts` + `registerBlock()` from `lib/ui-registry/resolve-block.ts` (see [Block Primitive Contract (ADR-0036)](#block-primitive-contract-adr-0036) above). The legacy registry remains in place while platform Blocks migrate over phases 4+.
New block types use `defineBlock()` (see [BlockDefinition Interface](#blockdefinition-interface)) — one object per type under `features/blocks/definitions/`. The definition includes both the resolver and the rendering component.
Legacy block components are also registered via `registerBlock()` in `features/blocks/registry.ts` for backward compatibility:
```typescript
interface BlockRegistration {
component: ComponentType<{ block: ResolvedBlock }>;
editComponent?: ComponentType<{
block: ResolvedBlock;
onSave: (data: unknown) => void;
}>;
defaultSize?: "full" | "half" | "third";
}
registerBlock("chart", {
component: ChartBlock,
defaultSize: "half",
});
```
The `BlockRenderer` component looks up the registration for a block's type and dispatches to the appropriate component. When `mode="edit"` and an `editComponent` is registered, the edit variant is rendered instead.
All block components are registered in the client-side barrel file at `features/blocks/components/index.ts`. Import this barrel once in any page that renders blocks. For the embed route, `app/embed/v/[token]/embed-view-client.tsx` imports this barrel alongside `features/custom/tools/ui` to ensure both block types and custom tool UIs are available in the public iframe context.
### BlockGrid Layout
`BlockGrid` arranges `ResolvedBlock[]` according to a `ViewLayout`:
- In `bento` mode, blocks flow into a responsive CSS grid and use their `size` property to span 1/3, 1/2, or full width.
- An `onSave` callback can be passed to `BlockGrid` to persist inline edits (e.g., field-card editing on detail pages).
### Bridge Functions
Bridge functions convert data from other systems into blocks:
| Function | Source | Location |
| ---------------------------- | ------------------------------- | ----------------------------------------- |
| `entityToBlocks()` | Entity fields + content | `features/blocks/lib/from-entity.ts` |
| `toolOutputToBlocks()` | Tool output sections | `features/blocks/lib/from-tool-output.ts` |
| `chatOutputToBlocks()` | Chat tool output | `features/blocks/lib/from-chat.ts` |
| `transientSpecToBlocks()` | Agent-generated transient views | `features/blocks/lib/transient-view.ts` |
| `migrateDashboardSections()` | Legacy dashboard config | `features/blocks/server/migrate.ts` |
| `filterConnectionEntities()` | Connection field data | `features/blocks/lib/from-entity.ts` |
**`entityToBlocks()`** is particularly important. It converts an entity type's JSON schema properties and an entity's content into `field-card` and `connection-list` blocks. It handles:
- Smart sizing based on field content length and type (long text gets `full`, enums get `third`, arrays get `half`)
- Connection field detection (emits `connection-list` blocks instead of `field-card`)
- Extraction status mapping to `BlockStatus`
- Empty field visibility control
**`toolOutputToBlocks()`** converts `ToolOutputSection[]` definitions into the corresponding block types. This is how tool results render as rich visual blocks in both the tool page and chat panel: a `summary` section becomes a `summary` block, a `table` section becomes a `table` block, and so on.
### The field-card Block
The `field-card` block is the workhorse of entity detail pages. It renders a single field value with smart display logic powered by `classifyValue()`:
- **currency** -- Formatted with locale-specific currency symbols
- **percentage** -- Shown with % suffix
- **date** -- Parsed and formatted
- **boolean** -- Rendered as a badge
- **tags** -- Rendered as a tag list
- **url** -- Rendered as a clickable link
- **array** -- Rendered as a comma-separated list or bullet list
In edit mode, `field-card` uses an inline edit UI with the same styling as view mode (avoiding Card component padding mismatch).
### The radar Block
The `radar` block unifies entity scoring with radar chart visualization. In view mode, it displays scoring criteria as a radar/spider chart. In edit mode, it shows sliders for each criterion alongside a live radar chart preview. Saving persists scores to `entity.metadata`.
### The form-flow Block
The `form-flow` block provides multi-step form wizards with built-in validation, progress tracking, and step navigation. In view mode, it shows a progress bar and step checklist. In edit mode, it renders a full interactive wizard with Back/Next navigation, per-step field validation, and a completion summary.
**Config shape (`FormFlowData`):**
- `config` -- `FormFlowConfig` with steps, fields, completion message
- `values` -- collected field values keyed by field name
- `completedStepIds` -- array of completed step IDs
- `currentStepIndex` -- index of the current step
- `completed` -- whether the form flow is done
**Field types:** text, textarea, rich-text, number, slider, date, select, multi-select, tag-input, entity-picker, file-upload.
**`file-upload` field type** (`FileUploadField` in `features/blocks/components/form-flow-file-upload.tsx`):
| Config key | Type | Description |
| ----------- | -------- | ---------------------------------------------------------------------- |
| `accept` | `string` | Comma-separated MIME types or extensions (e.g. `".pdf,.docx,image/*"`) |
| `maxFiles` | `number` | Maximum number of files allowed (default: unlimited) |
| `maxSizeMb` | `number` | Maximum size per file in megabytes |
Files are converted to base64 data URLs by `FileReader` and stored in `FormFlowData.values[fieldName]` as `FileUploadMeta[]`:
```typescript
interface FileUploadMeta {
name: string;
size: number;
type: string;
url?: string; // base64 data URL
}
```
The component supports drag-and-drop and click-to-browse. When `maxFiles` is reached, the drop zone becomes disabled. Files can be individually removed from the list.
**`FormFlowConfig.output`** — when set, completing a form-flow triggers entity creation via `POST /api/form-flow/complete`:
```typescript
interface FormFlowConfig {
steps: FormFlowStep[];
completionMessage?: string;
allowSkipOptional?: boolean;
/** When set, form completion creates an entity from collected values. */
output?: {
type: "entity";
entityTypeSlug: string;
/** Template for entity title. Use {fieldName} for interpolation. */
titleTemplate?: string;
};
}
```
The `titleTemplate` supports `{fieldName}` interpolation against the collected `values`. For example `"AI Enablement Intake: {full_name}"` produces a title like `"AI Enablement Intake: Jane Smith"`. The entity creation call is guarded by the `publishToken` of the embed — callers without a valid published view token receive a 403.
**Templates:** Two built-in templates (`discovery-workshop`, `ai-readiness-assessment`) are available via `createFormFlowFromTemplate()` in `features/blocks/components/form-flow-templates.ts`. Custom configs can be created via `createFormFlowFromConfig()`.
### The tool Block
The `tool` block renders any registered tool — its input form on the left and its output panel on the right — inline inside any view. It is the mechanism that allows tool surfaces to appear in entity detail pages, standalone views, and public embeds without navigating to `/tools/[slug]`.
**Config shape:**
| Key | Type | Description |
| --------------- | ------------------------- | -------------------------------------------------------- |
| `toolSlug` | `string` | Slug of the registered tool to render. |
| `defaultInputs` | `Record<string, unknown>` | Pre-filled form values shown when the block first loads. |
**Data resolution:**
The `tool` block has `dataRequirement: "none"` in block metadata, meaning `resolveView()` classifies it as a passthrough block. The server passes `block.config` directly as `block.data`. No server-side tool data is pre-fetched — the tool metadata (including `jsonSchema`) is loaded client-side when the user first views the block.
On the client, `ToolBlock` reads `block.data.tool` (a `ToolMeta` object with `jsonSchema`). When tool data is unavailable, it shows an `EmptyState` with the configured `toolSlug` as context. When tool data is present, it renders a two-column layout:
- **Left column** — `CustomForm` from the tool's registered FormSpec, or (if none is registered) `FormCompositionRenderer` built from `jsonSchemaToFormSpec(tool.jsonSchema)`. This gives slider and FieldChrome inputs the correct chrome even for tools that have not yet received a hand-authored FormSpec. `GenericToolForm` (the previous unstyled fallback) is no longer used.
- **Right column** — `CustomOutput`, `SectionedOutput` (if the tool declares `outputSections`), or `GenericToolOutput`.
Execution calls `POST /api/tools/[slug]/run` with the submitted input and shows a toast with duration on success.
**Grid container invocation mode:**
When a `tool` block is a child of `grid-block`, the grid now passes the child's configured `renderMode` (defaulting to `"input"`) as the invocation mode rather than hardcoding `"display"`. This means tool blocks added to a grid view mount interactive immediately — no mode toggle required. Known limitation: `grid-block` has no value-collection wiring; tool blocks inside a grid are self-contained and do not propagate their output to the grid's `onChange`.
**Embed use case:**
The `tool` block is the primary content type in published views created via `publishToolAsView()`. That server action creates a view with a single `tool` block (`size: "full"`, `layout: "single"`) and immediately publishes it, returning the embed snippet and preview URL in one call.
### The resource-download Block
The `resource-download` block renders a completion screen with an optional file download CTA. It is designed as the final slide/page of a guest quiz flow — shown after the form-flow block completes.
**Config shape (`ResourceDownloadConfigSchema`):**
| Key | Type | Default | Description |
| ------------- | -------- | ----------------------------------------- | ---------------------------------------- |
| `title` | `string` | `"Download Resource"` | Heading text |
| `description` | `string` | `"Thank you for completing this survey!"` | Body text |
| `downloadUrl` | `string` | — | URL of the file to download |
| `fileName` | `string` | — | Display file name shown below the button |
| `buttonLabel` | `string` | `"Download"` | CTA button label |
When `downloadUrl` is present, the component renders a large download button using the `<a download>` attribute. When omitted, only the completion message and icon are shown (useful as a "thank you" slide without a file).
Config values can also come from `block.data` — the component merges `block.config` and `block.data` so agent-generated or dynamically resolved data can override static config at runtime.
### The stat-cards Block
The `stat-cards` block renders a responsive grid of KPI cards. Each card in the `items` array can carry a `change` string and/or an explicit `trend` field to show a directional indicator:
```typescript
interface StatItem {
label: string;
value: string | number;
format?: "number" | "currency" | "percent" | "compact"; // display format
change?: string; // e.g. "+12%", "-0.2", "flat"
trend?: "up" | "down" | "flat"; // explicit override
description?: string;
icon?: string;
iconColor?: string;
iconBg?: string;
}
```
When a `stat-cards` block is entity-backed (using `entityTypeSlug` + `fields`), two additional config options control how numeric fields are aggregated and displayed:
```typescript
interface StatCardsConfig {
entityTypeSlug?: string;
fields?: string[];
fieldAggregations?: Record<string, "sum" | "avg" | "min" | "max" | "count">;
fieldFormats?: Record<string, "number" | "currency" | "percent" | "compact">;
limit?: number;
}
```
- `fieldAggregations` sets the aggregation mode per field. Defaults to `avg` for numeric fields. Use `sum` for portfolio-level totals (e.g. total annual value across all opportunities), `count` for cardinality.
- `fieldFormats` sets how the aggregated value is displayed per field. Values are formatted via `formatChartValue()` — `currency` renders with locale currency symbol, `percent` appends `%`, `compact` uses magnitude suffixes (K, M, B).
The stat-cards config panel (`stat-cards-config.tsx`) exposes per-field aggregation and format selectors in the UI when fields are selected, so users can configure these without editing JSON directly.
Trend direction is inferred automatically from the `change` string prefix by `inferTrend()` in `features/blocks/lib/trend-utils.ts`:
- Strings starting with `+` or `↑` → `"up"` (green arrow icon)
- Strings starting with `-`, `↓`, or `−` (minus sign) → `"down"` (red arrow icon)
- An explicit `trend` field overrides the inference
- When neither can be determined, no icon is shown
### Shared Chart Primitives
All Recharts chart components in the platform share constants from `lib/chart-colors.ts`:
| Export | Value | Purpose |
| ----------------------- | --------------------------------------------------- | ------------------------------------------------------------------ |
| `CHART_COLORS` | `["var(--chart-1)", …, "var(--chart-8)"]` | Default ordered palette for bars, lines, and pie slices |
| `CHART_PALETTES` | `ChartPalette[]` | Six named palettes (Default, Blue Scale, Warm, Cool, Earth, Vivid) |
| `getPaletteColors(id?)` | `string[]` | Resolve a palette to its color array; falls back to `CHART_COLORS` |
| `CHART_ANIMATION` | `{ duration: 800, easing: "ease-out" }` | Entrance animation applied to every data series |
| `CHART_TOOLTIP_STYLE` | CSS properties object | Consistent tooltip card appearance |
| `CHART_AXIS_TICK` | `{ fontSize: 12, fill: "var(--muted-foreground)" }` | Standard axis label style |
The `chart` block's renderer functions are split into individual files under `features/blocks/components/chart-renderers/`:
| File | Exports |
| ----------------------- | ---------------------------------------------------- |
| `bar-chart.tsx` | `BarChartRenderer` (vertical + horizontal) |
| `line-chart.tsx` | `LineChartRenderer` |
| `area-chart.tsx` | `AreaChartRenderer` |
| `pie-chart.tsx` | `PieChartRenderer` (pie + donut) |
| `stacked-bar-chart.tsx` | `StackedBarChartRenderer` |
| `axis-helpers.tsx` | `XAxisTick`, `YAxisTick`, shared axis utilities |
| `shared.tsx` | `TooltipContent`, `LegendContent`, shared primitives |
| `types.ts` | `ChartRendererProps`, shared render types |
| `index.ts` | Re-exports all renderers |
Import via the index for convenience: `import { BarChartRenderer } from "@/features/blocks/components/chart-renderers"`. Each renderer is independently reusable on any surface.
All Cartesian charts (bar, line, area, stacked-bar) include Recharts' `accessibilityLayer` prop, which injects ARIA attributes and keyboard navigation into the SVG output.
**Shared utilities extracted from chart components:**
| Module | Exports | Purpose |
| -------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `features/blocks/lib/chart-format.ts` | `formatChartValue(value, format?, prefix?, suffix?)` | Numeric formatting for chart labels and tooltips (number, currency, percent, compact) |
| `features/blocks/lib/trend-utils.ts` | `inferTrend(change?, trend?)` | Infer trend direction from a change string or explicit field |
| `features/blocks/lib/chart-healing.ts` | `diagnoseChartBlock()`, `applyChartHealing()` | Chart config diagnostics and auto-fix |
| `features/blocks/lib/chart-export.ts` | `exportChartDataAsCsv()`, `exportChartAsPng()` | Client-side CSV and PNG export from chart DOM |
### The kanban Block — Column Configuration
The `kanban` block groups entities by an enum, boolean, or string field into draggable columns. Column computation lives in `features/entities/components/kanban/group-by.ts` via `computeColumns()`.
An optional `columnConfig` in the block config controls column-level behavior:
```typescript
interface ColumnConfig {
visibleColumns?: string[]; // Only show these column IDs (enum values)
columnLabels?: Record<string, string>; // Override column display titles
wipLimits?: Record<string, number>; // Max items per column
}
```
- **Visibility filtering** — When `visibleColumns` is set, only those columns plus the built-in `UNCATEGORIZED` and `OTHER` columns are shown. This lets users hide irrelevant pipeline stages.
- **Custom labels** — `columnLabels` maps column IDs (enum values) to display titles. Useful when the enum value is a slug but the user wants a friendlier label.
- **WIP limits** — When `wipLimits[columnId]` is set and the column's item count exceeds it, a destructive `Badge` shows `WIP {count}/{limit}` on the column header.
The kanban config panel (`kanban-config.tsx`) exposes these options in the UI when the group field has enum values — visibility checkboxes, label inputs, and WIP limit number inputs per column.
### Pipeline Kanban — Per-Column Value Aggregation
When `valueField` is set in the kanban block config, a summary row appears above each column showing an aggregated value for the cards in that column.
```typescript
interface KanbanBlockConfig {
groupByField?: string;
columnConfig?: ColumnConfig;
valueField?: string; // Entity content field to aggregate
valueAggregation?: "sum" | "avg" | "count"; // Default: "sum"
valueFormat?: "number" | "currency" | "percent" | "compact";
}
```
The aggregation is computed client-side from the resolved entity list. When cards are dragged between columns (which triggers an `updateEntity()` call to change the grouped field value), the summary row updates reactively without a full data refetch.
This is intentionally generic — any entity type with a status/stage field and a numeric value field can show pipeline value summaries. The `valueField` and `valueAggregation` are configured via the kanban config panel.
### Status Transition Triggers
Entity types can define **status triggers** — agent actions that fire automatically when a kanban card moves from one column to another. Triggers are configured per entity type in **Admin > Data Types > Triggers tab**.
```typescript
// Stored in entity_types.config.statusTriggers
interface StatusTrigger {
id: string;
fromStatus: string; // Source column enum value (or "*" for any)
toStatus: string; // Destination column enum value
agentSlug: string; // Agent to dispatch
taskInstructions?: string; // Optional prompt injected into the agent task
}
```
When a drag-drop move completes:
1. `updateEntity()` patches the entity's grouped field to the new column value
2. `POST /api/entities/[id]/trigger-status-transition` evaluates `config.statusTriggers` for matching `(fromStatus, toStatus)` pairs
3. Matching triggers dispatch an Inngest event: `entity/status-transition`
4. The Inngest handler invokes the named agent with the entity and transition context as task input
The dispatched agent receives:
```typescript
{
entity: EntityRecord,
fromStatus: string,
toStatus: string,
instructions?: string,
}
```
Triggers are read-only from the agent's perspective — agents don't configure them. Configuration is done by admins via the entity type admin UI or via the `updateEntityTypeSchema` admin tool.
### External Data Sources for Dashboard Blocks
Stat-card and chart blocks can fetch data from tenant-registered external HTTP endpoints rather than (or in addition to) the Supabase entity store. This enables dashboard blocks to show data from CRMs, ERPs, or custom APIs alongside platform records.
External data sources are registered in **Admin > Data Sources** and stored in the `external_data_sources` table with HMAC-signed request authentication.
```typescript
interface ExternalDataSource {
id: string;
tenant_id: string;
name: string;
endpoint_url: string;
auth_type: "hmac" | "bearer" | "none";
auth_secret?: string; // Used for HMAC signing
response_schema: "stat-items" | "chart-items" | "entity-list";
}
```
To wire a block to an external source, set `DataSourceConfig.external` on the view's `dataSources` map:
```typescript
{
dataSources: {
crm_pipeline: {
entityTypeSlug: "", // Empty — no entity query
external: {
sourceId: "ext-source-uuid",
cacheTtlSeconds: 300, // Optional client-side cache
}
}
}
}
```
The block resolver calls the external endpoint server-side, validates the response against the declared `response_schema` using Zod, and returns the data in the same `ResolvedBlock.data` shape as entity-backed blocks. Failed external calls surface as `BlockStatus.failed` rather than crashing the entire view resolution.
**HMAC authentication:** Requests include an `X-Sprinter-Signature` header computed as `HMAC-SHA256(secret, timestamp + "." + body)`. The receiving endpoint validates the signature and timestamp (±5 minutes) before processing.
### Click Callbacks on Charts
Every chart component in the platform accepts an optional click callback. When the prop is provided, the chart sets `cursor: pointer` on interactive elements and fires the callback on click. When the prop is omitted, the chart behaves as a read-only visualization.
| Component | Callback prop | Arguments |
| ---------------------------- | ---------------- | -------------------------------------------------------- |
| `ChartBlock` | `onItemClick` | `(name: string, value: number) => void` |
| `EntityDistributionChart` | `onBarClick` | `(name: string, count: number) => void` |
| `ScoreDistributionChart` | `onBarClick` | `(title: string, score: number) => void` |
| `ScoreProgressionChart` | `onPointClick` | `(responseId: string, score: number) => void` |
| `ChartSection` (tool output) | `onItemClick` | `(name: string, value: number) => void` |
| `MultiSeriesRadarChart` | `onSegmentClick` | `(name: string, values: Record<string, number>) => void` |
| `BubbleChart` | `onPointClick` | `(point: BubbleChartDataPoint) => void` |
| `CategoryChart` | `onBarClick` | `(category: string, value: number) => void` |
| `EffortValueChart` | `onBarClick` | `(effort: string, value: number) => void` |
| `PaybackBandChart` | `onBarClick` | `(band: string, count: number) => void` |
### ChartEmptyState
`ChartEmptyState` (in `features/charts/components/chart-empty-state.tsx`) replaces silent `null` returns from charts that have no data. It renders a dashed-border placeholder card with an icon, a primary message, and an optional description sub-text.
```tsx
<ChartEmptyState
message="No data to display"
description="Data will appear here once available"
icon={TrendingUp}
className="h-[200px]"
/>
```
All chart blocks now return `ChartEmptyState` when their data array is empty, so dashboard layouts maintain consistent structure regardless of data state.
### Inspector tabs in `BlockConfigPanel`
When the author clicks a block in the editor, `BlockConfigPanel` opens in the right-side inspector. Rather than a single long scroll, the panel is organised as:
- **Fixed header** — block name + close X
- **Common strip** — Label + Description, always visible
- **Tabs**
- **Block** — block-specific config (`BlockSpecificConfig`)
- **Data** — data source picker (only rendered when `hasDataBinding(block.meta.source)` returns true; `rich-text` / content-only blocks hide this tab)
- **Layout** — width toggle group (full / half / third / quarter)
- **JSON** — the existing `JsonToggle` raw editor
- **Live Preview** — sibling Collapsible anchored to the bottom of the panel (outside the tab panels), with `aria-controls="live-preview-region"` for screen-reader navigation
The active tab resets when the selected block changes — implemented by keying the inner panel on `block.id` so local state unmounts / remounts without an effect. `hasDataBinding(source)` lives at `features/blocks/lib/has-data-binding.ts` and is driven by `BlockDefinition.meta.source`.
## API Reference
### Block Primitive Contract (`lib/ui-registry/`)
These are the canonical authoring surfaces for new Blocks (ADR-0036). Use these for all new Block work.
| Function / Component | Signature | Description |
|----------------------|-----------|-------------|
| `defineBlock(definition)` | `<TI,TO,TC>(def: BlockDefinition<TI,TO,TC>) => BlockDefinition<TI,TO,TC>` | Validates and returns the definition. Throws on misconfiguration at module load. Source: `lib/ui-registry/block-contract.ts`. |
| `registerBlock(definition, opts?)` | `(def: BlockDefinition, opts?: RegisterBlockOptions) => void` | Registers the definition into the slot registry. Throws if slot occupied unless `{ replaceLegacy: true }`. Source: `lib/ui-registry/resolve-block.ts`. |
| `resolveBlock(name, opts?)` | `(name: string, opts?: { tenantSlug?: string \| null }) => BlockDefinition \| undefined` | Look up a Block by name + optional tenant scope. MUST pass `tenantSlug` (slug), never UUID. Source: `lib/ui-registry/resolve-block.ts`. |
| `<MountBlock>` | See [MountBlock props](#mountblock--the-single-mount-surface) | Single mount surface. Drives resolve → mode check → schema validation → permission gate → sandbox routing → lazy render. Source: `lib/ui-registry/mount-block.tsx`. |
### Registry (`features/blocks/registry.ts`)
> **Deprecated.** Use `lib/ui-registry/resolve-block.ts` for new Blocks. See [Block Primitive Contract (ADR-0036)](#block-primitive-contract-adr-0036).
| Function | Signature | Description |
| ----------------------------------- | ----------------------------------------------------- | ------------------------------------------------------ |
| `registerBlock(type, registration)` | `(type: BlockType, reg: BlockRegistration) => void` | Register a block component with optional edit variant. |
| `getBlock(type)` | `(type: BlockType) => BlockRegistration \| undefined` | Look up a registered block. |
| `getRegisteredBlockTypes()` | `() => BlockType[]` | List all registered block types. |
### Unified Resolver (`features/blocks/server/resolve-view.ts`)
| Function | Signature | Description |
| ------------------------ | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resolveView(view, ctx)` | `(view: ViewRecord, ctx: ResolveViewContext) => Promise<ResolvedView>` | Single entry point — classifies blocks by dataRequirement, applies dataSources, delegates to sub-resolvers, returns results in original block order. |
`ResolveViewContext` accepts: `tenantId`, `mode` ("view" | "edit"), `entity` (for entity-single blocks), `entityType`, `related`, `fieldConfigs`, `workflowStatusMap`.
### Layout Utilities (`features/blocks/lib/layout.ts`)
| Export | Signature | Description |
| ----------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `resolveSpan(block)` | `(block: { span?: number; size?: string }) => number` | Returns column count (1-12). Prefers `span`, falls back to `size` preset, defaults to 12. |
| `hasSpanBlocks(blocks)` | `(blocks: Array<{ span?: number }>) => boolean` | True when any block uses numeric `span` — triggers 12-column CSS grid. |
| `GRID_12_CLASS` | `string` | `"grid grid-cols-12 gap-4"` — applied to `BlockGrid` when span blocks are present. |
### Block Metadata (`features/blocks/lib/block-metadata.ts`)
| Function | Signature | Description |
| -------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `getBlockMetadata(type)` | `(type: BlockType) => BlockMetadata` | Returns label, icon, description, source, display, role, and `dataRequirement` for a block type. |
| `getDataRequirement(type)` | `(type: BlockType) => DataRequirement` | Returns `"entity-list" \| "entity-single" \| "aggregation" \| "activity" \| "none"`. |
### Server Resolution (`features/blocks/server/resolve-server-blocks.ts`)
| Function | Signature | Description |
| ---------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resolveServerBlocks(blocks, ctx)` | `(blocks: BlockConfig[], ctx: ResolveServerBlocksContext) => Promise<ResolvedBlock[]>` | Hydrate block configs with server-fetched data. Called internally by `resolveView()` for entity-list, aggregation, and activity blocks. Prefer `resolveView()` for new call sites. |
### Bridge Functions
| Function | Signature | Description |
| ----------------------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------- |
| `entityToBlocks(properties, content, mode, options?)` | Returns `ResolvedBlock[]` | Entity fields to field-card + connection-list blocks. |
| `toolOutputToBlocks(sections, output)` | Returns `ResolvedBlock[]` | Tool output sections to typed blocks. |
| `filterConnectionEntities(config, relatedEntities, content, fieldName)` | Returns filtered entities | Filter related entities for a connection field by mode (auto/curated). |
### Chart Utilities
| Function | Signature | Description |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `diagnoseChartBlock(block, schemaProperties?)` | `(block: BlockConfig, props?: Record<string, unknown>) => ChartHealthReport` | Analyze chart config against entity schema, return typed diagnostics. |
| `applyChartHealing(config, report)` | `(config: Record<string, unknown>, report: ChartHealthReport) => Record<string, unknown>` | Apply all auto-fix suggestions from a health report to produce a corrected config. |
| `formatChartValue(value, format?, prefix?, suffix?)` | `(value: number, ...) => string` | Format a number for chart display. Formats: `number`, `currency`, `percent`, `compact`. |
| `inferTrend(change?, trend?)` | `(change?: string, trend?: string) => "up" \| "down" \| "flat" \| null` | Infer trend direction from change string prefix or explicit trend field. |
| `getPaletteColors(paletteId?)` | `(id?: string) => string[]` | Resolve a named palette to its color array (`lib/chart-colors.ts`). |
| `exportChartDataAsCsv(items, filename?)` | `(items: ChartItem[], filename?: string) => void` | Export chart data as RFC 4180-compliant CSV download. |
| `exportChartAsPng(containerEl, filename?)` | `(el: HTMLElement, filename?: string) => Promise<void>` | Export chart SVG as 2x retina PNG download. |
### Zod Schemas
| Schema | Description |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `BlockConfigSchema` | Validates a single block config (type, label, config, size, workflow). |
| `BlockConfigArraySchema` | Validates an array of block configs. |
| `BlockWorkflowSchema` | Validates block workflow metadata (instructions, assignee, dependsOn, output). |
| `ChartConfigSchema` | Per-block config schema for `chart` blocks. Includes `labelMap`, `footerStats` (`"auto"` or static array), and all display options. |
| `StatCardsConfigSchema` | Per-block config schema for `stat-cards` blocks. Includes `fieldAggregations` and `fieldFormats` for entity-backed stat cards. |
| `KanbanConfigSchema` | Per-block config schema for `kanban` blocks. Includes `columnConfig` with `visibleColumns`, `columnLabels`, and `wipLimits`. |
| `ColumnConfigSchema` | Nested schema for kanban column configuration (visibility, labels, WIP limits). |
## For Agents
Agents create blocks indirectly through several tools:
- **`manageView`** -- Agents compose `BlockConfig[]` arrays when creating or updating views. This is the primary way agents author visual layouts.
- **`generateView`** -- Creates a `TransientViewSpec` with inline block data for ephemeral chat displays. For `chart` blocks, the full config surface is available: `chartType` (`bar`, `pie`, `donut`, `line`, `area`, `stacked-bar`), `orientation`, `series`, `colorThresholds`, and `footerStats`.
- **`updateEntityTypeSchema`** -- Changes to JSON schema affect how `entityToBlocks()` generates field-card blocks.
- **`updateFieldConfig`** -- Changes to field configs affect block rendering (connection fields, extraction status, display types).
When authoring chart blocks, agents should pick the chart type that best matches the data shape:
- `bar` / `area` / `line` — time-series or ordered categorical data with a single value per point
- `stacked-bar` — multi-series composition data (pass `series` with the data keys to stack)
- `pie` / `donut` — part-to-whole distribution with fewer than 8 categories
- `colorThresholds` — use for RAG-status bars where color signals a range (e.g., red for score < 30, amber for 30–70, green for 70+)
Agents can also set display options directly in `block.config` to improve readability of generated charts:
```typescript
// Example: agent-generated chart with display polish
{
type: "chart",
label: "Pipeline by Stage",
config: {
entityTypeSlug: "opportunity",
groupBy: "stage",
chartType: "bar",
xAxisLabel: "Stage",
yAxisLabel: "Count",
valueFormat: "number",
showLegend: false,
paletteId: "blue-scale", // one of: default, blue-scale, warm, cool, earth, vivid
referenceLines: [{ value: 10, label: "Target", style: "dashed" }]
}
}
```
When agents return tool results in chat, the `chatOutputToBlocks()` bridge converts tool output into blocks for rich rendering in the chat panel.
### Cross-Block Filtering
The `entity-filter` block and `ViewFilterProvider` together enable interactive filtering that updates sibling blocks in the same view.
**Filter channels.** Cross-block filter state is keyed by a **filter channel**, not by a block's opaque `dataSourceId`. The channel is the resolved **entity-type slug** for plain entity sources, so every block showing that type filters together with no manual per-block binding — filtering "just works" on any view, including legacy/unbound ones. Sources that carry their own server-side scope (preset `filters` or `relationColumns`) or static data stay **isolated** under their data-source id, so a live cross-block filter never silently drops their base scope. `features/views/lib/filter-channel.ts` is the single source of truth: `buildDataSourceChannels(view.dataSources)` precomputes the `id → channel` map; `resolveFilterChannel({ dataSourceId, entityTypeSlug }, channels)` resolves a block's channel (a bound source maps to its channel; an unbound block falls back to its own entity type).
**`ViewFilterProvider`** (`features/views/hooks/use-view-filters.tsx`) wraps a view renderer and provides a React context for filter state keyed by channel. It exposes `resolveChannel(...)` (and the `useFilterChannel(dataSourceId, entityTypeSlug)` hook); both producers (the filter block, and interactive chart/kanban/bubble-chart clicks) and consumers route reads/writes through it, so a producer's broadcast and a consumer's read meet on the same channel. Persisting active filters back onto `view.dataSources` (Save as view / Apply to data source) reverses the channel→id mapping via `reverseChannelFiltersToDataSourceIds`.
**`useFilteredEntities`** (`features/views/hooks/use-filtered-entities.ts`) is a React Query hook for blocks that need to re-fetch when view-level filters change. It resolves the channel internally from `(dataSourceId, entityTypeSlug)`, so the cache key and filter lookup are channel-scoped — changing filters in a sibling block automatically triggers re-fetches for any block sharing the channel. It also returns `isFiltered` so consumers can show the filtered count (e.g. pagination totals) instead of the unfiltered server total. When no active filters exist, the hook is disabled and blocks use their SSR-resolved `initialData` — zero extra fetches on first render.
```typescript
// In a block component that should react to cross-block filters:
const { data: entities } = useFilteredEntities(
block.dataSourceId,
block.config?.entityTypeSlug,
{ initialData: block.data?.entities, limit: 50 },
);
```
### The entity-filter Block
The `entity-filter` block provides interactive schema-driven filtering of any entity type. It renders each schema field with the same `FieldInput` renderer used everywhere in the platform (date pickers, relation pickers, status selects, currency inputs, boolean toggles) and broadcasts the resulting `FilterRule[]` on its entity-type filter channel — so every block of that type in the view re-filters in lockstep.
**Config properties:**
| Key | Type | Description |
| ---------------- | ---------- | ---------------------------------------------------------------------------------------- |
| `entityTypeSlug` | `string` | Entity type to filter. Can also be provided via `block.data.entityType.slug`. |
| `visibleFields` | `string[]` | Limit which schema fields appear as filter controls. Omit to show all filterable fields. |
| `dataSourceId` | `string` | Data source key to broadcast filters to. Auto-set to the view's primary data source when added via the workspace editor. |
**Block data shape (resolved server-side):**
```typescript
{
entityType?: EntityTypeRecord, // Resolved entity type with json_schema
entities?: EntityRecord[], // Initial result set (pre-search)
total?: number, // Total count for initial result set
}
```
**Rendering pipeline:**
1. `resolveServerBlocks()` hydrates the block with the entity type record and optionally a default result set.
2. On the client, `fromEntityTypeFields()` derives `FieldDefinition[]` from `entityType.json_schema`, filtered to exclude `object` and `media` fields (`UNFILTERABLE_TYPES`). When `visibleFields` is set, only those fields are shown in the declared order.
3. `EntityFilterFields` (`features/blocks/components/entity-filter-fields.tsx`) renders each field using the shared `FieldInput` component — the same renderer used by entity forms and inline editors.
4. On "Search", `filterValuesToRules()` (`features/blocks/lib/filter-values.ts`) converts the collected values into a `FilterRule[]` and broadcasts them on the block's resolved filter channel via `setFilters(channel, rules)`. The block does **not** issue its own fetch — its result preview reads from the same shared `useFilteredEntities` hook every sibling consumer uses, so there is one request per channel (React Query dedups it) and the preview reflects live data rather than a frozen SSR snapshot.
5. Results render as a linked card list (up to 20 rows) behind a collapsible disclosure. The disclosure auto-expands on "Search" and shows the match count (`N result(s)`) as its trigger label.
**Filter channel resolution:**
The block broadcasts on the channel returned by `useFilterChannel(block.dataSourceId, entityTypeSlug)`:
- A **plain entity source** (or an unbound block) resolves to the **entity-type slug**, so every block of that type — bound or not — filters in lockstep. There is no in-block source picker; the channel does the routing.
- A **scoped or static source** resolves to its isolated data-source id, so a live filter never drops its base scope.
When the workspace editor adds a new filter block it pre-sets `dataSourceId` to the view's primary data source (`Object.keys(view.dataSources).sort()[0]`); for a plain source that still resolves to the type channel, so same-type blocks react out of the box.
**Filter utilities** (`features/blocks/lib/filter-values.ts`):
| Export | Description |
| ------------------------------ | ----------------------------------------------------------------------------------------------- |
| `filterValuesToRules(values, fields)` | Converts `Record<string, unknown>` filter values to `FilterRule[]`, skipping empty/null values. Arrays produce `"in"` rules; scalars produce `"eq"` rules. |
| `countActiveFilters(values)` | Returns the number of non-empty filter values (used for the active-count badge). |
Active filter count is shown as a badge in the card header. A "Clear" button resets all filter values and clears the broadcast filters.
> **Performance — JSONB filtering at scale.** Filter rules apply server-side via `applyEntityFilterRules` (`features/entities/server/entity-filter-rules.ts`) against `content->>field`. The `contains` operator (→ `ILIKE '%value%'`) and `in` on JSONB fields bypass standard B-Tree indexes and seq-scan the type's rows. For tenants with tens of thousands of records, add a functional index on the hot field — e.g. `CREATE INDEX CONCURRENTLY idx_entities_status ON entities ((content->>'status'))` (lead with `tenant_id` for an RLS-aligned composite) — or limit high-traffic filters to indexed fields. There is no query-time guard today.
### The data-table Block — Resizable Frozen First Column
The `data-table` block pins its first column (the entity title / primary field) and makes it resizable. The resize handle is a wide 12 px grab zone anchored to the column's right edge. A visible bar appears faint by default and turns primary on hover or during an active drag so the affordance is obvious.
**Keyboard operation** (`features/entities/components/data-table/data-table.tsx`):
| Key | Effect |
| ------------------------- | -------------------------------------------------------- |
| `ArrowRight` / `ArrowLeft` | Nudges the column width ±16 px (respects `minSize`). |
| `Enter` or `Backspace` | Resets the column to its default width. |
| Double-click | Also resets the column to its default width. |
The handle element carries `role="separator"`, `aria-orientation="vertical"`, `aria-label`, and the range attributes `aria-valuemin` / `aria-valuemax` / `aria-valuenow` (resize bounds and current width in px) for screen-reader accessibility. `onClick` is suppressed so grabbing the handle does not fire a column sort.
## Chrome Ownership
The block system has three layers of visual chrome, each with a clear owner. Understanding this prevents double-wrapping and explains why some blocks use `<Card>` directly while others use `<BlockShell>`.
### BlockFrame — theme-aware outer frame
`BlockFrame` (`features/blocks/components/block-frame.tsx`) is the outermost chrome. It wraps every block in `BlockGrid` and in the spec render paths. Its behavior is driven by the view's `theme` field:
| Theme | What BlockFrame does |
| ----------- | --------------------------------------------------------- |
| `page` | Wraps in `<section>` with an optional `<h3>` label |
| `dashboard` | Passthrough — blocks render their own `<Card>` chrome |
| `minimal` | Bare passthrough, no decoration |
| `embed` | Bare passthrough, optimized for iframe |
The `dashboard` passthrough is intentional: blocks that already wrap themselves in `<Card>` (chart, table, summary) do the right thing without an extra wrapper. `BlockFrame` only adds structure when the theme calls for it.
Density resolution from `BlockMetadata.display` lives on `BlockShell` (below), not on `BlockFrame`. `BlockFrame` is purely theme routing.
### BlockShell — canonical card chrome
`BlockShell` (`features/blocks/components/block-shell.tsx`) is the shared card primitive for blocks that need a `<Card>` shell but don't have domain-specific reasons to hand-roll one.
```typescript
interface BlockShellProps {
label?: string;
description?: string;
actions?: React.ReactNode;
footer?: React.ReactNode;
density?: "comfortable" | "compact" | "flush";
display?: BlockDisplay; // resolves density via defaultDensityFor() when set
interactive?: boolean; // toggles the Card "interactive" variant (hover lift)
className?: string;
children: React.ReactNode;
}
```
When `label` is present, renders `<CardHeader>` with `<CardTitle>`, optional `<CardDescription>`, and the `actions` slot. When absent, renders just `<CardContent>`.
**Density variants:**
- `comfortable` (default) — standard Card padding. Good for stat cells, insight cards, and any block without edge-to-edge content.
- `compact` — tighter padding for dense stat arrays. Used by `stat-cards-block` per-cell.
- `flush` — zero body padding. Correct for charts, tables, and anything that needs to own its own edge. Does NOT clip overflow — Recharts tooltips and overflow menus anchor to the chart root and require a non-clipping ancestor.
When `density` is unset and `display` is supplied, density is derived via the exported `defaultDensityFor(display)` helper:
| `BlockMetadata.display` | Density |
| ---------------------------------------------------------------- | ------------- |
| `chart`, `table`, `kanban`, `graph`, `tree`, `timeline`, `pdf` | `flush` |
| `inline`, `status` | `compact` |
| everything else (`card`, `list`, `rich-text`, `form`, `custom`) | `comfortable` |
**When to use BlockShell vs `<Card>` directly:**
- Use `<BlockShell>` for blocks that need consistent header/actions/footer chrome without duplicating the pattern.
- Keep using `<Card>` directly in chart, table, and summary blocks — they already compose correctly and are coherent.
- Do not use `<BlockShell>` as a re-wrapper around blocks that already use `<Card>` — that would double-wrap.
`stat-cards-block` was migrated from a hand-rolled `rounded-xl border bg-card shadow-sm px-3.5 py-2.5` div to `<BlockShell density="compact">` in this work. That's the canonical example.
### CustomBlock — shape-aware fallback renderer
`CustomBlock` (`features/blocks/components/custom-block.tsx`) is the catch-all renderer for the `custom` block type. Rather than dumping every unknown data shape as a flat `<dl>` key-value list, it dispatches on data shape:
| Detected shape | Rendering |
| ----------------- | ------------------------------------------------------------------------- |
| `markdown` | Prose via `<MarkdownViewer>`. Triggered when data is a string that passes `looksLikeMarkdown`, or an object with a `body`/`content`/`markdown` field that does. |
| `insight` | Heading + lede + metadata grid. Triggered when data has one of `title`/`name`/`label`/`headline` AND one of `summary`/`description`/`subtitle`/`lede`. |
| `list` | Compact bordered records (title + summary per row when present). Triggered when data is a homogeneous array of objects. |
| `metrics` | Stat strip via `<BlockShell density="compact">`. Triggered when data is a flat object with 2–8 keys and all leaf values are numeric. |
| `string` | Plain text paragraph. Triggered when data is a non-markdown string. |
| `primitiveArray` | Bulleted list. Triggered when data is an array of primitives. |
| `keyvalue` | Two-column `<dl>` with `humanize()` keys and right-aligned tabular numbers. Deepest fallback for objects that don't fit any other shape. |
The raw `<dl>` path is preserved as the innermost fallback inside the `keyvalue` branch, with humanized keys and better typography. Agent-generated views that produce object blocks now render with actual designed intent rather than debug output.
### Block placeholder states
`BlockEmptyState`, `BlockLoadingState`, and `BlockErrorState` are the canonical placeholder tiles for block render components. They live in `features/blocks/modules/_shared/block-states.tsx` — the three former competing `BlockEmptyState` exports (two of which shared a name) were consolidated into this single location in PR #2362.
`BlockEmptyState` accepts structured `icon / title / description / action` slots in addition to `children`, making it suitable for rich empty states (e.g. "No items yet — add one" with a CTA button) without hand-rolling layout:
```tsx
import {
BlockEmptyState,
BlockLoadingState,
BlockErrorState,
} from "@/features/blocks/modules/_shared/block-states"
// Structured empty state with icon + action
<BlockEmptyState
block="my-block"
icon={<PlusCircle className="size-5" />}
title="No items yet"
description="Add the first item to get started."
action={<Button size="sm" onClick={onCreate}>Add item</Button>}
/>
// Plain text empty state (children fallback)
<BlockEmptyState block="my-block">No items yet</BlockEmptyState>
// In-flight fetch (distinct from the host's Suspense boundary for lazy module loading)
<BlockLoadingState block="my-block">Loading…</BlockLoadingState>
// Block-detected config or binding error
<BlockErrorState block="my-block">Could not load data</BlockErrorState>
```
All three accept a required `block` prop (the block's slug, used as a `data-block` attribute) plus standard `className`. `BlockEmptyState` and `BlockLoadingState` carry `role="status"`; `BlockErrorState` carries `role="alert"` and destructive-tone border/text. Height overrides go through `className` — the tile sets `min-h-24` by default.
New blocks MUST use these tiles. Do not hand-roll `border-dashed` placeholder divs. The `statesCovered` quality hints in `meta` (`a11y` → `statesCovered: ["empty", "loading", "error"]`) should reflect which of these a block actually renders.
### Interactive-mode loading state
In interactive mode (`mode: "input"`), blocks hosted inside `<BlockHostClient>` now render `<BlockHostClientSkeleton>` (a `BlockSkeleton` variant) during the async data-fetch phase instead of the error tile. The previous behavior — rendering an error tile on the loading-to-loaded transition — was a v2 regression fixed in PR #2362. The loading → loaded transition is now: skeleton → resolved block (no flash of error chrome).
### Container-query authoring
Block render components that adapt to slot width (not viewport width) MUST declare `CONTAINER_INLINE_SCOPE` on their root element and use `@sm:` / `@lg:` / `@2xl:` container variants for responsive layout decisions.
```tsx
import { CONTAINER_INLINE_SCOPE } from "@/lib/responsive/container"
// Root element in the block's render.client.tsx — the scope must sit on an
// ANCESTOR of the element using @ variants (a container query never resolves
// against the element that declares the container-type itself).
<div className={cn("...", CONTAINER_INLINE_SCOPE)}>
<div className="grid grid-cols-1 @lg:grid-cols-2">…</div>
</div>
```
Use `useIsMobile()` (from `@/hooks/use-mobile`) only for shell-level decisions (sidebars, FAB clearance) — never inside block render components. The `input-multiselect` block is the canonical precedent; `form-block`, `layout-object-group`, `quiz-multi-choice`, `layout-split`, `layout-list`, and `quiz-reveal` all follow this pattern as of this PR. `layout-page` intentionally uses viewport-level padding and is the explicit exception.
## Design Decisions
**Single unified resolver, not separate entry points per page type.** `resolveView()` is the single entry point for all view resolution. Previously, entity detail pages, standalone views, and list views each had their own resolution code paths that diverged over time. A single resolver makes it easier to add features like data sources and cross-block filtering that need to work everywhere.
**Data sources as named references, not per-block copies.** `dataSourceId` is a reference into `view.dataSources`. The query config lives once on the view, and multiple blocks reference it by name. Changing the entity type or filter for a set of blocks only requires updating the data source, not each block individually. Block-level config can still override data source defaults when needed.
**12-column grid coexists with legacy size presets.** The old `full`/`half`/`third` size presets continue to work. `resolveSpan()` maps both to a column count (full→12, half→6, third→4). The 12-column grid only activates when at least one block uses a numeric `span`. This avoids a breaking change for existing views while enabling precise layout control for new ones.
**Schema-driven config panels eliminate bespoke form UI.** Block type config schemas annotated with Zod `.describe()` labels automatically get form controls via `SchemaConfigPanel`. This means adding a new config field to a block type requires only updating its Zod schema — no separate form component.
**`useFilteredEntities` uses `initialData` from SSR by default.** When no active view-level filters exist, the React Query hook is disabled and blocks use the server-resolved data they received as props. The hook only fires a client-side fetch when a sibling `entity-filter` block sets active filters. This keeps first-render performance at SSR speed for views without interactive filtering.
**Blocks never fetch data.** All data is resolved server-side via `resolveView()` and its internal helpers. This keeps block components pure renderers with predictable behavior, eliminates client-side waterfall requests, and ensures data is tenant-scoped through server-side auth.
**Single registry, not a component hierarchy.** The plugin pattern (`registerBlock()`) avoids deep component inheritance trees. Each block type is an independent component with a simple contract: receive a `ResolvedBlock`, render it. This makes adding new block types trivial.
**Config is a loose `Record<string, any>`.** Block config is intentionally untyped beyond the basic `BlockConfigSchema`. Each block type interprets its `config` object as needed. This keeps the schema flexible and avoids version migration issues when block types evolve.
**`layout-object-group` controlled mode is detected by value shape, not a prop flag.** When `layout-object-group` runs inside a `layout-repeater` row the repeater never passes `emit`, so the original `hostEmit?.()` write path was a silent no-op and all child inputs were dead. The fix adds a _controlled mode_: if `props.value` is a plain field-value object (the repeater row template shape) rather than the `ContainerChildren` session-bus shape, children seed from `props.value[slotId]` and assemble the full updated object through `props.onChange`. Session-bus mode (standalone usage) is unchanged. Mode detection by value shape avoids a new prop and a migration of all existing call sites.
**Bridge functions as the integration seam.** Rather than having tool output, entity data, and chat messages directly produce blocks, bridge functions provide an explicit conversion layer. This keeps the block system decoupled from its data sources and makes it easy to test each bridge independently.
**Smart sizing in entityToBlocks().** The bento layout requires each block to declare its size. Rather than making users configure this for every field, `entityToBlocks()` uses heuristics: long text fields get `full`, enum fields get `third`, arrays get `half`. This produces a sensible layout automatically.
**Chart renderers are functions, not components.** The five renderer functions in `chart-block-renderers.tsx` return JSX directly rather than wrapping in a React component. This allows `ChartBlock` to pass a single root element to Recharts' `ResponsiveContainer`, which requires exactly one child. Wrapping each renderer in a component would add an extra DOM node that breaks `ResponsiveContainer`'s measurement.
**Click callbacks are opt-in, not opt-out.** Charts that have no `onItemClick` prop render identically to the previous behavior. This avoids a breaking change for existing code that renders charts without interaction intent, and keeps the visual weight of cursor changes and hover states tied to surfaces where clicking is actually wired up.
**`ChartEmptyState` replaces null returns.** Previously, chart components returned `null` when data was empty, which caused blank gaps in bento grids. The empty state communicates to the user that the chart area is intentional and will be populated — it also prevents layout shifts when data arrives asynchronously.
**Chart healing separates diagnosis from repair.** `diagnoseChartBlock()` returns a typed `ChartHealthReport` rather than mutating the config in place. This allows the empty state and the config panel to both display diagnostics independently while `applyChartHealing()` is only called on explicit user action ("Auto-fix"). The separation also makes each function individually testable.
**Color palettes use `oklch()` rather than hex.** All non-default palette entries use `oklch(lightness chroma hue)` values. This ensures perceptual uniformity across palette variants and stays consistent with the platform's design system which expresses its primary color in `oklch`. Hex-defined palettes would require separate dark-mode overrides; `oklch` palettes look correct in both modes without duplication.
**Bar chart legend uses a custom payload, not Recharts' default Cell indexing.** When `showLegend` is enabled on a bar chart, the renderer explicitly builds a `payload` array from the resolved items (`[{ value: item.name, type: "rect", color: … }]`). Without this, Recharts derives legend entries from Cell indexes (0, 1, 2, …) which produces numeric labels instead of group names. The custom payload is the only way to guarantee correct name/color pairing when per-bar colors are driven by `colorMap` or `colorThresholds`.
**`labelMap` is applied at resolution time, not render time.** Substituting display labels in `buildGroupedChartItems()` rather than in the chart component means sort-by-label ordering uses the display label, which matches user expectation (e.g. "< 3 mo" sorts before "> 12 mo"). If the substitution happened only at render, the data array would still be sorted by raw key values.
**`footerStats: "auto"` avoids seed config divergence.** A static `footerStats` array must be kept in sync with the chart's live data — if groups change, the footer goes stale. The `"auto"` sentinel delegates generation to the resolver, which always reflects the current chart items. The static array form remains supported for views where the footer labels or values need to diverge from the chart items.
**`fieldAggregations` defaults to `avg`, not `sum`.** For most numeric fields (score, margin, growth rate), the average across a portfolio is more meaningful than the sum. Fields that represent totals (annual value, investment) require explicit `sum` in config. Defaulting to `avg` prevents accidental nonsense KPIs for non-additive measures.
**Trend direction is extracted to a pure utility.** `inferTrend()` in `trend-utils.ts` is a pure string → enum function with no React dependencies. This makes it trivially testable, allows reuse in future stat-card variants, and avoids importing component logic into non-component contexts like export utilities.
**Chart value formatting is a shared utility, not inline logic.** `formatChartValue()` in `chart-format.ts` was extracted from `chart-block-renderers.tsx` so the same formatting logic can be used in CSV export, tooltip formatters, axis tick formatters, and any future chart surface without duplication. The alternative — maintaining parallel implementations — caused subtle divergence in how large numbers were formatted in exports vs. in-chart labels.
## Named data sources & cross-filtering
Views declare a `dataSources` map inside `views.definition`. Every block in the view can bind to a source by `dataSourceId`. Data resolves server-side in one batched pass, and a **Filter Records** block bound to a source drives every sibling block bound to the same source — Tableau-style cross-filtering. This works identically on app pages and on the Asset Studio canvas.
### Declaring sources in `views.definition`
Sources live in `View.dataSources` (`lib/ui-registry/data-source.ts`) as a six-kind union: `literal · entity · entitySchema · session · param · computed`. The `entity` kind is the filterable kind; all other kinds (including `metric`) are isolated from cross-filtering. Authors write sources through the `PublicAuthorView.dataSources` interface — the same shape used by the view designer, `publish_view`, and tenant declarations.
```ts
// Example: two blocks sharing one entity source
const view: PublicAuthorView = {
dataSources: {
pipeline: { kind: "entity", entityTypeSlug: "deal", cardinality: "many" },
},
root: {
id: "root",
blockType: "layout-grid",
children: [
{ id: "tbl-1", blockType: "data-table", dataSourceId: "pipeline" },
{ id: "ch-1", blockType: "chart", dataSourceId: "pipeline" },
{ id: "flt-1", blockType: "entity-filter", dataSourceId: "pipeline" },
],
},
}
```
No second home for source declarations. Named sources persist inside `views.definition`; `pnpm db:types` is not required after changing source declarations.
### Binding blocks via `dataSourceId`
`BlockInstance.dataSourceId` is the binding key. At add-time the **Filter Records** block (`entity-filter`) auto-binds to `view.primaryDataSourceId`. Consumer blocks (`data-table`, `chart`, `stat-cards`, etc.) bind by setting `dataSourceId` on their config. Strict binding is enforced: a block is only joined to a channel when it references a `dataSourceId` that the view still declares. Vestigial bindings (source removed, block still references it) produce no channel — the block uses first-paint data only.
### The Filter Records block contract
The **Filter Records** block (`entity-filter`, registered in `features/blocks/lib/block-metadata.ts`) is the sole cross-filter producer. No other block type publishes filters. It:
1. Renders a filter UI for the bound source's entity type.
2. Publishes `FilterRule[]` onto the source's channel via `setFilters(dataSourceId, rules)`.
3. Consumer blocks on the same channel pick up the rules through `useFilterChannel` → `useFilteredEntities`.
The block auto-binds to `primaryDataSourceId` when dropped onto a view; its binding can be changed in the block config panel. This work changes **zero** producer/consumer block code — it reconnects the channel map and upgrades the re-fetch transport.
### Server-side resolution — one coalesced batch
`resolveCompositionDataSources` in `features/blocks/server/resolve-data-sources.ts` resolves all named sources in a single bounded-concurrency pass (cap 4). Identical queries are coalesced: N blocks bound to the same source run one query, not N. The resolver degrades per-source errors to empty data + chip rather than a 500. `computed` sources are resolved in a second, dependency-ordered wave after all base sources are settled.
`resolvePersistedViewForRender` in `lib/ui-registry/server/view-service.ts` is the single server entry point. RSC app pages, entity detail, share, present, and the studio canvas all reach it through this one seam.
### Channel projection on the envelope
`resolvePersistedView` computes a channel projection — `sourceChannels`, `sourceLabels`, `channelSources` — and attaches it to the `PersistedViewEnvelope` before zeroing `dataSources`. Every caller (typeSlug pages, entity detail, share, present, studio) inherits the projection with no per-caller threading.
Channel rule (kind-agnostic):
- **`entity` sources** — channel key = `entityTypeSlug`. Preset-filtered entity sources share the same channel as unfiltered sources for the same entity type; base scope is preserved server-side on re-query.
- **All other kinds (including `metric`)** — channel key = `sourceId` (isolated). A filter block cannot bind them; blocks on isolated channels keep first-paint data.
`ViewFilterProvider` is fed the channel projection as explicit props. The `viewRecordForBridge` stub that previously passed `dataSources: {}` (starving the channels) is deleted in the same PR that adds this feature (replace-means-remove).
### By-reference re-query via `GET /api/views/[viewId]/sources/[sourceId]`
Cross-filter interactions hit `GET /api/views/[viewId]/sources/[sourceId]?filters=<url-encoded JSON>&routeEntityId=<uuid?>`. This is the hot path — a route (not a server action) for abortable fetch, HTTP cache semantics, parallel GETs, and network-tab debuggability.
- Zod-validates all inputs; auth/tenant via the standard route context.
- Fail-closed: rejects unknown `sourceId`, non-`entity` kind, invalid filters, views outside the caller's tenant scope. Never silently returns unfiltered rows.
- The wire format uses the `FilterRule` shape from `features/blocks/types.ts` (`operator` field) — what `entity-filter` publishes. The route converts to the canonical `op` shape (`lib/ui-registry/data-source.ts`) via an adapter before AND-merging onto the source's own `filters`. The base query never leaves the server.
- Optional `routeEntityId` mirrors the first-paint host context for `entity`-kind sources that depend on a route entity ref (entity detail pages, studio with an active entity artifact).
- Resolution runs through `resolveCompositionDataSources({ [sourceId]: merged })` — the same engine, singleton map. Response mirrors `/api/entities` conventions: `{ items, total?, signature }`.
`useFilteredEntities` keeps its public hook surface. Only the view-bound branch (viewId + sourceId present via the provider) swaps the `/api/entities` fetch for this route, keyed `["view-source", viewId, sourceId, filtersHash]`. The 14 non-view callers (pickers, prefill, forms) are untouched. React Query dedups: N blocks bound to one source = one request per filter change. `initialData` bridges the SSR-resolved payload — zero extra fetches before the first interaction.
### Base scope preserved server-side
The base `filters` on an `entity` source are server-side constraints. They are never sent to the client and never appear in the filter-channel state. When a cross-filter re-query fires, the route AND-merges the UI-produced rules onto the base scope. Rows always remain a subset of the base scope — the regression class that previously forced preset-filtered sources onto isolated channels is closed.
For cross-reference: the [View System](/docs/features/view-system) documents how `dataSources` is authored in `views.definition` and the `PublicAuthorView` ingress contract.
### Kind rules summary
| Source kind | Cross-filterable? | Channel key | Re-query on filter change? |
|-------------|------------------|-------------|---------------------------|
| `entity` | Yes | `entityTypeSlug` | Yes, via `/api/views/[viewId]/sources/[sourceId]` |
| `literal` | No | `sourceId` (isolated) | No — static data |
| `entitySchema` | No | `sourceId` (isolated) | No |
| `session` | No | `sourceId` (isolated) | No |
| `param` | No | `sourceId` (isolated) | No |
| `computed` | No | `sourceId` (isolated) | No |
| `metric` (ADR-0052) | No | `sourceId` (isolated) | No |
### Explicit limitation: computed sources do not re-resolve on filter changes
`computed` sources that depend on a filtered `entity` source keep first-paint data when the filter changes. Their isolated channel means no filter block can bind them directly, so nothing silently lies to the user. Cross-source recompute (propagating filter changes through `computed` dependency chains) is a follow-up item.
### Asset Studio canvas
The studio canvas resolves named sources through the same `getResolvedViewById` server action used by app pages. Ephemeral agent-authored views (never persisted) resolve through `resolveViewDefinition`, which validates against `safeParsePublicAuthorView` and runs `resolveCompositionDataSources` with the same tenant-fenced request context. Filter blocks cross-filter on the canvas identically to app pages. Literal-only ephemeral views keep the synchronous client path (no regression in time-to-first-paint). `UnresolvedDataSourcesFallback` remains as a structural fail-closed guard but is unreachable for canvas artifacts that carry consumed sources.
## Container-mode safety
When `<BlockGrid>` runs in `responsive: 'container'` mode (queued for a follow-up PR — see `documents/work/2026-04-28-mobile-responsiveness-pr2-surfaces/decisions.md`), any ancestor with `container-type: inline-size` re-anchors `position: fixed` descendants to the container instead of the viewport. Block authors adding floating UI need to know which patterns survive that flip.
**Safe — Radix Portal usage.** Anything that teleports content via Radix's `Portal` primitive (Popover, Tooltip, Dialog, DropdownMenu, ContextMenu, HoverCard) lands at `document.body`, outside the container. Examples in `features/blocks/`: `block-config-panels/multi-combobox.tsx`, `canvas/canvas-entity-picker.tsx`, `canvas/materialize-popover.tsx`, `canvas/nodes/ink/pen-picker.tsx`, `canvas/nodes/shape/shape-picker.tsx`, `canvas/canvas-toolbar.tsx`, `pdf-viewer/ai-action-bar.tsx`.
**Safe — Recharts internal overlays.** Recharts positions its internal `<Tooltip>` and `<Legend>` relative to the chart's bounding box, not the viewport. Container-typed ancestors don't break it. Empirically verified across all chart blocks in `features/blocks/components/chart-renderers/`.
**Not safe — coordinate-driven `position: fixed`.** Components that anchor floating UI at raw `clientX/Y` from a pointer event (without going through a portal) snap to the wrong location under container-type. Known case: `features/blocks/components/canvas/canvas-context-menu.tsx` renders a virtual `<span>` trigger at the contextmenu's clientX/Y. Fix path is Floating UI's `useFloating` with a virtual element via `client-point`, or Radix `Popover anchor=...` with a real DOM element. Until that fix lands, canvas blocks stay in `viewport` mode.
When you add a new block that uses floating UI, route it through Radix's portal primitive or document the exception here with the same shape as Recharts above (why it's safe even without a portal).
## Related Modules
- [View System](/docs/features/view-system) -- Views store `BlockConfig[]` and pass them through the block pipeline
- [Entity System](/docs/features/entity-system) -- Entity data is the primary input to block resolution
- [Tool System](/docs/features/tool-system) -- Tool output sections map to block types via bridge functions
- [Agent System](/docs/features/agent-system) -- Agents author block configs through view management tools