Skip to content

Latest commit

 

History

History
1310 lines (1158 loc) · 98.8 KB

implementation-notes.md

File metadata and controls

1310 lines (1158 loc) · 98.8 KB

@miragon/modeler-shell — implementation notes

@miragon/modeler-shell is the shared, framework-free implementation of everything around a Miragon diagram-js modeler: menu button, tool palette, tooltips, zoom panel, keys, fit, problems pill, legend, status box and the plumbing to its host (a VS Code webview, a web app, or an embedding host such as designIQ). Status: 0.1 in packages/shell, Phase 4 of #4; its public API is documented in packages/shell/API.md. Where building 0.1 showed a note to be wrong or incomplete, the note was corrected here.

The source of truth for look and behaviour is the Miragon design system: the Claude skill modeler-tool-design in Miragon/corporate-identity, guide plugins/miragon-brand/skills/modeler-tool-design/assets/modeler-design-system.md, §5 (chrome anatomy) and §13.3 to §13.5 (appearance and IDE hosts). The guide defines what users and reviewers experience: sizes, anchors, surfaces, states, keys and their effect, timing, ARIA, accessibility and the rules per host. These notes explain how to get there with diagram-js, VS Code and browser APIs: services and events, DOM and CSS techniques, host quirks.

The design system repository is private, so the guide links in these notes open only for Miragon staff. Every section therefore links the guide section it implements and restates its rule in one line (Guide §x.y. Rule: …); the mechanics follow. If a note and the guide disagree, the guide wins and the note gets fixed. If you cannot read the guide and something here looks wrong or incomplete, open an issue in Miragon/modeler-api; a maintainer checks it against the guide.

Checked against diagram-js 15.28.1 (the guide requires at least 15.28) and against VS Code's webview host page (src/vs/workbench/contrib/webview/browser/pre/index.html on main, October 2026). What can only be confirmed in a running VS Code is collected in Open verification points.

Glossary

The design system names its tokens and attributes in German. The names used in these notes:

Name Meaning Value
--cd-shell-rand edge: distance of every island from the container edge 12px
--cd-shell-luecke gap: trigger to dropdown, island to island in one stack 8px
--cd-shell-zeile row: height of every island in the top and bottom rows 40px
--cd-shell-ziel target: button or entry inside an island 32px
--cd-shell-eintrag entry: minimum height of a menu entry, default button in dialogs 36px
--cd-shell-frei free line: first free line below the top row and above the bottom row 64px
--cd-shell-menue-breite, --cd-shell-inspector-breite minimum dropdown width, inspector width 248px, 288px
--cd-shell-einpassen-oben, -oben-schmal, -seite, -unten, -unten-web fit insets: top, top with the palette in the second row, left and right, bottom, bottom in the web app with the problems pill 88, 140, 24, 64, 92px
--cd-shell-schmal, -sehr-schmal, -niedrig breakpoints: narrow and very narrow (width), low (height) 760, 440, 820px
--cd-shell-ebene-inseln, -inspector, -reihe, -aufklapper, -dialog, -toast layers (z-index): islands, inspector, top row, dropdowns (dropdown, popover, chrome tooltip), dialog, toast 20, 25, 30, 40, 50, 60
--cd-ui-grund ground: app background and canvas surroundings per mode
--cd-ui-flaeche, --cd-ui-flaeche-hoch surface of a docked panel; raised surface (dropdown, dialog, tooltip, inspector) per mode
--cd-ui-hover, --cd-ui-plot hover and pressed surface; drawing area per mode
--cd-ui-linie, --cd-ui-kontur line (decorative borders, separators); contour (input borders, at least 3:1) per mode
--cd-ui-text, --cd-ui-text-leise, --cd-ui-link text, muted text, links per mode
--cd-ui-interaktion, --cd-ui-fokus interaction (selection, active-tool ring, hover icon); focus ring per mode
--cd-ui-<status>, --cd-ui-<status>-flaeche status colour and status surface for success, warning, danger, info per mode
--cd-ui-chrome-deckkraft opacity of glass islands 84 %, opaque in dark and high contrast
data-cd-theme mode attribute: light, dark or system
data-cd-kontrast="hoch", kontrast: "hoch" high contrast ("hoch" means high), on the same element as data-cd-theme
radius sm, md, lg, pill (--cd-radius-*) corner radii 6, 12, 16, 999px
shadow-1 to shadow-3 (--cd-shadow-*) elevation, from resting islands to dropdowns and dialogs
--cd-motion-fast, -base, -slow; --cd-ease, --cd-ease-out durations; easing 150, 250, 400ms

Where the tokens come from

  • cd-tokens.generated.css (every --cd-* variable, the roles per mode) and cd-tokens.generated.json (the roles per mode as hex, plus shell with the shell dimensions) are generated in the design system repository and published unchanged at https://corporate-identity.netlify.app/brand/cd-tokens.generated.css and https://corporate-identity.netlify.app/brand/cd-tokens.generated.json. The shell group appears there with design system release 1.12.0.
  • Modeler repositories vendor them unchanged, for example packages/renderer/src/theme/cd-tokens.generated.css in Miragon/wardley-maps-modeler. A vendored copy is never edited by hand; it is re-copied from the source.
  • The shell's scripts compute with their own copy of the sizes (SHELL), its stylesheet reads the vendored --cd-shell-* tokens. A modeler pins the two together in a unit test (Vitest or node:test), not in its e2e suite, because the check is pure data: expect(SHELL).toEqual(shellSizesFromTokens(json.shell)), and every name of SHELL_TOKENS present in json.shell.
  • Load the token CSS exactly once: first in the stylesheet order, unless the renderer's stylesheet already imports it (Event Storming's event-storming.css does, in a layer).
  • cd-canvas.css (the mapping of the diagram-js variables and the cd-c-* role classes) ships only with the design system skill so far and is not public yet.

What a modeler hands to the shell

Guide §5. Rule: the chrome is the same for every modeler; only the modeler knows its content.

The modeler supplies The shell uses it for
content bounds in diagram coordinates, tight (empty means nothing to fit) fit (§5.9)
whether the file is empty, whether an example exists empty-file hint (§13.5)
diagnostics with count and highest severity problems pill (§5.10)
whether the file is invalid status box, read-only canvas (§5.12, §13.5)
legend content legend (§5.10)
entries of the Model menu group menu (§5.4)
palette entries with name and shortcut (title, shortcut) palette and its tooltip (§5.5, §5.6)
the list of keyboard shortcuts "Keyboard shortcuts…" dialog (§5.4, 0.2)
the inspector inspector (§5.11)

What stays with the modeler, because only it knows its elements: keyboard creation from the palette at viewport.freeRectCenter() (see Palette), the canvas lock of a read-only file (context pad, move, resize, delete, paste, label editor; see Status box), select all on the canvas (see VS Code: key interception) and, in 0.1, the "Keyboard shortcuts…" dialog itself (see Modeler dialogs).

