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
| Op | Effect |
|---|---|
set-props | Merges props into the target; a null value deletes that prop. |
hide | Sets the hidden flag — the reversible "remove" for user toggles. |
remove | Deletes the node. |
replace | Swaps the target for another node. |
insert | Adds a node before/after the target, or at its start/end. |
move | Relocates the target relative to (or into) another node. |
set-meta | Overwrites 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,viewStateand 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)reproducesnext). 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.