@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.
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 |
cd-tokens.generated.css(every--cd-*variable, the roles per mode) andcd-tokens.generated.json(the roles per mode as hex, plusshellwith 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. Theshellgroup appears there with design system release 1.12.0.- Modeler repositories vendor them unchanged, for example
packages/renderer/src/theme/cd-tokens.generated.cssin 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 ornode:test), not in its e2e suite, because the check is pure data:expect(SHELL).toEqual(shellSizesFromTokens(json.shell)), and every name ofSHELL_TOKENSpresent injson.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.cssdoes, in a layer). cd-canvas.css(the mapping of the diagram-js variables and thecd-c-*role classes) ships only with the design system skill so far and is not public yet.
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.
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: sizeandposition: relative; the islands areposition: absoluteinside it. container-type: sizeapplies 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:
cqwandcqhinstead ofvwandvh. 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 rowcalc(100cqh - 116px - <bottom> - 12px); - problems popover
width: min(320px, 100cqw - 24px), status boxmax-width: min(560px, 100cqw - 24px), palette in the second rowmax-width: calc(100cqw - 24px); - the components of guide §8 wherever the shell renders them: dialog
width: min(560px, 100cqw - 32px), confirmationmin(440px, 100cqw - 32px), bothmax-height: calc(100cqh - 32px); toastwidth: min(480px, 100cqw - 24px); empty-state cardwidth: min(400px, 100cqw - 24px).
- dropdown
- No element inside the shell gets a
container-typeof its own.cqw/cqhresolve 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-containersetsposition: 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 thebackgroundshorthand), so the renderer's paper shows. That paper keeps--cd-ui-grundas 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'sstyle darkboard in navy under a light theme) is half-dark. Shell conformance compares the painted colour, the first non-transparentbackground-colorfrom.djs-containerout 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
@containerqueries on the shell root, never@media(see Narrow and low containers). - Scripts measure the container with a
ResizeObserveron the shell root, never withwindow.innerWidth/innerHeight. - The sizes come from the vendored tokens: CSS uses
var(--cd-shell-*), scripts read theshellobject ofcd-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"]).
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-chromewith its name:menu,palette,primary,zoom,problems,legend,inspector,status. On the palette the attribute goes onto diagram-js's.djs-paletteelement. -
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: absoluteislands anchor:getBoundingClientRect()minus the root'sgetBoundingClientRect()minusroot.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 menu40 × 40 at top: 12px; left: 12pxpalette40 high at top: 12px, horizontally centred; second rowtop: 64pxprimaryweb app only: 40 high at top: 12px; right: 12pxzoom40 high at right: 12px; bottom: 12px, 173px wide with a 56px readoutproblems40 high at left: 12px,bottom: 12px(web app40px, above the legal line)legendleft: 12px,bottom: 12pxwithout pill,60pxwith pill (web app40pxand88px); up to 440px width at least60pxinspector288 wide at top: 64px; right: 12px; up to 760px width a bottom sheet with 12px left, right, bottomstatushorizontally centred under the palette at top: 64px(second row116px); with an open inspector centred in the strip to its leftdropdown top: 60px; left: 12pxtooltip top row top: 60px, second rowtop: 112px, bottom rowbottom: 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 most45cqh, palette in the second row at most100cqw - 24px, dropdown between 248px andmin(320px, 100cqw - 24px)wide and at least 12px above the bottom, tooltip at mostmin(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).
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
pointerdownondocumentwith{ capture: true }. diagram-js stops pointer events on its own surfaces: palette and context pad callstopPropagation()onmousedown, the dragging module stops the events of a drag, and canvas interaction events stop propagation when a listener returnsfalse. 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
keydownwithpreventDefault(); 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 ondocumentruns before the shell's listener and does not look atdefaultPrevented: 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
pointerdownin it. Close the menu on the window'sblur, without moving the focus: from the user's view it was a click outside. Do not close on afocusoutwithoutrelatedTarget: 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).
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-paletteas a vertical column (left: 20px; top: 20px; width: 48px, floating 46 × 46 entries). The shell overrides it withtop: 12px; left: 50%; transform: translateX(-50%); width: autoand lays.djs-palette-entriesand each.groupout as one flex row of 32 × 32 entries. - Surface.
cd-canvas.css(vendored from the design system) maps the diagram-js surface variables (--bio-surfaceand 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)withbackdrop-filter: blur(8px), 1px border in--cd-ui-linie, radiusmd,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#_initappends the palette tocanvas.getContainer()after the<svg>and then firespalette.createwith{ container }. On that event the shell moves it in front of the canvas<svg>(thesvgof thecanvas.initevent, which diagram-js fires ondiagram.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>carriestabindex="0"). A renderer must not add a tab stop of its own around them: Event Storming setstabindex="0"oncanvas.getContainer()(the.djs-containerholding 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#_updateclears and rebuilds all entries on every change and then firespalette.changed. Entries arediv.entryelements withdata-action, plusrole="button"andaria-labelwhen the entry has atitle; entries without agroupland in groupdefault. After everypalette.changedthe shell re-applies what it adds:role="toolbar"andaria-label="Tools"on the entries container, rovingtabindex(0 on one entry, -1 on the rest),aria-pressedon tool entries andaria-keyshortcutson every entry with ashortcut(guide §5.6; the chip alone never reaches a screen reader, because the tooltip's label equals the entry'saria-label). Renderers givetitleandshortcutseparately; a title like "Domain Event — E" puts the key into the name instead. - Active tool. diagram-js fires
tool-manager.updatewith{ tool }(the tool name,nullwhen none is active) and marks the matching entry in grouptools(data-action="<tool>-tool") withhighlighted-entry. Do not mirror that class:Palette#updateToolHighlightcaches 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 lasttool-manager.updateitself, setsaria-pressedfrom it after everypalette.changed, and styles[aria-pressed="true"]. Until the first rebuild the class still sits on a live entry, anddiagram-js.csscolours.djs-palette .highlighted-entrywith!important: the skin overrides that colour with!importantat a higher specificity (plus a hover variant). - Colours in every state. Set
colorexplicitly 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 stalehighlighted-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 torgb(29, 30, 33)instead of--cd-ui-text. Only tool entries (grouptoolsordata-actionending 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 inappearance(after the 150ms colour transition),aria-disabledinreadOnly. - 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
clickon the entry: it goes through diagram-js's click delegate andpalette.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/clientYat 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 withviewport.freeRectCenter()(the centre of the container minus the fit insets, in diagram coordinates, as fit computes it), creates the shape there withmodeling.createShape, selects it, callscanvas.focus()and opens its label editor. The provider is built beforeconnectDiagramJs, 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
toolssuch as draw, space or connect.palette.triggercarries the entry definition without its id, so the veto finds the entry element through the event'sdelegateTargetortarget(min-dom's delegate setsdelegateTarget); without one only grouptoolspasses. The veto applies from the invalid import on, thearia-disabledmarks after the delay of Status box. - Two columns. On every
canvas.resizeddiagram-js decides from the container height whether the vertical palette needs two columns and togglestwo-columnon the palette anddjs-palette-two-columnon the container. The horizontal skin ignores both classes. - Group separator. Between two
.groupelements:margin-left: 8pxbefore the line andpadding-left: 9pxafter it (the 1px line atleft: 0sits inside the padding, so 9px leave 8px of space after it). Draw the line as a::beforeinset 4px at top and bottom; aborder-leftwould run the full 32px. - Separator entries. Entries with
separator: truerender 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).
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-indexis a stacking context of its own, so a tooltip inside it could not rise above the inspector, andbackdrop-filtermakes a glass island the containing block ofposition: fixeddescendants. Position the tooltip withposition: absolutein the shell root's padding-box coordinates: differences ofgetBoundingClientRect(), minusroot.clientLeftandclientTop(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:
pointerleaveon one control hides its tooltip beforepointerenterreaches the next, so at that moment none is shown. After a hover-hide, a neighbour shows at once forTOOLTIP_DELAY_MS. - Set
aria-describedbyonly 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 inaria-keyshortcuts(the zoom buttons, palette entries and menu actions do). - diagram-js 15.28 ships its own palette tooltip:
PaletteTooltipon top ofHoverTooltip, shown onmouseoveronly 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 listspaletteTooltipin__init__, which then resolves tonull). - Name and shortcut come from
palette.getEntries()(title,shortcut), keyed by the entry'sdata-action. Re-read them onpalette.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, includingbox-shadow: noneandvertical-align: baseline. Otherwise the host'skbdstyle leaks through: VS Code's default webview stylesheet colourskbdwith its keybinding colours and adds an inset shadow andvertical-align: middle(see VS Code: host hygiene).
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 }),langbeing 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 throughIntlwith 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'slangitself (guide §2), so read the language withroot.closest("[lang]").lang, not fromdocument.documentElement. - Update after
canvas.viewbox.changed, readingcanvas.zoom()at most once per animation frame (requestAnimationFrame). - If a modeler enables
canvas.deferUpdate, diagram-js debouncescanvas.viewbox.changedby 300ms, and the readout would lag.canvas.viewbox.changingstill fires synchronously before every change: schedule the frame read from it as well; by the time the frame runs, the change is applied.
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). UsingstepZoomis what keeps buttons and wheel on one raster. The shell adds no rounding of its own. - The raster:
ZoomScrollhas a fixedRANGE = { min: 0.2, max: 4 }andNUM_STEPS = 10. One step islog10(20) / 10in log space, a factor of20^(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 aswheelwithctrlKey; a plain wheel pans. The wheel listener is only bound while the pointer is over the canvas. ZoomScroll#_zoomfirst 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 %).
- A wheel half step lies exactly between two full steps, and
stepZoomwithout a position callscanvas.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 % iscanvas.zoom(1), also around the container centre.
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 fixedRANGE(0.2 to 4); it is not configurable throughconfig.zoomScroll. - Disable zoom out when
canvas.zoom()is at most0.2 * 1.001, zoom in when it is at least4 * 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.
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.ctrlKeyon every platform, likeisCmdin diagram-js (KeyboardUtil), which also returnsfalsewheneveraltKeyis 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.codealso covers Numpad0 with NumLock off, wherekeyis"Insert". - Fit is matched by
event.code === "Digit1"withshiftKeyand withoutmetaKey,ctrlKeyandaltKey, so it is the physical 1 key of the number row on every keyboard layout (keyis"!"on US and German layouts, but"1"on French AZERTY). - Ignore every shortcut with
altKeyor whileevent.isComposingis 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,selector[contenteditable]: check the event target withtarget.closest("input, textarea, select")ortarget.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.
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 +
+/=,-and0itself on the focused canvas<svg>as soon as the editor actionsstepZoomandzoomare registered (KeyboardBindings, default priority 1000). ItsKeyboardservice calls onlypreventDefault()on a handled key, neverstopPropagation(), so the event still bubbles todocument. - The shell registers
keyboard.addListener(2000, listener)and returnstruefor the chrome keys. A non-undefinedreturn value stops the diagram-js event bus, soKeyboardBindingsand 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'sKeyboardhas calledpreventDefault()on every key a listener returned a value for, including the shell's own. - diagram-js 15 binds its
Keyboardto the canvas<svg>and ignores no event target (_isEventIgnoredreturnsfalse), so a text field inside the<svg>(aforeignObjectlabel editor) reaches it too. For a chrome key from a text field the listener returnsfalseinstead oftrue: the event bus stops there, and since only a truthy result makes theKeyboardcallpreventDefault(), 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.
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
keydownlistener ondocument, bubble phase. The diagram-js keyboard on the<svg>has run by then, sodefaultPreventedtells 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 arerole="dialog"withoutaria-modaland 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 isaria-modal="true"or:modal(a<dialog>opened withshowModal()). 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.
keydownis dispatched to the focused element, so it only reaches the root while the focus is inside it. - Web app: call
preventDefault()on Cmd/Ctrl ++,-and0unless 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.
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 emptyuserAgentData.platformmeans unknown and must fall back tonavigator.platform. The webview always runs on the client, so its ownnavigatorreports the keyboard in front of the user. - Do not label shortcuts from
process.platformin the extension: with VS Code Remote (SSH, WSL, Dev Containers) a custom-editor extension usually runs on the remote machine, soprocess.platformnames the remote OS. See Open verification points. aria-keyshortcutsvalues (WAI-ARIA 1.2: modifiers first, then the key pressed, named by its unshifted character, the plus sign asPlus;codenames such asEqualare invalid): zoom inMeta+= Meta+Plus, zoom outMeta+-, 100 %Meta+0, fitShift+1; outside macOSControlinstead ofMeta.
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 usesscale = min(outer.width / box.width, outer.height / box.height)and putsbox.x/box.yat 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.
-
boundsare 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'sBoardBounds.contentBounds()adds 120px by default, so the webview passescontentBounds(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.
- top
-
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 withcanvas.viewbox(box). Record the target as the last fitted box before the first frame, and cancel the animation onwheel,pointerdownor a zoom key. While it runs, also compare incoming viewbox events with the last written frame (adeferUpdateevent 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.
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 andrestores 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).
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
ResizeObserverand coalesce the entries to one per animation frame. - Then call
canvas.resized(): diagram-js caches the viewbox including the container size (_cachedViewbox), andresized()drops that cache and firescanvas.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
restoreor 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.
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
widthandheight: 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 tomax(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 coordinatesxandycarry an error of up to0.0005 / scalethat 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 betweenimport.parse.startorimport.render.startandimport.doneare therefore no user moves. The window also closes on the viewport's ownafterImport,restoreandsetStateand on user input (wheel, pointerdown, keydown on the canvas container), because a failed import never firesimport.done. - Do not count own changes with a flag or counter around the fit: with
canvas.deferUpdatediagram-js emitscanvas.viewbox.changedup to 300ms later (debounced), so the delayed event would be attributed to the user. During a fit animation withoutdeferUpdate, 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.changedoutside an import window, after the first import and with content, therefore ends the wait for the first fit. Importers usually add shapes withcanvas.addShape(noelements.changed); one that goes through the command stack must fireimport.parse.startorimport.render.startfirst, 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.addedand.removed,connection.addedand.removed,diagram.clear,import.done, the viewport's ownafterImport,restore,setState): a modeler's bounds may be costly (Event Storming'sexportMap()validates the whole board).
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 intry/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.
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, thearia-disabledpalette 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 setskeepEnabledWhenReadOnlyitself. 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 arearia-disabled). - The canvas is the modeler's to lock, at the same moment as
setReadOnly(true). Event Storming'seventStormingReadOnlyis aRuleProviderat priority 5000 that answersfalsefor every edit rule (elements.move,shape.move,shape.resize,shape.create,elements.create,shape.append,connection.create,elements.deleteand 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'sshow.invalidhook 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.
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: isolateas 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 withshowModal()and an element of the popover API are rendered in the top layer, above every stacking context of the page, andz-indexdoes 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-index100 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.
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:modaltells the shell's key listener that a modal is open, so the zoom keys stop acting.::backdropinherits custom properties from its dialog since Chromium 122 (VS Code 1.90 ships it), sovar(--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-konturand a 3px focus ring (guide §6, §5.14); under@media (forced-colors: active)the border inCanvasTextand the focus ring inHighlight. - 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
onSelectruns). - 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 ondiagram.destroy(Event Storming does). The shell offers noonDestroyhook for it: the modeler already owns the teardown path that destroys diagram and shell.
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).
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.
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 itrole="application"and anaria-labelfrom 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 setsoutline: noneon:focusand the guide's ring on:focus-visible, inside (outline-offset: -2px,-3pxwith 3px in high contrast), because the slot clips withoverflow: hidden; under forced colours inHighlight. - 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.
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 removesbox-shadowand replaces backgrounds. The active tool and every toggle witharia-pressed="true"therefore getforced-color-adjust: noneand the ring asbox-shadow: inset 0 0 0 2px Highlight. - The status bar gets
forced-color-adjust: noneandbackground: CanvasText. - Islands get
border-color: CanvasText. - Icons use
stroke="currentColor"and so follow the system text colour without extra rules.
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 fromshellincd-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:scrollWidthleaves 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 + 16and, 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.
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 fromColorThemeKind(see VS Code: theme bridge) or the settingmiragon.<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.kontrastalways passes: under VS Code "High Contrast" (dark) a light-only modeler becomeslightwithhoch.themesis a static value of the modeler package (for example an exportedthemesconstant) that the host reads when it renders the HTML, not from the running instance; the attributes must be right before the first paint.resolveAppearanceships 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-apicontract adds theappearancesurface 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.
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 onsystemunder a dark OS) and draw dark chrome over a renderer that paints light attribute colours (guide §10.2): half-dark. The embedded root therefore carriesdata-cd-theme="<themes[0]>"itself (ShellOptions.themes, defaultlight). The root's own[data-cd-theme]block would also drop the host's high contrast, so the root mirrorsdata-cd-kontrastfrom the nearest mode carrier above it, with aMutationObserveron the document'sdata-cd-themeanddata-cd-kontrastattributes (guide §13.3: a container with its owndata-cd-themetakesdata-cd-kontrastover 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
appearancewiththemes(supported modes) andset(mode and contrast); hosts feature-detect it (if (modeler.appearance)). - In a foreign page
setwritesdata-cd-themeanddata-cd-kontraston 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:
onContentChangednever fires for it, and it has no effect onexportTextor image exports.
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.
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.
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,showWarningMessageorshowErrorMessage. - 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 commandvscode.commands.executeCommand("revealFileInOS", uri).
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.KeyboardBindingsbinds them (isUndo/isRedo) as soon as the editor actionsundoandredoare registered. In the IDE host the shell's keyboard listener (priority 2000) returnsfalsefor them: that stopsKeyboardBindingswithoutpreventDefault(), so the keys propagate towindow, 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()andcommandStack.undo()on Cmd+Z (also with an empty stack) unless the webview creates it withundoRedoKeys: 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 onwindow, once and unprevented. - Each canvas change is written as one
WorkspaceEditwith minimal line changes, applied withvscode.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,reasonisTextDocumentChangeReason.UndoorRedo). The webview re-imports and restores viewbox and selection, no refit (guide §5.9). - The extension also receives
onDidChangeTextDocumentfor 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 withoutpreventDefault(). VS Code's webview host callspreventDefault()on every Ctrl/Cmd + Z or Y that reacheswindowand forwards it: without the stop the field loses its native undo and VS Code undoes the last document change; withpreventDefault()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 tocodeKeyZ/KeyYonly whenkeyis not an ASCII character (Cyrillic, Greek layouts). Cmd/Ctrl+Shift+Y is not redo: VS Code toggles the Debug Console with it.
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
keydownon the webview'swindow(bubble phase) and forwards every event that reaches it to the workbench (did-keydown), regardless ofdefaultPrevented. 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
keydownlistener ondocument(bubble phase) and callspreventDefault()andstopPropagation()for every key it handles. A listener onwindowwould come too late: VS Code attaches its listener to the frame'swindowwhen it creates the frame, before the webview's scripts run, so it runs first on that target, andstopPropagation()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 withstopPropagation()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
KeyboardBindingsbinds 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 onwindow. - Test: a
keydownlistener onwindow(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 akeydownlistener ondocument, 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 reachwindowunprevented. 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.
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 throughresolveAppearance(see Appearance decision):ColorThemeKinddata-cd-themedata-cd-kontrast1 Lightlight2 Darkdark3 HighContrastdarkhoch4 HighContrastLightlighthoch -
Initial: the extension writes
data-cd-themeanddata-cd-kontrastinto<html>when it renders the webview HTML, always, alsolight. -
Change: on
vscode.window.onDidChangeActiveColorThemeand onvscode.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 setstransition: none, change the attributes, and remove the class in a nestedrequestAnimationFrame, 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
retainContextWhenHiddenreceives messages. Re-send the current appearance fromwebviewPanel.onDidChangeViewStatewhen 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-contrastandvscode-high-contrast-light; watch them with aMutationObserver. Checkvscode-high-contrast-lightfirst: for backwards compatibility VS Code addsvscode-high-contrastto 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 nodata-cd-themeat start (the HTML did not come fromrenderWebviewHtml), 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
themesonce more: they are what its bundle can actually show, whatever the extension passed.
Guide §13.5, §2. Rule: native contributions and webview speak one language; a localized modeler is localized completely.
- Contributions:
%key%placeholders inpackage.json, withpackage.nls.jsonandpackage.nls.<language>.json. - Extension code:
vscode.l10n.t(…), with the bundle directory named byl10ninpackage.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" })), notvscode.env.language. Only a fully localized modeler passesvscode.env.languagethere, andvscode.l10n.bundlefor the webview's strings (the shell's messages table), so native contributions and webview speak the same language.
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
htmlandbody. VS Code otherwise setsbody { 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), avar()without its own fallback makes the wholefont-familydeclaration invalid at computed-value time: a font list after thevar()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),codeandacompletely yourself (links in--cd-ui-link). VS Code's defaults colour all three with its own colours:kbdwith the keybinding-label colours, an inset shadow andvertical-align: middle;codewith the preformat colours and the editor's monospace font;awith the text-link colours. -
Focus: VS Code sets
outline: 1px solid -webkit-focus-ring-color; outline-offset: -1pxona:focus,input:focus,select:focusandtextarea: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:focusas well. -
Scrollbars:
html { scrollbar-color: var(--cd-ui-kontur) transparent; }. VS Code sets its ownhtml { scrollbar-color: … }from--vscode-scrollbarSlider-backgroundand--vscode-editor-background, plus::-webkit-scrollbar*colours.scrollbar-coloris inherited, and once it is set Chromium (121 and later) ignores the::-webkit-scrollbarpseudo-elements. -
CSP and fonts: bundle the fonts as files next to the webview bundle, not as data URLs. With esbuild's usual
dataurlloader,@fontsource-variable/geist-mono/wght.cssinlines all six Geist Mono subsets (Event Storming's webview CSS grew from 137 KB to 267 KB for the key chips alone); withloader: { ".woff2": "file", ".woff": "file" }andassetNames: "fonts/[name]-[hash]"it was 69 KB, and the browser fetches only the subsets a page'sunicode-rangeneeds.renderWebviewHtmlwritesfont-src ${webview.cspSource} data:, so the files load oncedistis in the panel'slocalResourceRoots;data:stays for renderers that inline fonts. Without either Geist does not load and the fallback font shows.style-srckeeps'unsafe-inline'for the renderers, which writestyleattributes 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-srcadmits only the nonce'd script, and that is right; never add'unsafe-eval'. zod 4 probes once for eval withnew Functionwhen it builds its first object schema; it catches the error, but the browser reports that probe as asecuritypolicyviolation(underjitlesszod skips it). A webview that bundles zod 4 runsz.config({ jitless: true })in a module of its own that it imports before any schema module (Event Storming'szod-jitless.ts, the first import ofmain.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.routebehindrenderWebviewHtml(a fixed nonce, the VS Code stub as a nonce'd script inbodythat also recordssecuritypolicyviolation), and fails on any violation, page error or missing font (recipe inAPI.md,./vscode/extension). A kit helper for it is a 0.2 candidate. -
Reduced motion: VS Code resolves
workbench.reduceMotionitself (autofollows the OS,onholds without it) and hands the result to the webview only as the body classvscode-reduce-motion;prefers-reduced-motionalone misseson. 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).
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 withvscode.setState({ … })(JSON-serializable), on every legend toggle and once the view has rested: everysetStateis an IPC message with the whole state, and the view changes on every frame of a pan, a wheel zoom or a fit animation (persistViewwrites 250ms after the last change). The shell'sreadShellStateandwriteShellStatekeep its part under the keymodelerShellnext to the modeler's own keys:legendOpenandview(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 arestore(see Detecting a user move). canvas.viewbox()cachesouteruntil the next change, so derive the stored width and height from the freshcanvas.getSize()and the scale.
These topics stay in the design guide because they are not part of the shell:
- canvas theming with
cd-canvas.cssand thecd-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
localStoragekey (guide §13.3); - the IntelliJ white-flash fix (guide §13.5).
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
keydownlistener onwindow: 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
hochunder both High Contrast themes). - High Contrast Light sets both body classes
vscode-high-contrast-lightandvscode-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
onDidChangeViewStateleaves the right mode. - A hidden tab with
retainContextWhenHiddenreports 0 × 0 to theResizeObserver; back in front, view and selection are unchanged and nothing refits. If VS Code only hides the webview without resizing it, skip fitting whilewebviewPanel.visibleisfalseinstead. - 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
navigatorreports the client's platform (with or withoutuserAgentData), so the labels show ⌘ on a Mac client even when the extension host runs on Linux or Windows. -
revealFileInOSfor a file in a remote workspace: offer the reveal action only where it works. - The default webview stylesheet is in
@layer vscode-defaultin 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.reduceMotiononsets the body classvscode-reduce-motionin the webview (andoffremoves it), at start and when the setting changes. - A click into the side bar or another editor while the webview menu is open fires
bluron 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.languageandvscode.l10n.bundlereach 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.changingsynchronous,deferUpdatedebounce of 300ms,resized()dropping the cached viewbox. -
Palette: appended after the<svg>,palette.createwith{ container }, full rebuild onpalette.changed,highlighted-entryfor the active tool on a cached (stale after a rebuild) tools group,two-columntoggled oncanvas.resized. -
PaletteTooltipstill registered aspaletteTooltip;HoverTooltipSHOW_DELAY = 300. -
Keyboard: bound to the canvas<svg>,preventDefault()withoutstopPropagation();KeyboardBindingsat default priority 1000.