The shell talks to diagram-js only through public services and events (canvas, zoomScroll, keyboard, palette, eventBus). It never reads private fields such as palette._container, which hosts still do today (Miragon/design-iq#136): the palette's container comes with the palette.create event.

Container, not window

Guide §5.1, §5.15. Rule: every size, breakpoint and height is relative to the modeler's container, never to the window, so an embedded modeler behaves like one in its own webview.

  • The shell root gets container-type: size and position: relative; the islands are position: absolute inside it.
  • container-type: size applies size containment: the root no longer takes its height from its content. It must get width and height from outside (height: 100% in the webview and the web app, an explicitly sized element in an embedding page), otherwise it collapses to 0.
  • Use container query units, never viewport units: cqw and cqh instead of vw and vh. Every container-relative size the guide writes as a percentage is written with them, so it resolves against the shell root and not against whatever positioned element happens to be the containing block:
    • dropdown max-width: min(320px, 100cqw - 24px), max-height: calc(100cqh - 72px);
    • tooltip max-width: min(280px, 100cqw - 24px);
    • bottom sheet max-height: 45cqh;
    • inspector max-height: calc(100cqh - 128px);
    • legend max-height: calc(100cqh - 64px - <bottom> - 12px), with the palette in the second row calc(100cqh - 116px - <bottom> - 12px);
    • problems popover width: min(320px, 100cqw - 24px), status box max-width: min(560px, 100cqw - 24px), palette in the second row max-width: calc(100cqw - 24px);
    • the components of guide §8 wherever the shell renders them: dialog width: min(560px, 100cqw - 32px), confirmation min(440px, 100cqw - 32px), both max-height: calc(100cqh - 32px); toast width: min(480px, 100cqw - 24px); empty-state card width: min(400px, 100cqw - 24px).
  • No element inside the shell gets a container-type of its own. cqw/cqh resolve against the nearest query container, so a nested one would silently change their meaning. Name the root (container-name: cd-shell) and write @container cd-shell (…) so host CSS cannot interfere.
  • The canvas slot is usually the renderer's own container (modeler.container), which brings its own class rule: Event Storming's .event-storming-container sets position: relative, a paper colour and a dot grid at the same specificity as .cd-shell-canvas. Split the slot's rule: the layout (position: absolute; inset: 0, layer 0, isolation: isolate) at a higher specificity (.cd-shell > .cd-shell-canvas), so it does not depend on stylesheet order; the paint at zero specificity (:where(.cd-shell-canvas) { background-color: var(--cd-ui-grund) }, never the background shorthand), so the renderer's paper shows. That paper keeps --cd-ui-grund as its colour in every mode, its dot grid a background image in --cd-ui-linie (guide §10.1): a renderer that paints its own surround (Event Storming's style dark board in navy under a light theme) is half-dark. Shell conformance compares the painted colour, the first non-transparent background-color from .djs-container out through the slot to the root, with --cd-ui-grund; comparing the custom properties alone would miss it.
  • In a foreign page the page's element rules (button { text-transform: uppercase }, p { font-size: 18px }, section { font-family: serif }, specificity 0,0,1) beat inheritance and any zero-specificity reset. Re-establish font, colour, letter and word spacing, text transform, indent and shadow on the chrome's own islands, controls and texts with one rule at class specificity (:where(.cd-shell) :is(.cd-shell-island, .cd-shell-target, .cd-shell-status-text, …), 0,1,0), placed before the component rules, which then set their own size and weight (the readout's 13px/600) at the same specificity later in the file.
  • Breakpoints are @container queries on the shell root, never @media (see Narrow and low containers).
  • Scripts measure the container with a ResizeObserver on the shell root, never with window.innerWidth/innerHeight.
  • The sizes come from the vendored tokens: CSS uses var(--cd-shell-*), scripts read the shell object of cd-tokens.generated.json, whose keys are the CSS variable names and whose values are strings with unit ("88px", layers "20"): parseFloat(tokens.shell["--cd-shell-einpassen-oben"]).

Geometry hooks

Guide §5.2, §13.4. Rule: every island sits at a fixed anchor with fixed sizes in every host; tests check the position of each island to 1px.

  • Every island carries data-chrome with its name: menu, palette, primary, zoom, problems, legend, inspector, status. On the palette the attribute goes onto diagram-js's .djs-palette element.

  • Shell conformance runs in every modeler's e2e suite in a real browser. It finds the islands through this attribute and asserts their boxes within ±1px. There are no pixel goldens: the islands are glass, so their pixels depend on the diagram underneath.

  • "Relative to the shell root" means relative to the root's padding box, where position: absolute islands anchor: getBoundingClientRect() minus the root's getBoundingClientRect() minus root.clientLeft/clientTop. A foreign page may give the root a border (a framed embedded modeler); measured against the border box, every island would be off by its width.

  • The expected boxes, relative to the shell root (guide §5.2 to §5.15, IDE host unless noted):

    Island Expected box
    menu 40 × 40 at top: 12px; left: 12px
    palette 40 high at top: 12px, horizontally centred; second row top: 64px
    primary web app only: 40 high at top: 12px; right: 12px
    zoom 40 high at right: 12px; bottom: 12px, 173px wide with a 56px readout
    problems 40 high at left: 12px, bottom: 12px (web app 40px, above the legal line)
    legend left: 12px, bottom: 12px without pill, 60px with pill (web app 40px and 88px); up to 440px width at least 60px
    inspector 288 wide at top: 64px; right: 12px; up to 760px width a bottom sheet with 12px left, right, bottom
    status horizontally centred under the palette at top: 64px (second row 116px); with an open inspector centred in the strip to its left
    dropdown top: 60px; left: 12px
    tooltip top row top: 60px, second row top: 112px, bottom row bottom: 60px, centred on its control
  • The guide's limits belong to the same check: legend at least 184px wide, status box at most min(560px, 100cqw - 24px), side inspector ending at least 64px above the bottom, bottom sheet at most 45cqh, palette in the second row at most 100cqw - 24px, dropdown between 248px and min(320px, 100cqw - 24px) wide and at least 12px above the bottom, tooltip at most min(280px, 100cqw - 24px) wide and 12px from the sides. While the bottom sheet is open, zoom panel, pill and legend are hidden (guide §5.11).

Menu

Guide §5.4. Rule: a click outside the open menu closes it without moving focus and still acts on its target, also on the canvas.

  • While the menu is open, listen for pointerdown on document with { capture: true }. diagram-js stops pointer events on its own surfaces: palette and context pad call stopPropagation() on mousedown, the dragging module stops the events of a drag, and canvas interaction events stop propagation when a listener returns false. A bubble-phase listener would miss these clicks.
  • Neither stop nor prevent the event, so the click still reaches its target (a palette tool, an element on the canvas).
  • Open on Enter, Space and ↓ in the trigger's keydown with preventDefault(); otherwise the button's native click toggles the menu a second time.
  • The dropdown lives in the layer at the end of the shell root (see Tooltip), so a Tab from it would leave the shell. On Tab, close the menu and move the focus to the trigger without preventDefault(): the browser's Tab then continues from the trigger to the palette.
  • Stop every key the menu consumes (↑ ↓ Home End, the type-ahead letters, Enter, Space, Delete, Backspace, and ↓ ↑ Enter Space on the trigger) with stopPropagation(), in every host, as the palette does. A modeler's keyboard on document runs before the shell's listener and does not look at defaultPrevented: Event Storming's would move the selected sticky on ↓ (and write that to the document), delete it on Delete and start a sticky on E. Let Escape, Tab, modified keys and the chrome keys through (the shell's listener closes on Escape, zooms and fits with the menu open).
  • A click into the VS Code workbench (side bar, another editor) or into another iframe moves the focus out of the document without a pointerdown in it. Close the menu on the window's blur, without moving the focus: from the user's view it was a click outside. Do not close on a focusout without relatedTarget: a click on the menu's own heading or separator causes one too.
  • Dropdown limits: max-width: min(320px, 100cqw - 24px), max-height: calc(100cqh - 72px) (see Container, not window).

Palette

Guide §5.5. Rule: the palette is one horizontal glass island, 40px high, at the top centre in every modeler; it lies above context pad and label editor and comes before the canvas in the tab order.

  • Position. diagram-js styles .djs-palette as a vertical column (left: 20px; top: 20px; width: 48px, floating 46 × 46 entries). The shell overrides it with top: 12px; left: 50%; transform: translateX(-50%); width: auto and lays .djs-palette-entries and each .group out as one flex row of 32 × 32 entries.
  • Surface. cd-canvas.css (vendored from the design system) maps the diagram-js surface variables (--bio-surface and friends) to the opaque --cd-ui-flaeche-hoch, so the diagram-js palette on its own is opaque. The shell CSS sets the glass on top (guide §5.3): background: color-mix(in srgb, var(--cd-ui-flaeche-hoch) var(--cd-ui-chrome-deckkraft), transparent) with backdrop-filter: blur(8px), 1px border in --cd-ui-linie, radius md, shadow-1. Use a selector of at least specificity 0,2,0 (.djs-palette[data-chrome="palette"]) so the result does not depend on stylesheet order.
  • Layer. The palette stays inside the diagram-js canvas container (layer 0, see Layers) with z-index: 110. The context pad has 100 (set by diagram-js), the label editor 100 (set by the modeler), and every other persistent overlay a modeler adds stays at 100 or below (see Layers).
  • DOM order. Palette#_init appends the palette to canvas.getContainer() after the <svg> and then fires palette.create with { container }. On that event the shell moves it in front of the canvas <svg> (the svg of the canvas.init event, which diagram-js fires on diagram.init, after every __init__ service exists): canvasContainer.insertBefore(container, svg). DOM order, and with it the tab order, then puts the palette before the canvas (the <svg> carries tabindex="0"). A renderer must not add a tab stop of its own around them: Event Storming sets tabindex="0" on canvas.getContainer() (the .djs-container holding palette and <svg>), an unnamed stop before the palette. Shell conformance tabs from the menu trigger to find such stops; a check of the elements' tree order alone does not.
  • Rebuilds. Palette#_update clears and rebuilds all entries on every change and then fires palette.changed. Entries are div.entry elements with data-action, plus role="button" and aria-label when the entry has a title; entries without a group land in group default. After every palette.changed the shell re-applies what it adds: role="toolbar" and aria-label="Tools" on the entries container, roving tabindex (0 on one entry, -1 on the rest), aria-pressed on tool entries and aria-keyshortcuts on every entry with a shortcut (guide §5.6; the chip alone never reaches a screen reader, because the tooltip's label equals the entry's aria-label). Renderers give title and shortcut separately; a title like "Domain Event — E" puts the key into the name instead.
  • Active tool. diagram-js fires tool-manager.update with { tool } (the tool name, null when none is active) and marks the matching entry in group tools (data-action="<tool>-tool") with highlighted-entry. Do not mirror that class: Palette#updateToolHighlight caches the tools group on its first call (_toolsContainer) and never refreshes it, so after a rebuild the class lands on detached entries. The shell keeps the tool name of the last tool-manager.update itself, sets aria-pressed from it after every palette.changed, and styles [aria-pressed="true"]. Until the first rebuild the class still sits on a live entry, and diagram-js.css colours .djs-palette .highlighted-entry with !important: the skin overrides that colour with !important at a higher specificity (plus a hover variant).
  • Colours in every state. Set color explicitly on a skinned tool entry in each state, never rely on inheritance: rest and [aria-pressed="true"] --cd-ui-text, hover (also while pressed) --cd-ui-interaktion, [aria-disabled="true"] --cd-ui-text-leise (also over a stale highlighted-entry), and give the icon (.entry > svg, .entry::before) color: inherit. A state the skin leaves open takes whatever a renderer or diagram-js rule sets on the entry or its icon: Event Storming's armed lasso computed to rgb(29, 30, 33) instead of --cd-ui-text. Only tool entries (group tools or data-action ending in -tool) inherit; notation previews keep their own colours (guide §5.5). Shell conformance compares the computed colours of every visible tool entry and its icon with the roles: at rest, pressed and hovered in appearance (after the 150ms colour transition), aria-disabled in readOnly.
  • Keyboard. Roving tabindex: ← and → move and wrap, Home and End jump, all without modifiers and stopped there (a modeler's document listener and VS Code never see them; chrome keys still bubble to the shell). Enter and Space dispatch a synthetic click on the entry: it goes through diagram-js's click delegate and palette.trigger, so the read-only veto applies without calling private palette methods. The click is a contract for palette providers: detail === 0 (every pointer click has 1 or more), clientX/clientY at the entry's centre, bubbling and cancelable.
  • Keyboard creation (guide §5.5: the element goes to the centre of the free rectangle, 24px diagonally further while that spot is taken, selected, with the label editor open where the notation has labels, and the focus on the canvas). diagram-js's create tool (create.start) makes the shape follow the pointer and never ends without one, so a creating entry that receives the synthetic click (detail === 0) must not start it: Event Storming's entries did nothing on Enter until they branched. The provider asks for the point with viewport.freeRectCenter() (the centre of the container minus the fit insets, in diagram coordinates, as fit computes it), creates the shape there with modeling.createShape, selects it, calls canvas.focus() and opens its label editor. The provider is built before connectDiagramJs, so it receives a function and reads the viewport when the entry fires. The 24px step is the provider's in 0.1; a shared placement helper is a 0.2 candidate. Shell conformance (create) presses Enter on the first creating entry and expects exactly one new, selected shape whose centre lies in the free rectangle.
  • Read-only (guide §13.5). Only Lasso and Hand stay active; every other entry changes the diagram and is vetoed, also tools of group tools such as draw, space or connect. palette.trigger carries the entry definition without its id, so the veto finds the entry element through the event's delegateTarget or target (min-dom's delegate sets delegateTarget); without one only group tools passes. The veto applies from the invalid import on, the aria-disabled marks after the delay of Status box.
  • Two columns. On every canvas.resized diagram-js decides from the container height whether the vertical palette needs two columns and toggles two-column on the palette and djs-palette-two-column on the container. The horizontal skin ignores both classes.
  • Group separator. Between two .group elements: margin-left: 8px before the line and padding-left: 9px after it (the 1px line at left: 0 sits inside the padding, so 9px leave 8px of space after it). Draw the line as a ::before inset 4px at top and bottom; a border-left would run the full 32px.
  • Separator entries. Entries with separator: true render as <hr class="separator">, which diagram-js styles as a horizontal line; the horizontal skin draws them as a 24px vertical rule with 8px of space on each side (6px margin plus the 2px gap).

Tooltip

Guide §5.6. Rule: palette entries and chrome buttons show the same chrome tooltip with a shortcut chip, after 300ms hover or at once on keyboard focus, on the dropdown layer above status box, legend and inspector.

  • Render the tooltip outside the canvas container, in a layer element directly under the shell root at z-index: var(--cd-shell-ebene-aufklapper) (40). Inside the canvas container (layer 0, isolation: isolate) it would sit below status box, legend and inspector whatever its z-index.
  • Do not render it inside an island either: an island with a z-index is a stacking context of its own, so a tooltip inside it could not rise above the inspector, and backdrop-filter makes a glass island the containing block of position: fixed descendants. Position the tooltip with position: absolute in the shell root's padding-box coordinates: differences of getBoundingClientRect(), minus root.clientLeft and clientTop (a foreign page may border the root).
  • "At once on keyboard focus" means :focus-visible: in Chromium a mouse click focuses the button too, and showing on every focus would flash the tooltip the click must hide.
  • "A neighbour's at once while one is shown" needs a grace window: pointerleave on one control hides its tooltip before pointerenter reaches the next, so at that moment none is shown. After a hover-hide, a neighbour shows at once for TOOLTIP_DELAY_MS.
  • Set aria-describedby only where the tooltip says more than the control's name (guide §5.6), for example the pill's "Show in Problems view". A shortcut chip says more, unless the control names its keys in aria-keyshortcuts (the zoom buttons, palette entries and menu actions do).
  • diagram-js 15.28 ships its own palette tooltip: PaletteTooltip on top of HoverTooltip, shown on mouseover only and therefore never on keyboard focus, placed to the right of a vertical palette. The shell replaces it with an empty service in the modeler's modules: { paletteTooltip: ["value", null] } (a later module overrides the earlier definition in didi; the palette module still lists paletteTooltip in __init__, which then resolves to null).
  • Name and shortcut come from palette.getEntries() (title, shortcut), keyed by the entry's data-action. Re-read them on palette.changed.
  • The 300ms hover delay is the delay of diagram-js's HoverTooltip (SHOW_DELAY = 300), so palette, chrome and canvas tooltips feel the same.
  • max-width: min(280px, 100cqw - 24px) (see Container, not window).
  • Shortcut chip (kbd): set every property of the guide's spec explicitly, including box-shadow: none and vertical-align: baseline. Otherwise the host's kbd style leaks through: VS Code's default webview stylesheet colours kbd with its keybinding colours and adds an inset shadow and vertical-align: middle (see VS Code: host hygiene).

Zoom panel

Readout

Guide §5.8, §2. Rule: the readout shows the zoom as a whole percentage in the UI language (English "86%", German "86 %" with a no-break space) and follows every zoom change; its aria-label names target and state ("Zoom to 100% (now 86%)").

  • Format with new Intl.NumberFormat(lang, { style: "percent", maximumFractionDigits: 0 }), lang being the tool's language (<html lang> in its own document, see the next bullet for a foreign page). For German it puts U+00A0 (no-break space) before the percent sign. Every other number in the chrome ("2 problems") goes through Intl with the same language (guide §2: one language per tool).
  • In a foreign page the page's <html lang> may name another language than the tool's. There the shell root carries the tool's lang itself (guide §2), so read the language with root.closest("[lang]").lang, not from document.documentElement.
  • Update after canvas.viewbox.changed, reading canvas.zoom() at most once per animation frame (requestAnimationFrame).
  • If a modeler enables canvas.deferUpdate, diagram-js debounces canvas.viewbox.changed by 300ms, and the readout would lag. canvas.viewbox.changing still fires synchronously before every change: schedule the frame read from it as well; by the time the frame runs, the change is applied.

Steps

Guide §5.8. Rule: buttons and keys zoom in fixed steps (22, 30, 41, 55, 74, 100, 135, 182, 246, 331 %) plus the limits 20 and 400 %, which are not steps (from 20 % zoom in goes to 30 %, from 400 % zoom out to 331 %); the mouse wheel uses the same steps plus the half steps between them and zooms only with Ctrl (on macOS also Cmd) or a pinch; a step starts from the nearest full step, at a wheel half step from either neighbour (86 % zooms in to 135 %, 64 % to 74 %); buttons and keys zoom around the container centre, the wheel around the pointer.

  • Buttons and keys call zoomScroll.stepZoom(±1). Using stepZoom is what keeps buttons and wheel on one raster. The shell adds no rounding of its own.
  • The raster: ZoomScroll has a fixed RANGE = { min: 0.2, max: 4 } and NUM_STEPS = 10. One step is log10(20) / 10 in log space, a factor of 20^(1/10) ≈ 1.349. The raster is anchored at 100 % (log10(1) = 0): full steps are 22.4, 30.2, 40.7, 54.9, 74.1, 100, 134.9, 182.1, 245.6 and 331.4 %; 20 % and 400 % are not on it and are only reached by capping (cap(RANGE, …)).
  • The wheel (ZoomScroll#zoom) uses half that step (26.0, 35.0, 47.3, 63.8, 86.1, 116.2 % and so on), one half step each time the accumulated wheel delta exceeds 0.1. diagram-js zooms with the wheel only with Ctrl (or Cmd on macOS) or a trackpad pinch, which browsers report as wheel with ctrlKey; a plain wheel pans. The wheel listener is only bound while the pointer is over the canvas.
  • ZoomScroll#_zoom first snaps the current zoom to the nearest step (Math.round(log10(zoom) / step) * step) and then moves one step. That gives the guide's examples: 95 % after a fit goes to 135 % (zoom in) or 74 % (zoom out); 86 % after one wheel notch goes to 135 % on zoom in.
  • The snap explains the guide's edge cases:
    • A wheel half step lies exactly between two full steps, and canvas.zoom() is rounded to three decimals, so the rounding decides which neighbour wins: 86.1 % snaps up (zoom in → 135 %), 63.8 % snaps down (zoom in → 74 %).
    • 20 % snaps to the 22.4 % step (zoom in → 30 %); 400 % snaps to the 447 % step beyond the limit (zoom out → 331 %). The wheel half step 385 % snaps down to 331 % (zoom out → 246 %).
  • stepZoom without a position calls canvas.zoom(scale), which zooms around the centre of the whole canvas container (outer.width / 2, outer.height / 2), not of the free rectangle. The wheel passes the pointer position. 100 % is canvas.zoom(1), also around the container centre.

Limits

Guide §5.8. Rule: zoom out is disabled at 20 % and below, zoom in at 400 %; zoom out never zooms in.

  • 20 % and 400 % are ZoomScroll's fixed RANGE (0.2 to 4); it is not configurable through config.zoomScroll.
  • Disable zoom out when canvas.zoom() is at most 0.2 * 1.001, zoom in when it is at least 4 * 0.999. The getter returns the scale rounded to three decimals (round(matrix.a, 1000)), and the tolerance absorbs that and floating-point noise at the limits.
  • Below 20 % (possible after fitting a large diagram) stepZoom(-1) snaps, steps down and then caps the result to 20 %, which zooms in. That is why zoom out is already disabled below the limit, not only at it.

Key matching

Guide §5.8. Rule: only Cmd/Ctrl + +/=, -/_ and 0 (also with Shift and on the numpad) and Shift+1 act; Cmd and Ctrl both work on every platform; nothing acts with Alt, during IME composition or in a text field (every input field, select list and editable text: label editor, inspector, dialog and popover fields).

  • "Cmd/Ctrl" is event.metaKey || event.ctrlKey on every platform, like isCmd in diagram-js (KeyboardUtil), which also returns false whenever altKey is set because Windows reports AltGr as Ctrl+Alt.
  • +/= and -/_ are matched by the produced character (event.key), with or without Shift.
  • Numpad keys are matched by event.code: NumpadAdd, NumpadSubtract, Numpad0. code also covers Numpad0 with NumLock off, where key is "Insert".
  • Fit is matched by event.code === "Digit1" with shiftKey and without metaKey, ctrlKey and altKey, so it is the physical 1 key of the number row on every keyboard layout (key is "!" on US and German layouts, but "1" on French AZERTY).
  • Ignore every shortcut with altKey or while event.isComposing is true. Escape during IME composition belongs to the IME: it does not close the menu (but in VS Code it is still stopped while a menu or dialog is open).
  • A text field is input, textarea, select or [contenteditable]: check the event target with target.closest("input, textarea, select") or target.isContentEditable; this covers the label editor and the fields of inspector, dialogs and popovers. There the zoom keys do not zoom and Shift+1 types !.
  • Unmodified +/- must not be bound anywhere, also not in a modeler's own keyboard module.

One place for the keys

Guide §5.8. Rule: every press of a zoom key acts exactly once, also where diagram-js or the modeler know the same key.

  • diagram-js binds Cmd/Ctrl + +/=, - and 0 itself on the focused canvas <svg> as soon as the editor actions stepZoom and zoom are registered (KeyboardBindings, default priority 1000). Its Keyboard service calls only preventDefault() on a handled key, never stopPropagation(), so the event still bubbles to document.
  • The shell registers keyboard.addListener(2000, listener) and returns true for the chrome keys. A non-undefined return value stops the diagram-js event bus, so KeyboardBindings and the modelers' keyboard modules (default priority 1000) never see these keys, and no key zooms twice.
  • The keys are executed only in the shell's own DOM listener (see Where the listener listens). It handles them regardless of defaultPrevented, because diagram-js's Keyboard has called preventDefault() on every key a listener returned a value for, including the shell's own.
  • diagram-js 15 binds its Keyboard to the canvas <svg> and ignores no event target (_isEventIgnored returns false), so a text field inside the <svg> (a foreignObject label editor) reaches it too. For a chrome key from a text field the listener returns false instead of true: the event bus stops there, and since only a truthy result makes the Keyboard call preventDefault(), Shift+1 still types "!" and the shell's DOM listener treats the zoom keys as blocked.
  • Listeners on one target run in registration order. A modeler whose own keyboard listens on document (Event Storming does) must register it before the shell's listener, so construct the diagram before mounting the shell; otherwise the shell's listener runs first and cannot see that the modeler handled the key (defaultPrevented). Listeners on the canvas <svg> are always earlier.

Where the listener listens

Guide §5.8. Rule: in its own document the keys work wherever the focus is, except in text fields and while a dialog is open; embedded in a foreign page only while the focus is inside the modeler. In the web app Cmd/Ctrl + +, - and 0 zoom the diagram, not the page, except in text fields and dialogs.

  • Own document (VS Code webview, web app, iframe): one keydown listener on document, bubble phase. The diagram-js keyboard on the <svg> has run by then, so defaultPrevented tells the shell which keys the canvas handled.
  • The listener does not execute the chrome keys while the focus is in a text field or a modal dialog (guide §8, [role="dialog"][aria-modal="true"]) is open. Popovers are role="dialog" without aria-modal and do not block the keys. Count [role="dialog"], [role="alertdialog"] and <dialog open> outside [hidden], in the document or (embedded) in the shell root; modal is aria-modal="true" or :modal (a <dialog> opened with showModal()). In VS Code the listener still stops the blocked keys and the keys the canvas handled (see VS Code: key interception).
  • Foreign page (embedded without iframe): the listener sits on the shell root. keydown is dispatched to the focused element, so it only reaches the root while the focus is inside it.
  • Web app: call preventDefault() on Cmd/Ctrl + +, - and 0 unless the focus is in a text field or a dialog, so the browser does not zoom the page. In text fields and dialogs do nothing: the browser zooms the page. Zooming via the browser menu always stays possible.
  • What the listener additionally stops in an IDE host: see VS Code: key interception.

Shortcut labels

Guide §5.8, §5.6. Rule: tooltips show the labels of the machine the user sits at, also when VS Code works on another system over Remote: on macOS ⌘+, ⌘−, ⌘0, ⇧1, elsewhere Ctrl+Plus, Ctrl+Minus, Ctrl+0, Shift+1 (German Strg, Umschalt); "Plus" because = is not on the plus key of German keyboards. aria-keyshortcuts follows the same platform, with WAI-ARIA key names.

  • Read the platform in the webview or page itself, in every host: navigator.userAgentData?.platform || navigator.platform, macOS if it matches /mac/i. Use ||, not ??: an empty userAgentData.platform means unknown and must fall back to navigator.platform. The webview always runs on the client, so its own navigator reports the keyboard in front of the user.
  • Do not label shortcuts from process.platform in the extension: with VS Code Remote (SSH, WSL, Dev Containers) a custom-editor extension usually runs on the remote machine, so process.platform names the remote OS. See Open verification points.
  • aria-keyshortcuts values (WAI-ARIA 1.2: modifiers first, then the key pressed, named by its unshifted character, the plus sign as Plus; code names such as Equal are invalid): zoom in Meta+= Meta+Plus, zoom out Meta+-, 100 % Meta+0, fit Shift+1; outside macOS Control instead of Meta.

Fit

Fit math

Guide §5.9. Rule: fit shows the whole content centred in the free rectangle (the container minus the fit insets and whatever is open), never above 100 %.

  • Do not use canvas.zoom("fit-viewport"): it knows no insets, and a diagram that already fits is aligned to the origin instead of being centred.

  • Compute the viewbox and set it with canvas.viewbox(box). The setter uses scale = min(outer.width / box.width, outer.height / box.height) and puts box.x/box.y at the top-left corner, so pass a box with the container's aspect ratio:

    const size = canvas.getSize(); // client size of the canvas container
    const free = {
      width: size.width - insets.left - insets.right,
      height: size.height - insets.top - insets.bottom,
    };
    const scale = Math.min(1, free.width / bounds.width, free.height / bounds.height);
    canvas.viewbox({
      x: bounds.x + bounds.width / 2 - (insets.left + free.width / 2) / scale,
      y: bounds.y + bounds.height / 2 - (insets.top + free.height / 2) / scale,
      width: size.width / scale,
      height: size.height / scale,
    });
  • Guard the degenerate cases the snippet leaves out: bounds with a zero width or height scale by the other dimension (a single line), bounds without both and a container with a 0 side fit nothing (a no-op), and an axis whose insets leave no room (a container lower than 88 + 64 or narrower than 48) centres in the whole container instead of producing a zero or negative scale.

  • bounds are the content bounds the modeler supplies: the tight bounds of the content, without a padding of its own. The insets below are the only margin; a padding (Event Storming's BoardBounds.contentBounds() adds 120px by default, so the webview passes contentBounds(0)) would add to them and fit the content smaller than the guide's rectangle. The insets (guide §5.9):

    • top --cd-shell-einpassen-oben (88px), with the palette in the second row --cd-shell-einpassen-oben-schmal (140px);
    • left and right --cd-shell-einpassen-seite (24px);
    • bottom --cd-shell-einpassen-unten (64px); in the web app while the problems pill is shown --cd-shell-einpassen-unten-web (92px) instead. The pill is not measured on top of that;
    • right with an open inspector (container wider than 760px): 324px (12 + 288 + 24);
    • left with an open legend (container wider than 440px): 12 + the legend's rendered width + 24; up to 440px the legend does not count;
    • bottom with an open bottom sheet (up to 760px): the distance from the container's bottom edge to the sheet's top edge, plus 12. While the sheet is open, zoom panel, pill and legend are hidden (guide §5.11), so the legend's left inset and the web pill's 92px do not count; the sheet alone sets the bottom. Without a measured sheet top the bottom stays 64.
  • Animated fit (explicit fit only, 250ms, guide §5.9): diagram-js has no animated viewbox change. Interpolate the viewbox in requestAnimationFrame (the scale in log space) and set each frame with canvas.viewbox(box). Record the target as the last fitted box before the first frame, and cancel the animation on wheel, pointerdown or a zoom key. While it runs, also compare incoming viewbox events with the last written frame (a deferUpdate event of a throttled frame carries an intermediate view); a cancelled animation leaves its last written frame as the reference. Stop the frames when the container drops to 0 × 0 (diagram-js's setter would set a scale of 0) and fit again once it has a size.

First fit and the empty file

Guide §5.9. Rule: the first import whose content bounds are not empty fits automatically (after an empty file: the first import with content, such as "Insert example"), unless the user moved the view; every later change from outside keeps viewbox and selection; without content fit does nothing.

  • afterImport() after every successful import carries this: it jump-fits once the bounds are not empty, and only the first time (see Detecting a user move for what ends the wait).
  • An empty file shows 100 % (guide §5.9 and §13.5 since design system 1.13.0). Without it the zoom is whatever the renderer's import left: Event Storming's importer sets a viewbox of its own on every import, and an empty board showed 118 %. So on the first import with empty bounds, when no stored view was applied (setState) and the user has not moved the view, afterImport() zooms to 1 around the centre of the free rectangle. That write is the viewport's own, not a user move, so the first content still fits; later re-imports and restores of the empty file keep the view. At 0 × 0 (a tab hidden at the first import) the 100 % waits for the first real size. A kit check that an empty document opens at 100 % with the hint is a 0.2 candidate (it needs a harness that opens more than one document).

Resize

Guide §5.9. Rule: when the container size changes (split, side bar, bottom panel), the view refits until the user has zoomed or panned; a 0 × 0 container (hidden tab) does not count.

  • Observe the container with a ResizeObserver and coalesce the entries to one per animation frame.
  • Then call canvas.resized(): diagram-js caches the viewbox including the container size (_cachedViewbox), and resized() drops that cache and fires canvas.resized (the palette re-lays itself out on it). Refit if the view is still automatic.
  • Skip entries with width or height 0 (hidden tab, see VS Code: host hygiene, retainContextWhenHidden).
  • Compare with the container size the view was last set for, not with the previous observation: a tab that comes back at the same size then neither refits nor needs canvas.resized().
  • A restore or a stored view that arrives while the container is 0 × 0 is kept and applied once the container has a size, without a refit at that moment; otherwise the stored view would never win.

Detecting a user move

Guide §5.9. Rule: every viewbox change that does not come from fitting or from restoring after an import counts as a user move (pan, wheel, zoom buttons, keys, 100 %); only an explicit fit (button, Shift+1, command) makes the view automatic again.

  • On every canvas.viewbox.changed, compare the event's viewbox with the last box the shell set itself (fit or restore), with a relative tolerance of 1/1000. If it differs, the user moved the view.
  • Compare the scale and the translation, not width and height: those change with the container size, and a resize must not count as a move. The getter rounds the transform to three decimals, which is why an exact comparison would fail. Compare the translation in screen pixels (x · scale, y · scale), relative to max(1, |value|): the getter rounds the screen translation (matrix.e, matrix.f) and then divides by the scale, so near the origin and at small zoom the diagram coordinates x and y carry an error of up to 0.0005 / scale that a tolerance relative to them would not absorb. Far from the origin the relative tolerance grows: at a translation of 10 000px a pan below 10px does not count as a move, which is harmless.
  • Renderers may set the viewbox while they import: Event Storming's importer calls canvas.viewbox(contentBounds) on every import. Changes between import.parse.start or import.render.start and import.done are therefore no user moves. The window also closes on the viewport's own afterImport, restore and setState and on user input (wheel, pointerdown, keydown on the canvas container), because a failed import never fires import.done.
  • Do not count own changes with a flag or counter around the fit: with canvas.deferUpdate diagram-js emits canvas.viewbox.changed up to 300ms later (debounced), so the delayed event would be attributed to the user. During a fit animation without deferUpdate, ignore the synchronous intermediate events until the animation ends or is cancelled.
  • A restore after an import (guide §5.9: external changes keep viewbox and selection) updates the reference box but does not reset the "user moved" state. It also does not count as a stored view: a modeler restores on every re-import, and an empty file must still fit its first content ("Insert example", guide §13.5). Only a persisted view (setState, see VS Code: state) applied while there is content replaces the first automatic fit.
  • An element the user creates on the canvas never fits (guide §5.9), and edits from the canvas are echo-suppressed, so no import follows them. Without care the first fit would still be due when the user next types in the text editor, and that import would jump-fit and throw the view away. An elements.changed outside an import window, after the first import and with content, therefore ends the wait for the first fit. Importers usually add shapes with canvas.addShape (no elements.changed); one that goes through the command stack must fire import.parse.start or import.render.start first, or its first content counts as the user's.
  • The zoom panel asks canFit() on every frame it renders (pan, wheel, fit animation). Cache it and read the content bounds again only after a content change (elements.changed, shape.added and .removed, connection.added and .removed, diagram.clear, import.done, the viewport's own afterImport, restore, setState): a modeler's bounds may be costly (Event Storming's exportMap() validates the whole board).

Legend state

Guide §5.10. Rule: the host remembers whether the legend is open, in VS Code also across a window reload, in the web app across sessions.

  • VS Code: vscode.setState, together with the viewbox (see VS Code: state).
  • Web app: localStorage. Wrap the access in try/catch: it throws when storage is blocked.
  • Without a stored state the start state (closed up to 820px height) is decided at the first non-zero container size: a tab hidden at mount reports 0 × 0 and waits for its first real size.

Status box

Guide §5.12. Rule: the 4px status bar runs straight over the full height and does not bend at the rounded corner.

Draw the bar as its own element (::before, absolutely positioned over the full height: top: 0; bottom: 0; left: 0; width: 4px). A border-left follows the border radius and turns into a crescent at the rounded corners. The box's own 1px border is in the status colour as well (guide §8: "Rand 1px plus links 4px deckend in --cd-ui-<status>").

Guide §5.12, §13.5. Rule: the box for an invalid file and the visible read-only state appear only after 800ms without a valid import and end at once with the next valid one; the read-only lock itself applies at once.

  • Repeated invalid imports while typing keep the first timer: the box shows once the text stayed invalid for 800ms, not 800ms after the last keystroke. A valid import cancels the timer.
  • The same holds for the read-only marks (data-readonly, the aria-disabled palette entries): delayed like the box, so the palette does not flicker while the user types in the text editor next to the diagram. The lock behind them (palette veto, Model entries, isReadOnly()) applies with the invalid import, so nothing can overwrite the text in the meantime.
  • An unchanged text is not written again, so role="alert" does not announce it again.

Guide §13.5. Rule: a read-only canvas keeps the palette visible with every entry that changes something aria-disabled (Lasso and Hand stay active), and has no context pad, no move, resize, delete or paste and no label editor; selection, zoom, pan, the export of the last valid state and the shortcuts dialog stay.

  • The shell locks what it owns: the palette veto and the Model entries of the menu. View toggles stay usable: the shell's Legend checkbox (keepEnabledWhenReadOnly) and the legend's close button, and a modeler's own view state such as Event Storming's "Type captions", which sets keepEnabledWhenReadOnly itself. A view toggle never touches the document, so a read-only file gives no reason to lock it (guide §13.5 since design system 1.13.0: only entries that change the document are aria-disabled).
  • The canvas is the modeler's to lock, at the same moment as setReadOnly(true). Event Storming's eventStormingReadOnly is a RuleProvider at priority 5000 that answers false for every edit rule (elements.move, shape.move, shape.resize, shape.create, elements.create, shape.append, connection.create, elements.delete and the other connection rules), and its keyboard, context pad, label editor and drawing handles ask it as well: rules alone do not stop a context pad from opening or a double-click from opening the label editor. Until it covered all of them, its lock held only the palette.
  • Shell conformance (readOnly) makes the file invalid through the harness's show.invalid hook and checks, after the delay, the marks, the palette, that Export, Help and the Legend checkbox stay enabled, and on a shape: no context-pad entry after a click, Delete and Backspace keep it, a 40px drag changes neither its position nor its size, a double-click opens no text field.

Layers

Guide §5.13. Rule: everything diagram-js shows on the canvas (search, context pad, popup menu, element tooltips, palette, label editor) stays on canvas layer 0 below every island; within it the palette lies above context pad and label editor.

  • The canvas container gets isolation: isolate, so diagram-js's own z-indices stay local to layer 0: search 10, context pad 100, popup menu 200, hover tooltips 1000 (.djs-hover-tooltip, position: fixed, which does not escape the stacking context), palette 110 and label editor 100 (set by the modeler). Once the palette tooltip is replaced (see Tooltip), only hover sources a modeler registers for canvas elements still use .djs-hover-tooltip.
  • The islands use the tokens --cd-shell-ebene-*: islands 20, inspector 25, top row 30, dropdowns, popovers and the chrome tooltip 40, dialog 50, toast 60.
  • The shell root gets isolation: isolate as well, so these values stay local when the shell is embedded in a foreign page.
  • The dialog layer (50) applies only to a dialog that is not in the browser's top layer. A <dialog> opened with showModal() and an element of the popover API are rendered in the top layer, above every stacking context of the page, and z-index does nothing for them; they need no layer token.
  • Overlays a modeler adds inside the canvas container: persistent ones (an element toolbar, its own context pad, the label editor) stay at z-index 100 or below, under the palette at 110; transient popups the user opens from them (diagram-js's popup menu at 200, a colour picker) may cover the palette while they are open, as diagram-js's own popup menu does. Everything stays inside .djs-container, and so below every island. This is the rule of guide §5.13 since design system 1.13.0. Event Storming shipped its note toolbar and colour picker at 300, above the palette, and nothing flagged it; a kit check for it is a 0.2 candidate. A flat "100 at most" would flag diagram-js's popup menu itself, hence the split into persistent and transient.

Modeler dialogs

Guide §8, §5.4. Rule: a dialog has a backdrop in --cd-ui-backdrop with blur(3px) grayscale(1) in both modes, an opaque panel with border, md radius and shadow-3, a header with Close; it holds the focus and returns it; Escape and the backdrop close it. The Help group's "Keyboard shortcuts…" is such a dialog, with one table per group (View, Edit, Tools).

0.1 has no dialog component, so a modeler's dialog meets these rules on its own (a shared cd-shell-dialog class and showShortcutsDialog are 0.2 candidates):

  • Open it with showModal(): the top layer puts it above every island without a layer token, the browser makes the rest of the page inert, and :modal tells the shell's key listener that a modal is open, so the zoom keys stop acting. ::backdrop inherits custom properties from its dialog since Chromium 122 (VS Code 1.90 ships it), so var(--cd-ui-backdrop) resolves there.
  • The shell's high-contrast and forced-colours rules match only cd-shell-* classes and do not reach the modeler's dialog. It sets them itself: under [data-cd-kontrast="hoch"] no shadow, border --cd-ui-kontur and a 3px focus ring (guide §6, §5.14); under @media (forced-colors: active) the border in CanvasText and the focus ring in Highlight.
  • Focus: into the dialog on open, held inside while it is open (Tab cycles), back to the control that opened it on close (the menu trigger after a menu entry, which takes the focus before onSelect runs).
  • The dialog stays usable on a read-only canvas (guide §13.5).
  • The dialog is modeler content in the shell root. shell.destroy() removes only what the shell added, so the modeler removes the dialog in its own teardown, for example on diagram.destroy (Event Storming does). The shell offers no onDestroy hook for it: the modeler already owns the teardown path that destroys diagram and shell.

Focus order, fonts and forced colours

Fonts

Guide §5.14. Rule: chrome text is Geist 13px, buttons and fields included.

Buttons and form fields get font: inherit; browsers give them their own control font otherwise (13.333px, see VS Code: host hygiene).

Tab order

Guide §5.14, §12. Rule: menu trigger, primary action, palette, canvas, then status box, empty-file hint, inspector, bottom left (pill before legend) and bottom right; the DOM has the same order, so screen readers read the chrome in it, and nothing has a positive tabindex.

The resulting DOM order of the shell root:

<div class="cd-shell">
  <button data-chrome="menu">…</button>
  <button data-chrome="primary">…</button>
  <div class="cd-shell-canvas">
    <!-- the element passed to diagram-js as `container`; diagram-js creates .djs-container in it -->
    <div class="djs-container">
      <div class="djs-palette" data-chrome="palette">…</div>
      <svg tabindex="0">…</svg>
    </div>
  </div>
  <div data-chrome="status">…</div>
  <div class="cd-shell-hint">…</div>
  <aside data-chrome="inspector">…</aside>
  <div data-chrome="problems">…</div>
  <div data-chrome="legend">…</div>
  <div data-chrome="zoom" role="group">…</div>
</div>

The palette comes before the <svg> because the shell moves it there (see Palette); the primary action exists only in the web app. Dropdowns, popovers and the tooltip live in the layer element described under Tooltip and are reached through their triggers, not by Tab.

The canvas as a tab stop

Guide §12, §5.14. Rule: every interactive element shows the focus ring in --cd-ui-fokus (3px in high contrast), and the focus never gets lost.

  • The canvas <svg> is focusable but unnamed: screen readers announce a bare "image", and in NVDA's and JAWS's browse mode the canvas's single-key commands (E, C, L, arrows, Delete) go to the screen reader. While a shell is attached the adapter gives it role="application" and an aria-label from the messages table ("Diagram"; a modeler names its notation), and restores both on detach.
  • diagram-js leaves the <svg> the browser's own ring (.djs-container svg:focus { outline-offset: -1px }). The shell sets outline: none on :focus and the guide's ring on :focus-visible, inside (outline-offset: -2px, -3px with 3px in high contrast), because the slot clips with overflow: hidden; under forced colours in Highlight.
  • When an island hides while it holds the focus, move the focus first, or it falls to <body>: the empty-file hint after "Insert example" and a closing inspector hand it to the canvas <svg> (the guide's "the focus goes to the canvas"), the legend's close button to the menu trigger.

Forced colours

Guide §5.14. Rule: under forced colours nothing disappears: the active tool and pressed toggles keep a 2px inner ring in Highlight, the status bar shows in CanvasText, islands have a CanvasText border, icons stay visible.

  • In @media (forced-colors: active) the browser removes box-shadow and replaces backgrounds. The active tool and every toggle with aria-pressed="true" therefore get forced-color-adjust: none and the ring as box-shadow: inset 0 0 0 2px Highlight.
  • The status bar gets forced-color-adjust: none and background: CanvasText.
  • Islands get border-color: CanvasText.
  • Icons use stroke="currentColor" and so follow the system text colour without extra rules.

Narrow and low containers

Guide §5.15. Rule: the breakpoints measure the container: palette in a second row up to 760px width or when less than 16px would stay free next to a top-row island; inspector as bottom sheet up to 760px; legend above the bottom row up to 440px; legend starts closed up to 820px height.

  • The breakpoints are @container cd-shell (…) queries on the shell root. Custom properties cannot be used in a container condition, so the queries carry the literal values (max-width: 760px, max-width: 440px, max-height: 820px); scripts read the same values from shell in cd-tokens.generated.json (--cd-shell-schmal, --cd-shell-sehr-schmal, --cd-shell-niedrig).
  • The 16px gap is measured by script on every container resize and on palette.changed. Use the palette's intrinsic border-box width (offsetWidth - clientWidth + scrollWidth: scrollWidth leaves out the 1px borders), not its current box, so the decision does not flip once the palette has moved: the top row fits if (width - paletteWidth) / 2 >= menuTrigger.right + 16 and, where a primary action exists, (width + paletteWidth) / 2 <= primaryAction.left - 16 (both relative to the shell root). In the web app evaluate twice, with the primary action's label and then with its icon-only width, because the label goes first.
  • The script's result is a state attribute on the shell root (for example data-palette="second-row"); the second-row CSS applies under the 760px container query or under that attribute, and fit uses the matching top inset.
  • The palette in the second row scrolls horizontally without a visible scrollbar: overflow-x: auto; scrollbar-width: none.
  • The legend's start state (closed up to 820px height) is state, not style, so it is decided by script from the container height (--cd-shell-niedrig), not by a container query.

Appearance decision

Guide §13.5, §13.3. Rule: each host decides in exactly one place; a mode the modeler cannot show is never shown (a light-only modeler stays light whatever the host reports); high contrast always passes through.

type Theme = "light" | "dark";
type Appearance = { theme: Theme; kontrast?: "hoch" };

function resolveAppearance(requested: Appearance, themes: readonly Theme[]): Appearance {
  const theme = themes.includes(requested.theme) ? requested.theme : themes[0];
  return requested.kontrast ? { theme, kontrast: requested.kontrast } : { theme };
}
  • requested: in an IDE host from ColorThemeKind (see VS Code: theme bridge) or the setting miragon.<modeler>.colorTheme, in the web app from the stored choice or the system.
  • themes: reported by the modeler. If it cannot show the requested theme, themes[0] wins. kontrast always passes: under VS Code "High Contrast" (dark) a light-only modeler becomes light with hoch.
  • themes is a static value of the modeler package (for example an exported themes constant) that the host reads when it renders the HTML, not from the running instance; the attributes must be right before the first paint.
  • resolveAppearance ships in @miragon/modeler-shell/core. The shell has no runtime dependencies, so it does not import the rule from the contract; a later 1.x minor of the @miragon/modeler-api contract adds the appearance surface for embedded hosts, which decide with the same rule (#4).
  • Shell conformance requests dark from a light-only modeler and asserts that nothing turns dark.

Appearance in embedded hosts

Guide §13.3. Rule: an embedded modeler reports which modes it supports, the host never requests another one, and the mode is view state that never touches the document or exports; until the contract's appearance surface (a later 1.x minor) an embedded modeler stays light.

  • Until then the shell keeps an embedded modeler light by itself. The token CSS resolves the roles on the nearest [data-cd-theme] and they inherit, so a root without its own attribute would take the mode of a themed host page (designIQ, a web app on system under a dark OS) and draw dark chrome over a renderer that paints light attribute colours (guide §10.2): half-dark. The embedded root therefore carries data-cd-theme="<themes[0]>" itself (ShellOptions.themes, default light). The root's own [data-cd-theme] block would also drop the host's high contrast, so the root mirrors data-cd-kontrast from the nearest mode carrier above it, with a MutationObserver on the document's data-cd-theme and data-cd-kontrast attributes (guide §13.3: a container with its own data-cd-theme takes data-cd-kontrast over from the host). Shell conformance puts the foreign page's <html> in the requested mode for the embedded host, so every suite proves this.
  • A later 1.x minor of the contract adds the optional surface appearance with themes (supported modes) and set (mode and contrast); hosts feature-detect it (if (modeler.appearance)).
  • In a foreign page set writes data-cd-theme and data-cd-kontrast on the modeler's container (both on the same element, as the token CSS requires); in its own webview they stay on <html>.
  • Appearance is view state: onContentChanged never fires for it, and it has no effect on exportText or image exports.

VS Code host

The VS Code sections below (commands and menu, notifications, undo, key interception, theme bridge, language, host hygiene, state) implement guide §13.5. In VS Code the extension owns file, document, history and mode; the webview shows the canvas with the chrome.

VS Code: commands and menu

Guide §13.5. Rule: every webview-menu action that makes sense without focus on the canvas also exists as a command in the Command Palette, and both do exactly the same.

Command and menu entry run the same code. The extension registers the command (vscode.commands.registerCommand("<prefix>.fitView", …)) and posts a message with the command name to the webview of the active custom editor (webviewPanel.webview.postMessage({ type: "command", name: "fitView" }); track the active panel with onDidChangeViewState). The webview dispatches the name to the same handler as the menu entry.

VS Code: notifications

Guide §13.5, §9. Rule: no toasts in the webview; info, warning and error appear as the host's notification of the same level, only for actions the user triggered, never for an import.

  • The webview posts a message to the extension, which calls vscode.window.showInformationMessage, showWarningMessage or showErrorMessage.
  • After an export the info carries an action that reveals the file, labelled like VS Code: "Reveal in Finder" (process.platform === "darwin"), "Reveal in File Explorer" ("win32"), "Open Containing Folder" (Linux). The action runs the built-in command vscode.commands.executeCommand("revealFileInOS", uri).

VS Code: undo

Guide §13.5. Rule: the history belongs to the host document; every canvas change is exactly one document change and one undo step; after a host undo the webview keeps viewbox and selection; in text fields the field's own undo applies.

  • The modeler does not bind Cmd+Z, Cmd+Shift+Z and Ctrl+Y to the diagram-js commandStack. KeyboardBindings binds them (isUndo/isRedo) as soon as the editor actions undo and redo are registered. In the IDE host the shell's keyboard listener (priority 2000) returns false for them: that stops KeyboardBindings without preventDefault(), so the keys propagate to window, where VS Code's webview host forwards them and VS Code undoes the document edit.
  • A modeler's own DOM binding must go in the webview as well. Event Storming's document keyboard calls preventDefault() and commandStack.undo() on Cmd+Z (also with an empty stack) unless the webview creates it with undoRedoKeys: false. With the binding on, the shell stops the key as one the canvas handled, VS Code never undoes, and since Event Storming clears its stack on every import, Cmd+Z on the canvas does nothing at all after an external edit. Shell conformance presses Cmd/Ctrl+Z, Shift+Z and Ctrl+Y on the canvas in the IDE host and expects each on window, once and unprevented.
  • Each canvas change is written as one WorkspaceEdit with minimal line changes, applied with vscode.workspace.applyEdit (one undo step). Multi-step actions (insert example, resize) are one edit.
  • A host undo comes back to the webview as an external document change (onDidChangeTextDocument, reason is TextDocumentChangeReason.Undo or Redo). The webview re-imports and restores viewbox and selection, no refit (guide §5.9).
  • The extension also receives onDidChangeTextDocument for its own edits. It recognises these echoes (the texts of its pending edits, compared CRLF-tolerant) and does not send them back as an external change.
  • In text fields of the webview (label editor, inspector, dialog and popover fields) stop Cmd+Z, Cmd+Shift+Z, Cmd+Y and Ctrl+Z, Ctrl+Shift+Z, Ctrl+Y with stopPropagation() but without preventDefault(). VS Code's webview host calls preventDefault() on every Ctrl/Cmd + Z or Y that reaches window and forwards it: without the stop the field loses its native undo and VS Code undoes the last document change; with preventDefault() the field loses its native undo too.
  • Match these keys by the typed character (event.key), because QWERTZ swaps the Y and Z keys; fall back to code KeyZ/KeyY only when key is not an ASCII character (Cyrillic, Greek layouts). Cmd/Ctrl+Shift+Y is not redo: VS Code toggles the Debug Console with it.

VS Code: key interception

Guide §13.5. Rule: a key the webview handles acts only in the webview: VS Code never sees it and never runs it again (for example as a window zoom); every other key goes on to VS Code.

  • VS Code's webview host page listens for keydown on the webview's window (bubble phase) and forwards every event that reaches it to the workbench (did-keydown), regardless of defaultPrevented. The workbench then runs its keybindings: window zoom, copy and paste, select all and so on. The one exception is VS Code for the Web: there the host page returns early for Cmd/Ctrl + C, X and V, leaves them to the browser and forwards nothing.
  • The webview therefore listens with exactly one keydown listener on document (bubble phase) and calls preventDefault() and stopPropagation() for every key it handles. A listener on window would come too late: VS Code attaches its listener to the frame's window when it creates the frame, before the webview's scripts run, so it runs first on that target, and stopPropagation() does not affect other listeners on the same target.
  • Handled keys: the zoom keys (Cmd/Ctrl + +, =, -, _, 0, also with Shift and on the numpad) and Shift+1; Escape while a menu, popover or dialog is open; in text fields the undo and redo keys, there with stopPropagation() only (see VS Code: undo). VS Code also zooms the window with Cmd/Ctrl+Shift+- and the numpad, so these belong to the list.
  • In text fields, during IME composition and with an open dialog the zoom keys do not zoom but are still stopped, so VS Code does not zoom the window; Shift+1 stays a character there.
  • Cmd/Ctrl+0 belongs to the canvas (100 %) while the webview has focus, although VS Code binds the key itself (by default: focus the primary side bar). That is intended.
  • Cmd and Ctrl both count on every platform (guide §5.8), so with the focus in the webview it also takes VS Code's Ctrl+- (Go Back) and Ctrl+Shift+- (Go Forward) on macOS, and Ctrl+Shift+- on Linux. That follows the guide like Cmd+0 does; the product owner confirms it is intended (see Open verification points).
  • Keys the open menu consumes are stopped at the menu (see Menu), so VS Code does not see them either.
  • A key the canvas has already handled (event.defaultPrevented, for example Cmd+C and Cmd+V from diagram-js, Cmd+A from a modeler's select all) is stopped as well. Otherwise VS Code runs it a second time on the webview: on desktop (Electron) its host page prevents copy, cut and paste and the workbench dispatches them back to the webview (in VS Code for the Web the browser handles them, see above), and select all would select the chrome's text. Every other key propagates to VS Code (Cmd+S, Cmd+P, Cmd+W, F1).
  • The shell can stop only what the canvas prevented, so Cmd/Ctrl+A on the focused canvas is the modeler's to handle (guide §13.5: keys the canvas uses act only on the canvas). diagram-js 15's KeyboardBindings binds no select all; a modeler binds it (select every element) or at least prevents the key. Event Storming had none, so VS Code ran its own Select All on the webview. Shell conformance presses Control+A and Meta+A on the canvas and expects them prevented, and in the IDE host never on window.
  • Test: a keydown listener on window (where VS Code's host page picks keys up) must not see any handled key. A window listener cannot see whether a stopped key was prevented, so add a keydown listener on document, registered after the shell's and the modeler's: handled keys must arrive prevented, blocked ones (zoom keys and undo/redo in text fields, Escape with a dialog or popover) unprevented, so the field keeps its native undo, and unhandled keys such as F1 and Cmd/Ctrl+Z on the canvas must still reach window unprevented. That is what catches a modeler's own document undo binding taking Cmd+Z from the label editor (an input, a textarea or editable text) or from the document's history on the canvas. Acceptance runs in real VS Code with Light, Dark and both High Contrast themes, plus Cmd+Shift+- and Cmd+A on the canvas.

VS Code: theme bridge

Guide §13.3, §13.5. Rule: the webview follows the VS Code color theme (later the setting miragon.<modeler>.colorTheme); the attributes are there before the first paint, and a theme change switches at once, both attributes in one step, without a fade.

  • Signal: vscode.window.activeColorTheme.kind, mapped to the requested appearance and then through resolveAppearance (see Appearance decision):

    ColorThemeKind data-cd-theme data-cd-kontrast
    1 Light light
    2 Dark dark
    3 HighContrast dark hoch
    4 HighContrastLight light hoch
  • Initial: the extension writes data-cd-theme and data-cd-kontrast into <html> when it renders the webview HTML, always, also light.

  • Change: on vscode.window.onDidChangeActiveColorTheme and on vscode.workspace.onDidChangeConfiguration (e.affectsConfiguration("miragon.<modeler>.colorTheme")) the extension posts the new appearance to the webview (webview.postMessage). The webview sets both attributes in the same task, with transitions disabled for one frame: add a class that sets transition: none, change the attributes, and remove the class in a nested requestAnimationFrame, so the first frame in the new mode renders without transitions.

  • Hidden tabs: the VS Code API documentation contradicts itself on whether a hidden webview with retainContextWhenHidden receives messages. Re-send the current appearance from webviewPanel.onDidChangeViewState when the panel goes from hidden to visible, and only then: the event also fires when the active state or the view column changes while the panel stays visible.

  • Fallback signal inside the webview: VS Code sets the body classes vscode-light, vscode-dark, vscode-high-contrast and vscode-high-contrast-light; watch them with a MutationObserver. Check vscode-high-contrast-light first: for backwards compatibility VS Code adds vscode-high-contrast to High Contrast Light as well, so "High Contrast Light" would otherwise be read as dark. Never use the body classes for styling (guide §13.5).

  • Use the fallback only where no extension speaks for the mode: when <html> has no data-cd-theme at start (the HTML did not come from renderWebviewHtml), and only until the first message. With the extension's attributes it stays off entirely: the setting can override the color theme and the body classes do not know it, and on a theme change VS Code's renderer updates the body classes before the extension host's message arrives from its own process. A fallback that applied them in between would flash the setting-less mode for a few frames (a dark-capable modeler set to "dark" turning light under Light+, then dark again). Hidden webviews may miss a message; the re-send on becoming visible covers that.

  • The webview resolves every message against its own themes once more: they are what its bundle can actually show, whatever the extension passed.

VS Code: language

Guide §13.5, §2. Rule: native contributions and webview speak one language; a localized modeler is localized completely.

  • Contributions: %key% placeholders in package.json, with package.nls.json and package.nls.<language>.json.
  • Extension code: vscode.l10n.t(…), with the bundle directory named by l10n in package.json (bundle.l10n.<language>.json).
  • Webview: the modelers are English today (guide §13.5), so the extension writes <html lang="en"> when it renders the webview HTML (renderWebviewHtml({ lang: "en" })), not vscode.env.language. Only a fully localized modeler passes vscode.env.language there, and vscode.l10n.bundle for the webview's strings (the shell's messages table), so native contributions and webview speak the same language.

VS Code: host hygiene

Guide §13.5. Rule: nothing of VS Code's webview styling shows: no edge padding, no host colours or fonts, own kbd, code, links, focus rings and scrollbars; Geist loads in the webview; a hidden tab keeps its state and neither fits nor measures.

VS Code injects a default stylesheet into every webview. In current releases it sits in @layer vscode-default, so unlayered author rules win regardless of specificity; keep the reset unlayered (or in a layer declared after it). The defaults and their resets:

  • Reset html and body. VS Code otherwise sets body { padding: 0 20px }, the editor text colour (--vscode-editor-foreground) and its font (--vscode-font-family, --vscode-font-weight, --vscode-font-size):

    html,
    body {
      margin: 0;
      padding: 0;
      height: 100%;
      overflow: hidden;
      background: var(--cd-ui-grund);
      color: var(--cd-ui-text);
      font:
        400 13px/1.5 "Geist Variable",
        "Geist",
        ui-sans-serif,
        system-ui,
        -apple-system,
        "Segoe UI",
        sans-serif; /* the stack from guide §5.14 */
    }
  • Font: never use var(--vscode-font-family). Wherever the variable is undefined (the same CSS in the web app, in tests, in an embedding host), a var() without its own fallback makes the whole font-family declaration invalid at computed-value time: a font list after the var() does not help, and the browser renders Times.

  • button, input, select, textarea { font: inherit; color: inherit; }; otherwise buttons render in the browser's control font, typically Arial 13.33px. Give this reset zero specificity (:where(html) :where(button, …)): any author rule beats the browser's control font, and a reset with specificity would also beat the chrome's own rules (the zoom readout's 13px/600).

  • Style kbd (guide §5.6), code and a completely yourself (links in --cd-ui-link). VS Code's defaults colour all three with its own colours: kbd with the keybinding-label colours, an inset shadow and vertical-align: middle; code with the preformat colours and the editor's monospace font; a with the text-link colours.

  • Focus: VS Code sets outline: 1px solid -webkit-focus-ring-color; outline-offset: -1px on a:focus, input:focus, select:focus and textarea:focus. Override it with the ring from guide §8 (outline: 2px solid var(--cd-ui-fokus); outline-offset: 2px). The defaults use :focus, not :focus-visible, so a link focused by mouse still gets VS Code's ring unless the override covers :focus as well.

  • Scrollbars: html { scrollbar-color: var(--cd-ui-kontur) transparent; }. VS Code sets its own html { scrollbar-color: … } from --vscode-scrollbarSlider-background and --vscode-editor-background, plus ::-webkit-scrollbar* colours. scrollbar-color is inherited, and once it is set Chromium (121 and later) ignores the ::-webkit-scrollbar pseudo-elements.

  • CSP and fonts: bundle the fonts as files next to the webview bundle, not as data URLs. With esbuild's usual dataurl loader, @fontsource-variable/geist-mono/wght.css inlines all six Geist Mono subsets (Event Storming's webview CSS grew from 137 KB to 267 KB for the key chips alone); with loader: { ".woff2": "file", ".woff": "file" } and assetNames: "fonts/[name]-[hash]" it was 69 KB, and the browser fetches only the subsets a page's unicode-range needs. renderWebviewHtml writes font-src ${webview.cspSource} data:, so the files load once dist is in the panel's localResourceRoots; data: stays for renderers that inline fonts. Without either Geist does not load and the fallback font shows. style-src keeps 'unsafe-inline' for the renderers, which write style attributes into markup (palette entries, SVG strings); the shell and diagram-js style only through the CSSOM, which the CSP does not restrict, and run without it (renderWebviewHtml({ inlineStyles: false })).

  • CSP and eval: script-src admits only the nonce'd script, and that is right; never add 'unsafe-eval'. zod 4 probes once for eval with new Function when it builds its first object schema; it catches the error, but the browser reports that probe as a securitypolicyviolation (under jitless zod skips it). A webview that bundles zod 4 runs z.config({ jitless: true }) in a module of its own that it imports before any schema module (Event Storming's zod-jitless.ts, the first import of main.ts).

  • A test page served by Vite runs the webview from source without a CSP, so a loader or CSP mistake of the shipped bundle shows only behind the real HTML: a smoke test builds the production bundle, serves it through Playwright's page.route behind renderWebviewHtml (a fixed nonce, the VS Code stub as a nonce'd script in body that also records securitypolicyviolation), and fails on any violation, page error or missing font (recipe in API.md, ./vscode/extension). A kit helper for it is a 0.2 candidate.

  • Reduced motion: VS Code resolves workbench.reduceMotion itself (auto follows the OS, on holds without it) and hands the result to the webview only as the body class vscode-reduce-motion; prefers-reduced-motion alone misses on. Mirror the class for the whole session as a class of your own on <html> (cd-shell-reduce-motion; never style with the host's class), stop the chrome's animations under it as under the media query, and let an explicit fit jump.

  • Hidden tabs: register the custom editor with webviewOptions: { retainContextWhenHidden: true }, so a hidden tab keeps its document, view and selection. A hidden tab then reports a 0 × 0 container: skip fitting and measuring while hidden (guide §5.9, see Resize).

VS Code: state

Guide §13.5, §5.10. Rule: legend state and view survive a rebuild of the webview (for example a window reload); the stored view wins over fitting.

  • const vscode = acquireVsCodeApi() (it may be called only once per webview). Store whether the legend is open and the viewbox with vscode.setState({ … }) (JSON-serializable), on every legend toggle and once the view has rested: every setState is an IPC message with the whole state, and the view changes on every frame of a pan, a wheel zoom or a fit animation (persistView writes 250ms after the last change). The shell's readShellState and writeShellState keep its part under the key modelerShell next to the modeler's own keys: legendOpen and view (viewbox and the user-moved state).
  • On start read vscode.getState(). If a viewbox is stored, apply it after the first import instead of the automatic first fit, as a stored view (setState), not as a restore (see Detecting a user move).
  • canvas.viewbox() caches outer until the next change, so derive the stored width and height from the fresh canvas.getSize() and the scale.

Not covered here

These topics stay in the design guide because they are not part of the shell:

  • canvas theming with cd-canvas.css and the cd-c-* role classes (guide §10.2, §10.3; renderers and Phase 7 of #4 own them);
  • light-only export (guide §10.7);
  • the token files and the token module (guide §13.1, §13.2);
  • the mode recipes for IntelliJ, Electron, Tauri and Theia and the web-app bootstrap script with its localStorage key (guide §13.3);
  • the IntelliJ white-flash fix (guide §13.5).

Open verification points

To confirm in a real VS Code before the shell's VS Code part is released. Each one is an assumption the notes rely on that the sources alone do not settle.

  • No handled key reaches a keydown listener on window: zoom keys including Cmd/Ctrl+Shift+- and the numpad, Shift+1, Escape with an open menu, undo and redo in text fields, Cmd/Ctrl+A, C and V after the canvas handled them. VS Code never zooms the window, and Cmd/Ctrl+A on the canvas selects the elements, never the chrome's text.
  • Cmd/Ctrl+0 with focus in the webview sets 100 % and does not focus the side bar.
  • VS Code for the Web: Cmd/Ctrl + C, X and V on the canvas copy, cut and paste exactly once (the host page leaves them to the browser and does not forward them).
  • Cmd/Ctrl+Z in the label editor undoes only in the field; the document stays unchanged.
  • One Cmd/Ctrl+Z on the canvas undoes exactly one canvas change; viewbox and selection stay.
  • Light, Dark, High Contrast and High Contrast Light: attributes before the first paint (no flash), switching at once, a light-only modeler stays light (with hoch under both High Contrast themes).
  • High Contrast Light sets both body classes vscode-high-contrast-light and vscode-high-contrast (the reason for the fallback's check order).
  • A theme change while the tab is hidden: does the message arrive in a retained webview? Either way the re-send on onDidChangeViewState leaves the right mode.
  • A hidden tab with retainContextWhenHidden reports 0 × 0 to the ResizeObserver; back in front, view and selection are unchanged and nothing refits. If VS Code only hides the webview without resizing it, skip fitting while webviewPanel.visible is false instead.
  • Developer: Reload Window: vscode.getState() returns legend state and viewbox in the restored custom editor, and the stored view wins over the first fit.
  • VS Code Remote (SSH, WSL, Dev Containers) and vscode.dev: the webview's navigator reports the client's platform (with or without userAgentData), so the labels show ⌘ on a Mac client even when the extension host runs on Linux or Windows.
  • revealFileInOS for a file in a remote workspace: offer the reveal action only where it works.
  • The default webview stylesheet is in @layer vscode-default in every VS Code version the extension supports (engines.vscode); on older versions the reset must win by order. Body padding, fonts (no Times, no Arial 13.33px), kbd, code, links, focus rings and scrollbars checked in all four themes.
  • Geist and Geist Mono load from the font files in dist/fonts/ without a CSP violation, and a webview with zod 4 reports no violation either (webview developer tools).
  • workbench.reduceMotion on sets the body class vscode-reduce-motion in the webview (and off removes it), at start and when the setting changes.
  • A click into the side bar or another editor while the webview menu is open fires blur on the webview's window, so the menu is closed when the user comes back.
  • Ctrl+- and Ctrl+Shift+- on macOS with the focus in the webview zoom the canvas instead of VS Code's Go Back and Go Forward: confirmed as intended by the product owner.
  • NVDA and JAWS with Chromium announce the status box when it appears with role="status" (a warning); role="alert" (an invalid file) is announced on appearance. If not, write the text one frame after the box shows.
  • vscode.env.language and vscode.l10n.bundle reach the webview, and <html lang> matches the native contributions.
  • The export notification shows the platform's reveal label and opens the file manager.

To re-check on every diagram-js upgrade, because these notes rely on internals:

  • ZoomScroll: RANGE = { min: 0.2, max: 4 }, NUM_STEPS = 10, the snap in _zoom, the wheel half step.
  • Canvas: zoom() rounded to three decimals, canvas.viewbox.changing synchronous, deferUpdate debounce of 300ms, resized() dropping the cached viewbox.
  • Palette: appended after the <svg>, palette.create with { container }, full rebuild on palette.changed, highlighted-entry for the active tool on a cached (stale after a rebuild) tools group, two-column toggled on canvas.resized.
  • PaletteTooltip still registered as paletteTooltip; HoverTooltip SHOW_DELAY = 300.
  • Keyboard: bound to the canvas <svg>, preventDefault() without stopPropagation(); KeyboardBindings at default priority 1000.