Skip to main content

Customization

Users change views; the base view keeps evolving. Storing the user's edited copy forks it — their copy goes stale the day the base view ships a new section. @zuilib/apps stores what changed instead: a list of patch operations keyed by node id, applied over the base at render time.

import { applyViewPatches, type ViewPatch } from '@zuilib/apps'

const patches: ViewPatch[] = [
{ op: 'set-props', target: 'title', props: { text: 'My pipeline' } },
{ op: 'hide', target: 'nps-metric' },
{ op: 'move', target: 'chart', destination: 'layout', position: 'start' },
]

const { spec, skipped } = applyViewPatches(baseSpec, patches)

applyViewPatches never mutates the input and never throws on a missing target: when the base view no longer has the node a patch names, the patch lands in skipped with a reason, and everything else still applies. Reset to default is deleting the patch list.

Operations

OpEffect
set-propsMerges props into the target; a null value deletes that prop.
hideSets the hidden flag — the reversible "remove" for user toggles.
removeDeletes the node.
replaceSwaps the target for another node.
insertAdds a node before/after the target, or at its start/end.
moveRelocates the target relative to (or into) another node.
set-metaOverwrites the view's title / description.

The inline editor

@zuilib/apps/editor ships <ViewEditor> — the rendered view is the editing surface. Users click a block to select it, drag it anywhere (within or across containers), retype titles and values in place, swap a $bind data source from the block's toolbar, and hide blocks reversibly (a hidden block stays on the canvas as a ghost with a "Show" affordance). A content drag can land precisely before or after another component, or directly in the whitespace of an empty or populated layout. Layouts drag as whole sections in their own lane. Compatible containers highlight, a floating cue follows the pointer, Escape cancels, and long canvases auto-scroll near their edges; hit testing is limited to one measurement pass per animation frame. Hovering a content component shows + chips on its edges for another component in the same layout container. Layout items use separate, green editor frames: their edge chips add sibling layout items, their inner "Add content" control fills the container, and an "Add layout item" strip sits at the end of the view. Each affordance opens the filtered insert drawer: a panel docked to the editor's edge (no portal, no backdrop) so the view and a marker at the insertion point stay visible while you browse. It lists the eligible layout or content definitions from the registry, grouped by category and searchable. Content entries show a live preview of the exact node they add, while layout entries use a structural schematic so empty containers are not mistaken for pre-filled templates. Picking one inserts it from its registry example (fresh unique ids on every node, $binds retargeted to the view's datasets), so the new block lands already selected, editable and movable. Selecting a block opens its properties panel automatically in the same docked surface — its controls are generated from the registry's prop metadata (enums become selects, booleans toggles, 'data' props dataset pickers, 'dataset' and 'column' props schema-fed pickers, 'action' props a picker over the host's declared actionCatalog, arrays and objects a validated JSON editor), and it follows the selection while open. When the editor is narrower than 42rem, the insert drawer and the properties panel open as a full-width bottom sheet (at most 70% of the viewport height) instead of a docked column; tap a block to reveal its toolbar and insert chips, and drag it by the ⠿ handle on touch.

Two prop shapes get more than a generic control. An 'object' prop marked editAs: 'chart' (the chart preset's spec) opens as a chart options editor: a chart-type switcher that remaps fields sensibly when you cross families (a bar's xKey/series become a donut's nameKey/valueKey, and back), axis and series pickers fed by the bound dataset's schema (declared, or inferred from the rows), a per-series palette row over the theme's --chart-1..8 slots, primary/secondary value-axis assignment, stacking, legend and value-format controls — with the raw ChartSpec JSON one disclosure away for everything else. And any selected block bound to a dataset gets a Data tab beside its options: the dataset's fields with their types, and the first rows formatted with the schema's format tokens — what the block is drawing, inspectable in place. Both are exported for hand-built surfaces too: ChartSpecEditor (and convertChartType) come from @zuilib/apps/editor.

Every gesture is undoable: ⌘Z / Ctrl+Z steps back through the whole history (⇧⌘Z or Ctrl+Y forward), with matching buttons floating over the canvas, and a Duplicate button on the selection toolbar inserts a fresh-id copy of a block right after it. Every gesture comes out as a patch:

import ViewEditor from '@zuilib/apps/editor'

<ViewEditor
spec={baseSpec}
registry={registry}
patches={patches}
onPatchesChange={savePatches} // the whole next list, after every gesture
editing={mode === 'edit'} // false renders plainly, patches still applied
/>

The registry drives what is editable: nodes need an id to become blocks, props marked editAs: 'text' (heading text, card titles, metric values, …) get inline editing, editAs: 'multiline' (markdown content) opens a textarea on double-click, props typed 'data' get the dataset picker, and each definition's example doubles as its insertion template (definitions without one get a node synthesized from their prop metadata). The editor keeps the patch list minimal — retyping a title back to the base value removes the patch instead of storing a no-op.

The editor page

@zuilib/apps/workbench ships <ViewWorkbench> — the page around the editor, so a host does not rebuild the same chrome: a toolbar with Edit / Preview / Code modes, the patch count, a reset button and slots for your own buttons (an export, a save state), the inline <ViewEditor> as the canvas, and a code pane showing what gets stored (the patch list) and what it produces (base view + patches). The patch list is the one thing of state — controlled with patches + onPatchesChange, or kept inside.

import ViewWorkbench from '@zuilib/apps/workbench'

<ViewWorkbench
spec={baseSpec}
registry={registry}
patches={patches}
onPatchesChange={savePatches}
datasetSchemas={[accounts]}
toolbarEnd={<ExportButton />}
assistant={assistant} // optional — see below
/>

The assistant

Given an assistant, the workbench docks an assistant panel beside the canvas. It is not a chat: it works on the view in front of the user. It knows the selected block (the context line names it), offers your context-aware suggestions, streams what it is doing with the @zuilib/ai cards (text, tool calls, sources), and turns the model's answer into a proposal — one checkbox per patch, previewed live on the canvas, with Apply (the checked patches join the stored list, undoable like any gesture) and Discard. A gesture on the canvas while a proposal is previewing accepts it. Questions get an answer with sources and, when it makes sense, a follow-up button that sends the next ask.

The host supplies only run — the model's side — and, optionally, the suggestions worth showing from the current context:

import { VIEW_ASSISTANT_PARTS, type ViewAssistant } from '@zuilib/apps/workbench'
import { editPrompt, patchJsonSchema, validateViewPatches } from '@zuilib/apps'
import { fromSSE } from '@zuilib/ai/adapters'

const assistant: ViewAssistant = {
// The model answers with ViewPatch ops; the gateway streams them back as
// a `view-proposal` part — plus whatever text, tool calls and sources it likes.
run: (ask, { spec, selectedId, patches }, { signal }) =>
fromSSE(fetch('/api/view-assistant', {
method: 'POST',
body: JSON.stringify({ ask, selectedId, prompt: editPrompt(registry, spec), schema: patchJsonSchema }),
signal,
})),
suggestions: ({ selectedId }) =>
selectedId
? [{ label: 'Hide it', ask: 'hide this block' }, { label: 'Move it to the top', ask: 'move this to the top' }]
: [{ label: 'Trim it to the essentials', ask: 'trim the report' }],
}

Two custom event kinds carry the assistant's answers: view-proposal ({ patches: ViewPatch[], lines?: string[] }lines are the human descriptions shown beside the checkboxes; without them each patch is described from its op) and view-action ({ label, ask }, a follow-up button). Everything else — text, tool-call, tool-result, citation — renders as it does in a thread. Validate the model's patches with validateViewPatches(patches, spec, registry) before you emit them. <ViewAssistantPanel> is exported on its own for hand-built surfaces; it takes the same assistant, the context (spec, selectedId, patches), onPreviewChange, onApply and onUndo.

Building your own customizer

The same contract works for hand-built panels. collectNodeIds(spec) lists every targetable node in tree order, and appendEditorPatch (from @zuilib/apps/editor) applies the same merge logic <ViewEditor> uses:

const ids = collectNodeIds(baseSpec)

function toggleSection(id: string, visible: boolean) {
setPatches((prev) => [
...prev.filter((p) => !(p.op === 'hide' && p.target === id)),
...(visible ? [] : [{ op: 'hide', target: id } as const]),
])
}

Persist the patch list wherever you persist preferences. An LLM emits the same patches — see AI generation & editing for editPrompt, patchJsonSchema and validateViewPatches: "move the churn chart above the table" is a two-line ViewPatch answer that merges, undoes and persists exactly like a drag.

For hosted ownership, immutable versions, and user/group grants, store the resulting ViewSpec with ZUI Cloud Config Store. The consuming application still owns the base-view/patch strategy and its data authorization policy.

Lifecycle utilities

Everything a host needs to run patches as its storage format:

  • assignNodeIds(spec) — ids every node that lacks one (patches, viewState and the editor all address nodes by id). Run it once before a spec first goes to storage.
  • diffViewSpecs(base, next) — expresses a whole revised spec as a patch list against the base (applyViewPatches(base, diff) reproduces next). This is how "the user saved a new version" or an externally produced spec becomes reviewable operations instead of an opaque blob.
  • migrateViewSpec(stored) — the load path: parses, upgrades older spec versions through registered migration steps, rejects newer ones. Store specs however you like; load them through this.