@miragon/modeler-shell: implementation notes
@miragon/modeler-shell is the chrome around every Miragon diagram-js modeler: menu, palette skin, tooltips, zoom panel and keys, fit, problems pill, legend, status box and the plumbing to its host (a VS Code webview, a web app or an embedding page). Its API is in packages/shell/API.md.
What the chrome looks like and how it behaves is the Miragon design system's guide (modeler-tool-design skill, §5, §8, §9, §12 and §13). These notes are the other half: the diagram-js, browser and VS Code mechanics behind it, and the traps that are not visible in the code. If a note and the guide disagree, the guide wins.
Principles
- The modeler owns its content, the shell owns the chrome. The modeler hands over content bounds, emptiness, diagnostics, legend, menu entries and palette entries (
titleandshortcutseparately). It keeps what only it can do: keyboard creation from the palette, the canvas lock of a read-only file, select all and its own dialogs. Renderers never import the shell; they hand it structurally typed parts. - Public diagram-js only. The shell talks to
canvas,zoomScroll,keyboard,paletteandeventBus, never to private fields. The palette's container arrives withpalette.create. - Container, not window. Every size and breakpoint is relative to the shell root:
container-type: size(the root then needs an explicit height),cqw/cqhinstead ofvw/vh,@container cd-shell (…)instead of@media, aResizeObserverinstead ofwindow.innerWidth. No element inside the root gets acontainer-typeof its own. - Sizes come from the vendored tokens. CSS reads
--cd-shell-*; scripts use their copySHELL, which every modeler pins tocd-tokens.generated.jsonin a unit test. - Foreign pages. Page rules such as
button { text-transform: uppercase }beat inheritance, so the chrome re-establishes font, colour and spacing on its own elements at class specificity. Positions are measured against the root's padding box (clientLeft/clientTop), since a page may border the root.
Palette
- The shell turns diagram-js's vertical palette into one horizontal glass island and moves it before the canvas
<svg>onpalette.create, so the tab order is palette, then canvas. Palette#_updaterebuilds every entry on each change. After everypalette.changedthe shell re-appliesrole="toolbar", the rovingtabindex,aria-pressedandaria-keyshortcuts.- The active tool comes from
tool-manager.update, not fromhighlighted-entry: diagram-js caches the tools group once and marks detached entries after a rebuild. - Enter and Space dispatch a synthetic
clickwithdetail === 0. Creating entries must branch on it:create.startnever ends without a pointer, so keyboard creation places the shape atfreeAreainstead. - A renderer must not add a tab stop around palette and
<svg>(atabindexon.djs-container). - diagram-js's own palette tooltip (hover only) is replaced:
{ paletteTooltip: ["value", null] }.
Tooltips and layers
- The canvas container is
isolation: isolate, so everything diagram-js shows (context pad 100, popup menu 200, palette 110, label editor 100) stays on layer 0, below every island. - Tooltips, dropdowns and popovers live in one layer element directly under the shell root, never inside an island: an island with
z-indexorbackdrop-filtertraps them. - Persistent overlays a modeler adds stay at
z-index100 or below; transient popups may cover the palette while open. - A
<dialog>opened withshowModal()sits in the browser's top layer; it needs no layer token and tells the key listener (:modal) that the zoom keys must stop. - Set every property of a
kbdchip explicitly: VS Code's default stylesheet styleskbd.
Zoom and keys
- Buttons and keys call
zoomScroll.stepZoom(±1), which snaps to diagram-js's raster first; the shell adds no rounding. The limits 20 % and 400 % areZoomScroll's fixedRANGE. Zoom out is disabled already below 20 %, sincestepZoom(-1)from there zooms in. canvas.zoom()is rounded to three decimals: compare with a tolerance.- Read the zoom once per animation frame after
canvas.viewbox.changed; withdeferUpdatethat event comes up to 300 ms late, so also schedule fromcanvas.viewbox.changing. - The shell's keyboard listener runs at priority 2000 and returns
truefor the chrome keys, so diagram-js'sKeyboardBindingsand the modelers' modules never zoom a second time. The keys execute in one DOMkeydownlistener ondocument(on the shell root when embedded). - Matching: Cmd and Ctrl both count on every platform, never with Alt (AltGr) or during IME composition.
+/-byevent.key, numpad byevent.code, fit bycode === "Digit1"with Shift (layout-independent). Nothing acts in a text field (isTextField: inputs, contenteditable,EditContext, Monaco) or behind a modal dialog. - A modeler whose keyboard listens on
documentmust register before the shell, so construct the diagram before mounting the shell. - Shortcut labels read the platform in the webview itself (
navigator.userAgentData?.platform || navigator.platform), neverprocess.platform, which names the remote machine under VS Code Remote. aria-keyshortcutsnames keys, not characters, for the US and the German layout (?→Shift+/ Shift+ß). Characters that need AltGr or a dead key get none.
Focus
- A pointer click (
event.detail >= 1) on the zoom panel, a palette entry or a File/Model menu action hands the focus to the canvas (canvas.focus()), so Delete and the arrows act at once. Enter and Space leave it on the control. - WebKit and Firefox on macOS do not focus a clicked button: never wait for the clicked control to hold the focus before handing it on.
- When an island hides while it holds the focus, move the focus first, or it falls to
<body>. - While a shell is attached the canvas
<svg>getsrole="application"and anaria-label, so screen readers do not swallow its single-key commands.
Fit
- Never
canvas.zoom("fit-viewport"): it knows no insets. Compute a viewbox with the container's aspect ratio from the tight content bounds and the fit insets, at most 100 %, and set it withcanvas.viewbox(box). - The content bounds come from the contract (
contentBounds(), tight, without padding). - The first import with content fits once; an empty file shows 100 %. Later imports keep viewbox and selection.
- A user move is any viewbox the shell did not write itself: compare scale and screen translation with the last written box (tolerance 1/1000), never with a flag (deferred events arrive late). Viewbox changes between
import.*.startandimport.doneare the importer's, not the user's. - Resizes refit while the view is automatic. Call
canvas.resized()first (it drops the cached viewbox) and skip 0 × 0 (hidden tabs). - An open legend measured before its font loaded refits once after
document.fonts.ready, only if nothing else moved the view. - A view change the host makes never commits an open label editor (contract L5): renderers keep the editor open across viewbox changes and commit only on the user's own gestures.
Status box and read-only
- The status box and the visible read-only marks wait 800 ms without a valid import, so typing in the text editor does not flicker. The lock itself (palette veto, Model entries) applies at once.
- The palette veto finds the entry through
delegateTarget, sincepalette.triggercarries no id. - The canvas lock is the modeler's: rules alone do not stop a context pad or a double-click label editor, so all of them must ask the lock.
- Draw the 4px status bar as a
::before; aborder-leftbends at the rounded corner.
Appearance
resolveAppearance(requested, themes): a mode the modeler cannot show falls back tothemes[0]; high contrast always passes.themesis static package data, so the host writes the attributes before the first paint.- An embedded root carries its own
data-cd-theme(else a dark host page darkens the chrome over a light canvas) and mirrors the host'sdata-cd-kontrast. - Web app:
webHeadMarkupwrites the head script and metas; the script is ES5, reads storage intry/catchand escapes every literal. Switching disables transitions for two frames.
Web app host
- Primary action: loses its label before the palette moves to the second row. Decide again once Geist has loaded.
- Legal line: wraps between entries instead of scrolling; pill, legend and the raised zoom panel stand on its measured height.
- Toasts: a
popover="manual", re-shown when a modal dialog opened, since layer 60 loses against the top layer. Screen readers hear them through two live regions rendered from mount on. - Start screen: the work chrome is
visibility: hidden, the canvasinert, and the zoom keys belong to the browser (page zoom, WCAG 1.4.4). - Drop zone: listeners on
windowwith a depth counter; MIME types are often empty before the drop, so an empty type never forbids.
VS Code host
The extension owns file, document, history and mode; the webview shows canvas and chrome.
- Commands. Every menu action that makes sense without canvas focus is also a command; both post the same message to the active panel.
- Notifications. No toasts in the webview: the extension shows VS Code notifications, only for actions the user triggered.
- Undo. The history is the document's. The webview never binds Cmd+Z to the
commandStack(undoRedoKeys: false); the shell lets the key reachwindow, where VS Code undoes. Each canvas change is oneWorkspaceEdit. The extension recognises its own edits as echoes. In text fields, undo keys getstopPropagation()withoutpreventDefault(), so the field keeps its native undo. - Key interception. VS Code forwards every
keydownthat reaches the webview'swindow, whether prevented or not. So the webview stops every key it handled ondocument(zoom keys, Escape with an open menu, keys the canvas already prevented such as Cmd+C or Cmd+A) and lets the rest through. - Theme bridge. The extension writes
data-cd-theme/data-cd-kontrastinto the HTML and posts changes; re-send when a hidden panel becomes visible. The body classes are only a fallback when no extension speaks, andvscode-high-contrast-lightmust be checked beforevscode-high-contrast. - Hygiene. Reset VS Code's default webview styles (body padding, fonts,
kbd,code, links, focus rings, scrollbars). Never usevar(--vscode-font-family): undefined elsewhere, it invalidates the whole declaration. Bundle fonts as files, not data URLs. Never add'unsafe-eval'; a webview with zod 4 setsz.config({ jitless: true })first. Mirrorvscode-reduce-motionas the shell's own class. - State.
retainContextWhenHidden: true; a hidden tab reports 0 × 0. Legend state and view go throughvscode.setStateundermodelerShell, written 250 ms after the view rests; a stored view wins over the first fit.
VS Code: smoke tests in a real VS Code
The stub in ./testing/vscode-stub reproduces VS Code's webview; ./testing/vscode-electron checks the real one (usage in the shell README).
- Playwright, not
runTests.runTestscannot reach the webview's DOM. The driver downloads VS Code with@vscode/test-electron(3.1.0 or later for the renamed macOS executable) and drives it with_electron.launchthrough the two webview frames. - Cache. VS Code goes to the OS cache directory, outside every repository (ESLint would lint a
.vscode-testfolder). Parallel runs download into private folders and move the result into the cache with onerename, so they never delete each other's install. - Clean profile. Temporary user data and extensions directories, no welcome page, no automatic theme detection, no
VSCODE_*variables (from a VS Code terminal they break the webview). - Keys.
keyboard.dispatch: keyCode, so Playwright's US key codes mean the same on a German layout. - Save. Wait for the tab to turn clean, then read the file until two reads agree; VS Code writes in place.
- Themes. Written to
settings.json. A missed switch is written again after 2 s, then routed through a third theme.extensions.autoUpdateis set to"off"so VS Code's own settings migration does not overwrite the file. - Failures with a cause. The driver quotes an activation error from the extension host's log and the webview's own uncaught errors, instead of waiting for the timeout.
- Docker.
modeler-shell-vscode-dockerruns the same test in a Linux container underxvfb-run, so no window takes the focus on a Mac. The repository is copied withoutnode_modulesand installed withnpm ciinside; VS Code and npm caches are shared volumes.
Re-check on a diagram-js upgrade
These notes rely on internals:
ZoomScroll:RANGE = { min: 0.2, max: 4 },NUM_STEPS = 10, the snap in_zoom.Canvas:zoom()rounded to three decimals,deferUpdatedebounce of 300 ms,resized()dropping the cached viewbox.Palette: appended after the<svg>,palette.createwith{ container }, full rebuild onpalette.changed, the cached tools group.PaletteTooltipregistered aspaletteTooltip.Keyboard: bound to the canvas<svg>,preventDefault()withoutstopPropagation().