# Layout The structural primitives: the stage shell, panels, sections, bars, and the canvas container. --- ## EditorPanels The outer stage shell — a CSS grid of five rows (top bar / top toolbars / panel band / bottom toolbars / bottom bar) with left/right panels in the band and your content in the centre. ```tsx } />} leftPanel={} rightPanel={} center={} persistKey="main" /> ``` A root `EditorPanels` owns the window (`position: fixed; inset: 0`). Pass `embedded` to make it fill a parent slot instead — that's the recursion point: a stage's `center` can be another ``. While a root `EditorPanels` is mounted, the page gets `overscroll-behavior: none`, so a touch drag can't pull-to-refresh the app away. Pinch zoom and scrolling inside panels keep working. A page with only embedded stages is untouched. To opt out, set `overscroll-behavior` on `html` and `body` yourself; the library's rule has zero specificity, so yours wins. ### Props | Prop | Type | Notes | |---|---|---| | `center` | `JSX.Element` | **Required.** The world/content region. | | `leftPanel?` / `rightPanel?` | `JSX.Element` | Side panel contents (sections, or a `DockZone`). | | `top?` / `bottom?` | `JSX.Element` | Full-width single-bar regions (transparent — put a `TitleBar` here for the prominent shade, or a plain `Toolbar`). | | `topToolbars?` / `bottomToolbars?` | `JSX.Element` | Full-width stacked toolbar rows (panel-bg). | | `topSub?` / `bottomSub?` | `JSX.Element` | Canvas-aligned toolbar stacks (column 2 only — edges match the centre). | | `embedded?` | `boolean` | Fill parent (relative) instead of owning the window (fixed). | | `persistKey?` | `string` | Namespace for persisting panel collapse + width. Give each stage a distinct one. | | `size?` | `ControlSize` | Size tier for the whole stage. | | `leftSize?` / `rightSize?` | `ControlSize` | Per-panel size tier. | | `wrapFocus?` | `boolean` | Loop Tab from last stop back to first (outermost stage only). | | `resizable?` | `boolean` | Enable drag-to-resize panel edges. | | `leftPanelMode?` / `rightPanelMode?` | `'inset' \| 'overlay'` | `'inset'` (default) reserves a column. `'overlay'` floats the panel over the center. See below. | | `leftCollapsedVariant?` / `rightCollapsedVariant?` | `'strip' \| 'tab' \| 'none'` | What a collapsed panel leaves behind. Default `'strip'`. See below. | | `defaultLeftTabPosition?` / `defaultRightTabPosition?` | `{ anchor: 'top' \| 'center' \| 'bottom', offset: number }` | Where a `'tab'` starts. See below. | **Overlay panels.** An overlay panel floats over the center, inside its own stage, so opening, collapsing or resizing it never changes the center's size. A WebGL canvas doesn't rebuild its buffers on every animation frame, and it can still be dragged beside an open panel. - Everything else works the same in both modes, and the mode can switch at runtime without remounting the panel's contents or losing its collapse and width state. - The panel covers `topSub` / `bottomSub`, since they're inside the center cell. It doesn't reach `topToolbars` / `bottomToolbars`, so put anything that must stay visible there. - Tapping outside doesn't close it. - Floating panels and sections always draw above it. - The center cell publishes `--stage-panel-cover-left` and `--stage-panel-cover-right`: how much of it each panel currently covers, or `0px` for an inset panel or a collapsed one. ```css .my-hud { left: calc(var(--stage-panel-cover-left) + 8px); } ``` Overlay suits a canvas-first app, where panels open now and then. A dense editor whose panels stay open is usually better off inset, so that nothing sits over the canvas. **Collapsed variants.** These choose what a collapsed panel leaves on screen, in either mode: | Variant | On screen | Space it takes | |---|---|---| | `'strip'` | A full-height bar | A strip-width column. In overlay mode the column stays open or collapsed, so the center resizes only at mount, and the bar never covers it. | | `'tab'` | A small button on the stage's edge | None. It sits over the center, which can reach the stage's edges. | | `'none'` | Nothing | None. | In overlay mode the center never resizes with any of them. In inset mode, `'tab'` and `'none'` give the center the panel's whole column on collapse, so the center does resize. With `'none'`, the app has to offer the way back. Control the panel and put the toggle somewhere that stays visible, such as `topToolbars`, which a panel never covers: ```tsx const [collapsed, setCollapsed] = createSignal(true); } leftPanelMode="overlay" leftCollapsedVariant="none" leftCollapsed={collapsed} onLeftToggle={setCollapsed} topToolbars={ !collapsed()} onChange={(open) => setCollapsed(!open)} />} /> } center={} /> ``` A tab starts at the middle of the edge. Set `defaultLeftTabPosition` / `defaultRightTabPosition` to start it elsewhere, with `offset` in px: ```tsx defaultLeftTabPosition={{ anchor: 'top', offset: 30 }} // 30px from the top defaultRightTabPosition={{ anchor: 'bottom', offset: 50 }} // 50px from the bottom defaultLeftTabPosition={{ anchor: 'center', offset: -10 }} // 10px above the middle ``` Inside a [`SectionLayoutProvider`](./dnd.md), the user can drag a tab along the edge whenever the layout's panels aren't locked (`panelsLocked`). On release the tab anchors to whichever third of the band holds its center, so it keeps its distance from that end when the window resizes. The position is saved under `persistKey`, and it wins over the default until `layout.reset()`. Escape cancels a drag. `--stage-panel-tab-width` and `--stage-panel-tab-height` set the tab's size, and on touch screens its target is at least 44px wide whatever the visible width. **Panel collapse** (per side, mirrored controlled/uncontrolled): | Prop | Type | |---|---| | `leftCollapsed?` / `rightCollapsed?` | `() => boolean` | | `onLeftToggle?` / `onRightToggle?` | `(next: boolean) => void` | | `defaultLeftCollapsed?` / `defaultRightCollapsed?` | `boolean` | | `leftToggleVariant?` / `rightToggleVariant?` | `'header' \| 'edge'` | | `exclusiveWhenNarrow?` | `boolean` (default `true`) | | `minCenterWidth?` | `number` (default `320`) | **One panel at a time on a narrow stage.** When both panels at their expanded width, plus `minCenterWidth` of center, don't fit the stage, opening one panel closes the other. If both end up open anyway (on load, after a resize, after switching to a split view, or opened from code), the right one closes. This keeps a phone from clipping a panel off the screen's edge. - It applies in both panel modes, and only to a stage that has both a left and a right panel. - Closing goes through the normal toggle path. A controlled panel receives `onLeftToggle(true)` / `onRightToggle(true)`, so honor it the same way you honor a click. - The widths are measured, so the size tier, `defaultLeftWidth` and a dragged width all count. - Set `exclusiveWhenNarrow={false}` to let both panels stay open at any width. **Panel width** (dragging needs `resizable`; persisted under `persistKey` if uncontrolled): | Prop | Type | |---|---| | `leftWidth?` / `rightWidth?` | `() => number \| undefined` | | `onLeftResize?` / `onRightResize?` | `(px: number) => void` | | `defaultLeftWidth?` / `defaultRightWidth?` | `number` | Uncontrolled panel collapse and width persist automatically when `persistKey` is set. Width clamps to `[140px, 40% of the stage]`, and double-clicking the gutter resets it to `defaultLeftWidth` / `defaultRightWidth`, or the tier default without one. Expanded width derives from the size tier, so a `large` stage gets wider panels. --- ## Panel A collapsible side column. You usually don't construct `Panel` directly — `EditorPanels` builds the internal panels from `leftPanel`/`rightPanel`. Use `Panel` directly only for a standalone collapsible column outside a stage. Collapsing **shrinks the panel to a strip**, it does not unmount the contents. The body stays in the document at its full width, hidden, clipped and `inert`, so its state, scroll position and measurements survive, and its effects keep running. Don't park something expensive in a panel expecting collapse to free it. An open select, color or search overlay belonging to the body closes when the panel collapses, however the collapse was triggered. Two toggle placements: - **`header`** (default) — a header strip across the top holds the collapse toggle. - **`edge`** — no header; a full-height toggle bar sits on the panel's inner edge, reclaiming the header's vertical space. | Prop | Type | Notes | |---|---|---| | `side` | `PanelSide` (`'left' \| 'right'`) | | | `collapsed` | `() => boolean` | Controlled collapse state. | | `onToggle` | `() => void` | | | `children` | `JSX.Element` | | | `size?` | `ControlSize` | | | `toggleVariant?` | `'header' \| 'edge'` | | | `resizable?` | `boolean` | | | `onResize?` | `(px: number, final: boolean) => void` | | | `onResetWidth?` | `() => void` | | Overflow is tracked with an `IntersectionObserver` (scroll-hint affordances) — not a scroll handler. --- ## Section A collapsible, titled container — the unit you build property UI from. Holds anything; naming it `Section` does **not** imply a registry. Its body adds padding and no spacing, so wrap a stack of fields in `FieldGroup`. See [Composing a section body](./guides.md#composing-a-section-body). ```tsx
``` | Prop | Type | Notes | |---|---|---| | `title` | `string` | | | `children` | `JSX.Element` | | | `class?` | `string` | Class on the section's root, for example to set `--stage-section-body-pad`. | | `defaultOpen?` | `boolean` | Uncontrolled seed (default expanded). | | `open?` | `() => boolean` | Controlled open state. | | `onToggle?` | `(next: boolean) => void` | | | `persistKey?` | `string` | Collapse-persist key (defaults to `title`). | | `persist?` | `boolean` | `false` opts out of persistence. | | `onClose?` | `() => void` | Dismissable mode (see below). | | `headerActions?` | `SectionHeaderAction[]` | Icon buttons on the title bar. | | `size?` | `ControlSize` | Sizes the section's *body* (header keeps the panel tier). | | `dragHandle?` | `boolean` | Set by `DockZone` — makes the title a pure drag grip (press-drag reorders). The **chevron is the sole collapse control**; pressing the title does *not* collapse (it reads as a move handle, like the empty header bar). | | `leadingAction?` | `SectionHeaderAction` | Replaces the chevron with a custom leading button (dragHandle mode). | | `collapsible?` | `boolean` | `false` → always open: no chevron, and a header click does nothing (a `dragHandle` still drags). Default `true`. | **Collapse keeps the body mounted** (height-collapsed + `inert`), not unmounted. So a field keeps its state, stays in the DOM, and is deep-linkable — `focusField` can expand and focus a collapsed section's field. (Trade-off: a collapsed section's effects stay live; don't park something genuinely expensive there expecting unmount to free it.) **`headerActions`** put borderless, mouse-only icon buttons on the title bar: ```tsx
locked() }, ]} > ``` `SectionHeaderAction` = `{ icon, title, onClick, active?, disabled?, tone? }` (`title` is required — it's the accessible name, rendered as `aria-label`; no OS tooltip). **Dismissable mode** (`onClose` set): a static header (no chevron), body always open, a × top-right. This is the shape the modal hosts. --- ## Toolbar One bar with `left` / `center` / `right` anchor cells. Transparent layout only — the hosting bar slot supplies the background. Stack several by listing them in a slot. ```tsx } center={} right={} /> ``` | Prop | Type | |---|---| | `left?` / `center?` / `right?` | `JSX.Element` | The center cell stays centered on the bar regardless of the left/right widths, which limits `left` and `right` to half the bar each. When `center` renders nothing there's nothing to keep centered, so they share the whole bar, and a `FieldBar grow` takes all the room the other anchor leaves. If the two don't fit, a fluid cell keeps half the bar and the other content overflows, so truncate long text yourself. --- ## TitleBar A **prominent** bar — the same `left` / `center` / `right` anchors as `Toolbar`, but it **paints itself** with the recessed darker background (`--stage-color-bg`) and an edge separator, marking it as the stage's title / primary bar. Use it where you want a bar to stand out from the panel-bg toolbars; because it carries its own shade, that prominence is a deliberate choice, not a side effect of position — drop a `TitleBar` into any bar slot (`top`, `bottom`, a sub-bar). ```tsx } />} bottom={} />} ... /> ``` | Prop | Type | Notes | |---|---|---| | `left?` / `center?` / `right?` | `JSX.Element` | Anchor cells (reuses `Toolbar`). | | `footer?` | `boolean` | Separator on the **top** edge (a footer). Default header (bottom edge). | | `class?` | `string` | Consumer style hook. | > `Toolbar` is transparent (the region paints it); `TitleBar` paints itself. Reach for > `TitleBar` when you want the prominent shade, `Toolbar` for a neutral bar. --- ## TabMenu A horizontal document/object tab strip for a bar slot (e.g. `topSub`). Behaves like a browser tab bar: arrow keys move + select, close (×) per tab, optional new (+) button, a "modified" dot, and overflow scroll arrows. ```tsx 'untitled'} onClose={(tab) => confirm(`Close ${tab.label}?`)} /> ``` Controlled or uncontrolled: | Prop | Type | Notes | |---|---|---| | `tabs?` | `() => ReadonlyArray` | Controlled tab list. | | `activeId?` | `() => string \| undefined` | Controlled selection. | | `initialTabs?` | `ReadonlyArray` | Uncontrolled seed (strings become tabs). | | `defaultActiveId?` | `string` | Uncontrolled seed. | | `onSelect?` | `(tab: TabMenuTab) => void` | | | `onNewTab?` | `() => string \| false \| void` | Return a label, or `false` to veto. Renders the + when set. | | `onClose?` | `(tab: TabMenuTab) => boolean \| void` | Return `false` to veto. | | `newTabTitle?` | `string` | | `TabMenuTab` = `{ id, label, modified? }`. A `modified` tab shows a dot (yielding to the × on hover); deciding *when* a tab is modified is the consumer's job. The menu fills its slot height — give it a full-height bar slot. --- ## CanvasStage A multi-layer canvas container with a sized viewport. Vertical stack: top bars / viewport / bottom bars. Bars *inset* (reflow) the viewport rather than overlaying it, and the `ResizeObserver` watches the viewport, so `onResize` reports the real world size. ```tsx renderer.setSize(width, height)} topBars={} />} > el.appendChild(renderer.domElement)} /> {/* DOM overlay UI, e.g. gizmos */} {/* pointer-capture layer */} ``` | Prop | Type | Notes | |---|---|---| | `onResize?` | `(size: StageSize) => void` | `StageSize = { width, height }`. | | `onMount?` | `(el: HTMLDivElement) => void` | The stage root element. | | `topBars?` / `bottomBars?` | `JSX.Element` | Inset bar stacks. | | `children?` | `JSX.Element` | The layers. | The three layers are a compound API: - **`CanvasStage.World`** — z-index 1; takes a `mountRef` to attach your renderer's DOM (canvas). - **`CanvasStage.Hud`** — z-index 20; overlay UI. - **`CanvasStage.Pointer`** — z-index 30; pointer capture. `STAGE_LAYERS` (`{ WORLD: 1, HUD: 20, POINTER: 30 }`) is exported if you need the z-values directly. Drop a `CanvasStage` into an `EditorPanels` `center`. --- ## Size tiers `EditorPanels`, `Panel`, and `Section` all take a `size?: ControlSize` (`'small' | 'normal' | 'large'`). It scales font size and control height together, and cascades — set it on a stage and everything inside scales; re-set it on a section to override. See [Theming → Size tiers](./theming.md#size-tiers). ## Focus & keyboard Tab order is the natural DOM order across the whole stage: top bars → left panel → centre (including a nested stage, recursively) → right panel → bottom bars. The library uses no positive `tabindex`. Set `wrapFocus` on the outermost stage to loop. A collapsed section's fields are skipped (they're `inert`) but still in the DOM. Escape inside a panel blurs the active field. See [architecture §4](./architecture.md) for the full model.