Workspace UI
A map page (components/map-workspace/) hosts three modes: MapEditor,
MapVisualiser and MapTable. ModeSwitcher in the header switches between them. All
three stay mounted, and MapWorkspace hides the inactive ones.
The layout rule
Section titled “The layout rule”Left = the map. Right = the selection. Floating = the viewport. Modal = interrupts.
| Surface | Where | Scope | Holds | Driven by |
|---|---|---|---|---|
| Workspace panel | Left, in the page flow: a w-80 panel. Edit’s Elements panel collapses to a w-10 labelled strip, like the dock; Visualise’s keeps a w-12 icon rail of tabs |
The whole map | Collections and their definitions (Edit’s Elements panel: Nodes, Connections, Tags, Measures), map-wide settings (Visualise’s Visualisation, Visibility filter, Settings tabs) | The user. Stays put when the selection changes. |
| Entity dock | Right, in the page flow: w-80, collapsing to a w-10 labelled strip |
The selection | Node details, connection details, the multi-selection summary | The selection. Never tabbed. |
| Canvas overlay | Floating cards over the canvas corners | The viewport and the map | Legends, the About this map panel and the keyboard shortcuts button beside it, Visualise’s filter status, status messages (CanvasStatus: “Saving…”, “Computing centrality…”) |
Canvas state |
| Dialog | Fixed, with a scrim | Blocks the app | Confirmations, the import wizard | An explicit action |
| Toast | Fixed, non-blocking | The app | Errors from background saves | Side effects |
Mode toolbars (EditorToolbar, VizToolbar) render in the page header, not over the
canvas. They’re portalled there through ToolbarSlotContext
(map-workspace/toolbar-slot.tsx). VizToolbar groups icon-only buttons under
short muted labels (Layout, Views, Export); each button has an aria-label and a
title naming the action.
Table mode is full-width, with no workspace panel and no dock: the grid is the collection, and cells edit in place.
- A detail view never replaces a list. The Nodes and Connections lists always show. A selected item opens in the dock.
- Selecting something never changes the left panel. Only the dock reacts to selection.
- Overlays hold viewport chrome and map-level context, never a selected entity’s data. Legends explain the canvas; the About this map panel describes the map.
- The dock shows the same body in Edit and Visualise. Both modes use
NodeInspectorandEdgeInspector. Visualise leaves out theireditingprop, which makes every section read-only. - “Inspector” means the right-hand dock. The editor’s left panel is
InspectorSidebar, but it’s a workspace panel in these terms. - Multi-selection is a dock state. The editor uses React Flow’s own
selectedflags. One selected item opens its inspector; more than one opensSelectionSummary(a count, Tag the selected nodes, Remove from map, Delete selected). Unplaced nodes picked together in the Nodes card (unplacedSelectioninmap-editor.tsx) open it too, without Remove from map. - Everything the map is made of shares one panel. Edit’s Elements panel
(
elements-panel.tsx) has Nodes, Connections, Tags and Measures asElementsCards under one search. Connection types are the Connections card’s list, alongside notes, each row with its usage count. A row’s menu covers both its connections (hide, remove) and its definition (edit, delete), and says which is which. Every definition the map has is listed; unused ones are dashed. - A mode and its settings share a surface. In Visualise, a visualisation mode’s
own controls sit inside it. What the map is drawn from in every mode (Connections,
Nodes) sits below, under “Applies to every mode”: everything on that tab can move
nodes. The Visibility filter (greys out, never moves) and Settings (line appearance)
have tabs of their own. A control that can’t do anything in a mode isn’t shown. The
filter shows only the conditions that exist, as cards added from a menu, and its
rail button counts them (
PanelRailButton’sbadgeTone="info"). - Emphasis is subtractive. To pick something out, fade everything else and zoom to
what’s left. Don’t recolour the thing itself. Faded elements stay clickable. The
state behind this (
focusEdgeIds,focusNodeIdsinmap-editor.tsx) is a React context, not a ref, so nodes re-render when it changes.NODE_TYPESandEDGE_TYPESmust keep a stable identity, or React Flow remounts everything. - A control in two places is one component with one handler. The connection entry
box appears in the dock and inline on the canvas. Both send intents to one handler
in
map-editor.tsx. Inline editing state lives in a context, not innode.dataoredge.data, which are rebuilt on every collection change.
Inside a height-capped area, put all collapsible sections in one shared scroll region. Two sibling scroll regions can push one out of reach. The Elements panel’s cards share one, each with a sticky header.
Map-wide actions that every mode needs go in the map’s header (AppHeader’s actions),
not in one mode’s panel: Import / Export (map-workspace/import-export-dialog.tsx)
is a dialog there.
Shared parts
Section titled “Shared parts”In components/map-workspace/layout/:
-
z-layers.ts: the named z-index scale. UseLAYERin new code rather than rawz-*classes. Some older code doesn’t yet.0 canvas10 canvas overlays; the active rail tab40 editor add-node click-catcher50 toasts, add-node drag ghost90/91 context-menu scrim / menu100 dialogs200 global loading indicator -
side-panel.tsx:SidePanel,PanelRail,PanelRailButton,PanelRailToggle,PanelColumn,PanelHeader,PanelBody,PanelStrip. These are the parts of a workspace panel.VizSidebarhas tabs, so it uses the rail.InspectorSidebaris one panel: its collapse button is in itsPanelHeader(closeIcon), and collapsed it’s aPanelStrip, the same stripEntityDockcollapses to. Both start collapsed. -
panel-section.tsx:PanelSection, a collapsible titled section, also used in the node inspector. Its open state is local and never saved, so it can’t affect a saved view or the force layout. It can also stay active while the rest of a panel is dimmed: on an empty map, Visualise’s Nodes section stays live, because unplaced or left-out nodes are often why there’s nothing to draw.openNonceopens a section from elsewhere, scrolls to it and focuses its header. -
canvas-overlay.tsx:CanvasOverlay, the card used byColourLegendandDirectionalLegend. It takes atitle, shown in a header that collapses the card. It starts open, is at most 50% of the canvas tall, and scrolls past that. ItscanvasCardClassis shared with the About this map panel and fixes all of them at 300px wide. The About panel is wider only while its recipe is being edited. One-line labels end in an ellipsis; sentences wrap. -
overlay-scroll.tsx:OverlayScroll, the scroll area inside every overlay. It draws the one scroll cue, an arrow at the bottom while there’s more below. -
../keyboard-shortcuts.tsx:KeyboardShortcutsButton, which each mode passes toAboutMapPanelasbeside. Hovering or focusing it lists the mode’s canvas shortcuts. Its list mirrors the user guide’s Keyboard and mouse shortcuts page; change both together. It’s hidden in a PNG export (data-export-hide). -
canvas-corners.tsx:CanvasCornersProviderwraps each canvas.useRegisterCanvasCornerdeclares what sits in a bottom corner (a legend, the editor’s minimap), anduseCanvasCornerreads it.CanvasOverlayregisters itself. -
entity-dock.tsx,use-entity-dock.ts,use-dismiss-on-escape.ts: the dock.
The entity dock
Section titled “The entity dock”EntityDock takes title, collapsed, onExpand, onDismiss, emptyHint,
railLabel, an optional headerAction and its body. Collapsed, it’s a PanelStrip: a
full-height button with railLabel written vertically, so the strip says what it opens.
- It starts collapsed. Selecting anything expands it.
- It collapses only when dismissed: its ×, Esc, or a click on empty
canvas.
useEntityDockreturns{ collapsed, expand, dismiss }. - It pushes the canvas narrower rather than floating over it, so it never covers what it describes. With both side panels open, the chrome takes 48 + 320 + 320 = 688px. A narrow-screen version doesn’t exist yet.
useDismissOnEscape(paneRef, onDismiss) ignores Esc when:
- its pane is hidden (
offsetParent === null). All three modes are mounted, so several listeners are live at once. - a dialog is open (
[aria-modal="true"]). - focus is in a form control.
The details panel
Section titled “The details panel”NodeInspector and EdgeInspector (map-editor/inspector/) take their data as props
and their write surface as one optional bundle: editing?: NodeInspectorEditing or
EdgeInspectorEditing. Without it, they’re read-only. Sub-sections such as
NodeAttributesSection and NodeTagsSection follow the same pattern. A caller hands
over the whole editing surface or none of it, and can’t half-wire it.
Both modes pass the panel React Flow-shaped data (MapNode, MapEdge). Visualise runs
its full, unfiltered graph through graphToFlow, so it can describe a node it isn’t
drawing.
In Visualise, the dock adds:
- an Analysis section, with betweenness, in Bridges;
- an Edit button in
headerAction. It goes throughMapWorkspace’snodeFocusRequest/edgeFocusRequest, and the editor opens the same node or connection, placed or not.
About this map
Section titled “About this map”AboutMapPanel (components/map-workspace/about/) floats top-right on both canvases.
It’s collapsed whenever the map opens, and Esc inside it closes it (or drops an
unchanged recipe draft) without reaching the dock’s own Esc handler.
- Mounting. Mount it inside the canvas’s
CanvasCornersProvider. The editor renders it over the React Flow canvas and registers its minimap as the bottom-right corner. Visualise passes it toVizCanvasthrough theoverlayprop, so it sits inside the element a PNG export captures. - Height.
aboutPanelMaxHeight(about/layout.ts) lets it grow to the canvas, less whatever is registered bottom-right (the minimap, or the colour legend) and a gap. Then it scrolls in anOverlayScroll. - What you’re looking at. Each mode builds a
Description(sentences of segments, names marked for italics,about/describe.ts) from its own state:describeVisualisation(map-visualiser/lib/describe-view.ts) anddescribeEditorCanvas(map-editor/lib/describe-canvas.ts). Both are pure and describe only what’s on screen, never what’s hidden, faded, unplaced or left out. - Where it comes from.
RecipeSectionandRecipeEditorshow and edit the recipe. Markdown renders throughreact-markdownwithremark-gfm, raw HTML skipped and images dropped (about/markdown.tsx, loaded lazily). How the recipe is saved and goes stale is in Editing internals.
Export. While exportPng runs, VizCanvas sets data-exporting on its container.
Rules under [data-exporting] in styles.css lay legends out in full, show collapsed
legends and the About panel open, and hide the scroll cue. Mark controls and on-screen
wording with data-export-hide, and their export-only replacements with
data-export-only (hidden until exporting). The recipe uses this to name the viewer
instead of “you” and give dates instead of relative times.
The app header
Section titled “The app header”Every signed-in page uses components/app-shell/app-header.tsx:
- Left: the brand mark (links to
/dashboard), then the page’s breadcrumb (children, separated byHeaderDivider). - Centre:
center.MapWorkspaceputs the toolbar portal target here. - Right:
actions, the Docs link (whenVITE_DOCS_URLis set), and the account menu.
The three groups are at least 32px apart (24px below 1024px). As the window narrows,
labels hide one at a time, least important first, each below a fixed width: Docs,
Import / Export, Visualise’s Export, Layout and Views group labels, the organisation
crumb, then Edit’s Arrange, Group by and Add Node. A hidden label is
sr-only, so it still names its control, and the control’s title shows it on hover.
The map’s name never hides: the toolbar slot is min-w-max, so the name is what
truncates once nothing else can give. AppHeader’s comment lists the widths. Status
messages never go in the header: they float on the canvas (CanvasStatus).
On a map page, the breadcrumb is organisation initials / map name / mode.
abbreviateOrgName (lib/org-abbreviation.ts) gives “CK” for Common Knowledge and “Au”
for Autonomic. The organisation comes from the cached ['orgs'] query and never
suspends. ModeSwitcher opens on hover (with a short close delay), click or
Enter, and sets the mode search param.
On the home page, the first crumb is the organisation switcher
(components/home/org-switcher.tsx). The choice is saved to
app.profile.settings.activeOrgId with updateSettings, applied optimistically to the
['profile'] cache (use-active-org.ts). A missing or stale value falls back to the
first organisation.
Menus use components/ui/dropdown-menu.tsx, a small click-to-open menu, with no Radix.
Use DropdownMenuLink for router links and DropdownMenuAnchor for downloads. A
container that clips or out-stacks it, such as a row with a hover transform, needs
hover:z-10 focus-within:z-10 (see map-spine.tsx).
New signed-in pages should use AppHeader.
The home view
Section titled “The home view”HomeView (components/home/, rendered by routes/_authed/dashboard.tsx) lists the
active organisation’s maps as MapSpine rows, using getMapSummaries. Every row is the
same height, and hover changes only its border and shadow, so rows never shift.
NewMapDialog is the one way to create a map:
- Blank:
createMap, then open Edit mode. - From a file:
createMap, then the sameImportWizardthe map header’s Import / Export dialog uses. Closing it without importing deletes the map, if it’s still empty. - Duplicate:
duplicateMap(components/home/duplicate-map.ts). It exports the source as internal JSON (GET /api/maps/$id/export?format=json), creates the new map, and imports the JSON into it (POST /api/maps/$new/import?format=json). The import returnsnodeIds, mapping each source node to its copy.placementsForuses that to put every copy at its source’s position withplaceNodes. ThencopyMapRecipecopies the recipe, unconfirmed; a failure there is only logged. Any other failure aftercreateMapdeletes the partial copy.
Adding new UI
Section titled “Adding new UI”- Something about one selected item goes in the entity dock, not a new panel.
- A collection or map-wide setting goes in a workspace panel tab.
- A viewport hint or legend goes in a
CanvasOverlay. - Anything that interrupts or needs confirming is a
DialogorConfirmDialog.