New to the library? Start with the developer guide:
user/README.md(tutorial),user/recipes.md(how-to) anduser/design.md(why the API is shaped this way).
The consumer contract. Hand-written, reviewed alongside the code: any public type
change needs an update here in the same commit. Sources of truth are
prd.md §5.1, §5.2, §6.5 and the frozen types in
packages/react-grid-engine/src/.
Naming note — the
Panel*smell is deliberate.PanelComponentDef,PanelComponentProps(and the historicalpanelId) keep thePanel*prefix for tab components; everything user-facing is container/tab. This is a naming smell, not a defect — do not rename half of it. Either the whole vocabulary moves in a breaking release, or it stays.
The layout is the versioned, serializable contract. weight is the only number on the
wire; all chrome comes from --twge-* CSS variables. Ids are data and authoritative on
load — the engine mints UUIDs only when it creates a node in a handler.
interface Layout {
version: 1;
root: Node; // a lone container is a valid root
activeContainerId?: string; // focused container; default = first in tree order
}
type Node = SplitNode | ContainerNode; // the only two node kinds
interface SplitNode { // internal node — the ONLY thing that owns space
type: 'split';
id: string;
axis: 'row' | 'column'; // row: side by side | column: stacked
weight?: number; // share along the PARENT's axis (default 1)
children: Node[]; // >= 2
}
interface ContainerNode { // leaf — owns a rect, owns tabs, shows exactly one
type: 'container';
id: string;
weight?: number;
activeTabId: string;
tabs: Tab[]; // >= 1, except an empty root
}
interface Tab {
id: string;
component: string; // registry string key — never a reference
title?: string; // serialized; registry `title(config)` is fallback
color?: string; // hex accent — see Color
config: unknown; // app-owned, opaque to the engine
}Schema rules (the rules, not the field list, are the schema):
- One size number per node, never
width+height.weightis the share along the parent's axis; the cross axis is always the parent's full extent. - Weights are normalized at render (
weight / Σ siblingWeights), likeflex-grow. They need not sum to 1. - Cross-axis spanning is a consequence of nesting; there is no
spanfield. - Invariants: a split has ≥ 2 children; a one-child split is spliced out; a container
has ≥ 1 tab except an empty root. Structural violations warn + fall back to
defaultLayout. Dangling references are repaired, not rejected —activeTabId→ that container's first tab,activeContainerId→ first container in tree order, rest of the layout kept. - Chrome is never serialized.
- Non-guillotine layouts (pinwheels) are out of scope.
- Ids are data, not render artefacts.
const CURRENT_LAYOUT_VERSION = 1;
function parseLayout(
input: unknown, // JSON string or already-parsed value
defaultLayout: Layout,
warn?: (message: string) => void,
): Layout;
function serializeLayout(layout: Layout): string;Reading never throws and never mints ids — ids in the JSON are authoritative:
- Unknown fields are dropped on read;
serializeLayoutemits only wire fields, never chrome. - A structural violation — bad node shape, a split with fewer than two children, a non-root
empty container, a tab without
id/component, nesting beyond the depth guard — warns and returns a copy ofdefaultLayout. Structural damage discards the workspace. - A dangling reference is repaired instead, and the rest of the layout survives:
activeTabId→ that container's first tab,activeContainerId→ first container in tree order. An absentactiveContainerIdstays absent (it is optional); a present-but-dangling one is rewritten. - A future
versionis unknown input — it warns and falls back like any other invalid payload. warndefaults toconsole.warnwith a[react-grid-engine]prefix; pass a sink to capture or silence it. Successful reads warn about nothing.
The engine root. The layout is uncontrolled: defaultLayout is parsed and normalized once, on
mount; every later write goes through the ref handle (no controlled layout prop).
<GridEngine
ref={engineRef} // GridEngineHandle
defaultLayout={layout} // Layout — read once, on mount
registry={registry} // Record<string, PanelComponentDef> — never serialized
onLayoutChange={(layout, meta) => {}} // optional — once per committed change
onTabEvent={(tabId, type, payload) => {}} // optional
onTabConfigChange={(tabId, config, meta) => {}} // optional
onTabColorChange={(tabId, color, meta) => {}} // optional — meta.source is 'ui' | 'app'
tabColorPalette={['#e11', '#0af']} // optional swatches; built-in presets when omitted
hideMaximize={false} // optional — hide "fill the engine area" buttons
hideFullscreen={false} // optional — hide "fill the viewport" buttons
className="workspace"
style={{ height: '100vh' }}
/>The root fills its host 100% × 100% and carries no stylesheet: a container's title bar, borders,
gaps, padding and tab colors all read --twge-* variables (defaults below). A component key
missing from the registry renders a placeholder and the tab node is retained.
Each title bar ends with two expand controls: maximize fills the engine root, fullscreen
fills the viewport (above host page chrome, per --twge-overlay-z-index). The overlay is transient
presentation only — the tab component is not remounted (state survives, including a mode switch),
nothing is serialized, and no onLayoutChange fires. The active control collapses it again;
hideMaximize / hideFullscreen hide the controls, but the active one still renders so an open
overlay is never trapped.
A stable, action-only bundle — no state, no subscription. The host gets it from the ref;
every tab component gets the same object as props.engine. Holding it never causes a
re-render. The layout is uncontrolled: defaultLayout in, onLayoutChange out,
writes through this handle. There is no controlled layout prop.
interface GridEngineHandle {
addTab(p: {
component: string;
config?: unknown;
title?: string;
color?: string;
target?: DropTarget; // default: active container, or root when empty
activate?: boolean;
}): string; // returns the new tab id
removeTab(id: string): void; // unconditional — bypasses canClose
updateTab(id: string, patch: { title?: string; color?: string | null }): void;
moveTab(id: string, target: DropTarget): void;
setTabConfig(id: string, config: unknown, rev?: number): void;
focusTab(id: string): void;
getLayout(): Layout; // snapshot, not reactive
}- Missing ids are a no-op — never a throw.
updateTabwithcolor: nullclearstab.color;color: undefineddoes not (A7).- New-tab config resolves
explicit config → createConfig() → defaultConfig → {}; the result is always serializable, neverundefined(A11). setTabConfigcarrying arev <= currentis ignored (stale-write guard, §5.2).
type SplitEdge = 'left' | 'right' | 'top' | 'bottom';
type DropTarget =
| { kind: 'tab'; containerId: string; index?: number } // tabify; index omitted = append
| { kind: 'split'; containerId: string; edge: SplitEdge }
| { kind: 'root' }; // empty layout onlyThere is no host-edge drop zone — the outer border/gap is chrome, and a drop there resolves to the container edge underneath it. Dropping a container into its own subtree, or a container's only tab onto its own edge, is a no-op. Edge splits start at 50/50.
Dragging shows a preview, never a live relayout: pointermove draws an overlay (the
target container for a center drop, the new container's region for an edge drop) plus a small
sprite that follows the pointer, and the layout is committed once, on release. Esc cancels —
the prior layout is still in place. Pointer Events only (setPointerCapture, touch-action: none),
so mouse and touch work by construction.
The registry is app-supplied and never serialized. It may be larger or smaller than
the layout; a missing component key renders a placeholder and the node survives a
round-trip.
interface PanelComponentProps<C = unknown> {
config: C; // read-only, newest committed value
tabId: string;
emit: (type: string, payload?: unknown) => void;
requestConfigChange: (patch: Partial<C>) => void; // app decides
engine: GridEngineHandle; // the stable handle
}
interface PanelComponentDef<C = any> { // a *tab component* definition
component: React.ComponentType<PanelComponentProps<C>>;
defaultConfig?: C; // shared reference by contract
createConfig?: () => C; // preferred: fresh object per tab
defaultColor?: string; // identity color for this component type
title?: (config: C) => React.ReactNode; // used when Tab.title is absent
titleEditable?: boolean; // default true — rename writes Tab.title
addable?: boolean; // default true — appears in the "+" menu
allowMultiple?: boolean; // default true; false + an instance exists =>
// hidden from "+" and not closeable
closeable?: boolean; // default true — close control + menu entry
canClose?: (config: C) => boolean | Promise<boolean>; // unsaved-state guard
keepMountedWhenInactive?: boolean; // default false
}The C = any default is deliberate: a registry mixes entries with different config
shapes, and a contravariant ComponentType would reject them all. It is the single
sanctioned any in the public surface.
canCloseis on the UI close path only;removeTabis unconditional. Afalseresult aborts; a rejection warns and offers force close (A14).- Close-others/close-all skip non-closeable tabs and any
allowMultiple: falseinstance. - The
+control is always rendered — it is chrome, not an opt-in.addable: falsefilters the menu; with no addable entry the control is disabled. allowMultiple: falsegates the menu only; programmaticaddTaband layouts loaded from JSON are not blocked (that is how such a tab gets created).
All four fire after the change is committed, never during render. onLayoutChange
fires once per committed change (gesture end, not per pointermove). The engine never
applies a tab's own request — it reports it and the app decides (FR-17). Revs are
runtime-only and are not serialized.
onLayoutChange(layout: Layout, meta: {
action: LayoutAction;
tabId?: string;
containerId?: string;
programmatic: boolean; // true = ref API, false = user gesture
}): void;
onTabEvent(tabId: string, type: string, payload?: unknown): void;
onTabConfigChange(tabId: string, config: unknown, meta: {
rev: number;
source: 'tab' | 'app';
}): void;
onTabColorChange(tabId: string, color: string | null, meta: {
source: 'ui' | 'app';
}): void;
type LayoutAction =
| 'add-tab' | 'remove-tab' | 'reorder-tab' | 'move-tab'
| 'split' | 'resize' | 'focus' | 'set-color' | 'set-title' | 'set-config';rev mechanism (§5.2): the engine keeps a monotonic rev per tab config cell.
Downward setTabConfig(id, config, rev?) ignores rev <= current. Upward
onTabConfigChange reports the rev the value carries. No echo — a value received
from a tab is never written back down in the same tick.
Tab.color is a hex accent, engine-rendered, serialized. Accepted: #rgb, #rrggbb,
#rrggbbaa (case-insensitive). Anything else is ignored with a dev-mode warning and
falls through the resolution chain — the engine never writes an arbitrary CSS token into
a style attribute.
Resolution order (first hit wins):
tab.colorregistry[tab.component].defaultColor--twge-tab-accent
Rendering is an accent, not a fill: when a tab has an accent, it gets a leading pip,
background: color-mix(in oklab, <color> 12%, var(--twge-tab-bg)) on the active tab, and
the accent as the active-tab underline. With no accent (the --twge-tab-accent theme
default — "no color") there is no pip or tint: the active tab keeps
--twge-tab-active-bg, and --twge-tab-accent still paints the underline. Tab text
color stays theme-controlled. Color is never the only carrier of meaning. A fill-style
mode is deferred (it drags in contrast handling).
The context-menu popover offers the app-supplied tabColorPalette?: string[] (or the
themeable --twge-tab-color-* presets), a Custom… entry opening the native
<input type="color">, and a Default entry that clears tab.color by emitting
null. Applying a color updates the UI immediately, then emits onTabColorChange and
onLayoutChange — the app owns persistence. Palette entries must be hex; non-hex
values are filtered. The built-in preset swatches read --twge-tab-color-<name> for
their appearance but always emit the built-in hex.
The library ships no stylesheet (D9). All chrome comes from these custom properties;
the --twge- prefix is the stable public theming contract, so adding or renaming a
variable is a public API change. Every value below has a built-in default. The layout JSON
carries none of them.
| Variable | Used for |
|---|---|
--twge-titlebar-height |
title-bar height |
--twge-titlebar-bg |
title-bar background |
--twge-titlebar-fg |
title-bar text color |
--twge-border-color |
container borders |
--twge-border-width |
container border thickness |
--twge-gap |
gap between adjacent containers — this is also the draggable splitter handle |
--twge-padding |
inset padding around a container's content box |
--twge-tab-bg |
inactive tab background (also the color-mix base) |
--twge-tab-active-bg |
active tab background |
--twge-tab-accent |
theme default accent — "no color" |
--twge-drop-bg |
drop-preview fill |
--twge-drop-border-color |
drop-preview outline |
--twge-overlay-z-index |
stacking order of the fullscreen overlay vs host page chrome (default 9999) |
--twge-tab-color-<name> |
built-in preset swatches (e.g. --twge-tab-color-red) |
color-mix(in oklab, …) and <input type="color"> are assumed available (PRD §8).
Splitters are the gap itself: dragging one resizes the two adjacent siblings and the
sibling absorbs the difference, so the parent keeps filling exactly. Weights stay relative —
the clamp is a 0.05 weight floor per side, never a pixel measurement or ResizeObserver
(FR-7 / A3). Keyboard resize is a v2 item.
- Fill: the engine fills its host 100% × 100% at all times; content overflow scrolls
inside the container (
min-width:0; min-height:0; overflow:hiddenon split children). - Stability (A12b):
props.engineis the same object as the ref handle, and its identity is stable across layout changes — a memoized tab component does not re-render when a sibling moves, resizes or changes color. - Client-only: import is DOM-safe (no DOM access at module top level, no generated ids during render). Server rendering is out of scope.
- Zero production dependencies;
react/react-domare peers (^18.3 || ^19). - ESM-only, no
mainfield,sideEffects: false. - Out of scope for v1: keyboard/a11y (ARIA roles still shipped), pen-specific input,
corrupt-layout salvage, floating windows, cross-window drag, animated transitions,
undo/redo,
minSize, fill-style tab colors, saved presets.