Layout patterns
AppShell, ModuleRoot, SplitPane, PageHeader, DataTable, FloatingDock and Island — the props each takes and the decision each one makes for you.
Every layout below makes one decision for you and stays out of the rest, so a module composes them and never re-derives the same answer in its own markup; the sizes and colours they reach for are tokens.
| Layout | Where it lives | Use it for |
|---|---|---|
AppShell + Topbar | The app, around every module | The frame: a top bar, a nav on the left, and a hamburger plus a drawer below the collapse breakpoint. |
NavRail | Inside AppShell's nav slot | A flat list of modules, icon-only or expanded. |
SideNav | Inside AppShell's nav slot | The same list with a group level above it — a rail of group icons and a hover flyout. |
ModuleRoot | The outermost element of a module | The container, the observed node, the gutter scope and the focus target. Every module starts here. |
SplitPane | Inside a module | Master and detail: a list beside a thread, a nav beside a canvas. |
PageHeader | Inside a module, at the top | The module's own title bar, with meta, actions and an optional tab strip. |
DataTable | Inside a module | Rows: sorting, selection, resizing, reordering, inline editing. |
FloatingDock | Over a canvas | A window that stays open while the scene behind it stays whole. |
Island | Over a canvas | The elevated surface every floating toolbar, palette and zoom widget is built from. |
The frame
AppShell is slots, not data: it knows nothing about bots or modules, so what goes in the top bar
is the app's business and what goes in the nav is a component you choose.
Prop
Type
Topbar says where you are working, not what you are looking at. Fill at least one slot — an empty
bar is still a topbar's worth of border and background, so it reads as a chrome bug rather than as
restraint.
| Prop | Type | What goes in it |
|---|---|---|
brand | ReactNode | The product's mark. Leftmost, because it is the one thing that never changes while the app is open. A node, not a src: the design system does not know where an app keeps its assets. |
workspace | ReactNode | Which account the session is pointed at — a bot label, a workspace name, a picker. The bar's primary content, and never hidden at narrow widths, where the nav rail has already collapsed. |
title | string | The product's name, if the app wants one in the chrome. Not the active module's name: the rail and the module's own PageHeader already carry that. |
right | ReactNode | Session-level controls: theme toggle, account menu. Never module actions. |
NavRail takes items (id, title, icon), activeId and onSelect. variant="rail" is the
icon-only strip with titles as tooltips and its own right-hand border; variant="expanded" adds
labels, fills its host and draws no edge, because that host is a drawer which already has one.
SideNav is the same contract one level up: groups of { id, title, icon, items }, the active
item id, and onSelect. The group level is information architecture, not a route — nothing
below the component knows groups exist, and the address bar still says /<moduleId>. It renders
nothing for zero groups and falls back to a flat NavRail for one, because a rail of a single icon
hiding everything behind a hover is worse than the flat rail it replaced. In rail the items open
as a flyout that survives the pointer leaving it for 180 ms, which is the diagonal-move grace.
ModuleRoot
The outermost element of every module, and the thing that makes the rest of the layout system work.
It takes children and an optional className, forwards its ref to the outer element, and does
four jobs at once.
useBand() reads the band from a child — never in ModuleRoot itself, where the provider is still
only a return value. It answers 'compact' below 600px, 'narrow' below 900px, 'wide' below
1280px and 'inline' at or above it; outside a ModuleRoot it answers 'wide', so a component in a
gallery cell or a test gets the ordinary desktop layout rather than throwing. bandAtLeast(band, 'wide') is the comparison.
The rule for which tool answers a layout question is one line: if the answer changes what React renders, ask the band; if it only changes how something looks, ask a container variant; never ask the viewport from module code.
SplitPane
Master and detail — two panes above the collapse band, one pane plus a back control below it.
Prop
Type
Stacking is a JS decision rather than a container query on purpose: it changes which pane is in the DOM and adds a control that does not otherwise exist. CSS can only restyle what is already rendered — it cannot make a back button appear, and hiding the other pane in CSS would keep mounting, measuring and subscribing for a list nobody can see.
PageHeader
The module's own top bar: title (a node, when it carries a live dot or a count), meta beside it,
actions on the right, an optional tabs row and a className.
It is min-h-topbar with flex-wrap, not a fixed height: at desktop widths the two are identical,
but a fixed height in a narrow container squashes the actions into each other instead of letting
them drop to a second line. The padding is the gutter token, so the header steps with the band
without knowing the band exists.
tabs is a slot of its own because an underline tab strip carries its own baseline rule. Put in
actions, that rule floats a few pixels above the header's own bottom border and draws two parallel
lines. On its own full-width row, a -mb-px pulls the header's border up by exactly the pixel the
strip occupies, and the two land on one continuous edge.
DataTable
Fully controlled, deliberately: rows are server-paged and merged from subscriptions, so any sort or
selection the table held internally would be overwritten by the next live echo. The owner holds the
state and lib/data/table.ts computes the next one. Two things are not controlled, because they die
with the interaction rather than surviving a page of rows: which cell is open for editing, and where
the cell cursor sits.
Prop
Type
A column is { key, header, render? } plus the optional capabilities: width and minWidth
(the resize floor, 64px), align, sortable, resizable, wrap for a cell holding a control or a
stacked message, and reorderable. reorderable is the odd one out and opts a column out:
sorting and resizing are per-column capabilities, while reordering is a property of the table, and
the exception is the pinned identity column everything else is read against.
The inline-edit contract
column.edit turns a cell into an editor. The table holds the draft, not you — that is what lets
Escape restore the original, lets blur ask whether anything actually changed, and lets Enter carry a
half-typed value into the cell below without a round trip through the owner's state, which at 500
rows would re-render the whole body on every keystroke.
| Field | Type | What it does |
|---|---|---|
value | (row: T) => string | The value the editor opens with. |
commit | (row: T, value: string) => void | Promise<unknown> | Save. Called only when the draft differs from what the editor opened with, because blur commits and every cell somebody merely tabs through would otherwise fire a mutation. Return a promise and the cell shows pending, then a tick or the rejection's message. |
render | (context: DataTableEditorContext<T>) => ReactNode | A custom editor. The default is a single-line text input. |
enabled | (row: T) => boolean | Per-row veto — a computed field, an attribute this record does not carry. |
placeholder | string | For the default input. |
A custom editor receives row, column, the draft value and setValue, commit and cancel
(already wired to Enter, Tab, blur and Escape), a status, and inputProps. Spread inputProps
onto the editor's own focusable element — a native input, select or textarea, not a wrapper. An
editor that drops it still edits, but Enter, Tab and Escape stop working, and its onFocus is what
tells the table which editor is live, so a commit already made by a key is not made again by the
blur that follows. commit takes an optional value because a <select> needs it: the change event
carries the chosen option, and setValue then commit() in one handler would commit the value from
before the change.
FloatingDock
A floating window over a canvas — a test chat, a notes pane, anything that has to stay open while the scene behind it stays whole. The alternative is a third column, and a canvas narrowed twice is no longer the thing being edited; a window costs the scene the rectangle it covers and nothing else.
Prop
Type
It is positioned against its nearest positioned ancestor, so the host wraps it in a relative
element — usually the same one the canvas fills. Collapsing is not closing: the pill keeps its place
and the children stay mounted, because behind a test chat is a live conversation and a subscription.
Escape collapses it, but only from inside and never out of a text field, because the canvas has its
own Escape and a half-written message is about the message.
Island
The skin every floating surface shares: children, padding ('none', 'sm', 'md', default
'md'), orientation ('horizontal' or 'vertical') and a className. One radius, one
elevation, one border, one padding scale — that tininess is the value, because a rebrand retunes
--radius-island and --shadow-island and every floating surface in the product moves together.
It is not a positioning primitive. FloatingSurface — portal, anchoring, presence, dismissal —
remains the engine for anything anchored to a trigger; Island is the skin, and the two compose.
For a toolbar pinned to the corner of a canvas, absolute positioning plus this is the whole story.