dashfoo

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" },
);
BuilderReturnsDefaults it fills
tab(component, name, opts?)TabNodetype: "tab"; id defaults to component
tabset(children, opts?)TabsetNodetype: "tabset"; selected: 0; auto id (tabset-…)
row(children, opts?)RowNodetype: "row"; orientation: "row"; auto id (row-…)
model(layout, opts?)Dashfooversion: 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;
};
FieldTypeWhat it holds
layoutRowNodeThe tiled center area. Always a row at the root.
globalGlobalAttributesDefaults that apply tree-wide unless a node overrides them.
version1The payload format version, pinned by the schema itself.
activeTabsetIdstring?Id of the tabset that currently has focus.
maximizedTabsetIdstring?Id of the tabset rendered full-area, hiding its siblings.
floatsFloatNode[]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

The same two children under the two orientations. Nesting a column row inside a row (and vice versa) is how any grid of panes is built.

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:

FlagEffect when false
enableCloseTabs in this tabset show no close buttons (turns off per-tab closing for the pane).
enableMaximizeThe 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:

FlagEffect when false
enableCloseThe tab shows no close button.
enableDragThe tab cannot be dragged to another tabset.
enableRenameDouble-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 weight on every row and tabset. dashfooSchema defaults an omitted weight to 1 and the builders do the same, so a reader can sum child.weight without deciding what a missing one means.
  • A selected inside its strip. tabsetNodeSchema clamps selected into [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, writing selected through updateNodeAttributes) re-clamp the one tabset they touch.
  • A floats array. 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 Dashfoo

toJSON 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