Documentation source
View/Block System v4 — AI-Native Rendering Primitive
Lock view/block into a stable foundation where agents and humans compose, preview, save, and rollback dynamic views; remove broken data surfaces; polish analytical defaults.
> **Relationship to prior specs.** This spec supersedes none — it *completes* the in-progress work begun in [view-block-system-v3](/docs/roadmap/specs/view-block-system-v3) and closes the gaps identified in `docs/superpowers/specs/2026-04-07-shared-data-sources-cross-block-filtering.md`, `docs/superpowers/plans/2026-04-07-surface-block-architecture-cleanup.md`, `docs/superpowers/specs/2026-04-02-surface-types-definitive-spec.md`, `SprinterVault/20-Ventures/Amble/SPRINTER-UI-WORKSPACE-IMPLEMENTATION-BACKLOG.md` (2026-04-08 update), and `SprinterVault/20-Ventures/Amble/shared-context/2026-04-08-view-block-system-review-and-improvement-plan.md` (codex system review). v3 delivered the unified workspace editor, response mode, and Tableau-style cross-block filtering infrastructure. v4 turns that infrastructure into a **stable, AI-native rendering primitive** by closing five concrete gaps — **without introducing new state-management systems, without duplicating the existing ephemeral-view flow, and without building features the codex review asks us to defer**.
> **Revision — 2026-04-09a.** Earlier draft proposed a client-side Zustand store for proposed views. That was rejected: amble is primarily SSR-driven and already has four state-management layers (SSR initial data, React Query, Supabase Realtime, React Context). Adding Zustand would be a fifth overlapping system. **Stream C has been redesigned to reuse existing layers** — chat messages store the proposed payload, a thin React Context tracks which message is "active on the page", and React Query handles the save mutation. The earlier ephemeral-view plumbing (`saveTransientView`, `chatOutputToBlocks`) is **extended, not replaced**. See the "State Management Discipline" section below for the full decision tree.
> **Revision — 2026-04-09b.** Further simplification: **"proposed view" and "tool output pop-out" are the same concept.** A proposed mutation (chart added to the current view) is just an ephemeral `ViewRecord` attached to a chat message. A popped-out `searchEntities` result is also an ephemeral `ViewRecord` attached to a chat message. A `generateView` output is too. **There is one concept: an Ephemeral View.** Stream C is renamed to "Ephemeral View Loop" and generalized so any tool whose output can be expressed as a `ViewRecord` gets a uniform pop-out / save / revisit experience. This collapses three half-designed systems (proposed views, transient views, tool inline previews) into one.
## Problem
The view/block system has a solid foundation after v3 and PR #577 shipped, but it does not yet feel like a stable rendering primitive that AI agents and humans can treat as a trusted substrate:
1. **Broken data surfaces are still visible.** `kanban`, `calendar`, `gallery`, `table`, `map`, `timeline`, and `split` are registered as `category: "data"` surfaces but have no data binding. They still appear in the surface-type picker, sometimes crash, and confuse the vocabulary — data rendering belongs to **blocks**, not surfaces. PR #563 and PR #577 never closed this loop.
2. **The agent has no idea what view the user is looking at.** `EntityContextBridge` syncs entity context to the agent sidebar, but there is no analogous `ViewContextBridge`. An agent asked to "change this chart to a kanban" cannot see the current view's id, surface_type, dataSources, active filters, or block layout. It can only fabricate a detached preview via `generateView`, which feels like tool output, not a mutation of what the user is looking at.
3. **There is no proposed-view loop.** When chat produces a view change, the user has no accept / reset / save / revisit flow. The transient-view concept exists in `saveTransientView` as a write to the DB, but nothing holds a draft overlay on the current page, nothing lets the user roll it back, nothing lets them return to the conversation later to reopen or persist it. Preview, applied, saved, and reverted are not distinguishable states.
4. **Cross-block filter adjustments are lossy.** The `ActiveFilterBar` shows active filters and lets the user clear them, but there is no "save these filters as a new view" or "promote this filter into the data source" action. A user who spends two minutes drilling down through filters has no way to preserve that as a real artifact.
5. **Default analytical surfaces look generic.** The dashboard and grid surfaces render correctly but feel like a plain admin dashboard, not the dense, calm, premium analytical boards the product is aiming at. Card chrome, density, chart defaults, and dark theme tokens are inconsistent.
Each gap is independently solvable, and — critically — **each gap maps to a different area of the codebase**. That makes this spec implementable as five parallel streams with near-zero file-level conflict. See the companion plan at `docs/superpowers/plans/2026-04-08-view-block-v4-parallel-streams.md`.
## Solution
Five focused streams, each owning a clear slice of the codebase:
| Stream | Purpose | Mental model |
| --- | --- | --- |
| **A. Data feed cleanup** | Hide broken data surfaces, ship missing data blocks, remove synthetic-block injection hack, wire remaining data blocks to `useFilteredEntities`. | "Surfaces arrange. Blocks fetch." |
| **B. Active view context bridge** | Add a `ViewContextBridge` that syncs the current view into the agent sidebar, mirroring `EntityContextBridge`. Inject that context into the chat system prompt. | "Agents can see what the user is looking at." |
| **C. Ephemeral View loop (unified)** | Any tool output that produces a `ViewRecord`-shaped payload becomes an **Ephemeral View** living in its chat message. A thin React Context pointer identifies which message's ephemeral view is active on the current page. The same `EphemeralViewOverlay` renders proposed mutations, popped-out tool results, and `generateView` outputs — one overlay, one action bar, one save path. **Zero new state-management layers.** | "Every tool output is a draft view; the chat history IS the draft store; any of them can take over the page." |
| **D. Filter save-as-view** | Extend `ActiveFilterBar` so the user can promote current filter state to either a new scoped view or the underlying data source. | "What I filtered to, I can keep." |
| **E. Surface polish baseline** | Ship opinionated defaults for dashboard/grid/page surfaces + chart components so analytical boards feel premium with zero config. | "Premium by default, not by tuning." |
Together these deliver the vision in the 2026-04-08 UX backlog: *chat updates feel like mutations of the current live view, transient and saved views are visually indistinguishable, reset is always available, and analytical boards look calm and intentional.* We reach that vision **without a new workspace shell, without new top-level abstractions, and without blocking on a builder rewrite.**
## Design
### A. Data Feed Cleanup
**Goal:** Align with the 2026-04-07 surface-block cleanup plan — surfaces arrange blocks; blocks fetch data.
**Concrete changes:**
1. **Hide data-category surfaces from the palette and surface-type picker.** In `features/views/surfaces/definitions/{kanban,calendar,gallery,table,map,timeline,split}.ts`, add `hidden: true` to the definition metadata (extend `SurfaceDefinition` if that field does not yet exist). Update `surface-type-select.tsx` to filter out hidden surfaces. Keep the definitions so existing views with these surface types render through a graceful fallback to `"grid"` — never orphan data.
2. **Remove the synthetic block injection hack in `features/blocks/server/resolve-view.ts`.** Phase 1 of the surface cleanup plan flagged this; it should no longer be needed once data surfaces are hidden.
3. **Ship `calendar-block`, `gallery-block`, `timeline-block`.** Each follows the existing data-block pattern: Zod config schema, `dataSourceId` support, `useFilteredEntities` subscription, `acceptsFilters: true`, `resolve()` function in `features/blocks/server/resolvers/`, and a `.test.tsx` co-located with the component. Data binding identical to `kanban-block` / `map-block` — no new contracts.
4. **Wire `entity-feed-block` to `useFilteredEntities`.** Currently all other data blocks (kanban, table, data-table, chart, map, stat-cards) subscribe to the filter context; `entity-feed-block.tsx` still renders `block.data` statically. Bring it onto the same pattern.
5. **Backfill block metadata.** Confirm all blocks with `acceptsFilters: true` declare their data requirement and data-source binding correctly in `features/blocks/lib/block-metadata.ts` and the registry.
**Stream A ships independently. It does not touch the unified renderer, agent context, or chat.**
### B. Active View Context Bridge
**Goal:** Give agents read-only awareness of the current view the user is looking at, so chat can mutate that view natively instead of fabricating detached previews.
**New component:** `features/views/components/view-context-bridge.tsx`
```tsx
"use client";
import { useEffect, useMemo } from "react";
import { useAgentSidebar } from "@/components/app-shell/agent-sidebar-context";
import { useViewFilters } from "../hooks/use-view-filters";
import type { ViewRecord } from "../types";
interface ViewContextBridgeProps {
view: ViewRecord;
page: "list" | "workspace" | "standalone";
permissions: {
canEdit: boolean;
canSave: boolean;
canPropose: boolean;
};
}
export function ViewContextBridge({ view, page, permissions }: ViewContextBridgeProps) {
const { setViewContext } = useAgentSidebar();
const filters = useViewFilters();
const viewContext = useMemo(() => ({
viewId: view.id,
title: view.title,
surfaceType: view.surface_type,
pageType: view.page_type,
layout: view.layout,
theme: view.theme,
entityTypeSlug: view.entity_type_slug,
dataSourceIds: Object.keys(view.dataSources ?? {}),
blockCount: view.blockOrder.length,
activeFilters: Array.from(filters.activeFilters.entries()).map(([dsId, rules]) => ({ dataSourceId: dsId, ruleCount: rules.length })),
page,
permissions,
}), [view, filters.activeFilters, page, permissions]);
useEffect(() => {
setViewContext(viewContext);
return () => setViewContext(null);
}, [viewContext, setViewContext]);
return null;
}
```
**Agent sidebar store update** (`components/app-shell/agent-sidebar-context.tsx`): add `viewContext: ViewContext | null` and `setViewContext()` alongside the existing entity context. Keep the APIs identical so both can co-exist.
**Build-context integration** (`features/agents/lib/build-context.ts`): when the chat route assembles the agent prompt, include a "Current View" section whenever `viewContext` is non-null. Format it as a compact table so caching stays stable. Never include the full block tree — that would blow the cache key. Only the snapshot above.
**Mount point:** add exactly one line to `features/views/components/unified-view-renderer.tsx` to render `<ViewContextBridge view={view} page={page} permissions={permissions} />` inside the `ViewFilterProvider`, above `ActiveFilterBar`. The parent supplies `page` and `permissions` via existing props or a new optional prop. This is the **only** file Stream B touches that other streams might care about.
**Stream B ships independently. It does not touch surfaces, blocks, or chat UI.**
### C. Ephemeral View Loop (Unified — Preview / Save / Pop-Out / Rollback)
**Goal:** One concept — the **Ephemeral View** — powers proposed mutations of the current view, popped-out tool results, and `generateView` previews. All three are chat-message-attached `ViewRecord` payloads. A single React Context pointer identifies which message's ephemeral view is active on the current page, and a single overlay renders them all through the existing `UnifiedViewRenderer`. Users can preview, reset, save, or revisit any of them from chat history.
**Core insight:** the ephemeral payload is already durably stored. The `messages` table holds every chat tool call with its output. `manageView`, `generateView`, `saveTransientView`, `searchEntities`, and any other tool that produces a `ViewRecord`-shaped payload all write to the same place. **There is no "proposed view store" — chat history IS the store.** Stream C's job is to define a tiny discriminator (`kind: "ephemeral-view"`), make every relevant tool output carry it, and teach the page to render the active one on demand.
#### The discriminator
Every ephemeral-view-producing tool returns an output envelope conforming to:
```ts
/** Unified envelope for any tool output that can be popped out as a page-level view. */
interface EphemeralViewToolOutput {
kind: "ephemeral-view";
/** Human label — "Added revenue chart", "12 accounts matching 'acme'", "Quarterly summary". */
label: string;
/** The renderable view. Must be a valid ViewRecord — surface_type, blocks, layout, theme, dataSources. */
view: ViewRecord;
/**
* If this is a mutation of an existing view (agent called manageView with mode: "propose"),
* this is the id of that base view. Controls whether Save acts as update-in-place or create-new.
*/
baseViewId?: string;
/** Optional structured diff when baseViewId is set — computed by view-diff.ts. */
diff?: ProposedDiff;
}
```
`view-diff.ts` is the pure helper that computes `diff` when `baseViewId` is present. It has zero runtime dependencies and is already integrated in the branch.
#### Tools that emit `EphemeralViewToolOutput`
| Tool | Use case | Envelope produced |
| --- | --- | --- |
| `manageView({mode: "propose", viewId, ...mutations})` | "Change the current view" (chart → kanban, add block, change filter default) | `{ kind, label, view: mutated, baseViewId: viewId, diff }` |
| `manageView({mode: "persist", ...})` | **Default; unchanged.** Persists directly. Backwards-compatible. | (existing output — not an ephemeral view) |
| `generateView` | "Create a brand-new dashboard from scratch" | `{ kind, label, view: fresh, baseViewId: undefined }` |
| `saveTransientView` | **Unchanged tool surface; role clarified.** Now explicitly documented as: "promote an ephemeral view from message N to a persisted view row". Reads the target message's `EphemeralViewToolOutput`, calls `createView()`. | Returns the persisted id — not an ephemeral view itself. |
| `searchEntities` (optional v4 addition) | "Show me all deals over $50k in this quarter" can produce a grid surface of the hits as an ephemeral view so the user can pop it out and save it as a "filtered list" view | `{ kind, label: "N results", view: generatedFromResults, baseViewId: undefined }` |
| **Future tools** | Any custom tool whose output is best rendered as a full surface/block composition | Uniform envelope, no special UI code |
`searchEntities` producing a view is the proof point that this generalizes beyond view mutations — it answers the user's "any tool output can be popped out" ask without building a second mechanism.
#### Chat inline vs page overlay
The existing `chatOutputToBlocks` path inside `tool-call-card.tsx` keeps rendering an inline preview inside the chat bubble — that's the "peek at what the agent produced" affordance and it already works. Stream C **adds** an "Open on page" action to any tool output that carries `kind: "ephemeral-view"`. Clicking it activates the page overlay with that message's id.
Inline and overlay render the **same `ViewRecord` through the same `UnifiedViewRenderer`** — the only difference is the container (chat bubble vs full-page overlay). This guarantees visual consistency and eliminates "two rendering paths" drift.
#### Page activation — one React Context pointer
`features/views/hooks/use-ephemeral-view.tsx` exports:
```tsx
const EphemeralViewContext = createContext<{
messageId: string | null;
setActiveEphemeralView: (messageId: string | null) => void;
}>({ messageId: null, setActiveEphemeralView: () => {} });
export function EphemeralViewProvider({ children }: { children: React.ReactNode }) {
const [messageId, setMessageId] = useState<string | null>(null);
const value = useMemo(
() => ({ messageId, setActiveEphemeralView: setMessageId }),
[messageId]
);
return <EphemeralViewContext.Provider value={value}>{children}</EphemeralViewContext.Provider>;
}
export function useEphemeralView() { return useContext(EphemeralViewContext); }
```
**One pointer. One piece of state. That is the whole "store".**
Mounted once in the app shell layout beside `AgentSidebarProvider`. Survives route navigation. Cleared on sign-out.
#### Page overlay — one component, two modes
`features/views/components/ephemeral-view-overlay.tsx`:
```tsx
export function EphemeralViewOverlay({
baseView,
children,
}: {
/** The view the page would render without any ephemeral override. */
baseView?: ViewRecord;
/** The base-view render — shown whenever no ephemeral view is active. */
children: React.ReactNode;
}) {
const { messageId, setActiveEphemeralView } = useEphemeralView();
const envelope = useEphemeralViewFromMessage(messageId); // reads from chat history query cache
if (!messageId || !envelope) return <>{children}</>;
const isProposalOfBase = baseView && envelope.baseViewId === baseView.id;
return (
<div className="relative">
<EphemeralViewActionBar
label={envelope.label}
diff={envelope.diff}
isProposalOfBase={isProposalOfBase}
onClose={() => setActiveEphemeralView(null)}
onSaveInPlace={isProposalOfBase ? () => savePatch(baseView!, envelope.view) : undefined}
onSaveAsNew={() => saveAsNew(envelope)}
/>
<UnifiedViewRenderer view={envelope.view} resolvedBlocks={resolveClient(envelope.view)} />
</div>
);
}
```
**Action bar affordances adapt to context:**
- `isProposalOfBase === true` (current page view id matches envelope.baseViewId):
- **Close** — clears the pointer, back to base view (rollback)
- **Save changes** — primary; `updateView(baseViewId, envelope.view)` via mutation
- **Save as new** — secondary; `createView(envelope.view)` via mutation
- `isProposalOfBase === false` (pop-out from a different view, or fresh tool output):
- **Close** — clears the pointer
- **Save as new** — primary; `createView(envelope.view)` via mutation
- In all cases, a compact **"Source message"** link scrolls the chat sidebar to the originating message for revisit.
#### Mount points
- `app/(app)/layout.tsx` (or wherever `AgentSidebarProvider` mounts) — add `<EphemeralViewProvider>` once, at the same level
- `app/(app)/[typeSlug]/[id]/entity-detail-client.tsx` — wrap the existing `UnifiedViewRenderer` usage with `<EphemeralViewOverlay baseView={view}>…</EphemeralViewOverlay>`
- `app/(app)/view/[id]/view-page-client.tsx` — same wrap
- `app/(app)/[typeSlug]/page.tsx` (list pages) — optional; wrap the list view renderer so list→chart pop-outs work too
- `/chat` page — overlay wraps nothing; `setActiveEphemeralView(null)` is the only "close" needed; if the user is in `/chat` and opens an ephemeral view, the overlay renders inside the chat workspace area
#### Chat card behavior
`features/chat/components/ephemeral-view-card.tsx`:
```tsx
export function EphemeralViewCard({
messageId,
envelope,
}: {
messageId: string;
envelope: EphemeralViewToolOutput;
}) {
const { setActiveEphemeralView } = useEphemeralView();
return (
<Card>
<CardHeader>{envelope.label}</CardHeader>
{envelope.diff && <DiffSummary diff={envelope.diff} />}
<CardFooter>
<Button onClick={() => setActiveEphemeralView(messageId)}>Open on page</Button>
</CardFooter>
</Card>
);
}
```
Rendered by `tool-call-card.tsx` whenever a tool output has `kind: "ephemeral-view"` — regardless of which tool produced it. Adding a new tool that emits ephemeral views requires **zero chat UI changes**.
#### Permissions
The `canPropose` / `canSave` flags that Stream B's `ViewContextBridge` publishes to the agent sidebar tell the agent whether to call `manageView({mode: "propose"})` vs a detached `generateView` vs nothing. On the client, the action bar hides "Save changes" when the user lacks edit rights on the base view — but "Save as new" stays available (uses `createView` with `scope: "user"`).
#### What this DOES NOT add
- **No Zustand.** One `useState` inside one Context provider. That's it.
- **No new table.** `messages` is already the durable store.
- **No new API route.** Uses existing `createView` / `updateView` server actions.
- **No new concept.** Proposed views, transient views, tool pop-outs, and inline chat previews all become facets of the same Ephemeral View primitive.
- **No special case for "detail page" vs "list page" vs "standalone view".** Every page that renders a `ViewRecord` gets the same wrap.
- **No required change to existing tool outputs.** Tools opt in by adding the envelope. Existing callers of `manageView` default to `mode: "persist"` and keep working unchanged.
#### Files Stream C owns
- `features/views/hooks/use-ephemeral-view.tsx` — Context + provider (new)
- `features/views/hooks/use-ephemeral-view-from-message.ts` — selector hook that reads chat history React Query cache and extracts the envelope by message id (new, thin)
- `features/views/hooks/use-save-ephemeral-view.ts` — React Query mutation wrapping `updateView`/`createView` (new, thin)
- `features/views/components/ephemeral-view-overlay.tsx` — the overlay wrapper (new)
- `features/views/components/ephemeral-view-action-bar.tsx` — action strip with context-adaptive buttons (new)
- `features/views/lib/view-diff.ts` — **already integrated** (commit `fec1ffb1`)
- `features/views/lib/view-diff.test.ts` — **already integrated**
- `features/chat/components/ephemeral-view-card.tsx` — chat summary card (new)
- `features/chat/components/tool-call-card.tsx` — single new branch for `kind: "ephemeral-view"`
- `features/tools/view-tools.ts` — extend `manageView` with `mode: "propose" | "persist"`; document and align `generateView` / `saveTransientView` output envelopes
- `app/(app)/[typeSlug]/[id]/entity-detail-client.tsx` — wrap in `EphemeralViewOverlay`
- `app/(app)/view/[id]/view-page-client.tsx` — wrap in `EphemeralViewOverlay`
- `app/(app)/[typeSlug]/page.tsx` — optional wrap for list views
- `app/(app)/layout.tsx` (or wherever `AgentSidebarProvider` is mounted) — add `EphemeralViewProvider`
### D. Filter Save-As-View
**Goal:** Filter work is persistable. When a user adjusts the `ActiveFilterBar` to narrow a view, they can promote that state to either a new scoped view or a tweak of the underlying data source.
**Additive changes:**
1. **`ActiveFilterBar`** — add a trailing `[ Save… ]` button (shadcn `DropdownMenu`) with two items:
- **Save as new view** → opens `SaveFiltersDialog`
- **Apply to data source** → overwrites `view.dataSources[dsId].filters` with the current active rules, then calls `updateView`
2. **`SaveFiltersDialog`** (new) — name field, scope selector (`user` / `shared`), "Save in sidebar" toggle. On submit, calls `useSaveFilters()` which wraps a new `saveFiltersAsView()` server action. The action clones the current view with the proposed filters merged into each affected data source and persists it as a new row.
3. **`saveFiltersAsView()` server action** — lives in `features/views/server/actions.ts` alongside `createView`, `updateView`, `forkViewForEntity`. Tenant-scoped, uses `requireAuth()`, follows existing DB patterns (no `.single()` on UPDATE, tenant-scoped client, activity log).
4. **`useSaveFilters()` hook** — React Query mutation wrapper; invalidates the views list + pins.
**Stream D ships independently. Its only additive touch points are `active-filter-bar.tsx` (single button drop-in) and `features/views/server/actions.ts` (appended function).**
### E. Surface Polish Baseline
**Goal:** Dashboard / grid / page surfaces look premium without configuration. No new abstractions — only tightened defaults.
**Work:**
1. **`dashboard-surface.tsx`** — tighten card padding to `p-4` baseline, unify block header metrics, stronger type hierarchy between KPI / chart / table / feed blocks, consistent bordering using `oklch(0.94 0 0)` muted borders.
2. **`grid-surface.tsx` / `page-surface.tsx`** — adopt the same chrome tokens so `surface_type: "grid"` and `"page"` look identical to `"dashboard"` in terms of card frame, spacing rhythm, and hover state.
3. **Chart defaults** (`features/charts/components/`) — opinionated grid line color, axis typography, tick spacing, tooltip chrome that matches the zero-chroma neutrals system. Lean on CSS variables only — no hardcoded Tailwind color classes.
4. **`features/views/lib/surface-theme.ts`** (new) — small module exporting constants (`DENSITY`, `BLOCK_CHROME`, `CHART_DEFAULTS`) so future surfaces inherit without copy-paste.
5. **`app/globals.css`** — add any new neutral tokens or density variables required. Additive-only; no existing selectors touched.
**Stream E ships independently. It owns its files and never touches data binding, agent context, proposed-view state, or filter save flows.**
## Architecture Diagram
```
┌─────────────────────── Page (entity detail / standalone / list) ───────────────────────┐
│ │
│ <ViewContextBridge view page permissions /> ← Stream B │
│ │
│ ┌──────────────── UnifiedViewRenderer ──────────────────────────────────┐ │
│ │ <ViewFilterProvider> │ │
│ │ <ActiveFilterBar [Save…] /> ← Stream D │ │
│ │ <SurfaceRenderer surface_type …> │ │
│ │ dashboard | grid | page (Stream E polish) │ │
│ │ sequence | slides | form | canvas | … │ │
│ │ → BlockGrid → kanban / table / chart / map / stat-cards / │ │
│ │ calendar / gallery / timeline / entity-feed (Stream A) │ │
│ │ each calls useFilteredEntities(dataSourceId) │ │
│ │ </SurfaceRenderer> │ │
│ │ </ViewFilterProvider> │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ <EphemeralViewOverlay> ← Stream C WRAPS UnifiedViewRenderer — when │
│ EphemeralViewContext.messageId is set, reads envelope from chat history React │
│ Query cache and renders the ephemeral view (proposed mutation OR popped-out │
│ tool output OR generateView result) through the SAME UnifiedViewRenderer. │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
Agent Sidebar (unchanged shell)
────────────────────────────────
Entity context (existing)
View context (Stream B)
│
▼
Chat → manageView(mode: "propose") → ProposedView store
│
▼
<ProposedViewCard /> (linked in chat history)
```
## Trade-offs
- **Proposed-view store is client-only.** Proposed drafts do not survive a reload unless the user hits "Save". This is intentional — the alternative (persisted transient rows) creates garbage and complicates RLS. If a user wants to preserve a draft across sessions, they hit "Save" or "Keep as Draft with name" (Stream C follow-up).
- **Hiding data surfaces is non-destructive.** Existing views with `surface_type: "kanban"` render through a grid fallback but keep their data. A follow-up migration can rename these once we confirm zero regressions — not in v4.
- **ViewContext prompt payload is deliberately small.** We pass the snapshot (ids, counts, flags) but not the full block tree. That keeps Anthropic prompt caching stable and avoids context bloat. Agents that need block detail call `inspectViews({ id })`.
- **Surface polish is opinionated, not themable.** Stream E is not a theme system; it locks in defaults. A real theme editor is a separate spec.
- **Only one `unified-view-renderer.tsx` edit.** Stream B owns that file for the single bridge mount. Stream C stays out by mounting the overlay at the page level. Stream E stays out by tweaking only the inner surface components. This keeps the five streams merge-safe when dispatched in parallel.
## Acceptance Criteria
### Stream A — Data Feed Cleanup
- [ ] `kanban`, `calendar`, `gallery`, `table`, `map`, `timeline`, `split` surfaces are hidden from the surface-type picker and workspace-editor block palette
- [ ] Existing views with these surface types render through a grid fallback without breaking
- [ ] `calendar-block`, `gallery-block`, `timeline-block` ship with Zod config schemas, resolvers, tests, and `useFilteredEntities` wiring
- [ ] `entity-feed-block` subscribes to cross-block filters like the other data blocks
- [ ] Synthetic block injection hack in `resolve-view.ts` is deleted
- [ ] `pnpm test` + `pnpm typecheck` pass for all touched files
### Stream B — View Context Bridge
- [ ] `ViewContextBridge` mounts inside `UnifiedViewRenderer` and publishes view snapshot to the agent sidebar store
- [ ] Agent sidebar context exposes `viewContext` + `setViewContext`
- [ ] Chat system prompt (via `features/agents/lib/build-context.ts`) includes a "Current View" section when `viewContext` is present
- [ ] Prompt caching does not regress — snapshot is serialized in a stable order
- [ ] Unmounting the view clears view context
- [ ] Co-located `view-context-bridge.test.tsx` covers mount, update, unmount
### Stream C — Ephemeral View Loop (unified: proposed + tool pop-out; no Zustand)
- [ ] `EphemeralViewContext` + `EphemeralViewProvider` expose `{ messageId, setActiveEphemeralView }` — one `useState`, mounted once in the app shell
- [ ] `view-diff.ts` pure helper landed (commit `fec1ffb1`) ✅
- [ ] `EphemeralViewToolOutput` envelope type published in `features/tools/view-tools.ts` with `kind: "ephemeral-view"` discriminator
- [ ] `manageView({ mode: "propose" })` returns the envelope with `baseViewId` set and `diff` computed
- [ ] `manageView({ mode: "persist" })` default path preserves current write-through behavior (backwards compatible)
- [ ] `generateView` output conforms to the envelope with `baseViewId: undefined`
- [ ] `saveTransientView` reads an ephemeral view from a specified message id and promotes it via `createView()` — becomes the canonical promotion path, no DB-schema change
- [ ] `useEphemeralViewFromMessage(messageId)` reads from existing chat history React Query cache (no new fetch layer)
- [ ] `useSaveEphemeralView()` React Query mutation dispatches `updateView` (in-place patch when `baseViewId` matches) or `createView` (new row), invalidates views list
- [ ] `EphemeralViewOverlay` wraps `UnifiedViewRenderer`: renders `children` when inactive, renders the envelope's view with an action bar when active
- [ ] `EphemeralViewActionBar` affordances adapt: in-place proposal shows `Close | Save changes | Save as new`, fresh/pop-out shows `Close | Save as new`
- [ ] `EphemeralViewCard` in chat renders ANY tool output with `kind: "ephemeral-view"` — one card for every ephemeral-view-producing tool
- [ ] `tool-call-card.tsx` adds one branch detecting the envelope and delegating to `EphemeralViewCard`
- [ ] Close is literally `setActiveEphemeralView(null)` — no store cleanup, no state transitions
- [ ] Revisit later: every ephemeral view lives in chat history; clicking any old card re-activates it
- [ ] Inline chat preview keeps working unchanged — overlay is additive
- [ ] Flow works on entity detail, standalone `/view/[id]`, and list pages via one-line overlay wraps each
- [ ] **Zero new state-management layers; zero new tables; zero new API routes.** One React Context, one `useState`, two new hooks, two new components, one tool-output discriminator — that is the entire state surface
- [ ] (Optional stretch) `searchEntities` gains an optional envelope output for results-as-grid pop-out, proving the generalization beyond view mutations
- [ ] Tests cover: envelope type, view-diff helper, overlay mounting, context toggle, mutation happy/failure, tool propose-mode payload, chat card activation, action-bar mode switching
### Stream D — Filter Save-As-View
- [ ] `ActiveFilterBar` exposes a "Save…" dropdown with "Save as new view" and "Apply to data source"
- [ ] `SaveFiltersDialog` collects name, scope, pin-to-sidebar option
- [ ] `saveFiltersAsView()` server action clones the active view with filters merged into data sources, tenant-scoped, activity-logged
- [ ] "Apply to data source" updates `view.dataSources[dsId].filters` and persists via `updateView`
- [ ] Tests cover both paths and permission gating
- [ ] Works when zero / one / many data sources are involved
### Stream E — Surface Polish Baseline
- [ ] `dashboard-surface.tsx`, `grid-surface.tsx`, `page-surface.tsx` use a shared chrome token set (`features/views/lib/surface-theme.ts`)
- [ ] Chart default styles (grid lines, axes, tooltip) match the zero-chroma neutrals system
- [ ] Visual regression via `qa-tester` agent: dashboard, grid, page surfaces render identically in light and dark mode with the new tokens
- [ ] No hardcoded Tailwind color classes introduced
- [ ] Additive-only `app/globals.css` changes
### Global
- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build` pass on the unified branch before PR
- [ ] Changelog entry + feature-doc update via `documentarian` agent
- [ ] QA pass via `qa-tester` on `/dashboard`, entity list, entity detail, standalone view, chat-driven proposal
## Files (consolidated)
See `files-touched` frontmatter above and the companion plan for exact ownership per stream.
## State Management Discipline
**v4 introduces zero new state-management layers.** Every piece of state added by this spec maps to an existing pattern. Before touching client state anywhere in this codebase, consult this decision tree and pick the lowest layer that works.
| State need | Layer to use | Why |
| --- | --- | --- |
| Authoritative server-owned data (entities, views, messages, user info) shared across requests | **SSR fetch in `page.tsx` → pass as props or React Query `initialData`** | Tenant-scoped via RLS, cacheable, hydrates once without flash |
| Interactive client-side reads of server data, mutations, optimistic updates | **React Query (`@tanstack/react-query`)** | Single cache, invalidation hooks, mutation state, background refetch |
| Live updates from other users or the server | **Supabase Realtime → invalidates React Query** | One source of truth for "what changed"; existing channel patterns |
| Form / local UI state (open/closed, selections, hover, draft text) | **`useState` / `useReducer`** in the owning component | Component-scoped; no tree pollution |
| Scoped state a small component tree needs to read | **React Context** (wrap the subtree only) | Lightweight; no dependencies; pattern already used by `AgentSidebarProvider`, `ViewFilterProvider`, `EditModeProvider` |
| App-shell-wide UI state (sidebar pinned, theme, active pointer into chat) | **React Context at the layout root** | Same pattern as above, just higher mount |
| URL-addressable state (filters, active tab, selected view) | **Next.js search params + `useSearchParams`** | Shareable, reloadable, backable |
| Cross-request shared read-only server data (entity types, nav config) | **`'use cache'` via `cacheTag`** — see `features/entities/server/cached-queries.ts` | Stable, tenant-scoped, invalidatable on mutation |
### Layers we deliberately do NOT use
- **Zustand / Jotai / Redux / MobX / Recoil / any external state library.** We already have SSR + React Query + Context + URL + `'use cache'`. A fifth layer would fragment mental models, double the number of places data can live, and make SSR hydration harder. **Every piece of state the v4 proposed-view loop needs fits into the layers above.**
- **`useEffect` fetching.** Always use React Query. Raw `useEffect(() => fetch(...))` patterns are forbidden by the performance rules.
- **Global singleton modules holding state.** Leaks across requests in Next.js; breaks React strict mode.
### How v4 Stream C satisfies "preview / save / rollback / revisit / pop-out" without a new layer
| Concern | Layer used |
| --- | --- |
| Where every ephemeral `ViewRecord` lives (proposed mutations, pop-outs, `generateView` outputs) | `messages` table → chat history React Query cache (already exists) |
| How the page knows which ephemeral view is active | Thin React Context pointer (`EphemeralViewContext`) holding a single `messageId` |
| How "Save in place" / "Save as new" persist | React Query mutation → existing `updateView()` / `createView()` server actions |
| How "Close" rolls back | `setActiveEphemeralView(null)` — no store cleanup |
| How "Revisit later" works | Chat history is already persistent via the `messages` table; clicking any old ephemeral-view card re-activates it |
| How inline chat preview and page overlay stay visually consistent | Both render the same `ViewRecord` through the same `UnifiedViewRenderer` with the same theme tokens |
| How "any tool output can be popped out" | Tool returns the `EphemeralViewToolOutput` envelope; chat and overlay route uniformly without per-tool UI |
### Documentation home
This table will be lifted into `content/docs/features/view-system.mdx` → "State Management" subsection when Stream C lands, and cross-linked from `content/docs/architecture.mdx` and `.claude/rules/performance.md`. Any future spec that proposes a new state layer must first explain why none of the above works and get explicit approval.
## Relationship to Existing Ephemeral-View Flows
The chat already has ephemeral view rendering. v4 extends it — it does not replace it.
| Existing piece | Today's role | v4's treatment |
| --- | --- | --- |
| `saveTransientView` tool | Writes a view row with `scope: "user"` directly to DB from chat output | Keep. Its new documented role: "promote an existing chat-message proposal to a persistent `scope: user` row." Internally becomes a thin wrapper over `createView()` that reads the proposal payload from the referenced message. |
| `generateView` tool | Builds a fresh detached view from natural language, returns a `ViewRecord` | Keep. Its new documented role: "create a fresh proposal with no `baseViewId` (fresh-page intent)." Output shape aligns with `ProposedViewToolOutput` so the chat card/overlay UX is uniform. |
| `manageView` tool | Currently persists mutations directly to DB | Extend with `mode: "propose" \| "persist"`. Default stays `"persist"` for backwards compatibility; propose mode returns the proposed payload without writing. |
| `chatOutputToBlocks` + inline surface rendering in chat | Renders tool output inline as a block grid inside a chat bubble | Keep untouched. The inline preview is still a nice convenience. Stream C is additive — the page-level overlay is a SECOND rendering that appears only when the user clicks "Open on page". |
| `features/chat/components/tool-call-card.tsx` branches | Dispatches per tool slug | Add ONE new branch detecting `kind: "proposed-view"` in tool output. All existing branches stay untouched. |
**Migration path:** no data migration required. Existing `saveTransientView` calls still produce the same DB rows; their tool output shape gets a thin wrapping to match `ProposedViewToolOutput` so the new card UI handles them. Old chat messages with raw tool output continue to render through the existing inline path.
## Follow-up Quality Workstreams (NOT in v4 scope)
The 2026-04-08 codex system review (`SprinterVault/20-Ventures/Amble/shared-context/2026-04-08-view-block-system-review-and-improvement-plan.md`) identifies a different class of gap: **not missing features, but missing quality/safety rails**. Agents can produce structurally valid configs that render badly (ugly horizontal bar charts, narrow-container overflow, weak composition). v4 does NOT attempt to solve that — it is a foundational pass. The following workstreams are carried into the backlog as **Quality Rails v1** and should be scoped as a separate spec once v4 lands:
1. **View Health / Block Health model** — per-block health states (`healthy` / `healed` / `degraded` / `broken`) bubbling up to view-level health. Surfaces repair metadata to users and agents instead of failing silently.
2. **Chart Suitability & Downgrade Engine** — before rendering any chart, score data shape vs container vs label density and auto-downgrade (e.g. horizontal bar → ranked list) when the naïve choice would look bad.
3. **Semantic Visualization Chooser** — agents pick *intent* (ranking / trend / composition / progress / distribution) instead of chart type; the engine chooses the visual.
4. **Layout Recipe Engine** — reusable row archetypes (hero / compare / trend / evidence / action) with placement rules per surface intent (executive / analytical / operational / record / workflow).
5. **Template Quality Tiers** — `experimental / stable / flagship` template tiers; agents default to flagship; linting prevents weak compositions.
6. **Presentation Validity Layer** — validation split into structural / semantic / presentation. "Will this render cleanly" is a separate check from "is the schema legal".
7. **Degraded-but-usable Fallbacks** — every data block declares a safe fallback (chart → summary table, feed → recent-items list) so one weak block never breaks a whole view.
8. **Operation-based Editing API for Chat** — agents mutate views through high-level operations ("add comparison chart for metric X grouped by Y") instead of emitting raw block arrays. Prevents valid-but-ugly configs.
9. **Publish-quality Gate** — before a view is persisted with `scope: "shared"` or "published", run the health + suitability + coherence checks. Block publish if critical.
10. **Vertical Template Packs** — Marbella operator pack, DOC'S protocol pack, OCI marketing ops pack. Anchors AI-generated views in domain-relevant compositions.
11. **Container-aware Visual Regression** — each block family gets playwright snapshots at narrow / medium / full widths + sparse / dense data.
12. **Intent-led Editor Mode** — workspace editor gains a guided mode that asks "what are you trying to show?" and fills in block choices; advanced JSON/config editing still available behind a toggle.
**Why these are NOT in v4.** v4 is about closing gaps in the **existing primitives** (surfaces, blocks, data sources, filters, agent context, proposed-view loop, polish baseline) so they form a stable substrate. The codex plan builds a **quality system on top of that substrate**. Doing them in the same pass creates sprawl and risks shipping neither cleanly. v4 is the foundation; Quality Rails v1 is the next spec. That sequencing is deliberate.
**Where they land next.** When v4 is merged, open a new spec at `content/docs/roadmap/specs/view-block-quality-rails-v1.mdx` drafted from the twelve items above and prioritize items 1 (Health Model), 2 (Chart Suitability), and 7 (Degraded Fallbacks) as P0. Items 8 (Operation-based Editing), 10 (Vertical Packs), and 12 (Intent-led Editor) are P1. The rest are P2.
## References
- `content/docs/roadmap/specs/view-block-system-v3.mdx` — workspace editor + response mode (in-progress, mostly shipped)
- `content/docs/roadmap/specs/view-block-system-v2.mdx` — superseded, still a useful history of the unified renderer decision
- `docs/superpowers/specs/2026-04-07-shared-data-sources-cross-block-filtering.md` — Tableau model plumbing (now mostly shipped via PR #577)
- `docs/superpowers/plans/2026-04-07-surface-block-architecture-cleanup.md` — surfaces vs blocks cleanup plan (Stream A closes this)
- `docs/superpowers/specs/2026-04-02-surface-types-definitive-spec.md` — canonical surface vocabulary
- `SprinterVault/20-Ventures/Amble/shared-context/2026-04-08-view-block-system-review-and-improvement-plan.md` — codex review; Quality Rails v1 backlog source
- `SprinterVault/20-Ventures/Amble/SPRINTER-UI-WORKSPACE-IMPLEMENTATION-BACKLOG.md` — 2026-04-08 UX backlog update (native chat-to-view loop, seam removal, premium polish)
- `docs/superpowers/plans/2026-04-08-view-block-v4-parallel-streams.md` — implementation plan with exact task decomposition per stream