UI layout conventions
The map workspace (components/map-workspace/, hosting MapEditor,
MapVisualiser, and MapTable) follows one layout contract for where a piece
of UI lives. This exists so future additions have an obvious home instead of
each screen inventing its own placement — see COLLAB-5610, which established
it while fixing Wolfgang’s feedback that the node info panel’s position, and
how to open/close it, were unpredictable.
MapTable (COLLAB-5641) is the third of three modes switched via
ModeSwitcher, alongside Edit and Visualise — not a new kind of surface in
the contract below. In V1 it renders full-width with no left workspace panel
and no entity dock: it is the collection (a grid of nodes), so there’s no
list for a workspace panel to hold and no separate detail view for a dock to
open. A future connections table belongs as a second surface within Table
mode (e.g. its own tab), not a fourth mode.
The contract
Section titled “The contract”Left = map-scoped. Right = selection-scoped. Floating = viewport-scoped. Modal = interrupting.
| Surface | Position | Scope | Example content | Driven by |
|---|---|---|---|---|
| Workspace panel | Left, in-flow: w-12 rail + w-80 panel, collapses to w-0 |
The whole map | Collections and their field catalogues (Nodes, Connections), map-wide config (Visualisation, Filter, Weighting, Appearance, Settings), map-wide ops (Import/Export) | The user, via tabs — sticky across selections |
| Entity dock | Right, in-flow: w-80, collapses to w-10 |
The current selection (one entity, or several) | Node info, Connection info, a multi-select summary, any future single-entity surface | The selection — never tabbed |
| Canvas overlay | absolute over the canvas: bottom-left / bottom-right cards |
The current viewport | Legends | Canvas state |
| Dialog | fixed inset-0 + scrim |
The whole app; blocks | Destructive confirmations, the import wizard | An explicit user action |
| Toast | fixed, non-blocking |
The whole app | Async mutation errors | Side effects |
Toolbars (EditorToolbar/VizToolbar) are a fifth case, but they don’t live
in this workspace layout at all — COLLAB-5622 portals them into
MapWorkspace’s header instead of floating over the canvas, via
ToolbarSlotContext (see components/map-workspace/toolbar-slot.tsx).
The rules this implies
Section titled “The rules this implies”- A collection is never replaced by a detail view. The Nodes/Connections lists in the left workspace panel always render; a selected node or connection’s own detail view opens in the right-hand dock instead of swapping the list out. (Before COLLAB-5610, the editor’s sidebar did exactly that swap — the opposite of this rule.)
- Selecting something never moves the left panel.
selectNode/selectEdgeinmap-editor.tsxdon’t touchinspectorTab; only the entity dock reacts to selection. - A canvas overlay never holds entity data — only viewport-scoped chrome (currently just the two visualiser legends).
- The entity dock is the same shell in both canvas modes. Visualise mode
fills it with a read-only
SelectionDetail; Edit mode fills it withNodeInspector/EdgeInspector. Shared shell, mode-specific body — this is how “consistent with wherever the inspector sits in the edit view” (the original ask) is satisfied. Table mode has no selection concept and no dock — a row’s cells edit inline instead of opening a detail view. - “Inspector” means the right-hand dock. The editor’s left panel
(
InspectorSidebar) is a workspace panel in this vocabulary, not an inspector — it’s kept a distinct name to avoid exactly the confusion that prompted this doc. - Multi-selection is a dock state, not a second selection model
(COLLAB-5611). The editor reads React Flow’s own
node.selected/edge.selectedflags as the single source of truth — a click always replaces the selection, a Shift-drag box-select can grow it to several nodes/connections. Exactly one selected entity still opensNodeInspector/EdgeInspector; more than one opensSelectionSummary(a count plus bulk delete) instead. There’s no separate “multi-select mode” to leave — the dock just reacts to however many things are selected. - A collection and its field definitions share one tab (COLLAB-5639).
There is no separate Fields tab: node fields are a section pinned to the
bottom of the Nodes tab, and connection fields are the Connections tab’s
list — each row carrying both the definition and how many connections use
it. The rule generalises: a catalogue belongs in the tab whose collection
it describes, not a tab of its own, so a count and the thing it counts are
never a tab apart. Two corollaries the implementation leans on:
- One row, one actions menu. A connection field’s row menu spans both scopes — map-scoped (hide/remove its connections) and org-scoped (edit/delete the field) — and names the scope in each item, rather than splitting them across two surfaces.
- A pinned section outranks a long list. The Nodes tab’s field section
is a
shrink-0sibling of the lists’ scroll region, capped at40%with its own inner scroll, so neither side can squeeze the other out of view.
Shared primitives
Section titled “Shared primitives”components/map-workspace/layout/:
z-layers.ts— the named z-index scale (below). New/edited code should importLAYERrather than write a rawz-*class; existing correct call sites weren’t swept when this was introduced, so don’t assume every z-indexed element in the codebase already uses it.side-panel.tsx—SidePanel/PanelRail/PanelRailButton/PanelRailToggle/PanelColumn/PanelHeader/PanelBody: the rail + content-panel shell. Used byVizSidebarandInspectorSidebar(both left workspace panels). Deliberately composable parts, not one component — the two consumers differ in real ways (rail width, whether the rail itself collapses, a badge) that don’t collapse into one prop bag cleanly.canvas-overlay.tsx—CanvasOverlay: the floating-card shell used byColourLegend/DirectionalLegend.entity-dock.tsx+use-entity-dock.ts+use-dismiss-on-escape.ts— the right-hand dock and its behaviour (below).
Z-index scale
Section titled “Z-index scale”0 canvas (React Flow / force-graph)10 canvas overlays (the two legends); the active rail tab's own stacking40 editor add-node click-catcher50 toasts, add-node drag ghost90/91 context menu scrim / menu100 dialogs200 global loading indicatorThe entity dock itself needs no z-index — see below.
The entity dock
Section titled “The entity dock”EntityDock (entity-dock.tsx) takes title, collapsed, onExpand,
onDismiss, emptyHint, railLabel, and children (the mode-specific body
— SelectionDetail, or NodeInspector/EdgeInspector). It renders either:
- Expanded (
w-80): a header (title + close) over the children, oremptyHintwhen nothing is selected. - Collapsed (
w-10): a full-height button showingrailLabelas vertical text ([writing-mode:vertical-rl]) plus a chevron, so the rail names what it opens rather than being a bare icon.
Empty state. The dock opens expanded, showing emptyHint — the same
role the visualiser’s old persistent bottom bar played (COLLAB-5605: “click a
node” was signposted nowhere, so the panel’s own existence, visible before
the first click, is the affordance). It only collapses to the rail after an
explicit dismissal — the header’s X, Escape, or a canvas background
click — never on its own. Selecting anything re-expands it. useEntityDock
owns this as { collapsed, expand, dismiss }.
Push, not float. The dock is in-flow (SidePanel-style), so it narrows
the canvas rather than overlaying it — it never covers the very node/
connection it’s describing. This is a deliberate simplicity-over-polish
call: with both the left workspace panel and the right dock open, the chrome
budget is 48 + 320 + 320 = 688px, which gets tight well before common
laptop widths. Making the dock float below some breakpoint instead is a
known, not-yet-built follow-up — see
Constraints & edge cases.
Dismissal and Escape. useDismissOnEscape(paneRef, onDismiss) attaches a
keydown listener with three guards, all necessary because MapEditor and
MapVisualiser both stay permanently mounted (MapWorkspace only toggles
hidden between them, per its own module comment) — so two of these
listeners are live at any time:
- Skip if this pane is the hidden one (
offsetParent === null). - Skip if a dialog owns Escape (
[aria-modal="true"]is present —ui/dialog.tsx/ui/confirm-dialog.tsx). - Skip if focus is in a form control (
input,textarea,select,[contenteditable="true"]) — protects the editor’s own inputs andVizToolbar’s Escape-to-cancel-rename from being hijacked.
Adding new UI
Section titled “Adding new UI”- A new single-entity surface (something that only makes sense for one selected node/connection/whatever) → the entity dock, not a new panel.
- A new collection or map-wide config → a tab in the left workspace panel.
- A new viewport-scoped hint or legend →
CanvasOverlay. - Anything that interrupts the user or needs explicit confirmation → a
Dialog/ConfirmDialog, not a dock or panel.