The layout model
The Dashfoo node schema, the model builders, per-node enable flags, and how normalize() keeps the tree canonical.
Everything dashfoo knows about a layout lives in one serializable tree: the
Dashfoo object. It is plain JSON, with no class instances, functions, or DOM
references. The pure reducer in @dashfoo/core takes a Dashfoo plus an action
and returns a new Dashfoo (via structuredClone, never mutation), so the same
value round-trips through JSON.stringify, a database column, a postMessage, or
React state without losing anything.
This guide walks the shape of that tree, the three node types and their per-node
enable* flags, how normalize() keeps the tree canonical after every action,
and the serialization helpers (toJSON / fromJSON) that move a
model in and out of storage.
Every type and schema referenced here is exported from @dashfoo/core and
defined in
packages/core/src/model/schema.ts.
If the words tabset, pane, splitter, or float are new, start with
Concepts & terminology, which introduces them in one annotated
figure.
Building a model: the builders
You can write the tree as a plain object (the rest of this guide shows the full
shape), but the builders make it terse by filling the mechanical fields
(type, version, selected, default global). They're the recommended way to
author a model:
import { model, row, tabset, tab } from "@dashfoo/core";
const m = model(
row(
[
tabset([tab("chart", "Chart"), tab("depth", "Depth")], { id: "left", weight: 2 }),
tabset([tab("book", "Order Book")], { id: "right" }),
],
{ id: "root" },
),
{ activeTabsetId: "left" },
);| Builder | Returns | Defaults it fills |
|---|---|---|
tab(component, name, opts?) | TabNode | type: "tab"; id defaults to component |
tabset(children, opts?) | TabsetNode | type: "tabset"; selected: 0; auto id (tabset-…) |
row(children, opts?) | RowNode | type: "row"; orientation: "row"; auto id (row-…) |
model(layout, opts?) | Dashfoo | version: 1; global: {} |
opts carries the meaningful fields: id (pass one for any node you reference,
e.g. via activeTabsetId), weight, selected, orientation, per-node
enable* flags, min/max, name (on tabset, its accessible label),
config, and on model: activeTabsetId, maximizedTabsetId, global.
Because a tab's id defaults to its component, two tab("chart", …) calls
collide, so pass explicit ids when reusing a component; model() warns once if
it detects duplicates. The output is exactly the plain object documented below.
Validate it with dashfooSchema.parse if you like. The raw form remains
fully supported (e.g. for models loaded from JSON).
The root: Dashfoo
type Dashfoo = {
activeTabsetId?: string;
global: GlobalAttributes;
layout: RowNode;
floats: FloatNode[];
maximizedTabsetId?: string;
version: 1;
};| Field | Type | What it holds |
|---|---|---|
layout | RowNode | The tiled center area. Always a row at the root. |
global | GlobalAttributes | Defaults that apply tree-wide unless a node overrides them. |
version | 1 | The payload format version, pinned by the schema itself. |
activeTabsetId | string? | Id of the tabset that currently has focus. |
maximizedTabsetId | string? | Id of the tabset rendered full-area, hiding its siblings. |
floats | FloatNode[] | Floating panels, each its own layout subtree. Empty when nothing is floating. See Floating panels. |
layout is the part most people picture: a recursive grid of rows and tabsets.
Both activeTabsetId and maximizedTabsetId are id references, not nested
objects, which is why normalize() has to check that they still point at a
tabset that exists.
Node types
There are three node types, discriminated by their type field: row,
tabset, and tab. Tabs are the leaves. Tabsets hold tabs. Rows hold
tabsets and other rows.
RowNode: the recursive container
type RowNode = {
children: Array<RowNode | TabsetNode>;
id: string;
max?: Dimension;
min?: Dimension;
orientation: "row" | "column";
snap?: { step?: number; divisions?: number | "panels"; threshold?: number };
type: "row";
weight: number;
};A row lays its children out along one axis. orientation: "row" arranges them
left-to-right; orientation: "column" arranges them top-to-bottom. Children are
either tabsets (leaves of the grid) or nested rows (which is how you get a column
inside a row, and the resizable splitters between them). weight is the child's
share of its parent's space relative to its siblings, defaulting to 1 when the
input omits it; the root row's own weight is ignored. min and max are optional Dimension constraints on the
row as a whole, useful for clamping a nested column without constraining each
tabset inside it. See Dimensions below. snap overrides the
layout-wide global.snap for this row's splitters; an empty {} opts a single
row out of an inherited snap default.
orientation: "row"
children flow left-to-right; the splitter is vertical
orientation: "column"
children stack top-to-bottom; the splitter is horizontal
TabsetNode: a tabbed pane
type TabsetNode = {
children: TabNode[];
enableClose?: boolean;
enableMaximize?: boolean;
id: string;
max?: Dimension;
min?: Dimension;
name?: string;
selected: number;
type: "tabset";
weight: number;
};A tabset is one pane with a tab strip. children is its tabs; selected is the
index of the visible one (zero-based). weight works as it does on rows. min
and max are optional Dimension constraints (see Dimensions
below). name labels the tabset for assistive technology: the React renderer
uses it as the tab strip's aria-label, falling back to "Tabs" when omitted.
The two flags gate this tabset's chrome:
| Flag | Effect when false |
|---|---|
enableClose | Tabs in this tabset show no close buttons (turns off per-tab closing for the pane). |
enableMaximize | The tabset has no maximize affordance. |
Omitting enableMaximize falls back to global.tabSetEnableMaximize. There is
no tabset-level close global: an omitted enableClose defers to
global.tabEnableClose and each tab's own enableClose (a tab is closable only
when every level allows it).
TabNode: a leaf
type TabNode = {
component: string;
config?: Json;
enableClose?: boolean;
enableDrag?: boolean;
enableRename?: boolean;
id: string;
name: string;
type: "tab";
};A tab is one document. component is a string key your renderer maps to a React
component; dashfoo stores the key, never the component itself, which is what
keeps the model serializable. name is the label shown in the strip. config is arbitrary per-tab state, but
it is validated against a JSON schema (jsonValueSchema), so a function or a
Symbol in config fails parsing rather than silently breaking serialization.
The three per-tab flags:
| Flag | Effect when false |
|---|---|
enableClose | The tab shows no close button. |
enableDrag | The tab cannot be dragged to another tabset. |
enableRename | Double-clicking the tab will not rename it. |
Dimensions
The min / max constraints on tabsets and rows are Dimension values, not
raw numbers:
type Dimension = {
unit: "px" | "%" | "em" | "rem" | "vh" | "vw";
value: number;
};Carrying the unit alongside the value lets a constraint read { unit: "px", value: 320 }
or { unit: "%", value: 30 } without an out-of-band convention about what the
number means. All six units pass straight to react-resizable-panels, which
resolves them: em/rem against the computed font size, vh/vw against the
viewport. One nuance is px-only: the minimum dashfoo derives for a row from its
descendant tabsets folds in only px mins, so a non-px min anywhere in that
subtree opts it out of the derivation. As a shorthand, the tabset() and
row() builders also accept a bare number for min / max and wrap it as
px.
Global attributes
global holds defaults that a node inherits unless it sets its own value. Every
field is optional; an empty global: {} is valid and means "use the renderer's
built-in defaults."
type GlobalAttributes = {
enableSplitDock?: boolean;
enableSplitResize?: boolean;
snap?: { step?: number; divisions?: number | "panels"; threshold?: number };
splitterSize?: number;
tabEnableClose?: boolean;
tabEnableDrag?: boolean;
tabEnableRename?: boolean;
tabLocation?: "top" | "bottom";
tabSetEnableMaximize?: boolean;
tabSetEnableTabStrip?: boolean;
tabSetMinSize?: number;
};Four keys are tree-wide defaults for the per-node enable* flags above:
tabEnableClose sits behind both the tabset and the tab enableClose,
tabEnableRename backs tab.enableRename, tabEnableDrag backs
tab.enableDrag, and tabSetEnableMaximize backs tabset.enableMaximize. Set
one to false to turn the feature off everywhere.
tabLocation: "bottom" moves the tab strip below the content;
tabSetEnableTabStrip: false hides the strip entirely (a pure resizable-pane
grid). enableSplitDock toggles whether drops can split a tabset at all, and
enableSplitResize: false disables the splitters (the gutters stay mounted at
their themed size, so nothing reflows).
splitterSize sets the resize-handle gutter in pixels, and tabSetMinSize
sets the default tabset minimum size in pixels. The React adapter uses 320
when tabSetMinSize is omitted.
snap turns on magnetic snapping for splitter drags: the dragged boundary pulls
onto a grid while within threshold percent (default 4), then commits on
release as one undo step. The grid is the union of two optional sources: step
(multiples of a fixed percent) and divisions (even splits: multiples of
100/d, where d is the number, or "panels" to divide by the row's own panel
count, so a 3-panel row snaps to thirds and a 4-panel row to quarters). Any
RowNode can override the layout-wide snap with its own; an empty {} (or
{ step: 0 } with no divisions) opts that row out. The DashfooLayout snap
prop replaces this global when both are set, and compact mode locks it off with
the rest of resize.
These globals travel with the serialized document. The renderer-level "make the
whole dashboard static" switch is deliberately not a global: it is the
editable prop on DashfooLayout / Layout.Root (see
static layouts), so a layout saved while
locked does not come back locked.
A real model
Here is the demo's overview layout
(apps/demo-vite/src/models.ts),
expanded from its builder calls into the plain object they produce. A wide main tabset on the left, weighted 2, sits beside a
right-hand column (a nested row with orientation: "column") holding two
stacked tabsets, each weighted 1.
import type { Dashfoo } from "@dashfoo/core";
const overviewModel: Dashfoo = {
activeTabsetId: "ts-main",
global: {},
layout: {
children: [
{
children: [
{ component: "canvas", id: "canvas", name: "Canvas", type: "tab" },
{ component: "detail", id: "detail", name: "Detail", type: "tab" },
],
id: "ts-main",
selected: 0,
type: "tabset",
weight: 2,
},
{
children: [
{
children: [
{ component: "activity", id: "activity", name: "Activity", type: "tab" },
{ component: "tasks", id: "tasks", name: "Tasks", type: "tab" },
],
id: "ts-side-top",
selected: 0,
type: "tabset",
weight: 1,
},
{
children: [
{ component: "metrics", id: "metrics", name: "Metrics", type: "tab" },
{ component: "history", id: "history", name: "History", type: "tab" },
{ component: "reports", id: "reports", name: "Reports", type: "tab" },
],
id: "ts-side-bottom",
selected: 0,
type: "tabset",
weight: 1,
},
],
id: "right",
orientation: "column",
type: "row",
weight: 1,
},
],
id: "root",
orientation: "row",
type: "row",
},
version: 1,
};The object omits enable* flags (the defaults are fine), min/max
(weight handles the proportions), and maximizedTabsetId (nothing starts
maximized). The model carries only what differs from the defaults; the builders
fill in weight, selected and floats, exactly as dashfooSchema does for a
parsed payload.
Invariants at the boundary
Three invariants are enforced where a model enters the library rather than being repaired afterwards, so no reader has to carry a fallback for them:
- A
weighton every row and tabset.dashfooSchemadefaults an omittedweightto1and the builders do the same, so a reader can sumchild.weightwithout deciding what a missing one means. - A
selectedinside its strip.tabsetNodeSchemaclampsselectedinto[0, children.length - 1]at parse,tabset()clamps what you pass it, and the actions that can invalidate it (selecting a tab, closing one, moving one out, writingselectedthroughupdateNodeAttributes) re-clamp the one tabset they touch. - A
floatsarray. Omitted on input, always present on output, empty when nothing is floating.
How normalize() self-heals
Run after every action (and on every parse), normalize() rewrites a model into
its canonical form. The reducer can therefore produce a slightly-off intermediate
tree (a tabset whose last tab just closed, a row left with one child), then let
the normalize pass clean it up. There is one definition of "valid layout," and
it lives in
packages/core/src/model/invariants.ts.
The pass enforces four structural invariants. Clamping selected is not one of
them: that happens at the boundary (see above), not on every dispatch.
1. Drop empty tabsets. A tabset whose children array is empty is removed
from its parent row. Closing the last tab in a pane removes the pane, rather than
leaving an empty frame behind.
2. Drop empty rows. A row whose children all vanished (recursively) is removed too. Emptiness propagates upward.
3. Collapse single-child rows. A row left with exactly one child is
redundant (it adds a nesting level without splitting anything), so the lone
child is lifted into the grandparent's place. The child inherits the lifted row's
weight, so the visible sizing is preserved across the simplification. At the root,
if the tree reduces to a single child that is itself a row, that row is absorbed:
its children and orientation replace the root's, avoiding redundant nesting.
4. Repair the id references. activeTabsetId is checked against the set of
tabset ids that remain after the structural rewrites. If it no longer
points at a real tabset, it falls back to the first tabset in the tree.
maximizedTabsetId gets the same existence check, but a stale value is cleared
to undefined rather than reassigned: you do not want to silently maximize a
different pane than the one the user chose.
Because normalize is idempotent, applying it twice gives the same result as applying it once. That property is what lets the reducer call it unconditionally.
Serialization: toJSON, fromJSON
The helpers in
packages/core/src/model/serialize.ts
move a model across a storage boundary safely.
import { fromJSON, model, row, tab, tabset, toJSON } from "@dashfoo/core";
const m = model(row([tabset([tab("chart", "Chart")])]));
const json = toJSON(m); // Dashfoo -> string
const restored = fromJSON(json); // string -> validated, normalized DashfootoJSON is a thin JSON.stringify. The work is on the way back in. fromJSON
parses the string, validates against dashfooSchema, and returns a normalized
model, so anything you load from localStorage, a URL, or a server is
guaranteed to be a canonical Dashfoo or to throw. There is no path where a
half-valid tree reaches your renderer.
Under the hood fromJSON is parseModel(JSON.parse(json)), and parseModel
reduces to two steps:
const parseModel = (value: unknown): Dashfoo => normalize(dashfooSchema.parse(value));The real implementation adds one more check: it warns (without throwing) when
the loaded layout contains duplicate node ids, mirroring the model() builder's
warning.
The payload's version field is pinned to 1 by the schema (z.literal(1)),
so the format itself is versioned without any migration machinery: a payload
written in a different format fails validation instead of loading lossily. A
future format change bumps the literal.
Order is deliberate: validate first (so a malformed payload is rejected with a zod error, and the schema's own defaults and clamps apply), normalize second (so the structure is canonical). Skip normalize and you might hand your renderer a row that adds a nesting level without splitting anything, or an id reference pointing at a tabset that is no longer there.
See also
packages/core/src/model/schema.ts: the zod schemas and exported types for every node and forDashfooitself.packages/core/src/model/invariants.ts: the fullnormalize()implementation.packages/core/src/model/serialize.ts:toJSON,fromJSON,parseModel.apps/demo-vite/src/models.ts: the five demo models (overviewModel,dockingModel,chromeModel,playgroundModel,sizingModel), all written with the builders, that you can copy from.
Concepts & terminology
The names dashfoo uses for tabs, panes, rows, splitters, and floating panels, with one annotated figure, the vocabulary, and the same layout as data.
Drag and dock
How dragging a tab or a whole tabset resolves to a stack or a directional split. The landing zones, the pipeline, and the gates. Pointer-only.