Skip to content

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.

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.

  1. A detail view never replaces a list. The Nodes and Connections lists always show. A selected item opens in the dock.
  2. Selecting something never changes the left panel. Only the dock reacts to selection.
  3. 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.
  4. The dock shows the same body in Edit and Visualise. Both modes use NodeInspector and EdgeInspector. Visualise leaves out their editing prop, which makes every section read-only.
  5. “Inspector” means the right-hand dock. The editor’s left panel is InspectorSidebar, but it’s a workspace panel in these terms.
  6. Multi-selection is a dock state. The editor uses React Flow’s own selected flags. One selected item opens its inspector; more than one opens SelectionSummary (a count, Tag the selected nodes, Remove from map, Delete selected). Unplaced nodes picked together in the Nodes card (unplacedSelection in map-editor.tsx) open it too, without Remove from map.
  7. Everything the map is made of shares one panel. Edit’s Elements panel (elements-panel.tsx) has Nodes, Connections, Tags and Measures as ElementsCards 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.
  8. 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’s badgeTone="info").
  9. 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, focusNodeIds in map-editor.tsx) is a React context, not a ref, so nodes re-render when it changes. NODE_TYPES and EDGE_TYPES must keep a stable identity, or React Flow remounts everything.
  10. 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 in node.data or edge.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.

In components/map-workspace/layout/:

  • z-layers.ts: the named z-index scale. Use LAYER in new code rather than raw z-* classes. Some older code doesn’t yet.

    0 canvas
    10 canvas overlays; the active rail tab
    40 editor add-node click-catcher
    50 toasts, add-node drag ghost
    90/91 context-menu scrim / menu
    100 dialogs
    200 global loading indicator
  • side-panel.tsx: SidePanel, PanelRail, PanelRailButton, PanelRailToggle, PanelColumn, PanelHeader, PanelBody, PanelStrip. These are the parts of a workspace panel. VizSidebar has tabs, so it uses the rail. InspectorSidebar is one panel: its collapse button is in its PanelHeader (closeIcon), and collapsed it’s a PanelStrip, the same strip EntityDock collapses 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. openNonce opens a section from elsewhere, scrolls to it and focuses its header.

  • canvas-overlay.tsx: CanvasOverlay, the card used by ColourLegend and DirectionalLegend. It takes a title, shown in a header that collapses the card. It starts open, is at most 50% of the canvas tall, and scrolls past that. Its canvasCardClass is 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 to AboutMapPanel as beside. 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: CanvasCornersProvider wraps each canvas. useRegisterCanvasCorner declares what sits in a bottom corner (a legend, the editor’s minimap), and useCanvasCorner reads it. CanvasOverlay registers itself.

  • entity-dock.tsx, use-entity-dock.ts, use-dismiss-on-escape.ts: the 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. useEntityDock returns { 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:

  1. its pane is hidden (offsetParent === null). All three modes are mounted, so several listeners are live at once.
  2. a dialog is open ([aria-modal="true"]).
  3. focus is in a form control.

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 through MapWorkspace’s nodeFocusRequest/edgeFocusRequest, and the editor opens the same node or connection, placed or not.

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 to VizCanvas through the overlay prop, 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 an OverlayScroll.
  • 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) and describeEditorCanvas (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. RecipeSection and RecipeEditor show and edit the recipe. Markdown renders through react-markdown with remark-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.

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 by HeaderDivider).
  • Centre: center. MapWorkspace puts the toolbar portal target here.
  • Right: actions, the Docs link (when VITE_DOCS_URL is 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.

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 same ImportWizard the 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 returns nodeIds, mapping each source node to its copy. placementsFor uses that to put every copy at its source’s position with placeNodes. Then copyMapRecipe copies the recipe, unconfirmed; a failure there is only logged. Any other failure after createMap deletes the partial copy.
  • 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 Dialog or ConfirmDialog.