@miragon/modeler-shell 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/API.md +2869 -0
  2. package/LICENSE +21 -0
  3. package/README.md +566 -2
  4. package/THIRD_PARTY_NOTICES.md +52 -0
  5. package/dist/appearance-BBaM_4bD.d.ts +323 -0
  6. package/dist/appearance-D3FUcbu6.d.cts +53 -0
  7. package/dist/appearance-D3FUcbu6.d.ts +53 -0
  8. package/dist/appearance-XX3JC3pI.d.cts +323 -0
  9. package/dist/chunk-66HCSJNY.js +2792 -0
  10. package/dist/chunk-66HCSJNY.js.map +1 -0
  11. package/dist/chunk-KYDG5DMX.js +136 -0
  12. package/dist/chunk-KYDG5DMX.js.map +1 -0
  13. package/dist/chunk-N4QXFF3A.js +23 -0
  14. package/dist/chunk-N4QXFF3A.js.map +1 -0
  15. package/dist/chunk-OADW3GPA.js +785 -0
  16. package/dist/chunk-OADW3GPA.js.map +1 -0
  17. package/dist/core.cjs +873 -0
  18. package/dist/core.cjs.map +1 -0
  19. package/dist/core.d.cts +246 -0
  20. package/dist/core.d.ts +246 -0
  21. package/dist/core.js +131 -0
  22. package/dist/core.js.map +1 -0
  23. package/dist/diagram-js.cjs +1147 -0
  24. package/dist/diagram-js.cjs.map +1 -0
  25. package/dist/diagram-js.d.cts +170 -0
  26. package/dist/diagram-js.d.ts +170 -0
  27. package/dist/diagram-js.js +879 -0
  28. package/dist/diagram-js.js.map +1 -0
  29. package/dist/index.cjs +3632 -0
  30. package/dist/index.cjs.map +1 -0
  31. package/dist/index.d.cts +29 -0
  32. package/dist/index.d.ts +29 -0
  33. package/dist/index.js +143 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/protocol-DhYBakWL.d.cts +15 -0
  36. package/dist/protocol-IeW8bf4f.d.ts +15 -0
  37. package/dist/shell.css +1603 -0
  38. package/dist/testing-vscode-stub.cjs +332 -0
  39. package/dist/testing-vscode-stub.cjs.map +1 -0
  40. package/dist/testing-vscode-stub.d.cts +65 -0
  41. package/dist/testing-vscode-stub.d.ts +65 -0
  42. package/dist/testing-vscode-stub.js +154 -0
  43. package/dist/testing-vscode-stub.js.map +1 -0
  44. package/dist/testing.cjs +4663 -0
  45. package/dist/testing.cjs.map +1 -0
  46. package/dist/testing.d.cts +231 -0
  47. package/dist/testing.d.ts +231 -0
  48. package/dist/testing.js +4483 -0
  49. package/dist/testing.js.map +1 -0
  50. package/dist/toasts-Bp0REqdq.d.cts +142 -0
  51. package/dist/toasts-Bp0REqdq.d.ts +142 -0
  52. package/dist/types-CnRrINRs.d.cts +445 -0
  53. package/dist/types-PuDZczzy.d.ts +445 -0
  54. package/dist/vscode-extension.cjs +215 -0
  55. package/dist/vscode-extension.cjs.map +1 -0
  56. package/dist/vscode-extension.d.cts +103 -0
  57. package/dist/vscode-extension.d.ts +103 -0
  58. package/dist/vscode-extension.js +24 -0
  59. package/dist/vscode-extension.js.map +1 -0
  60. package/dist/vscode-webview.cjs +3561 -0
  61. package/dist/vscode-webview.cjs.map +1 -0
  62. package/dist/vscode-webview.d.cts +84 -0
  63. package/dist/vscode-webview.d.ts +84 -0
  64. package/dist/vscode-webview.js +259 -0
  65. package/dist/vscode-webview.js.map +1 -0
  66. package/package.json +145 -4
  67. package/src/core/appearance.ts +104 -0
  68. package/src/core/feedback/appearance.ts +141 -0
  69. package/src/core/feedback/problems.ts +23 -0
  70. package/src/core/feedback/toasts.ts +118 -0
  71. package/src/core/fit.ts +220 -0
  72. package/src/core/index.ts +127 -0
  73. package/src/core/keymap.ts +295 -0
  74. package/src/core/layout.ts +69 -0
  75. package/src/core/messages.ts +159 -0
  76. package/src/core/sizes.ts +85 -0
  77. package/src/core/types.ts +106 -0
  78. package/src/core/web-chrome/bottom-row.ts +37 -0
  79. package/src/core/web-chrome/drop.ts +78 -0
  80. package/src/core/web-chrome/top-row.ts +66 -0
  81. package/src/core/zoom.ts +74 -0
  82. package/src/core.ts +2 -0
  83. package/src/diagram-js/connect.ts +75 -0
  84. package/src/diagram-js/index.ts +16 -0
  85. package/src/diagram-js/module.ts +20 -0
  86. package/src/diagram-js/palette-skin.ts +267 -0
  87. package/src/diagram-js/palette.css +269 -0
  88. package/src/diagram-js/service.ts +243 -0
  89. package/src/diagram-js/types.ts +67 -0
  90. package/src/diagram-js/viewport.ts +583 -0
  91. package/src/diagram-js.ts +2 -0
  92. package/src/dom/buttons.css +124 -0
  93. package/src/dom/element.ts +51 -0
  94. package/src/dom/feedback/appearance.ts +184 -0
  95. package/src/dom/feedback/feedback.css +435 -0
  96. package/src/dom/feedback/problems-popover.ts +287 -0
  97. package/src/dom/feedback/toasts.ts +431 -0
  98. package/src/dom/hint.ts +51 -0
  99. package/src/dom/icons.ts +309 -0
  100. package/src/dom/index.ts +42 -0
  101. package/src/dom/inspector.ts +35 -0
  102. package/src/dom/keys.ts +67 -0
  103. package/src/dom/ledger.ts +87 -0
  104. package/src/dom/legend.ts +85 -0
  105. package/src/dom/menu.ts +404 -0
  106. package/src/dom/mount.ts +731 -0
  107. package/src/dom/problems.ts +73 -0
  108. package/src/dom/shell-dom.css +969 -0
  109. package/src/dom/status.ts +72 -0
  110. package/src/dom/tooltip.ts +237 -0
  111. package/src/dom/types.ts +459 -0
  112. package/src/dom/web/drop-zone.ts +181 -0
  113. package/src/dom/web/empty-state.ts +144 -0
  114. package/src/dom/web/legal.ts +75 -0
  115. package/src/dom/web/menu-groups.ts +69 -0
  116. package/src/dom/web/primary.ts +124 -0
  117. package/src/dom/web/web.css +386 -0
  118. package/src/dom/zoom-panel.ts +154 -0
  119. package/src/index.ts +3 -0
  120. package/src/shell.css +9 -0
  121. package/src/testing/case-tools.ts +164 -0
  122. package/src/testing/cases.ts +1077 -0
  123. package/src/testing/checks.ts +868 -0
  124. package/src/testing/facts.ts +641 -0
  125. package/src/testing/geometry.ts +652 -0
  126. package/src/testing/harness.ts +187 -0
  127. package/src/testing/index.ts +19 -0
  128. package/src/testing/page-tools.ts +457 -0
  129. package/src/testing/page.ts +27 -0
  130. package/src/testing/vscode-stub.ts +237 -0
  131. package/src/testing/web-cases.ts +913 -0
  132. package/src/testing/web-checks.ts +1049 -0
  133. package/src/testing/web-facts.ts +713 -0
  134. package/src/testing-vscode-stub.ts +2 -0
  135. package/src/testing.ts +2 -0
  136. package/src/vscode/extension/html.ts +107 -0
  137. package/src/vscode/extension/index.ts +8 -0
  138. package/src/vscode/extension/reveal.ts +9 -0
  139. package/src/vscode/extension/theme-bridge.ts +85 -0
  140. package/src/vscode/extension/types.ts +36 -0
  141. package/src/vscode/protocol.ts +31 -0
  142. package/src/vscode/webview/appearance.ts +141 -0
  143. package/src/vscode/webview/index.ts +8 -0
  144. package/src/vscode/webview/mount.ts +88 -0
  145. package/src/vscode/webview/state.ts +116 -0
  146. package/src/vscode-extension.ts +2 -0
  147. package/src/vscode-webview.ts +2 -0
package/API.md ADDED
@@ -0,0 +1,2869 @@
1
+ # @miragon/modeler-shell 0.2 — public API
2
+
3
+ The shared, framework-free implementation of everything around a Miragon diagram-js modeler: menu
4
+ button with dropdown, horizontal palette skin, chrome tooltip, zoom panel, zoom and fit keys, fit,
5
+ problems pill, status box, empty-file hint, legend and inspector slots, the plumbing to a VS Code
6
+ webview, and (0.2) the web-app chrome: primary action, legal line, File and Appearance groups,
7
+ problems popover, toasts, empty-state card, drop zone and the web app's mode. Planned in
8
+ [Miragon/modeler-api#4](https://github.com/Miragon/modeler-api/issues/4) (Phase 4); the first
9
+ consumer is the Event Storming modeler, in VS Code (0.1) and in its web app (0.2, see
10
+ [Usage: Event Storming in the web app](#usage-event-storming-in-the-web-app)).
11
+
12
+ - **Look and behaviour** come from the Miragon design system, guide `modeler-design-system.md`
13
+ (skill `miragon-brand:modeler-tool-design`, design system 1.13.0), §2, §5.1 to §5.15, §6, §7, §8,
14
+ §9, §11, §12 and §13.3 to §13.5. Section numbers below (§x.y) refer to it.
15
+ - **Mechanics** (diagram-js services, VS Code quirks) are in
16
+ [`docs/shell/implementation-notes.md`](../../docs/shell/implementation-notes.md).
17
+ - **Zero runtime dependencies.** The shell never imports `diagram-js`, `didi`, `vscode`, Lucide or
18
+ React, not even for types: diagram-js services are typed structurally, the `vscode` namespace and
19
+ Playwright's `page`/`test` are passed in.
20
+ - **Tokens are the consumer's.** The modeler vendors `cd-tokens.generated.css` unchanged; the shell
21
+ CSS reads only `var(--cd-ui-*)` roles, `var(--cd-shell-*)` sizes and the mode-independent
22
+ non-colour tokens `--cd-radius-*`, `--cd-shadow-*`, `--cd-motion-*`, `--cd-ease`, `--cd-ease-out`.
23
+ It contains no colour literal and no fixed palette token (`--cd-blau`, `--cd-schwarz`, …), with
24
+ one exception the guide itself makes: the primary button's fill is `--cd-blau` with `--cd-weiss`
25
+ text in both modes, hover `--cd-blau-link` (§8: "Feste Werte nimmst du nur für die Fläche von
26
+ Primär- und Destruktiv-Button"). Those three tokens appear in `src/dom/buttons.css` only, and the
27
+ stylesheet test allows them nowhere else (see [Buttons](#buttons)). Values the shell needs in
28
+ script (the two `theme-color`s) come from the modeler's vendored JSON as well.
29
+ - **English by default.** Every text comes from one messages table (`ShellMessages`), every number
30
+ goes through `Intl` in the UI language.
31
+
32
+ ## Entry points
33
+
34
+ | Entry | For | Needs a DOM |
35
+ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------ |
36
+ | `@miragon/modeler-shell` | the chrome (`mountShell`, web chrome included), icons, plus all of `./core` | yes (at call time) |
37
+ | `@miragon/modeler-shell/core` | fit math, keymap, zoom readout, appearance, sizes, messages, the web host's decisions and head script | no |
38
+ | `@miragon/modeler-shell/diagram-js` | `shellModule`, viewport, `connectDiagramJs` | yes |
39
+ | `@miragon/modeler-shell/vscode/extension` | webview HTML, nonce, theme bridge (extension host) | no |
40
+ | `@miragon/modeler-shell/vscode/webview` | `mountVsCodeShell`, appearance handler, webview state | yes |
41
+ | `@miragon/modeler-shell/testing` | Playwright-driven shell conformance kit | runs in Node, drives a browser |
42
+ | `@miragon/modeler-shell/testing/vscode-stub` | a stand-in for VS Code around a webview test page | yes (in the test page) |
43
+ | `@miragon/modeler-shell/shell.css` | every style of the shell | — |
44
+
45
+ All JavaScript entries ship as ESM and CJS with types (`typesVersions` covers `moduleResolution:
46
+ node10`); importing any of them has no side effects. The package also ships `src/` without its tests,
47
+ the files the sourcemaps point at, so stack traces and test locations resolve to real files. Every
48
+ optional input field also accepts an explicit `undefined`, so consumers compiling with
49
+ `exactOptionalPropertyTypes` can forward optional values (`{ view: viewport.getState() }`,
50
+ `{ open: stored.legendOpen }`); the shell compiles with it too.
51
+
52
+ ### Stylesheet order
53
+
54
+ The tokens come first, unless the renderer's stylesheet already imports them (Event Storming's
55
+ `event-storming.css` does, in a layer), and are loaded exactly once:
56
+
57
+ ```ts
58
+ import "./theme/cd-tokens.generated.css"; // vendored, unchanged, first; left out when the renderer CSS brings it
59
+ import "@fontsource-variable/geist/wght.css";
60
+ import "@fontsource-variable/geist-mono/wght.css"; // shortcut chips
61
+ import "diagram-js/assets/diagram-js.css"; // usually through the renderer's CSS
62
+ import "@miragon/<notation>-renderer/…css"; // the renderer
63
+ import "@miragon/modeler-shell/shell.css"; // last
64
+ ```
65
+
66
+ A bundle ships the fonts as files, not as data URLs (see `./vscode/extension`).
67
+
68
+ ## `./core` — DOM-free
69
+
70
+ Compiles without the DOM library (`tsconfig.core.json`), usable in the extension host and in workers.
71
+
72
+ ### Geometry and shared types
73
+
74
+ ```ts
75
+ interface Point {
76
+ x: number;
77
+ y: number;
78
+ }
79
+ interface Size {
80
+ width: number;
81
+ height: number;
82
+ }
83
+ // content bounds and viewboxes are in diagram coordinates
84
+ interface Bounds {
85
+ x: number;
86
+ y: number;
87
+ width: number;
88
+ height: number;
89
+ }
90
+ interface ScaledViewbox extends Bounds {
91
+ scale: number;
92
+ }
93
+ // px from the edges of the modeler's container
94
+ interface Insets {
95
+ top: number;
96
+ right: number;
97
+ bottom: number;
98
+ left: number;
99
+ }
100
+
101
+ type ShellHost = "ide" | "web" | "embedded";
102
+ type ChromeIsland =
103
+ | "menu"
104
+ | "palette"
105
+ | "primary"
106
+ | "zoom"
107
+ | "problems"
108
+ | "legend"
109
+ | "inspector"
110
+ | "status"
111
+ // 0.2, web column (toasts and problems-popover also embedded)
112
+ | "legal"
113
+ | "toasts"
114
+ | "empty-state"
115
+ | "drop-zone"
116
+ | "problems-popover";
117
+ const CHROME_ISLANDS: readonly ChromeIsland[]; // in this order
118
+ type PaletteRow = "top" | "second-row";
119
+
120
+ interface ZoomTarget {
121
+ zoom(): number; // 1 = 100 %
122
+ stepZoom(direction: 1 | -1): void; // zoomScroll raster, around the container centre
123
+ zoomToActual(): void; // 100 %, a user move
124
+ fit(): void; // explicit fit: animated unless reduced motion; view becomes automatic
125
+ canFit(): boolean; // false while the content bounds are empty
126
+ onChange(listener: () => void): () => void; // every viewbox change and canFit change; unsubscribe
127
+ }
128
+
129
+ // JSON-serializable, for VS Code setState
130
+ interface ViewportState {
131
+ viewbox: Bounds;
132
+ userMoved: boolean; // zoomed or panned since the last fit: a resize no longer refits
133
+ }
134
+ ```
135
+
136
+ `ShellHost` is the host column of §5.2: `ide` (VS Code webview, later IntelliJ), `web` (a Miragon web
137
+ app owning its page), `embedded` (a foreign page such as designIQ; geometry like `ide`).
138
+
139
+ ### Sizes
140
+
141
+ ```ts
142
+ const SHELL_TOKENS: {
143
+ edge: "--cd-shell-rand";
144
+ gap: "--cd-shell-luecke";
145
+ row: "--cd-shell-zeile";
146
+ target: "--cd-shell-ziel";
147
+ entry: "--cd-shell-eintrag";
148
+ free: "--cd-shell-frei";
149
+ menuMinWidth: "--cd-shell-menue-breite";
150
+ inspectorWidth: "--cd-shell-inspector-breite";
151
+ fitInsetTop: "--cd-shell-einpassen-oben";
152
+ fitInsetTopSecondRow: "--cd-shell-einpassen-oben-schmal";
153
+ fitInsetSide: "--cd-shell-einpassen-seite";
154
+ fitInsetBottom: "--cd-shell-einpassen-unten";
155
+ fitInsetBottomWeb: "--cd-shell-einpassen-unten-web";
156
+ narrow: "--cd-shell-schmal";
157
+ veryNarrow: "--cd-shell-sehr-schmal";
158
+ low: "--cd-shell-niedrig";
159
+ layerIslands: "--cd-shell-ebene-inseln";
160
+ layerInspector: "--cd-shell-ebene-inspector";
161
+ layerTopRow: "--cd-shell-ebene-reihe";
162
+ layerPopups: "--cd-shell-ebene-aufklapper";
163
+ layerDialog: "--cd-shell-ebene-dialog";
164
+ layerToast: "--cd-shell-ebene-toast";
165
+ };
166
+ type ShellSizeName = keyof typeof SHELL_TOKENS;
167
+ type ShellSizes = Readonly<Record<ShellSizeName, number>>;
168
+ const SHELL: ShellSizes; // edge 12, gap 8, row 40, target 32, entry 36, free 64, menuMinWidth 248,
169
+ // inspectorWidth 288, fitInsetTop 88, fitInsetTopSecondRow 140, fitInsetSide 24, fitInsetBottom 64,
170
+ // fitInsetBottomWeb 92, narrow 760, veryNarrow 440, low 820, layers 20, 25, 30, 40, 50, 60
171
+ function shellSizesFromTokens(shellGroup: Readonly<Record<string, string>>): ShellSizes;
172
+ ```
173
+
174
+ `SHELL` equals `parseFloat` of the `shell` group of `cd-tokens.generated.json`; a drift test pins it
175
+ to the token fixture. `shellSizesFromTokens` reads a vendored JSON's `shell` group and throws, naming
176
+ the token, when a size is missing or neither `"<n>px"` nor a plain number (so `"12em"` fails instead
177
+ of silently becoming 12); unknown tokens are ignored.
178
+
179
+ A modeler pins its own vendored JSON the same way, in a unit test (Vitest or `node:test`), never in
180
+ its e2e suite: the check is pure data. Both expectations, because the fit math reads `SHELL` and the
181
+ stylesheet reads the tokens:
182
+
183
+ ```ts
184
+ expect(SHELL).toEqual(shellSizesFromTokens(vendoredTokens.shell));
185
+ expect(Object.keys(vendoredTokens.shell)).toEqual(expect.arrayContaining(Object.values(SHELL_TOKENS)));
186
+ ```
187
+
188
+ ### Fit
189
+
190
+ ```ts
191
+ const MAX_FIT_ZOOM = 1;
192
+ const FIT_ANIMATION_MS = 250;
193
+ const VIEW_TOLERANCE = 1e-3;
194
+
195
+ function fitViewbox(
196
+ bounds: Bounds,
197
+ container: Size,
198
+ insets: Insets,
199
+ maxZoom?: number,
200
+ ): ScaledViewbox | undefined;
201
+
202
+ interface FitLayout {
203
+ container: Size;
204
+ host: ShellHost;
205
+ paletteRow: PaletteRow;
206
+ problemsVisible: boolean;
207
+ zoomRaised?: boolean; // web: the zoom panel stands above the legal line (zoomRowRaised)
208
+ legalHeight?: number; // web: the legal line's measured height, 20 per line (default one line)
209
+ legendWidth?: number; // open legend's rendered width, undefined when closed/absent
210
+ inspector?: { sheetTop?: number }; // present = open; sheetTop measured when it is a bottom sheet
211
+ }
212
+ function fitInsets(layout: FitLayout): Insets;
213
+ function freeRectCenter(container: Size, insets: Insets): Point | undefined; // px from the container's top left
214
+
215
+ function sameView(
216
+ a: Pick<ScaledViewbox, "x" | "y" | "scale">,
217
+ b: Pick<ScaledViewbox, "x" | "y" | "scale">,
218
+ tolerance?: number,
219
+ ): boolean;
220
+ function interpolateViewbox(from: ScaledViewbox, to: ScaledViewbox, progress: number): ScaledViewbox;
221
+ function easeStandard(progress: number): number; // cubic-bezier(0.4, 0, 0.2, 1)
222
+ ```
223
+
224
+ - `fitViewbox` centres `bounds` in the free rectangle (container minus insets) at the largest scale
225
+ that fits, capped at `maxZoom`, and returns a box with the container's aspect ratio, so
226
+ `canvas.viewbox(box)` shows exactly that view (§5.9). `undefined` for a container with a 0 side,
227
+ for bounds without width and height, or for a non-finite result; a zero width or height scales by
228
+ the other dimension. An axis whose insets leave no room (a container lower or narrower than its
229
+ chrome) centres in the whole container.
230
+ - `fitInsets` (§5.9): top 88, 140 in the second row; left and right 24; bottom 64, in the `web` host
231
+ with a visible pill or with the zoom panel raised above the legal line (`zoomRaised`) 92, plus 20
232
+ for every extra line of a wrapped legal line (`legalHeight`); right 324 (12 + 288 + 24) with an
233
+ open side inspector (container wider
234
+ than 760); left 12 + legend width + 24 with an open legend in containers wider than 440
235
+ (`legendWidth <= 0` counts as closed); bottom `container.height − sheetTop + 12` with an open
236
+ bottom sheet (up to 760). The sheet hides zoom panel, pill and legend (§5.11), so while it is open
237
+ neither the legend nor the web pill counts; without a measured `sheetTop` the bottom stays 64.
238
+ - `freeRectCenter` is the centre of the free rectangle, where `fitViewbox` centres the content and
239
+ keyboard creation places an element (§5.5), in pixels from the container's top left; an axis whose
240
+ insets leave no room centres in the whole container, as in `fitViewbox`. `undefined` for a
241
+ container with a 0 side. `Viewport.freeRectCenter()` turns it into diagram coordinates.
242
+ - `sameView` compares `scale` and the screen translation (`x · scale`, `y · scale`) within
243
+ `tolerance` relative to `max(1, |value|)`; width and height never count, so a resize is no move.
244
+ - `interpolateViewbox` interpolates the scale in log space and the centre linearly.
245
+
246
+ ### Zoom readout
247
+
248
+ ```ts
249
+ const ZOOM_MIN = 0.2;
250
+ const ZOOM_MAX = 4;
251
+ function canZoomOut(zoom: number): boolean; // zoom > 0.2 · 1.001
252
+ function canZoomIn(zoom: number): boolean; // zoom < 4 · 0.999
253
+ function formatPercent(zoom: number, lang: string): string; // "86%", German "86 %" (U+00A0)
254
+ function formatNumber(value: number, lang: string): string; // a tag Intl rejects ("") formats as "en"
255
+ interface ZoomReadout {
256
+ text: string;
257
+ ariaLabel: string;
258
+ canZoomOut: boolean;
259
+ canZoomIn: boolean;
260
+ }
261
+ function zoomReadout(
262
+ zoom: number,
263
+ lang: string,
264
+ messages: Pick<ShellMessages, "zoomToActualNow">,
265
+ ): ZoomReadout;
266
+ ```
267
+
268
+ ### Keymap
269
+
270
+ ```ts
271
+ type ChromeKey = "zoomIn" | "zoomOut" | "zoomToActual" | "fit";
272
+ interface KeyLike {
273
+ key: string;
274
+ code: string;
275
+ metaKey: boolean;
276
+ ctrlKey: boolean;
277
+ shiftKey: boolean;
278
+ altKey: boolean;
279
+ isComposing?: boolean;
280
+ }
281
+ function matchChromeKey(event: KeyLike): ChromeKey | undefined;
282
+ function isUndoRedoKey(event: KeyLike): boolean;
283
+ const TEXT_FIELD_SELECTOR = "input, textarea, select, [contenteditable]:not([contenteditable='false'])";
284
+
285
+ interface KeyContext {
286
+ host: ShellHost;
287
+ inTextField: boolean; // target matches TEXT_FIELD_SELECTOR or isContentEditable
288
+ menuOpen: boolean; // the shell's dropdown
289
+ dialogOpen: boolean; // any shown [role="dialog"], [role="alertdialog"] or <dialog open>, modal or not
290
+ modalOpen: boolean; // one of them with aria-modal="true" or :modal
291
+ defaultPrevented: boolean; // the canvas or the modeler handled the key
292
+ startScreen?: boolean; // web: the start screen shows; the chrome keys go to the browser
293
+ }
294
+ type KeyAction = ChromeKey | "closeMenu";
295
+ interface KeyDecision {
296
+ action?: KeyAction;
297
+ preventDefault: boolean;
298
+ stopPropagation: boolean;
299
+ }
300
+ function decideKey(event: KeyLike, context: KeyContext): KeyDecision;
301
+
302
+ type Platform = "mac" | "other";
303
+ interface NavigatorLike {
304
+ platform?: string;
305
+ userAgentData?: { platform?: string };
306
+ }
307
+ function detectPlatform(navigator?: NavigatorLike): Platform;
308
+ function shortcutLabel(key: ChromeKey, platform: Platform, keyNames: ShellMessages["keyNames"]): string;
309
+ function ariaKeyShortcuts(key: ChromeKey, platform: Platform): string;
310
+ function ariaKeyShortcutsFromLabel(label: string): string | undefined;
311
+ function commandKeyLabel(key: string, platform: Platform, keyNames: { ctrl: string }): string; // "⌘O", "Ctrl+O"
312
+ ```
313
+
314
+ `matchChromeKey` (§5.8) — Cmd/Ctrl is `metaKey || ctrlKey` on every platform, nothing matches with
315
+ `altKey` (AltGr):
316
+
317
+ | Chrome key | Matches |
318
+ | -------------- | -------------------------------------------------------------------------- |
319
+ | `zoomIn` | Cmd/Ctrl + `key` `+` or `=` (with or without Shift), or `code` `NumpadAdd` |
320
+ | `zoomOut` | Cmd/Ctrl + `key` `-` or `_`, or `code` `NumpadSubtract` |
321
+ | `zoomToActual` | Cmd/Ctrl + `key` `0`, or `code` `Numpad0` (also with NumLock off) |
322
+ | `fit` | `code` `Digit1` with Shift, without Cmd, Ctrl and Alt (every layout) |
323
+
324
+ `decideKey` holds every key rule of §5.8 and §13.5; the shell's one `keydown` listener only applies
325
+ it. `stopPropagation` is only ever `true` in the `ide` host.
326
+
327
+ | Case | Action | preventDefault | stopPropagation (`ide`) |
328
+ | --------------------------------------------------------- | ----------- | -------------- | ----------------------- |
329
+ | Escape (no modifier), menu open, not composing | `closeMenu` | yes | yes |
330
+ | Escape, popover or dialog open, or menu open while IME | — | no | yes |
331
+ | zoom key or Shift+1 behind the web app's start screen | — | no | — (web only) |
332
+ | zoom key, not composing, not in a text field, no modal | the key | yes | yes |
333
+ | zoom key while composing, in a text field or with a modal | — | no | yes |
334
+ | Shift+1, not composing, not in a text field, no modal | `fit` | yes | yes |
335
+ | Shift+1 otherwise | — | no | no (types "!") |
336
+ | undo/redo key in a text field | — | no | yes |
337
+ | any other key with `defaultPrevented` | — | no | yes |
338
+ | everything else | — | no | no |
339
+
340
+ - "No modifier" means no Meta, Ctrl, Alt or Shift. During IME composition Escape belongs to the IME.
341
+ - Zoom keys act whatever `defaultPrevented` says (diagram-js's keyboard prevents every key a listener
342
+ answered, the shell's own guard included); a blocked Shift+1 is never stopped.
343
+ - `isUndoRedoKey` (Cmd/Ctrl + Z, Shift+Z, Y) decides by the typed character when it is ASCII, so
344
+ QWERTZ and Dvorak work, and falls back to `code` `KeyZ`/`KeyY` only for non-ASCII layouts
345
+ (Cyrillic, Greek). Cmd/Ctrl+Shift+Y is not redo: VS Code toggles its Debug Console with it.
346
+
347
+ Shortcut labels (§5.8): macOS `⌘+`, `⌘−` (U+2212), `⌘0`, `⇧1`; elsewhere `Ctrl+Plus`, `Ctrl+Minus`,
348
+ `Ctrl+0`, `Shift+1` from `keyNames`. `aria-keyshortcuts`: `Meta+= Meta+Plus`, `Meta+-`, `Meta+0`,
349
+ `Shift+1`; outside macOS `Control` for `Meta`. The platform is read in the page itself
350
+ (`detectPlatform(navigator)`, `userAgentData.platform || platform`, so an empty `userAgentData`
351
+ platform falls back), never in the extension host (VS Code Remote).
352
+
353
+ `ariaKeyShortcutsFromLabel` turns a shortcut chip label into `aria-keyshortcuts` (§5.6) for palette
354
+ entries and menu actions: `"L"` → `"L"`, `"⇧L"` → `"Shift+L"`, `"⌘⇧Z"` → `"Meta+Shift+Z"`,
355
+ `"Ctrl+Shift+E"` → `"Control+Shift+E"`, `"Del"` → `"Delete"`, `"Ctrl++"` → `"Control+Plus"`; a label
356
+ that is not one key combination (`"G then D"`, `"1–9"`) gives `undefined`.
357
+
358
+ ### Appearance
359
+
360
+ ```ts
361
+ type Theme = "light" | "dark";
362
+ type Kontrast = "hoch";
363
+ interface Appearance {
364
+ theme: Theme;
365
+ kontrast?: Kontrast;
366
+ }
367
+ function resolveAppearance(requested: Appearance, themes: readonly Theme[]): Appearance;
368
+ function appearanceFromColorThemeKind(kind: number): Appearance;
369
+ type ColorThemeSetting = "automatic" | "light" | "dark";
370
+ function requestedAppearance(kind: number, setting?: ColorThemeSetting): Appearance;
371
+ function appearanceFromBodyClasses(classes: Iterable<string>): Appearance | undefined;
372
+ function reducedMotionFromBodyClasses(classes: Iterable<string>): boolean; // vscode-reduce-motion
373
+ const REDUCED_MOTION_CLASS = "cd-shell-reduce-motion"; // on <html>, mirrored by mountVsCodeShell
374
+ interface AppearanceAttributes {
375
+ "data-cd-theme": Theme;
376
+ "data-cd-kontrast"?: Kontrast;
377
+ }
378
+ function appearanceAttributes(appearance: Appearance): AppearanceAttributes;
379
+ ```
380
+
381
+ - `resolveAppearance` is the one decision of §13.5: a theme outside `themes` becomes `themes[0]`, so
382
+ a modeler declaring `["light"]` is never half-dark; `kontrast: "hoch"` always passes (VS Code High
383
+ Contrast on a light-only modeler gives `{ theme: "light", kontrast: "hoch" }`), any other value is
384
+ dropped, since appearances arrive by `postMessage`. Throws for empty `themes`. `themes` is a static
385
+ value of the modeler package, known before the first paint.
386
+ - `appearanceFromColorThemeKind`: 1 → light, 2 → dark, 3 → dark + hoch, 4 → light + hoch, other →
387
+ light. `requestedAppearance` lets the setting's theme win unless it is `automatic`; the contrast
388
+ always comes from the kind.
389
+ - `appearanceFromBodyClasses` checks `vscode-high-contrast-light` before `vscode-high-contrast`.
390
+ - `reducedMotionFromBodyClasses` reads VS Code's `workbench.reduceMotion` from the body class
391
+ `vscode-reduce-motion`; while `<html>` carries `REDUCED_MOTION_CLASS`, the shell's animations stop
392
+ and an explicit fit jumps, as under `prefers-reduced-motion`.
393
+
394
+ ### Messages
395
+
396
+ ```ts
397
+ interface ShellMessages {
398
+ menu: string; // "Menu"
399
+ tools: string; // "Tools"
400
+ canvas: string; // "Diagram": aria-label of the focusable canvas <svg>, name the notation
401
+ zoom: string; // "Zoom"
402
+ zoomOut: string; // "Zoom out"
403
+ zoomIn: string; // "Zoom in"
404
+ zoomToActual(percent: string): string; // "Zoom to 100%"
405
+ zoomToActualNow(percent: string, now: string): string; // "Zoom to 100% (now 86%)"
406
+ fit: string; // "Fit to view"
407
+ legend: string; // "Legend"
408
+ closeLegend: string; // "Close legend"
409
+ problems(count: number, formattedCount: string): string; // "1 problem", "2 problems"
410
+ problemsLabel(count: number, errors: number, formattedCount: string, formattedErrors: string): string;
411
+ // "2 problems, 1 error", "2 problems, no errors"
412
+ showProblems: string; // "Show in Problems view"
413
+ emptyHint: string; // "Pick a tool from the palette to start."
414
+ insertExample: string; // "Insert example"
415
+ invalidFile(diagram: string): string;
416
+ // "This file isn't a valid <diagram> right now. The diagram shows the last valid version and is read-only until you fix the text."
417
+ keyNames: { ctrl: string; shift: string; plus: string; minus: string }; // Ctrl, Shift, Plus, Minus
418
+ // 0.2, web host (guide §2 shared terms)
419
+ share: string; // "Share": default label of the primary action
420
+ legal: string; // "Legal": aria-label of the legal line's <nav>
421
+ copyright(year: number): string; // "© 2026 Miragon GmbH"
422
+ opensInNewTab: string; // "Opens in a new tab": the legal links' description for screen readers
423
+ newDiagram: string; // "New diagram": File group and the empty state's primary button
424
+ openFile: string; // "Open…"
425
+ showExample: string; // "Show example": File group and the empty state's ghost button
426
+ importFile: string; // "Import…"
427
+ appearance: string; // "Appearance"
428
+ appearanceSystem: string; // "System"
429
+ appearanceLight: string; // "Light"
430
+ appearanceDark: string; // "Dark"
431
+ problemsTitle: string; // "Problems": title of the problems popover
432
+ problemSeverity(severity: ProblemSeverity): string; // "Error", "Warning": a popover row's icon, for screen readers
433
+ close: string; // "Close": a toast's Close button
434
+ dropFile: string; // "Drop file here"
435
+ dropForbidden(reason: DropForbiddenReason): string;
436
+ // "Drop one file at a time", "This modeler can't open this file type", "Close the dialog to drop a file"
437
+ dropHint: string; // "Or drop a file here"
438
+ openFileLink: string; // "Open a file…": the start screen's link to the File group's Open…
439
+ cannotOpenFile(name: string, accepted: string): string;
440
+ // "Couldn't open “map.png”. Drop a .storm, .json, or .txt file." (accepted: Intl.ListFormat disjunction)
441
+ }
442
+ // every key optional; an undefined value keeps the default
443
+ type ShellMessageOverrides = {
444
+ [Key in Exclude<keyof ShellMessages, "keyNames">]?: ShellMessages[Key] | undefined;
445
+ } & { keyNames?: { [Key in keyof ShellMessages["keyNames"]]?: string | undefined } | undefined };
446
+ const defaultMessages: ShellMessages;
447
+ function resolveMessages(overrides?: ShellMessageOverrides): ShellMessages;
448
+ ```
449
+
450
+ A localized modeler overrides the whole table (guide §2: one language per tool) and passes `lang`.
451
+ "Impressum" and "Datenschutz" are not in the table: they stay German with `lang="de"` in every tool
452
+ language (§2).
453
+
454
+ ### Layout decisions
455
+
456
+ ```ts
457
+ const PALETTE_CLEARANCE = 16;
458
+ const TOOLTIP_DELAY_MS = 300;
459
+ const INVALID_STATUS_DELAY_MS = 800;
460
+ interface PaletteRowInput {
461
+ containerWidth: number;
462
+ paletteWidth: number;
463
+ menuRight: number;
464
+ primaryLeft?: number;
465
+ }
466
+ function decidePaletteRow(input: PaletteRowInput): PaletteRow;
467
+ function legendStartsOpen(containerHeight: number): boolean; // > 820
468
+ interface TooltipPlacementInput {
469
+ anchor: Bounds;
470
+ island: Bounds;
471
+ tooltip: Size;
472
+ container: Size;
473
+ }
474
+ function placeTooltip(input: TooltipPlacementInput): Point;
475
+ ```
476
+
477
+ `decidePaletteRow` (§5.15): `second-row` up to 760px container width, or when the centred palette
478
+ would keep less than 16px to the menu trigger (or to the primary action); `paletteWidth` is the
479
+ intrinsic border-box width (`offsetWidth − clientWidth + scrollWidth`: the content including the
480
+ borders), so the decision does not flip once the palette scrolls in the second row.
481
+
482
+ `placeTooltip` (§5.6): below an island in the upper half at `island.bottom + 8` (top row 60, second
483
+ row 112), above one in the lower half ending 8px above it (bottom row: `bottom: 60px`), centred on
484
+ `anchor`, shifted to keep 12px to the sides.
485
+
486
+ ### Web host decisions (0.2)
487
+
488
+ The web column's DOM-free parts are in `./core` as well, documented with their islands under
489
+ [Web host](#web-host-02): `decideTopRow` (label collapse before the second row), `zoomRowRaised`
490
+ (legal line and zoom panel), `dragVerdict` and `acceptsFile` (drop zone), `toastLifetime`,
491
+ `toastToEvict`, `toastStackWidth`, `toastStackBottom` and their constants (toasts),
492
+ `parseAppearanceChoice`, `documentMode`, `webModeScript` and `webHeadMarkup` (mode; they run in a
493
+ Vite config or a build script), `problemSummary` (problems popover).
494
+
495
+ ## `.` — the chrome
496
+
497
+ Exports everything of `./core` plus:
498
+
499
+ ```ts
500
+ function mountShell(root: HTMLElement, options: ShellOptions): ShellHandle;
501
+
502
+ interface ShellOptions {
503
+ host: ShellHost;
504
+ canvas?: HTMLElement; // diagram-js's container; omitted: the shell creates it (handle.canvas)
505
+ lang?: string; // see below; the embedded root carries lang itself
506
+ messages?: ShellMessageOverrides;
507
+ platform?: Platform; // default detectPlatform(navigator)
508
+ keyScope?: "document" | "root"; // default "document", "root" for embedded
509
+ themes?: readonly Theme[]; // default ["light"]; read in the embedded and web hosts, see below
510
+ menu?: MenuGroups;
511
+ problems?: ProblemsOptions;
512
+ legend?: LegendOptions;
513
+ inspector?: InspectorOptions;
514
+ emptyHint?: EmptyHintOptions;
515
+ // 0.2, read in the web host only (see Web host below)
516
+ primary?: PrimaryActionOptions;
517
+ legal?: LegalLineOptions;
518
+ file?: FileMenuOptions;
519
+ appearance?: WebAppearanceOptions; // required when themes includes "dark"
520
+ emptyState?: EmptyStateOptions;
521
+ dropZone?: DropZoneOptions;
522
+ }
523
+ ```
524
+
525
+ `mountShell` turns `root` into the shell: class `cd-shell`, `container: cd-shell / size`,
526
+ `isolation: isolate`. `root` must be sized from outside, otherwise size containment collapses it to 0:
527
+ in an own document a root that is a direct child of `<body>` gets `height: 100%` from `shell.css`
528
+ (a zero-specificity rule), any other root and an embedded one need an explicit size. In the `ide` and
529
+ `web` hosts `<html>` gets `cd-shell-document` (own-document reset). The shell observes the root and
530
+ the registered palette with a `ResizeObserver` (palette row, legend start state; 0 × 0 is skipped)
531
+ and installs exactly one `keydown` listener (bubble phase) on `document` or on the root.
532
+
533
+ `lang` is the tool's UI language for `Intl` (guide §2: one language per tool). Default in an own
534
+ document (`ide`, `web`): the closest `[lang]` of the root (the `<html lang>` the host wrote), else
535
+ `"en"`. In the `embedded` host the page's language is not the tool's, so the default is the root's
536
+ own `lang` attribute, else `"en"`; the embedded root carries `lang` itself. A localized modeler
537
+ passes `lang` together with its messages.
538
+
539
+ **The embedded root's mode** (§13.3). The token CSS resolves the roles on the nearest
540
+ `[data-cd-theme]`, so a root without one would take the mode of a themed host page (designIQ, a web
541
+ app on `system` under a dark OS) and be half-dark against its light-drawn diagram. The `embedded`
542
+ root therefore carries `data-cd-theme="<themes[0]>"` itself (until the contract's `appearance`
543
+ surface, planned for a later 1.x minor of `@miragon/modeler-api`, there is no channel to ask an
544
+ embedded modeler for another mode, so it stays in its first one), and mirrors the host's
545
+ `data-cd-kontrast` from the nearest mode carrier above it for as long as it lives (a
546
+ `MutationObserver` on the document's `data-cd-theme`/`data-cd-kontrast` attributes), because its own
547
+ `data-cd-theme` would otherwise drop the host's high contrast. In the `ide` host the
548
+ host writes the mode on `<html>` and `themes` is not read; in the `web` host the page's head script
549
+ writes it before the first paint and `themes` decides whether the shell renders the Appearance
550
+ group (see [Mode](#mode-133)). An empty `themes` throws before anything is mounted.
551
+
552
+ **Order with document listeners.** Listeners on one target run in registration order. A modeler
553
+ whose own keyboard listens on `document` (Event Storming does) constructs its diagram **before**
554
+ `mountShell`, so the shell's listener runs after it and sees `defaultPrevented`; a renderer that
555
+ creates its diagram lazily (Event Storming's `Modeler` on the first `get`) calls
556
+ `modeler.get("canvas")` first. Listeners on the canvas `<svg>` (diagram-js `keyboard`) have no
557
+ ordering constraint. Such a document keyboard must leave alone what is not meant for the canvas:
558
+ keys with `defaultPrevented` and keys whose target lies outside the canvas container (the menu
559
+ stops the keys it consumes, the palette its own, but the zoom panel's buttons do not); in the IDE
560
+ host it binds no undo or redo, and on the canvas it handles Cmd/Ctrl+A (see `./diagram-js`,
561
+ Keyboard).
562
+
563
+ ### Menu (§5.4)
564
+
565
+ ```ts
566
+ interface MenuGroups {
567
+ model?: readonly MenuEntry[];
568
+ export?: readonly MenuEntry[];
569
+ help?: readonly MenuEntry[];
570
+ }
571
+ type MenuGroupName = "file" | "model" | "export" | "appearance" | "help"; // data-menu-group
572
+ type MenuEntry = MenuActionEntry | MenuDialogEntry | MenuCheckboxEntry | MenuRadioGroup;
573
+
574
+ interface MenuItemBase {
575
+ id: string;
576
+ label: string;
577
+ icon?: ShellIconName;
578
+ disabled?: () => boolean;
579
+ keepEnabledWhenReadOnly?: boolean; // a Model entry that is pure view state; default false
580
+ }
581
+ interface MenuActionEntry extends MenuItemBase {
582
+ kind: "action";
583
+ shortcut?: string;
584
+ ariaKeyShortcuts?: string; // default ariaKeyShortcutsFromLabel(shortcut); "" sets none
585
+ danger?: boolean;
586
+ onSelect(): void;
587
+ }
588
+ interface MenuDialogEntry extends MenuItemBase {
589
+ kind: "dialog";
590
+ shortcut?: string; // 0.2: the chip of a dialog entry ("?" for "Keyboard shortcuts…")
591
+ ariaKeyShortcuts?: string; // as for an action
592
+ onSelect(): void;
593
+ }
594
+ interface MenuCheckboxEntry extends MenuItemBase {
595
+ kind: "checkbox";
596
+ checked(): boolean;
597
+ onToggle(checked: boolean): void;
598
+ }
599
+ interface MenuRadioGroup {
600
+ kind: "radio";
601
+ id: string;
602
+ label: string;
603
+ options: readonly MenuRadioOption[];
604
+ value(): string | undefined;
605
+ onSelect(value: string): void;
606
+ disabled?: () => boolean;
607
+ keepEnabledWhenReadOnly?: boolean;
608
+ }
609
+ interface MenuRadioOption {
610
+ value: string;
611
+ label: string;
612
+ }
613
+ ```
614
+
615
+ - Groups render in the fixed order File, Model, Export, Appearance, Help with separators; empty
616
+ groups collapse. File and Appearance exist in the web host only and the shell builds them
617
+ (`ShellOptions.file`, the Appearance group of a dark-capable modeler, see
618
+ [File and Appearance groups](#file-and-appearance-groups-54-133)); Model, Export and Help come
619
+ from `MenuGroups`. With a legend (and `legend.menuEntry !== false`) the shell appends the checkbox
620
+ "Legend" to the Model group.
621
+ - `action` and `dialog`: the menu closes, focus returns to the trigger, then `onSelect` runs (a host
622
+ dialog then hands focus back to a visible control). `dialog` adds `aria-haspopup="dialog"`; its
623
+ label ends with "…" (U+2026), and in 0.2 the modeler renders the dialog itself (see
624
+ [Modeler dialogs](#modeler-dialogs)).
625
+ - `checkbox` and `radio` toggle and keep the menu open. State is read through `checked()`, `value()`
626
+ and `disabled()` whenever the menu opens and on `menu.refresh()`.
627
+ - Icons only from the common set and only where the guide gives one; the icon column stays 16px wide
628
+ without an icon. An action or dialog entry with a `shortcut` shows its chip and carries
629
+ `aria-keyshortcuts`.
630
+ - On a read-only canvas (`setReadOnly(true)`, §13.5) every Model entry is `aria-disabled` unless it
631
+ sets `keepEnabledWhenReadOnly`; disabled entries stay focusable and do nothing. View toggles stay
632
+ usable on a read-only canvas: the shell's Legend checkbox carries `keepEnabledWhenReadOnly` itself,
633
+ and a modeler sets it on its own pure view state (Event Storming's "Type captions"), never on an
634
+ entry that changes the document (Level, "Map size…"). File, Export, Appearance and Help entries are never locked.
635
+ Guide §13.5 (design system 1.13.0): only Model entries that change the document are `aria-disabled`.
636
+ - The trigger is `hidden` while no group has an entry (no menu groups and no Legend entry).
637
+ - Keys: Enter, Space and ↓ on the trigger open on the first entry, ↑ on the last; ↑/↓ wrap, Home/End
638
+ jump, a letter jumps to the next entry starting with it; Escape and Tab close (Escape returns focus
639
+ to the trigger). Every key the menu consumes (those, plus Enter, Space, Delete and Backspace in the
640
+ dropdown) is stopped at the menu in every host, so a modeler's keyboard on `document` never acts
641
+ on it (Event Storming would move the selection on ↓); Escape, Tab, modified keys and the chrome
642
+ keys go on (Shift+1 fits, Cmd/Ctrl + `+` zooms with the menu open).
643
+ - A `pointerdown` outside (capture phase on `document`) closes without moving focus and without
644
+ stopping the event, so it still acts on its target. So does the window losing the focus (a click
645
+ into the VS Code workbench or another iframe).
646
+
647
+ ### Modeler dialogs
648
+
649
+ 0.2 has no dialog component (a later candidate, see [Out of scope for 0.2](#out-of-scope-for-02)), so
650
+ a modeler renders its dialogs, the "Keyboard shortcuts…" dialog of the Help group (§5.4) first among
651
+ them, and meets §8 on its own:
652
+
653
+ - **Layer.** `z-index: var(--cd-shell-ebene-dialog)` (50) applies only to a dialog outside the top
654
+ layer, inside `shell.root`. A `<dialog>` opened with `showModal()` or an element of the popover API
655
+ lies in the browser's top layer above everything, whatever its `z-index`. The shell's keys count it
656
+ either way: any shown `<dialog>`, `[role="dialog"]` or `[role="alertdialog"]` in the document
657
+ (embedded: in the root) is open, and a modal one blocks the zoom keys.
658
+ - **Panel and backdrop** (§8): panel `--cd-ui-flaeche-hoch` with 1px `--cd-ui-linie`, radius `md`,
659
+ `shadow-3`, `width: min(560px, 100cqw - 32px)`, `max-height: calc(100cqh - 32px)` with a scrolling
660
+ body; header (padding `16px 16px 8px 24px`, title 21px/600) with Close as a 32 × 32 ghost icon
661
+ button (Lucide `x` 16px, `aria-label` "Close"); backdrop
662
+ `background: var(--cd-ui-backdrop); backdrop-filter: blur(3px) grayscale(1)` in both modes. The
663
+ shortcut chips look like the tooltip's (§5.6).
664
+ - **High contrast** (`[data-cd-kontrast="hoch"]`, §6, §5.14): no shadow, border `--cd-ui-kontur`,
665
+ focus ring 3px.
666
+ - **Forced colours** (`@media (forced-colors: active)`): border `CanvasText`, focus ring `Highlight`.
667
+ The shell's own high-contrast and forced-colours rules match only `cd-shell-*` classes, so they
668
+ do not reach a modeler's dialog.
669
+ - **Focus** (§8): it moves into the dialog and stays there, Escape and a click on the backdrop close
670
+ it, and it returns to the control that opened it (the menu trigger after a menu entry).
671
+ - **Read-only:** the shortcuts dialog stays usable on a read-only canvas (§13.5).
672
+ - **Teardown:** the dialog is the modeler's content in `shell.root`, removed by the modeler (see
673
+ `destroy()` below).
674
+
675
+ ### Problems, legend, inspector, empty-file hint
676
+
677
+ ```ts
678
+ interface ProblemsOptions {
679
+ onActivate?(): void; // the IDE host opens its Problems view (give it there); ignored in the web host
680
+ onSelectItem?(item: ProblemItem): void; // 0.2: a row of the problems popover, after it closed
681
+ }
682
+ interface LegendOptions {
683
+ render(body: HTMLElement): void; // once; a throw unwinds the whole mount
684
+ open?: boolean; // remembered start state; default (also undefined) legendStartsOpen(container height)
685
+ onToggle?(open: boolean): void; // every change (close button, checkbox, setOpen, toggle); persist it
686
+ menuEntry?: boolean; // default true
687
+ }
688
+ interface InspectorOptions {
689
+ label: string; // aria-label of the <aside>
690
+ }
691
+ interface EmptyHintOptions {
692
+ onInsertExample?(): void; // shows "Insert example"; the modeler writes the example as one edit
693
+ }
694
+ ```
695
+
696
+ ### Handle
697
+
698
+ ```ts
699
+ interface ShellHandle {
700
+ readonly root: HTMLElement;
701
+ readonly canvas: HTMLElement;
702
+ readonly host: ShellHost;
703
+ readonly lang: string;
704
+ readonly platform: Platform;
705
+ readonly messages: ShellMessages;
706
+ readonly menu: MenuHandle;
707
+ readonly tooltip: TooltipHandle;
708
+ readonly legend: LegendHandle | undefined;
709
+ readonly inspector: InspectorHandle | undefined;
710
+ readonly primary: PrimaryActionHandle | undefined; // 0.2, web host with `primary`
711
+ readonly appearance: AppearanceHandle | undefined; // 0.2, web host with "dark" in `themes`
712
+ setZoomTarget(target: ZoomTarget | null): void;
713
+ fitInsets(): Insets;
714
+ registerPalette(palette: PaletteRegistration): () => void;
715
+ setProblems(summary: ProblemSummary): void;
716
+ setStatus(status: StatusMessage | null, options?: { delayMs?: number }): void;
717
+ setEmptyHint(visible: boolean): void;
718
+ setReadOnly(readOnly: boolean, options?: { delayMs?: number }): void;
719
+ isReadOnly(): boolean;
720
+ setEmptyState(shown: boolean): void; // 0.2, web host with `emptyState`
721
+ isEmptyStateShown(): boolean;
722
+ toast(request: ToastRequest): ToastHandle; // 0.2, web and embedded; inert in the ide host
723
+ destroy(): void;
724
+ }
725
+ interface MenuHandle {
726
+ readonly trigger: HTMLButtonElement;
727
+ readonly dropdown: HTMLElement;
728
+ isOpen(): boolean;
729
+ open(focus?: "first" | "last"): void;
730
+ close(options?: { restoreFocus?: boolean }): void;
731
+ refresh(): void;
732
+ setGroups(groups: MenuGroups): void;
733
+ }
734
+ interface TooltipContent {
735
+ label: string;
736
+ shortcut?: string;
737
+ }
738
+ interface TooltipHandle {
739
+ attach(control: HTMLElement, content: TooltipContent | (() => TooltipContent | null)): () => void;
740
+ hide(): void;
741
+ }
742
+ interface LegendHandle {
743
+ readonly element: HTMLElement;
744
+ readonly body: HTMLElement;
745
+ isOpen(): boolean;
746
+ setOpen(open: boolean): void;
747
+ toggle(): void;
748
+ }
749
+ interface InspectorHandle {
750
+ readonly element: HTMLElement;
751
+ isOpen(): boolean;
752
+ open(): void;
753
+ close(): void;
754
+ }
755
+ interface PaletteRegistration {
756
+ element: HTMLElement; // .djs-palette
757
+ entryTooltip(entry: HTMLElement): TooltipContent | null;
758
+ setReadOnly(state: PaletteReadOnlyState): void; // on registration and on every change
759
+ }
760
+ interface PaletteReadOnlyState {
761
+ locked: boolean; // veto every entry but Lasso and Hand, from the invalid import on
762
+ shown: boolean; // mark them aria-disabled and muted, once the delay has passed
763
+ }
764
+ interface ProblemSummary {
765
+ count: number;
766
+ errors: number;
767
+ items?: readonly ProblemItem[]; // 0.2: the popover's rows; the ide host ignores them
768
+ }
769
+ type StatusSeverity = "danger" | "warning";
770
+ interface StatusMessage {
771
+ severity: StatusSeverity;
772
+ text: string;
773
+ }
774
+ ```
775
+
776
+ - `setZoomTarget` binds zoom panel, readout and chrome keys; the readout reads `zoom()` at most once
777
+ per animation frame after `onChange`. With `null` the three buttons are `aria-disabled`; the readout
778
+ never is (§5.8), its click does nothing then. Buttons and keys never call `stepZoom` past a limit
779
+ (zoom out at or below 20 % never zooms in), never `zoomToActual` at exactly 100 % and never `fit`
780
+ while `canFit()` is false.
781
+ - `fitInsets()` is `fitInsets(layout)` of `./core` for what is open and measured right now (palette
782
+ row, pill, legend width, side inspector or measured sheet top).
783
+ - `registerPalette` is the adapter's API (`connectDiagramJs` calls it, possibly from inside
784
+ diagram-js's `palette.create`, before any entry exists); a later registration replaces the
785
+ earlier one, and the returned unregister function is safe to call twice and after `destroy()`.
786
+ - `setProblems`: the pill shows for `count > 0` with `Intl` numbers in `lang` and the icon by severity;
787
+ its tooltip "Show in Problems view" exists only in the `ide` host (`aria-describedby`, since it
788
+ says more than the label). A click calls `onActivate` in the `ide` host and in an `embedded` host
789
+ that gives it; in the `web` host (and an `embedded` one without `onActivate`) it opens the
790
+ problems popover with `summary.items` (see [Problems popover](#problems-popover-510)).
791
+ - `setStatus(status, { delayMs })`: shows after `delayMs` (an invalid file: `INVALID_STATUS_DELAY_MS`,
792
+ so typing does not flicker and `role="alert"` does not re-announce), hides at once with `null`.
793
+ Repeated calls while the delay runs keep the first timer (the box appears once the text stayed
794
+ invalid for 800ms), `null` drops it, and an unchanged text is not written again.
795
+ - `setReadOnly(true, { delayMs })` (invalid file, §13.5) locks at once: `isReadOnly()`, the palette
796
+ veto (every entry but Lasso and Hand) and the Model entries of the menu without
797
+ `keepEnabledWhenReadOnly`. The marks follow after `delayMs` (pass `INVALID_STATUS_DELAY_MS`, as for
798
+ the status box): `data-readonly` on the root and the palette entries `aria-disabled`, muted,
799
+ previews in `grayscale(1)`. Repeated calls keep the first timer; `setReadOnly(false)` unlocks and
800
+ hides at once. The legend is a view toggle: its Legend checkbox and its close button keep working
801
+ under the lock (guide §13.5, design system 1.13.0).
802
+ - The canvas is the modeler's to lock, from the same invalid import on (§13.5, what the read-only
803
+ state blocks): no context pad, no move, resize, delete or paste, and no label editor (also not on
804
+ a double-click). Selection, zoom, pan, the export of the last valid state and the "Keyboard
805
+ shortcuts…" dialog stay. The modeler reads `isReadOnly()` or keeps a lock of its own next to
806
+ `setReadOnly` (Event Storming's `eventStormingReadOnly` service); the kit's `readOnly` case checks
807
+ each of these.
808
+ - `setEmptyHint` shows or hides the hint; `connectDiagramJs` keeps it in sync with the diagram. When
809
+ the hint (its "Insert example") or the closing inspector holds the focus, the focus moves to the
810
+ canvas `<svg>` (else the menu trigger) instead of falling to `<body>`.
811
+ - `tooltip.attach` shows the chrome tooltip after `TOOLTIP_DELAY_MS` hover or at once on keyboard
812
+ focus (`:focus-visible`, also on `aria-disabled` buttons); after a hover-hide a neighbour shows at
813
+ once for the same delay. It hides on click (until the pointer leaves), Escape, leave, blur and menu
814
+ open, never on touch. It sets `aria-describedby` only when its text says more than the control's
815
+ name, which a shortcut chip does unless the control names its keys in `aria-keyshortcuts`. The
816
+ shell attaches it to its own buttons; the palette gets it through `registerPalette`.
817
+ - The legend's start state comes from `legend.open` or, at the first non-zero container size, from
818
+ `legendStartsOpen`; that automatic decision does not call `onToggle`. Its close button returns the
819
+ focus to the menu trigger.
820
+ - `destroy()` removes every element, class, attribute, listener, observer and timer the shell added
821
+ and restores what was there before the mount: a passed `canvas` stays where it is, without
822
+ `cd-shell-canvas`, and a `cd-shell-document` class or a `lang` the page already had stays (the
823
+ class `renderWebviewHtml` writes on `<html>` included). A second call and every later handle call
824
+ are no-ops. A mount that throws (a modeler's `legend.render`) undoes the same before the error
825
+ propagates. Content a modeler adds to `shell.root` (a dialog, a popover) is the modeler's: it
826
+ removes it in its own teardown, before or with `shell.destroy()`, for example on `diagram.destroy`,
827
+ because the shell removes only what it added.
828
+ - Element ids (`cd-shell-menu-N`, `cd-shell-tooltip-N`, `cd-shell-legend-title-N`) count on one
829
+ page-global counter (`Symbol.for("@miragon/modeler-shell.mounts")`), so two modelers that each
830
+ bundle the shell never share an id on one foreign page.
831
+
832
+ ### Icons
833
+
834
+ ```ts
835
+ type IconNode = ReadonlyArray<readonly [tag: string, attributes: Readonly<Record<string, string>>]>;
836
+ type ShellIconName =
837
+ | "menu"
838
+ | "minus"
839
+ | "plus"
840
+ | "maximize"
841
+ | "check"
842
+ | "x"
843
+ | "file-plus"
844
+ | "folder-open"
845
+ | "import"
846
+ | "file-image"
847
+ | "file-code-corner"
848
+ | "keyboard"
849
+ | "share-2"
850
+ | "circle-x"
851
+ | "triangle-alert"
852
+ | "square-dashed-mouse-pointer"
853
+ | "hand"
854
+ | "line-segment"
855
+ | "unfold-horizontal"
856
+ | "workflow";
857
+ const ICONS: Readonly<Record<ShellIconName, IconNode>>; // lucide-static 1.59.0, unchanged (ISC); line-segment = spline with a straight segment
858
+ interface IconOptions {
859
+ size?: number; // default 16
860
+ strokeWidth?: number; // default 2.5
861
+ className?: string;
862
+ }
863
+ const PALETTE_TOOL_ICON: Readonly<IconOptions>; // { size: 20, strokeWidth: 2 }
864
+ function iconMarkup(icon: ShellIconName | IconNode, options?: IconOptions): string;
865
+ function iconElement(icon: ShellIconName | IconNode, options?: IconOptions): SVGSVGElement;
866
+ ```
867
+
868
+ `iconMarkup` renders decorative SVG (`aria-hidden="true"`, `focusable="false"`, `fill="none"`,
869
+ `stroke="currentColor"`, round caps and joins) and lets only Lucide's elements and geometry
870
+ attributes pass, so palette providers can put it into an entry's `html`.
871
+
872
+ The geometry is Lucide's (ISC, © Lucide Icons and Contributors); `check`, `circle-x`, `maximize`,
873
+ `minus`, `plus`, `triangle-alert` and `x` derive from Feather (MIT, © Cole Bemis), as Lucide's
874
+ LICENSE lists them (two by their Feather names, `x-circle` and `alert-triangle`). `src/dom/icons.ts`
875
+ opens with a legal comment (`/*! … @license … */`) naming both, which esbuild keeps in the `dist`
876
+ chunk that carries the icons (ESM and CJS). A consumer's bundler may drop or move legal comments (its
877
+ `legalComments` setting), so a VSIX or a web bundle that contains the shell carries the Lucide and
878
+ Feather notices of `THIRD_PARTY_NOTICES.md` itself.
879
+
880
+ ### DOM, `data-chrome` and classes
881
+
882
+ ```html
883
+ <html class="cd-shell-document" lang="en" data-cd-theme="light"> <!-- own document: ide, web -->
884
+ <div class="cd-shell" data-cd-shell-host="ide" data-palette="top"
885
+ data-legend="open" data-inspector="open" data-problems="shown" data-readonly> <!-- state attributes only while true -->
886
+ <!-- embedded: lang="en" data-cd-theme="light" [data-cd-kontrast="hoch"] on the root itself -->
887
+ <button class="cd-shell-menu-trigger" data-chrome="menu" aria-label="Menu" aria-haspopup="menu"
888
+ aria-expanded="false" aria-controls="cd-shell-menu-1">…</button>
889
+ <div class="cd-shell-canvas"> <!-- diagram-js container -->
890
+ <div class="djs-container">
891
+ <div class="djs-palette" data-chrome="palette"> <!-- moved before the <svg> -->
892
+ <div class="djs-palette-entries" role="toolbar" aria-label="Tools">
893
+ <div class="group" data-group="tools">
894
+ <div class="entry" data-action="lasso-tool" role="button" aria-label="…" tabindex="0" aria-pressed="false" aria-keyshortcuts="L">…</div>
895
+ <svg tabindex="0" role="application" aria-label="Diagram">…</svg>
896
+ <div class="cd-shell-status" data-chrome="status" data-severity="danger" role="alert">…</div>
897
+ <div class="cd-shell-hint"><p class="cd-shell-hint-text">…</p><button class="cd-shell-hint-action">…</button></div>
898
+ <aside class="cd-shell-inspector" data-chrome="inspector" aria-label="…">…</aside>
899
+ <button class="cd-shell-problems" data-chrome="problems" aria-label="2 problems, 1 error">…</button>
900
+ <section class="cd-shell-legend" data-chrome="legend" aria-labelledby="…">…</section>
901
+ <div class="cd-shell-zoom" data-chrome="zoom" role="group" aria-label="Zoom">
902
+ <button data-zoom="out"> <button class="cd-shell-zoom-readout" data-zoom="actual"> <button data-zoom="in">
903
+ <span class="cd-shell-zoom-separator"> <button data-zoom="fit">
904
+ </div>
905
+ <div class="cd-shell-layer"> <!-- z 40: dropdown and tooltip -->
906
+ <div class="cd-shell-menu" id="cd-shell-menu-1" role="menu" aria-label="Menu">…</div>
907
+ <div class="cd-shell-tooltip" role="tooltip"><span class="cd-shell-tooltip-label">Zoom in</span><kbd class="cd-shell-kbd">⌘+</kbd></div>
908
+ </div>
909
+ </div>
910
+ ```
911
+
912
+ The DOM order is the tab order of §5.14 (menu trigger, palette, canvas, status box, hint, inspector,
913
+ pill, legend, zoom panel; the web host adds the primary action after the menu trigger, the
914
+ empty-state card after the hint and the legal line before the zoom panel, see
915
+ [Web host](#web-host-02)); nothing has a positive `tabindex`, and a renderer adds no tab stop of its
916
+ own around the canvas (no `tabindex` on `.djs-container`). Islands that are not shown are `hidden`.
917
+
918
+ | `data-chrome` | Element | Notes |
919
+ | ------------------ | ----------------------------- | ------------------------------------------ |
920
+ | `menu` | menu trigger | icon only, Lucide `menu` |
921
+ | `palette` | diagram-js `.djs-palette` | set by `shellModule` |
922
+ | `primary` | primary action (web) | only with `primary`, see Web host |
923
+ | `zoom` | zoom panel | `[−][NN%][+] \| [Fit]` |
924
+ | `problems` | problems pill | `hidden` at 0 |
925
+ | `legend` | legend card | only with `legend` |
926
+ | `inspector` | inspector `<aside>` | only with `inspector` |
927
+ | `status` | status box under the palette | `role="alert"` (danger) or `role="status"` |
928
+ | `legal` | legal line (web) | always in the web host |
929
+ | `toasts` | toast stack (web, embedded) | always present, no box while empty |
930
+ | `empty-state` | start screen card (web) | only with `emptyState` |
931
+ | `drop-zone` | drop overlay (web) | only with `dropZone`, during a file drag |
932
+ | `problems-popover` | problems list (web, embedded) | in the layer, opened by the pill |
933
+
934
+ | Class | Part |
935
+ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
936
+ | `cd-shell`, `cd-shell-document`, `cd-shell-canvas`, `cd-shell-layer` | root, own document, canvas slot, layer |
937
+ | `cd-shell-island`, `cd-shell-target` | glass island, 32px target in an island |
938
+ | `cd-shell-menu-trigger`, `cd-shell-menu`, `cd-shell-menu-item`, `cd-shell-menu-item-icon`, `cd-shell-menu-item-label`, `cd-shell-menu-item-end`, `cd-shell-menu-separator`, `cd-shell-menu-heading`, `cd-shell-menu-group` | menu |
939
+ | `cd-shell-menu-shortcut`, `cd-shell-menu-check` | shortcut text and check mark of an entry |
940
+ | `cd-shell-tooltip`, `cd-shell-tooltip-label`, `cd-shell-kbd` | tooltip and shortcut chip |
941
+ | `cd-shell-zoom`, `cd-shell-zoom-readout`, `cd-shell-zoom-separator` | zoom panel |
942
+ | `cd-shell-problems`, `cd-shell-problems-icon`, `cd-shell-problems-text` | pill |
943
+ | `cd-shell-status`, `cd-shell-status-icon`, `cd-shell-status-text` | status box (bar as `::before`) |
944
+ | `cd-shell-hint`, `cd-shell-hint-text`, `cd-shell-hint-action` | empty-file hint |
945
+ | `cd-shell-legend`, `cd-shell-legend-header`, `cd-shell-legend-title`, `cd-shell-legend-close`, `cd-shell-legend-body` | legend card |
946
+ | `cd-shell-legend-row`, `cd-shell-legend-preview`, `cd-shell-legend-separator` | rows the modeler renders into the body |
947
+ | `cd-shell-inspector` | inspector |
948
+ | `cd-shell-no-transition` | on `<html>` for one frame of a mode switch |
949
+ | `cd-shell-button` (`data-variant="primary"` / `"ghost"`) | buttons of the web chrome (0.2) |
950
+ | `cd-shell-primary`, `cd-shell-primary-label` | primary action (0.2) |
951
+ | `cd-shell-legal`, `cd-shell-legal-item` | legal line, a separator with its link (0.2) |
952
+ | `cd-shell-empty-state`, `cd-shell-empty-state-mark`, `cd-shell-empty-state-title`, `cd-shell-empty-state-text`, `cd-shell-empty-state-actions`, `cd-shell-empty-state-hint`, `cd-shell-empty-state-open-item`, `cd-shell-empty-state-open`, `cd-shell-empty-state-key` | empty-state card (0.2) |
953
+ | `cd-shell-drop-zone`, `cd-shell-drop-zone-chip` | drop zone (0.2) |
954
+ | `cd-shell-toasts`, `cd-shell-toast`, `cd-shell-toast-dot`, `cd-shell-toast-text`, `cd-shell-toast-action`, `cd-shell-toast-close` | toasts (0.2) |
955
+ | `cd-shell-live`, `cd-shell-live-region` (`data-live="polite"` / `"assertive"`) | the toasts' live regions (0.2) |
956
+ | `cd-shell-problems-popover`, `cd-shell-problems-popover-title`, `cd-shell-problems-list`, `cd-shell-problem`, `cd-shell-problem-icon`, `cd-shell-problem-message`, `cd-shell-problem-location` | problems popover (0.2) |
957
+
958
+ Internal attributes: `data-severity` on pill and status box, `data-danger` on a dangerous menu
959
+ entry, `data-menu-id` on entries and radio groups (the shell's Legend entry is `data-menu-id="legend"`,
960
+ so a modeler does not use the id `legend`), `data-menu-group` (`file`, `model`, `export`, `appearance`,
961
+ `help`) on entries and radio groups, which the kit's `readOnly` case reads, `data-menu-value` on radio options,
962
+ `data-zoom` on the zoom buttons.
963
+
964
+ Modelers style only their own content (legend rows, inspector fields); every other class is
965
+ internal to the shell CSS and may change in a minor release.
966
+
967
+ ### Geometry
968
+
969
+ Relative to the shell root's padding box (a border a foreign page gives the root does not count),
970
+ ±1px (§5.2 to §5.15). `ide` and `embedded` share the IDE column.
971
+
972
+ | Island | Box |
973
+ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
974
+ | `menu` | 40 × 40 at `top: 12px; left: 12px` |
975
+ | `palette` | 40 high at `top: 12px`, horizontally centred; second row `top: 64px`, at most `100cqw − 24px` wide |
976
+ | `zoom` | 173 × 40 (56px readout) at `right: 12px; bottom: 12px` |
977
+ | `problems` | 40 high at `left: 12px; bottom: 12px` (web `40px`) |
978
+ | `legend` | `left: 12px`, `bottom: 12px` without pill, `60px` with pill (web `40px`, `88px`); up to 440px width at least `60px` |
979
+ | `inspector` | 288 wide at `top: 64px; right: 12px`; up to 760px width a bottom sheet with 12px left, right and bottom |
980
+ | `status` | centred at `top: 64px` (second row `116px`); with an open side inspector centred in the strip left of it; 1px border and 4px bar in the status colour |
981
+ | dropdown | `top: 60px; left: 12px`, at least 248px wide |
982
+ | tooltip | top row `top: 60px`, second row `top: 112px`, bottom row `bottom: 60px` |
983
+
984
+ The web column adds (see [Web host](#web-host-02)):
985
+
986
+ | Island | Box (web host) |
987
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
988
+ | `primary` | 40 high at `top: 12px; right: 12px`; 40 wide while `data-primary="collapsed"`, else icon, gap and label with padding `0 16px` |
989
+ | `legal` | 20 high at `left: 12px; bottom: 12px`, at most `100cqw − 24px` wide; wrapped 20 per line, the stack above rising with it |
990
+ | `problems` | `left: 12px; bottom: 40px` (0.1) |
991
+ | `legend` | `bottom: 40px`, with pill `88px` (0.1); up to 440px width at least `60px`, `88px` with `data-zoom-row="raised"` |
992
+ | `zoom` | `right: 12px; bottom: 12px`; `bottom: 40px` with `data-zoom-row="raised"` |
993
+ | `problems-popover` | `left: 12px`, its bottom 8px above the pill's top (`bottom: 88px`), `min(320px, 100cqw − 24px)` wide, at most 320 high |
994
+ | `toasts` | centred, `min(480px, 100cqw − 24px)` wide, `bottom: toastStackBottom(…)` (12px unless its widest card meets the bottom row) |
995
+ | `empty-state` | centred in the container (`centerX`, `centerY`), `min(400px, 100cqw − 24px)` wide |
996
+ | `drop-zone` | `inset: 12px` while a file drag is on the page |
997
+
998
+ Layers (§5.13): the canvas slot is layer 0 with `isolation: isolate` (palette `z-index: 110` above
999
+ context pad and label editor at 100); islands `--cd-shell-ebene-inseln` (20, the legal line and the
1000
+ empty-state card included), inspector 25, menu trigger and primary action 30, layer 40 (dropdown,
1001
+ tooltip, problems popover, drop zone), modeler dialogs 50, toasts 60 (in the web host a manual
1002
+ popover in the top layer, see [Toasts](#toasts-8-9)). The 50 holds only outside the top layer; a `showModal()`
1003
+ `<dialog>` or a popover-API element lies above everything (see [Modeler dialogs](#modeler-dialogs)).
1004
+
1005
+ Inside the canvas slot, persistent overlays a modeler adds (element toolbars, its own context pad,
1006
+ the label editor) stay at `z-index` 100 or below, under the palette at 110. Transient popups the user
1007
+ opens from them (diagram-js's popup menu at 200, a colour picker) may cover the palette while they are
1008
+ open. All of them stay inside `.djs-container`, so they never cover an island. This is the shell's
1009
+ rule of guide §5.13 (design system 1.13.0).
1010
+
1011
+ ## Web host (0.2)
1012
+
1013
+ `host: "web"` is a Miragon web app that owns its page: the web column of guide §5.2. 0.1 covered its
1014
+ keys (the chrome keys prevent the browser's page zoom), fit insets and the pill and legend offsets;
1015
+ 0.2 renders the rest of the column. Everything in this section is read in the `web` host only and
1016
+ ignored by `ide` and `embedded` hosts, except toasts and the problems popover, which an embedded
1017
+ modeler shows as well (§5.2: until the contract has a channel for messages to the host).
1018
+ `mountVsCodeShell` does not accept these options at all; the IDE host is unchanged.
1019
+
1020
+ | Anchor (§5.2) | Web app | Option → handle | `data-chrome` |
1021
+ | ------------- | -------------------------------------------------------- | --------------------------------------- | ------------------------------------------------- |
1022
+ | top left | menu trigger: File, Model, Export, Appearance, Help | `file`, `menu`, `themes` + `appearance` | `menu` |
1023
+ | top centre | palette | `shellModule` | `palette` |
1024
+ | top right | primary action, label collapses first (§5.15) | `primary` → `shell.primary` | `primary` |
1025
+ | bottom left | legal line; above it the pill (with its list) and legend | `legal`, `problems`, `legend` | `legal`, `problems`, `problems-popover`, `legend` |
1026
+ | bottom right | zoom panel, raised above the legal line where they meet | — | `zoom` |
1027
+ | bottom centre | toasts | `shell.toast()` | `toasts` |
1028
+ | centre | empty-state card on a fresh start | `emptyState` → `shell.setEmptyState()` | `empty-state` |
1029
+ | whole page | drop zone for files | `dropZone` | `drop-zone` |
1030
+
1031
+ ```html
1032
+ <html lang="en" class="cd-shell-document" data-cd-theme="light">
1033
+ <!-- static, see Mode -->
1034
+ <div
1035
+ class="cd-shell"
1036
+ data-cd-shell-host="web"
1037
+ data-palette="top"
1038
+ data-primary="collapsed"
1039
+ data-zoom-row="raised"
1040
+ data-empty-state="shown"
1041
+ data-drop="over|forbidden"
1042
+ data-problems="shown"
1043
+ >
1044
+ <!-- state attributes only while true -->
1045
+ <button class="cd-shell-menu-trigger" data-chrome="menu" …>…</button>
1046
+ <button
1047
+ class="cd-shell-primary cd-shell-button"
1048
+ data-variant="primary"
1049
+ data-chrome="primary"
1050
+ aria-label="Share"
1051
+ >
1052
+ <svg … /><span class="cd-shell-primary-label">Share</span>
1053
+ </button>
1054
+ <div class="cd-shell-canvas" role="main" [inert]>…</div>
1055
+ <!-- the page's main landmark; inert behind the start screen -->
1056
+ <div class="cd-shell-status" data-chrome="status" …>…</div>
1057
+ <div class="cd-shell-hint">…</div>
1058
+ <section
1059
+ class="cd-shell-empty-state"
1060
+ data-chrome="empty-state"
1061
+ aria-labelledby="cd-shell-empty-state-title-1"
1062
+ >
1063
+ …
1064
+ </section>
1065
+ <aside class="cd-shell-inspector" data-chrome="inspector" …>…</aside>
1066
+ <button
1067
+ class="cd-shell-problems"
1068
+ data-chrome="problems"
1069
+ aria-haspopup="dialog"
1070
+ aria-expanded="false"
1071
+ aria-controls="cd-shell-problems-1"
1072
+ …
1073
+ >
1074
+ …
1075
+ </button>
1076
+ <section class="cd-shell-legend" data-chrome="legend" …>…</section>
1077
+ <nav class="cd-shell-legal" data-chrome="legal" aria-label="Legal">…</nav>
1078
+ <div class="cd-shell-zoom" data-chrome="zoom" …>…</div>
1079
+ <div class="cd-shell-layer">
1080
+ <!-- z 40 -->
1081
+ <div class="cd-shell-menu" role="menu" …>…</div>
1082
+ <div class="cd-shell-tooltip" role="tooltip">…</div>
1083
+ <div
1084
+ class="cd-shell-problems-popover"
1085
+ data-chrome="problems-popover"
1086
+ role="dialog"
1087
+ id="cd-shell-problems-1"
1088
+ aria-labelledby="cd-shell-problems-title-1"
1089
+ hidden
1090
+ >
1091
+ …
1092
+ </div>
1093
+ <div class="cd-shell-drop-zone" data-chrome="drop-zone" aria-hidden="true" hidden>…</div>
1094
+ </div>
1095
+ <div class="cd-shell-live">
1096
+ <!-- visually hidden, always rendered: the toasts' live regions -->
1097
+ <div class="cd-shell-live-region" role="status" aria-live="polite" aria-atomic="true"></div>
1098
+ <div class="cd-shell-live-region" role="alert" aria-live="assertive" aria-atomic="true"></div>
1099
+ </div>
1100
+ <div class="cd-shell-toasts" data-chrome="toasts" popover="manual">…</div>
1101
+ <!-- z 60; embedded: no popover -->
1102
+ </div>
1103
+ </html>
1104
+ ```
1105
+
1106
+ Tab order (§5.14): menu trigger, primary action, palette, canvas, status box, hint, empty-state card,
1107
+ inspector, pill, legend, legal line, zoom panel, then the toasts' buttons. Each 0.2 island has its
1108
+ `data-chrome` so the kit measures it; ids count on the page-global mount counter as in 0.1. The
1109
+ canvas slot is the page's `main` landmark (`role="main"`, unless it already has a role; the web host
1110
+ only: a foreign page has its own).
1111
+
1112
+ ### Primary action (§5.7, §5.15)
1113
+
1114
+ ```ts
1115
+ interface PrimaryActionOptions {
1116
+ label?: string; // default messages.share ("Share"); also the aria-label
1117
+ icon?: ShellIconName; // default "share-2"
1118
+ onSelect(): void;
1119
+ disabled?: () => boolean; // read on mount and on primary.refresh(); aria-disabled, stays focusable
1120
+ }
1121
+ interface PrimaryActionHandle {
1122
+ readonly element: HTMLButtonElement;
1123
+ isCollapsed(): boolean; // icon only right now
1124
+ refresh(): void;
1125
+ }
1126
+
1127
+ // ./core
1128
+ interface PrimaryWidths {
1129
+ withLabel: number;
1130
+ iconOnly: number; // 40
1131
+ }
1132
+ interface TopRowInput {
1133
+ containerWidth: number;
1134
+ paletteWidth: number;
1135
+ menuRight: number;
1136
+ primary?: PrimaryWidths;
1137
+ }
1138
+ interface TopRow {
1139
+ paletteRow: PaletteRow;
1140
+ primaryLabel: "shown" | "collapsed";
1141
+ }
1142
+ function decideTopRow(input: TopRowInput): TopRow;
1143
+ ```
1144
+
1145
+ - A blue primary button (§8, [Buttons](#buttons)) at `top: 12px; right: 12px`, 40px high, layer 30
1146
+ (`--cd-shell-ebene-reihe`), padding `0 16px`, Lucide icon 16px, 8px gap, label 13px/600. Hover and
1147
+ active `--cd-blau-link`, `shadow-2`, lift `translateY(-1px)`; disabled opaque and neutral, no lift.
1148
+ - **Label collapse** (§5.15: "Wird es oben eng, verliert in der Web-App zuerst die Primäraktion ihr
1149
+ Label, erst danach rutscht die Palette"): the layout pass (root and palette resize) runs
1150
+ `decideTopRow` with the measured widths. With the label the palette must keep 16px to both the
1151
+ menu trigger and the primary action; failing that, the icon-only width is tried; failing that, the
1152
+ palette moves to the second row and the label comes back if it fits next to the menu trigger.
1153
+ Collapsed: 40 × 40, the label not rendered, `aria-label` unchanged, the chrome tooltip shows the
1154
+ label (no `aria-describedby`, it repeats the name); the root carries `data-primary="collapsed"`.
1155
+ The labelled width is measured afresh while the label shows (a late web font counts) and, while
1156
+ collapsed, in one synchronous layout with the label that is never painted.
1157
+ - `disabled()` maps to `aria-disabled` (read on mount and on `primary.refresh()`); a click on a
1158
+ disabled primary action does nothing.
1159
+ - One primary action in 0.2; the further actions "links daneben mit 8px Lücke" (§5.7) are a later
1160
+ candidate. Hidden on the start screen, never locked by `setReadOnly`.
1161
+
1162
+ ### Legal line (§5.10, §2)
1163
+
1164
+ ```ts
1165
+ interface LegalLink {
1166
+ label: string;
1167
+ href: string;
1168
+ lang?: string;
1169
+ }
1170
+ interface LegalLineOptions {
1171
+ links?: readonly LegalLink[]; // after Impressum and Datenschutz, e.g. GitHub
1172
+ year?: number; // default the current year at mount
1173
+ }
1174
+ ```
1175
+
1176
+ ```html
1177
+ <nav class="cd-shell-legal" data-chrome="legal" aria-label="Legal">
1178
+ <a href="https://www.miragon.io/" … aria-describedby="cd-shell-legal-1-new-tab">© 2026 Miragon GmbH</a>
1179
+ <span class="cd-shell-legal-item">
1180
+ <span aria-hidden="true">·</span>
1181
+ <a href="https://www.miragon.io/impressum/" lang="de" target="_blank" rel="noopener noreferrer" …
1182
+ >Impressum</a
1183
+ >
1184
+ </span>
1185
+ <span class="cd-shell-legal-item">
1186
+ <span aria-hidden="true">·</span>
1187
+ <a href="https://www.miragon.io/datenschutz/" lang="de" …>Datenschutz</a>
1188
+ </span>
1189
+ <span class="cd-shell-legal-item">
1190
+ <span aria-hidden="true">·</span>
1191
+ <a href="https://github.com/Miragon/event-storming-modeler" …>GitHub</a>
1192
+ </span>
1193
+ <span id="cd-shell-legal-1-new-tab" hidden>Opens in a new tab</span>
1194
+ </nav>
1195
+ ```
1196
+
1197
+ - Always rendered in the web host, also on the start screen ("die Zeile Rechtliches, die eine
1198
+ öffentliche Seite immer zeigt", §8). Copyright, URLs and the two German labels are those of the
1199
+ corporate-identity docs-site kit (`miragon/legal.ts`); `messages.copyright` and `messages.legal`
1200
+ translate, the URLs and "Impressum"/"Datenschutz" never do.
1201
+ - One line, 13px, line height 20px, at `left: 12px; bottom: 12px`, glass (§5.3) without border and
1202
+ shadow, padding `0 8px`, radius `sm`, links `--cd-ui-link` (underlined on hover and focus, always
1203
+ in high contrast and forced colours), separators `--cd-ui-text-leise`, layer 20. Pill and legend
1204
+ stand above it (0.1: pill `bottom: 40px`, legend 40/88px). Every link opens a new tab and says so
1205
+ to screen readers through `aria-describedby` (`messages.opensInNewTab`, "Opens in a new tab").
1206
+ - **Narrow containers.** The line is at most `100cqw − 24px` wide; where it does not fit (with a
1207
+ GitHub link it is about 391px wide, so below about 415px) it wraps between its entries, each
1208
+ separator staying with the link after it, and grows upwards from `bottom: 12px`. Nothing is cut
1209
+ off or scrolled away (Datenschutz must stay reachable; WCAG 1.4.10 at 320 CSS px). The shell
1210
+ measures the line's height in whole lines (`legalLineHeight`) into `--modeler-shell-legal-height`
1211
+ on the root, and everything stacked on it rises with it: the pill and the problems popover, the
1212
+ legend, the raised zoom panel and the fit's bottom (`FitLayout.legalHeight`; 112 instead of 92 on
1213
+ two lines).
1214
+ - **Bottom-row collision.** In a narrow container the legal line and the zoom panel meet:
1215
+ `zoomRowRaised` (`./core`) is true when the zoom panel at `right: 12px` would keep less than 8px to
1216
+ the legal line's measured right edge. The root then carries `data-zoom-row="raised"`, the zoom
1217
+ panel stands above the legal line at `bottom: 40px` like the pill (8px above a wrapped line), the
1218
+ fit keeps 92px at the bottom (`FitLayout.zoomRaised`), and up to 440px the legend's lowest position
1219
+ is 88px. §5.10 and §5.15 do not settle this case; it is the shell's rule until the guide does.
1220
+
1221
+ ```ts
1222
+ // ./core
1223
+ const LEGAL_LINE_HEIGHT = 20; // one line of the legal line
1224
+ function legalLineHeight(measured: number | undefined): number; // whole lines, at least one: 20, 40, …
1225
+ interface BottomRowInput {
1226
+ containerWidth: number;
1227
+ legalRight?: number; // measured, relative to the root; absent while not shown
1228
+ zoomWidth?: number; // measured; absent while not shown
1229
+ }
1230
+ function zoomRowRaised(input: BottomRowInput): boolean;
1231
+ ```
1232
+
1233
+ ### File and Appearance groups (§5.4, §13.3)
1234
+
1235
+ ```ts
1236
+ interface FileMenuAction {
1237
+ onSelect(): void;
1238
+ shortcut?: string; // chip, e.g. commandKeyLabel("O", platform, keyNames); the web app binds the key itself
1239
+ dialog?: boolean; // kind "dialog": it opens an in-page dialog, not the browser's file picker
1240
+ disabled?: () => boolean;
1241
+ }
1242
+ interface FileMenuOptions {
1243
+ newDiagram?: FileMenuAction;
1244
+ open?: FileMenuAction;
1245
+ showExample?: FileMenuAction;
1246
+ import?: FileMenuAction;
1247
+ }
1248
+ ```
1249
+
1250
+ | Option | Entry (`messages`) | Icon | `data-menu-id` |
1251
+ | ------------- | ------------------ | ------------- | -------------- |
1252
+ | `newDiagram` | "New diagram" | `file-plus` | `new-diagram` |
1253
+ | `open` | "Open…" | `folder-open` | `open` |
1254
+ | `showExample` | "Show example" | none (§5.14) | `show-example` |
1255
+ | `import` | "Import…" | `import` | `import` |
1256
+
1257
+ - The File group comes first, the shell builds it in this order from what the modeler gives; labels
1258
+ and icons are the shared terms of §2 and the common set of §5.14, not the modeler's. Entries are
1259
+ `action`s (`dialog` with `FileMenuAction.dialog`), never locked on a read-only canvas. `open` also
1260
+ becomes the start screen's text link (see [Empty state](#empty-state-8)).
1261
+ - The chip of a Cmd/Ctrl key the app binds itself comes from `./core`, so it reads the same in every
1262
+ modeler and on every platform:
1263
+
1264
+ ```ts
1265
+ function commandKeyLabel(key: string, platform: Platform, keyNames: { ctrl: string }): string;
1266
+ // commandKeyLabel("O", "mac", messages.keyNames) → "⌘O"; "other" → "Ctrl+O" (German "Strg+O")
1267
+ ```
1268
+
1269
+ - The **Appearance** group is a radio group (`data-menu-id="appearance"`, heading and `aria-label`
1270
+ "Appearance", options `system`, `light`, `dark`: "System", "Light", "Dark") between Export and
1271
+ Help. The shell renders it only when `themes` includes `"dark"`; a light-only modeler has no
1272
+ Appearance group (§13.3 "Nur Hell, bis der Modeler Dunkel kann"). Its value is
1273
+ `shell.appearance.choice()`, selecting runs `shell.appearance.setChoice()` and keeps the menu open.
1274
+ - Neither group exists in the IDE host (native, §13.5) or the embedded host (§5.2).
1275
+
1276
+ ### Mode (§13.3)
1277
+
1278
+ ```ts
1279
+ // ./core
1280
+ type AppearanceChoice = "system" | "light" | "dark";
1281
+ const APPEARANCE_STORAGE_KEY = "miragon.darstellung";
1282
+ interface ThemeColors {
1283
+ light: string; // the app's theme-color: fest["--cd-blau"] of the vendored JSON
1284
+ dark: string; // dunkel["--cd-ui-grund"]
1285
+ }
1286
+ function parseAppearanceChoice(stored: unknown): AppearanceChoice; // "light" | "dark", else "system"
1287
+ interface DocumentMode {
1288
+ theme: "system" | Theme; // data-cd-theme on <html>
1289
+ colorScheme: "light dark" | Theme; // <meta name="color-scheme">
1290
+ themeColor: ThemeColors; // the two theme-color metas
1291
+ }
1292
+ function documentMode(choice: AppearanceChoice, themes: readonly Theme[], colors: ThemeColors): DocumentMode;
1293
+ interface WebModeScriptOptions {
1294
+ themes: readonly Theme[];
1295
+ themeColors: ThemeColors;
1296
+ }
1297
+ function webModeScript(options: WebModeScriptOptions): string; // body of the inline head <script>
1298
+ function webHeadMarkup(options: WebModeScriptOptions): string; // metas + <script>, for index.html
1299
+
1300
+ // .
1301
+ interface StorageLike {
1302
+ getItem(key: string): string | null;
1303
+ setItem(key: string, value: string): void;
1304
+ removeItem(key: string): void;
1305
+ }
1306
+ interface WebAppearanceOptions {
1307
+ themeColors: ThemeColors;
1308
+ storage?: StorageLike; // default globalThis.localStorage
1309
+ }
1310
+ interface AppearanceHandle {
1311
+ choice(): AppearanceChoice;
1312
+ setChoice(choice: AppearanceChoice): void;
1313
+ }
1314
+ ```
1315
+
1316
+ - **Before the first paint** the page itself decides (§13.3, §13.5: one place, the host). A
1317
+ light-only modeler writes `<html lang="en" data-cd-theme="light">` statically, one
1318
+ `<meta name="theme-color">` without `media`, and of the guide's script only the contrast line;
1319
+ `webHeadMarkup({ themes: ["light"], themeColors })` returns exactly that. A dark-capable modeler
1320
+ writes `data-cd-theme="system"` statically and the guide's whole script with both `theme-color`s:
1321
+ `webHeadMarkup({ themes: ["light", "dark"], themeColors })`. The markup goes into `<head>` before
1322
+ the first stylesheet, for example through a Vite `transformIndexHtml` hook:
1323
+
1324
+ ```ts
1325
+ // apps/webapp/vite.config.ts
1326
+ import { webHeadMarkup } from "@miragon/modeler-shell/core";
1327
+ import tokens from "./src/theme/cd-tokens.generated.json" with { type: "json" };
1328
+ import { THEMES } from "./src/themes";
1329
+
1330
+ const themeColors = { light: tokens.fest["--cd-blau"], dark: tokens.dunkel["--cd-ui-grund"] };
1331
+ const modePlugin = {
1332
+ name: "miragon-mode",
1333
+ transformIndexHtml: (html: string) =>
1334
+ html.replace("<!-- miragon-mode -->", webHeadMarkup({ themes: THEMES, themeColors })),
1335
+ };
1336
+ ```
1337
+
1338
+ - **At runtime** `mountShell` (web host) follows `(prefers-contrast: more)` changes on `<html>`
1339
+ (`data-cd-kontrast="hoch"`), for every modeler. With `"dark"` in `themes` it needs
1340
+ `appearance.themeColors` (it throws without them, before anything mounts) and offers
1341
+ `shell.appearance`: `setChoice` stores the choice (`"system"` removes the entry; a blocked storage
1342
+ is caught and the choice applies for the page), applies `documentMode` on `<html data-cd-theme>`,
1343
+ `color-scheme` and both `theme-color` metas (created when missing) in one task with
1344
+ `cd-shell-no-transition` for one frame (§7), and a `storage` event from another tab applies the
1345
+ same way (a cleared storage means `system`). `choice()` is read from storage once at mount and then
1346
+ kept in memory, so a choice a blocked storage refused still holds for the page; mounting does not
1347
+ re-apply the mode, the head script owns the first paint. Nothing refits (§5.9). Light-only:
1348
+ `shell.appearance` is `undefined`.
1349
+ - `webModeScript` is the guide's script as ES5 in an immediately invoked function; it reads the
1350
+ storage inside `try`/`catch`, tolerates a missing `color-scheme` meta and JSON-escapes every
1351
+ literal (with `<`, U+2028 and U+2029), so no value ends the `<script>` element.
1352
+ - The kit's `appearance` case drives the web host through `PageLike.emulateMedia` and the stored
1353
+ choice (see [`./testing`](#testing)).
1354
+
1355
+ ### Problems popover (§5.10)
1356
+
1357
+ ```ts
1358
+ // ./core
1359
+ type ProblemSeverity = "error" | "warning";
1360
+ interface ProblemItem {
1361
+ severity: ProblemSeverity;
1362
+ message: string; // 13px --cd-ui-text
1363
+ location?: string; // "Line 12", "“Order Placed”": 13px --cd-ui-text-leise below the message
1364
+ elementId?: string; // the element a click on the row selects
1365
+ }
1366
+ function problemSummary(items: readonly ProblemItem[]): {
1367
+ count: number;
1368
+ errors: number;
1369
+ items: readonly ProblemItem[];
1370
+ };
1371
+ ```
1372
+
1373
+ - In the web host the pill (0.1) is the trigger of a non-modal dialog: `aria-haspopup="dialog"`,
1374
+ `aria-expanded`, `aria-controls` on the pill; the popover `role="dialog"` without `aria-modal`,
1375
+ `aria-labelledby` its title "Problems" (13px/600). An embedded shell shows it too unless the
1376
+ modeler gives `problems.onActivate` (an embedding host with a list of its own); the IDE host never.
1377
+ - Above the pill, left-aligned with it: `left: 12px`, its bottom 8px above the pill's top, opaque
1378
+ (§5.3), 1px `--cd-ui-linie`, radius `md`, `shadow-3`, `min(320px, 100cqw − 24px)` wide, at most
1379
+ 320px high with a scrolling list, layer 40, pop-in 150ms (§5.14). Rows at least 36px: the pill's
1380
+ icon 16px (`circle-x` in `--cd-ui-danger`, `triangle-alert` in `--cd-ui-warning`), the message,
1381
+ below it the location.
1382
+ - A row with an `elementId` is a button: a click closes the popover, puts the focus on the canvas and
1383
+ then calls `problems.onSelectItem(item)`, where the modeler selects the element. Rows without one
1384
+ are text that still takes the focus (`<div tabindex="-1">`), so the keyboard reads every row of a
1385
+ list longer than its 320px (WCAG 2.1.1); Enter and Space act on row buttons only. The icon carries
1386
+ the severity's name for screen readers (`role="img"`, `messages.problemSeverity`), so the shape and
1387
+ colour are not its only signal (§12).
1388
+ - Opening focuses the first row (else the popover, with an inset focus ring); ↑/↓/Home/End move
1389
+ between all rows and scroll the focused one into the list (only the list scrolls, never the
1390
+ root). Escape and Tab out close it with the focus back on the pill (Tab from the last row then
1391
+ moves on from the pill, Shift+Tab from the first stays on it); an outside `pointerdown` and the
1392
+ window's blur close it without moving the focus, as for the menu. Not modal: the zoom keys keep
1393
+ working. Like the menu it stops Enter, Space, Delete, Backspace and printable keys, so a modeler's
1394
+ keyboard on `document` never acts on them.
1395
+ - `setProblems` re-renders the rows while it is open and keeps the focused problem; `count` 0 hides
1396
+ pill and popover, and a focus in the list goes to the canvas (else the menu trigger).
1397
+
1398
+ ### Toasts (§8, §9)
1399
+
1400
+ ```ts
1401
+ // ./core
1402
+ type ToastStatus = "success" | "info" | "warning" | "danger";
1403
+ interface ToastAction {
1404
+ label: string;
1405
+ onSelect(): void; // runs after the toast closed
1406
+ }
1407
+ interface ToastRequest {
1408
+ status: ToastStatus;
1409
+ text: string;
1410
+ action?: ToastAction;
1411
+ durationMs?: number; // only to lengthen; ignored with an action
1412
+ }
1413
+ const MAX_TOASTS = 3;
1414
+ const TOAST_DURATION_MS: Readonly<Record<ToastStatus, number>>; // success, info 2800; warning, danger 5000
1415
+ const TOAST_MAX_WIDTH = 480;
1416
+ function toastLifetime(request: ToastRequest): number | undefined; // undefined with an action
1417
+ interface ToastSlot {
1418
+ id: number;
1419
+ hasAction: boolean;
1420
+ engaged: boolean; // hovered or focused
1421
+ }
1422
+ function toastToEvict(stack: readonly ToastSlot[]): number | undefined; // oldest first
1423
+ interface BottomObstacle {
1424
+ left: number;
1425
+ right: number;
1426
+ top: number; // px from the container's bottom to its top edge
1427
+ }
1428
+ interface ToastStackInput {
1429
+ containerWidth: number;
1430
+ obstacles: readonly BottomObstacle[];
1431
+ cardWidth?: number; // the widest card's measured width; absent = the whole stack width
1432
+ }
1433
+ function toastStackWidth(containerWidth: number): number; // min(480, width − 24)
1434
+ function toastStackBottom(input: ToastStackInput): number;
1435
+
1436
+ // .
1437
+ interface ToastHandle {
1438
+ dismiss(): void; // idempotent
1439
+ }
1440
+ ```
1441
+
1442
+ - `shell.toast(request)` in the web and embedded hosts; in the IDE host it shows nothing and returns
1443
+ an inert handle (§13.5: the extension shows the host's notification). An import that runs by itself
1444
+ never shows a toast (§9); that is the modeler's discipline, the shell does not know imports.
1445
+ - **Card** (§8): opaque `--cd-ui-flaeche-hoch`, 1px `--cd-ui-linie`, a straight 4px bar and an 8px
1446
+ dot in `--cd-ui-<status>` (a dot, not an icon: the common set has no success or info icon, §5.14),
1447
+ text 13px/1.5 `--cd-ui-text`, radius `md`, `shadow-3`, at least 40px high, padding `4px 4px 4px
1448
+ 16px`, 8px gap; the action as a 32px ghost button, then Close as a 32 × 32 ghost icon button
1449
+ (Lucide `x`, `aria-label` "Close", `aria-describedby` the toast's text, so three Close buttons
1450
+ are told apart). Slide and fade in from `translateY(8px)` over 250ms, out the same way; none under
1451
+ reduced motion. A card is as wide as its content, at most the stack's width, as in the Wardley and
1452
+ Team Topologies web apps (§8's `min(480px, 100% − 24px)` read as the maximum).
1453
+ - **Screen readers** (§8, §12): two live regions in the root, rendered from mount on and visually
1454
+ hidden (`.cd-shell-live`): `role="status"` with `aria-live="polite"`, and `role="alert"`. A toast's
1455
+ text goes into the alert region for danger, else into the status region, 100ms after the region
1456
+ was emptied (`TOAST_ANNOUNCE_DELAY_MS`), so a repeated toast ("Link copied" twice) is announced
1457
+ again; the region empties when its toast closes. A live region created together with its text is
1458
+ often not announced, which is why the regions never come and go with the stack. The cards carry no
1459
+ live role, so nothing is read twice.
1460
+ - **Stack**: centred, `toastStackWidth`, column with 8px gap, the newest at the bottom, at most three;
1461
+ a fourth evicts per `toastToEvict` (the oldest without action that is not engaged first). A
1462
+ request equal to a shown toast (status and text) restarts that toast's time instead of stacking,
1463
+ and takes the newer request's action and lifetime (a repeated "Board replaced · Undo" undoes the
1464
+ latest replacement). Layer 60; in the web host the stack is a `popover="manual"`, shown with the
1465
+ first toast and shown again with a toast that arrives while a modal dialog is open, so it stays
1466
+ above a `showModal()` dialog in the top layer (visible, but inert like everything outside a modal
1467
+ until the dialog closes); without a modal it is not hidden and re-shown, which would rebuild it for
1468
+ screen readers. In the embedded host it is absolute inside the root and never leaves its box.
1469
+ - **Time**: `toastLifetime` (about 2.8s, errors about 5s, §8; a warning, a sentence to act on, gets
1470
+ the error's 5s), paused while hovered or focused and resumed with at least 1.5s left; a toast with
1471
+ an action never closes by itself. A closing toast that held the focus hands it to the next
1472
+ toast's Close button, else to the canvas; on the start screen to its "New diagram" (the canvas and
1473
+ the menu trigger cannot take it there), never silently to `<body>`.
1474
+ - **Bottom row**: the stack sits at `bottom: 12px` (§8) unless the centred column of its widest card
1475
+ comes closer than 8px horizontally to a visible zoom panel, legal line or pill; then it stands 8px
1476
+ above the highest of those (`toastStackBottom` with `cardWidth`, recomputed in the layout pass,
1477
+ whenever a toast comes or goes, and whenever the pill, the inspector or the start screen changes;
1478
+ a bottom sheet hides zoom panel and pill). At 1280px with a GitHub link the legal line ends 7.5px
1479
+ before the 480px column, so "Link copied" (146px) stays at 12 while a full-width card rises to 40. §8 does not settle this case; it is the shell's rule until the guide does.
1480
+
1481
+ ### Empty state (§8)
1482
+
1483
+ ```ts
1484
+ interface EmptyStateOptions {
1485
+ title: string; // the modeler's name: "Event Storming Modeler"
1486
+ text: string; // one line of help: "Start a new diagram, or explore the example."
1487
+ komet: string; // URL of the vendored, unchanged miragon-komet-blau.svg (§2, §11)
1488
+ onNewDiagram(): void; // "New diagram", blue primary button; the shell hides the card first
1489
+ onShowExample?(): void; // "Show example", ghost button; the shell hides the card first
1490
+ dropHint?: string; // shown with a dropZone only; default messages.dropHint
1491
+ }
1492
+ ```
1493
+
1494
+ - `shell.setEmptyState(true)` shows the card; the modeler calls it on a fresh start (no document in
1495
+ the URL, nothing restored). While it shows (`data-empty-state="shown"` on the root) the work chrome
1496
+ is hidden (`visibility: hidden`): menu trigger, primary action, palette, zoom panel, pill, legend,
1497
+ status box, hint and inspector; the canvas slot is `inert`, menu and popover close. The chrome keys
1498
+ (Cmd/Ctrl + `+`/`−`/`0`, Shift+1) go to the browser unprevented, which zooms the page: there is no
1499
+ diagram to zoom, and a low-vision user's Ctrl+ must not be swallowed (WCAG 1.4.4); the shell's
1500
+ diagram-js guard returns `false` for them meanwhile, so diagram-js zooms nothing either. Visible:
1501
+ the card, the legal line, toasts and the drop zone (§8: "sichtbar sind nur die Karte und die Zeile
1502
+ Rechtliches").
1503
+ - The card: centred in the container, `min(400px, 100cqw − 24px)` wide, glass (§5.3), `shadow-2`,
1504
+ radius `lg`, padding `32px 40px`, content centred with 16px gaps: the Komet at 64px (`alt=""`), the
1505
+ title as `<h1>` (21px/600), the help line (16px/1.6, `--cd-ui-text-leise`), the two 36px buttons
1506
+ side by side with 8px gap, primary first, then the drop hint (13px, `--cd-ui-text-leise`). Layer 20.
1507
+ In high contrast its border is `--cd-ui-linie` through the role like every other island (§5.3).
1508
+ - **Open on the start screen.** The start screen hides the menu, and a drop needs a pointer; so with
1509
+ `file.open` the hint ends in a text link "Open a file…" (`messages.openFileLink`, `--cd-ui-link`,
1510
+ `aria-keyshortcuts` and a Geist Mono chip from `file.open.shortcut`, the two never parted by a
1511
+ line break): "Or drop a .storm file here
1512
+ · Open a file… ⌘O". It is part of the hint, not a third action (§8 keeps exactly two buttons), and
1513
+ runs `file.open.onSelect` without leaving the start screen: the modeler ends it once the file has
1514
+ loaded.
1515
+ - Either button hides the card (`setEmptyState(false)`), moves the focus to the canvas `<svg>` and
1516
+ then runs its handler. `setEmptyState(true)` while the focus is in the work chrome or the canvas
1517
+ moves it to "New diagram"; a focus on the legal line or a toast stays. The Komet is not draggable
1518
+ (an image drag can carry a file and would open the drop zone). Any other start (Open…, a dropped
1519
+ file, a shared link, the first edit) is the modeler's to end with `setEmptyState(false)`; the card
1520
+ does not come back in the session.
1521
+ - The IDE and embedded hosts have no start screen; they use the empty-file hint (§13.5).
1522
+
1523
+ ### Drop zone (§9)
1524
+
1525
+ ```ts
1526
+ interface DropZoneOptions {
1527
+ accept: readonly string[]; // ".storm", "application/json", "text/*"; empty: any file
1528
+ multiple?: boolean; // default false
1529
+ onDrop(files: readonly File[]): void; // the accepted files
1530
+ onReject?(files: readonly File[]): void; // default: a danger toast messages.cannotOpenFile
1531
+ }
1532
+
1533
+ // ./core
1534
+ type DropForbiddenReason = "multiple" | "type" | "dialog";
1535
+ interface DragItemLike {
1536
+ kind: string;
1537
+ type: string;
1538
+ }
1539
+ interface DragContext {
1540
+ accept: readonly string[];
1541
+ multiple: boolean;
1542
+ dialogOpen: boolean;
1543
+ }
1544
+ type DragVerdict =
1545
+ { state: "none" } | { state: "over" } | { state: "forbidden"; reason: DropForbiddenReason };
1546
+ function dragVerdict(items: readonly DragItemLike[], context: DragContext): DragVerdict;
1547
+ function acceptsFile(file: { name: string; type: string }, accept: readonly string[]): boolean;
1548
+ ```
1549
+
1550
+ - Page-wide (`window` listeners) and for drags that carry files only, so palette drags and text
1551
+ dragged into the label editor stay alone; every file drag is `preventDefault`-ed, so the browser
1552
+ never navigates to the file. `dragVerdict` decides: a modal dialog open → forbidden (`dialog`);
1553
+ more than one file without `multiple` → forbidden (`multiple`); every file of a MIME type that none
1554
+ of `accept`'s MIME entries matches → forbidden (`type`; extensions are unreadable before the drop,
1555
+ so list the MIME types as well; an empty type never forbids); else over. Only items of kind
1556
+ `"file"` count. `accept` entries are trimmed and lower-cased (extensions match the name's suffix
1557
+ case-insensitively, MIME types exactly or as `type/*`), blank ones dropped; a list without a usable
1558
+ entry accepts every file, like `<input accept>`.
1559
+ - **Drag-over**: `data-drop="over"` on the root, the overlay at `inset: 12px`, fill `--cd-ui-hover`,
1560
+ 2px dashed `--cd-ui-interaktion`, radius `lg`, the chip "Drop file here" centred on
1561
+ `--cd-ui-flaeche-hoch`, `dropEffect = "copy"`. **Forbidden**: `data-drop="forbidden"`, fill
1562
+ `--cd-ui-danger-flaeche`, dashed `--cd-ui-danger`, the chip `messages.dropForbidden(reason)`,
1563
+ `dropEffect = "none"` (the cursor shows not allowed). Layer 40, `aria-hidden`.
1564
+ - **Drop**: `acceptsFile` splits the files; `onDrop(accepted)` when there are any, `onReject(rest)`
1565
+ for the others (default: one danger toast per file, "Couldn't open “map.png”. Drop a .storm,
1566
+ .json, or .txt file.", the extensions of `accept` joined by `Intl.ListFormat` in `lang`, its MIME
1567
+ entries where it lists no extension). A drop forbidden for `dialog` or `multiple` opens nothing and
1568
+ shows no toast. After a `type` verdict a browser fires no `drop` (`dropEffect = "none"`); a
1569
+ synthetic one is split by `acceptsFile` like any other. Whether the drop replaces the open document is the modeler's
1570
+ (see the usage below for an undo toast instead of `confirm()`).
1571
+
1572
+ ### Buttons
1573
+
1574
+ `src/dom/buttons.css` styles `.cd-shell-button` for the web chrome (§8): `data-variant="primary"`
1575
+ (`--cd-blau` with `--cd-weiss` text in both modes, `shadow-1`, hover and active `--cd-blau-link`,
1576
+ `shadow-2` and a 1px lift, no lift under reduced motion; disabled `--cd-ui-hover` with
1577
+ `--cd-ui-linie` border, muted text, no lift) and `data-variant="ghost"` (transparent, hover
1578
+ `--cd-ui-hover`); 36px high, padding `0 16px`,
1579
+ 13px/600, radius `md`; focus ring 2px `--cd-ui-fokus` with 2px offset (3px in high contrast, which
1580
+ also drops the shadow); under forced colours borders in `ButtonText`, rings in `Highlight`. It is
1581
+ the only stylesheet that reads fixed palette tokens, and only these three.
1582
+
1583
+ ## `./diagram-js`
1584
+
1585
+ ```ts
1586
+ // a Diagram, a renderer's Modeler, a didi injector
1587
+ interface InjectorLike {
1588
+ get<T>(name: string): T;
1589
+ }
1590
+ interface ModuleDeclarationLike {
1591
+ __init__?: string[];
1592
+ __depends__?: ModuleDeclarationLike[];
1593
+ [name: string]: unknown;
1594
+ }
1595
+ type DiagramEventListener = (event: never) => unknown;
1596
+ interface EventBusLike {
1597
+ on(events: string | string[], priority: number, listener: DiagramEventListener): void;
1598
+ on(events: string | string[], listener: DiagramEventListener): void;
1599
+ off(events: string | string[], listener?: DiagramEventListener): void;
1600
+ }
1601
+ interface DiagramViewbox extends ScaledViewbox {
1602
+ inner: Bounds;
1603
+ outer: Size;
1604
+ }
1605
+ interface CanvasLike {
1606
+ getContainer(): HTMLElement;
1607
+ getSize(): Size;
1608
+ viewbox(): DiagramViewbox;
1609
+ viewbox(box: Bounds): void;
1610
+ zoom(): number;
1611
+ zoom(scale: number, center?: Point): number;
1612
+ resized(): void;
1613
+ }
1614
+ interface ZoomScrollLike {
1615
+ stepZoom(delta: number, position?: Point): void;
1616
+ }
1617
+ interface KeyboardLike {
1618
+ addListener(priority: number, listener: (context: { keyEvent: KeyboardEvent }) => unknown): void;
1619
+ }
1620
+ interface PaletteLike {
1621
+ getEntries(): Record<
1622
+ string,
1623
+ { title?: string; shortcut?: string; ariaKeyShortcuts?: string; group?: string }
1624
+ >;
1625
+ }
1626
+
1627
+ const shellModule: ModuleDeclarationLike;
1628
+
1629
+ interface ConnectOptions {
1630
+ contentBounds(): Bounds | undefined; // tight, no padding: the fit insets are the only margin (§5.9)
1631
+ isEmpty?(): boolean; // §13.5 empty file (no element); default: the content bounds are empty
1632
+ prefersReducedMotion?(): boolean;
1633
+ }
1634
+ interface DiagramJsConnection {
1635
+ readonly viewport: Viewport;
1636
+ destroy(): void;
1637
+ }
1638
+ function connectDiagramJs(
1639
+ shell: ShellHandle,
1640
+ diagram: InjectorLike,
1641
+ options: ConnectOptions,
1642
+ ): DiagramJsConnection;
1643
+
1644
+ interface ViewportOptions {
1645
+ contentBounds(): Bounds | undefined;
1646
+ insets(): Insets;
1647
+ prefersReducedMotion?(): boolean;
1648
+ }
1649
+ interface Viewport extends ZoomTarget {
1650
+ fit(options?: { animate?: boolean }): void;
1651
+ freeRectCenter(): Point | undefined; // diagram coordinates; undefined at 0 × 0 and after destroy
1652
+ afterImport(): void;
1653
+ newDocument(): void; // 0.2, web app: the next afterImport() counts as the first again
1654
+ restore(viewbox: Bounds): void;
1655
+ getViewbox(): Bounds | undefined;
1656
+ isAutomatic(): boolean;
1657
+ getState(): ViewportState | undefined;
1658
+ setState(state: ViewportState): void;
1659
+ destroy(): void;
1660
+ }
1661
+ function createViewport(diagram: InjectorLike, options: ViewportOptions): Viewport;
1662
+ ```
1663
+
1664
+ **`shellModule`** goes into the modeler's modules after the renderer's own (`additionalModules`). It
1665
+ registers one internal didi service named `modelerShell` (a modeler does not reuse the name):
1666
+
1667
+ - `paletteTooltip: ["value", null]` replaces diagram-js's (and a renderer's) palette tooltip; the
1668
+ chrome tooltip takes over with `title` and `shortcut` from `palette.getEntries()`.
1669
+ - Palette skin: on `palette.create` it sets `data-chrome="palette"` and moves the container in front
1670
+ of the canvas `<svg>` (`canvas.getContainer()`'s child), so tab order puts the palette first. A
1671
+ renderer's export (`saveSVG`) must therefore take the container's own `<svg>` child, not the
1672
+ first `<svg>` it finds (`container.querySelector("svg")` now gets a palette icon), and strip the
1673
+ `tabindex`, `role="application"` and `aria-label` the live canvas carries from its copy. After
1674
+ every `palette.changed` it re-applies `role="toolbar"` and `aria-label` (`messages.tools`) on
1675
+ `.djs-palette-entries`, `role="button"` on every entry, `aria-keyshortcuts` from the entry's
1676
+ `ariaKeyShortcuts` or `ariaKeyShortcutsFromLabel(shortcut)` (§5.6; split the shortcut off the
1677
+ title: `title: "Domain event", shortcut: "E"`), roving `tabindex` (the last focused entry keeps the
1678
+ tab stop across rebuilds) and `aria-pressed` for the tool of the last
1679
+ `tool-manager.update` (group `tools`, `data-action="<tool>-tool"`), styled through
1680
+ `[aria-pressed="true"]`: diagram-js's `highlighted-entry` goes stale after a rebuild, and its
1681
+ `two-column` class is ignored. `separator` entries become vertical rules.
1682
+ - Palette keys, without modifiers: ← and → move and wrap, Home and End jump, Enter and Space trigger
1683
+ the entry once (repeats ignored). They are stopped at the palette, so a modeler's document listener
1684
+ and VS Code never see them; chrome keys still bubble to the shell.
1685
+ - Enter and Space trigger through a synthetic `click`, and that click is a contract: a `MouseEvent`
1686
+ with `detail === 0` (a pointer click has 1 or more), `clientX`/`clientY` at the entry's centre,
1687
+ bubbling and cancelable, dispatched on the entry. It goes through diagram-js's click delegate and
1688
+ `palette.trigger` like a pointer click, so under the read-only lock it is vetoed the same way. A
1689
+ provider tells the keyboard apart by `detail === 0` (see Palette keyboard creation below).
1690
+ - Read-only (§13.5): only `lasso-tool` and `hand-tool` stay active. Every other entry, also tools of
1691
+ group `tools` such as draw, space or connect, is vetoed on `palette.trigger` from the lock on (a
1692
+ vetoed `dragstart` is `preventDefault`-ed) and marked `aria-disabled` once the shell shows the
1693
+ read-only state; an entry's own `aria-disabled` comes back afterwards. Without an entry element
1694
+ in the event only group `tools` passes.
1695
+ - Keyboard: `keyboard.addListener(2000, …)` returns `true` for every `matchChromeKey` hit, so
1696
+ `KeyboardBindings` and the modeler's keyboard modules (priority 1000) never act on them; the
1697
+ shell's `keydown` listener executes them exactly once. It returns `false` (stops them without
1698
+ `preventDefault`) for a chrome key from a text field inside the `<svg>`, which stays the field's,
1699
+ for a chrome key while the web app's start screen shows (the browser zooms the page), and, while
1700
+ the attached shell's host is `ide`, for undo and redo (`isUndoRedoKey`): the canvas
1701
+ `commandStack` never takes them, VS Code undoes the document (§13.5). A modeler's own DOM undo
1702
+ binding must go in the IDE host as well; the kit's `keys` case checks it.
1703
+ - Keys the canvas uses beyond the chrome keys are the modeler's (§13.5). Cmd/Ctrl+A on the focused
1704
+ canvas selects every element, or is at least `preventDefault`-ed: diagram-js 15 binds no select
1705
+ all, and an unprevented key reaches VS Code, whose Select All then selects the chrome's text. The
1706
+ shell stops a prevented key in the IDE host; the kit's `keys` case presses Control+A and Meta+A on
1707
+ the canvas.
1708
+ - While a shell is attached, the canvas `<svg>` is `role="application"` with `aria-label`
1709
+ `messages.canvas` (WCAG 4.1.2; screen readers switch to focus mode, so single-key commands reach the
1710
+ canvas); a role or name the modeler set stays, and both are restored on detach.
1711
+ - It buffers the palette until `connectDiagramJs`, so the diagram may exist before the shell.
1712
+
1713
+ **`connectDiagramJs`** creates the viewport with `insets: shell.fitInsets`, binds it
1714
+ (`shell.setZoomTarget`), registers the palette and calls `canvas.resized()` (the canvas element has
1715
+ moved). After every `import.done` and `elements.changed` it calls `shell.setEmptyHint(isEmpty())`,
1716
+ so the first element the user creates on the canvas hides the hint (and its "Insert example").
1717
+ `isEmpty` defaults to empty content bounds; a notation whose bounds are never empty (Wardley's plot)
1718
+ passes its own. It throws when `shellModule` is missing. A later connection to another shell takes
1719
+ the palette over; `destroy()` (idempotent, also run on `diagram.destroy`) unregisters the palette,
1720
+ unbinds the zoom target and the hint sync and destroys the viewport, while the keyboard guard stays
1721
+ until `diagram.destroy`. `contentBounds` returns the tight bounds of the content, without a padding
1722
+ of its own (Event Storming: `boardBounds.contentBounds(0)`, not its default of 120px): the shell's fit
1723
+ insets are the only margin (§5.9), and a padding would add to them.
1724
+
1725
+ **`Viewport`** (§5.8, §5.9):
1726
+
1727
+ - `stepZoom` is `zoomScroll.stepZoom(±1)` (the wheel's raster, snapping to the nearest step);
1728
+ `zoomToActual` is `canvas.zoom(1)`; both around the container centre. No rounding of its own. A
1729
+ step past a limit is refused (`canZoomOut`/`canZoomIn`), so zoom out never zooms in, whatever the
1730
+ caller checks; without a `zoomScroll` service it steps on the same raster itself.
1731
+ - `fit()` uses `fitViewbox(contentBounds(), canvas.getSize(), insets())` and `canvas.viewbox(box)`,
1732
+ never `canvas.zoom("fit-viewport")`; animated over 250ms in `requestAnimationFrame` with
1733
+ `easeStandard` unless `animate: false` or reduced motion (`prefers-reduced-motion`, or
1734
+ `REDUCED_MOTION_CLASS` on `<html>`); a `wheel`, `pointerdown` or zoom key cancels the animation. A
1735
+ no-op when the bounds are empty.
1736
+ - `canFit()` reads `contentBounds()` once per content change (`elements.changed`, `shape.added`,
1737
+ `shape.removed`, `connection.added`, `connection.removed`, `diagram.clear`, `import.done` and the
1738
+ viewport's own `afterImport`, `restore`, `setState`), not on every frame the zoom panel renders.
1739
+ - `afterImport()` jump-fits the first import whose bounds are non-empty (also after an empty file got
1740
+ its first content), unless the user moved the view or a stored view was set with `setState` while
1741
+ there was content. `restore` does not block it: a modeler restores on every re-import, and an
1742
+ empty file must still fit its first content ("Insert example"). Content the user creates on the
1743
+ canvas does (§5.9: an element created on the canvas never fits): an `elements.changed` outside an
1744
+ import window after the first import, with content, ends the wait for the first fit, so the next
1745
+ external change keeps the view. A renderer that changes elements through the command stack while
1746
+ it imports therefore fires `import.parse.start` or `import.render.start` first.
1747
+ - An empty file shows 100 %, whatever zoom the renderer's import left: on the first import with
1748
+ empty bounds, without a stored view (`setState`) and without a user move, `afterImport()` zooms to
1749
+ 1 around the centre of the free rectangle (at 0 × 0 once the container has a size). That write is
1750
+ the viewport's own, not a user move, so the first content still fits; later re-imports and
1751
+ `restore`s of the empty file keep the view (guide §5.9 and §13.5, design system 1.13.0).
1752
+ - `newDocument()` (0.2): in the web app every new document (New diagram, Open…, Import…, Show
1753
+ example, a shared link, a dropped file) opens like the first (§5.9). Called before the import, it
1754
+ forgets the user's moves, a stored view and a pending restore and stops a fit animation, so the
1755
+ next `afterImport()` jump-fits non-empty content or shows an empty document at 100 %.
1756
+ - `freeRectCenter()` is the centre of the free rectangle (the container minus `insets()`, as fit
1757
+ uses them) in diagram coordinates, for the current viewbox; `undefined` while the container is
1758
+ 0 × 0 and after `destroy`. It is where keyboard creation places an element.
1759
+ - A `ResizeObserver` on `canvas.getContainer()`, coalesced per frame, skips 0 × 0 (hidden tab) and
1760
+ compares with the size the view was last set for, so a tab coming back at the same size neither
1761
+ refits nor calls `resized()`. On a real change it calls `canvas.resized()` and refits while the view
1762
+ is automatic. A `restore` or `setState` made at 0 × 0 waits for a size and is not refitted then.
1763
+ - User moves: every `canvas.viewbox.changed` (and `canvas.viewbox.changing` for the readout under
1764
+ `deferUpdate`) is compared with the last view the viewport set (`sameView`; during an animation
1765
+ also with the last written frame); a difference is a user move. Changes between
1766
+ `import.parse.start`/`import.render.start` and `import.done` are not (renderers set the viewbox
1767
+ while importing); that window also ends on `afterImport`, `restore`, `setState` and user input.
1768
+ Fit makes the view automatic again; `restore` updates the reference without resetting it.
1769
+ - `getViewbox()` takes width and height from the fresh container size; `getState()` is `undefined`
1770
+ before the first `afterImport`, `restore` or `setState`. `onChange` fires on
1771
+ `canvas.viewbox.changing`/`.changed`, `elements.changed` (so Fit enables with the first element),
1772
+ `import.done` and the viewport's own calls.
1773
+ - The viewport destroys itself on `diagram.destroy`; afterwards `zoom()` returns the last zoom,
1774
+ `canFit()` false and `getViewbox()`/`getState()` `undefined`, so a shell still bound to it never
1775
+ calls into a destroyed canvas.
1776
+ - diagram-js events used: `canvas.init`, `palette.create`, `palette.changed`, `tool-manager.update`,
1777
+ `palette.trigger`, `keyboard.keydown` (through `keyboard.addListener`), `canvas.viewbox.changing`,
1778
+ `canvas.viewbox.changed`, `elements.changed`, `shape.added`, `shape.removed`, `connection.added`,
1779
+ `connection.removed`, `diagram.clear`, `import.parse.start`, `import.render.start`, `import.done`,
1780
+ `diagram.destroy`. The shell fires no events of its own.
1781
+
1782
+ **Palette keyboard creation** (§5.5). Enter or Space on an entry that creates something places the
1783
+ element in the centre of the free rectangle, selects it, opens the label editor where the notation
1784
+ has labels and puts the focus on the canvas; tool entries only switch the tool. diagram-js's create
1785
+ tool follows the pointer and never ends without one, so the provider branches on the synthetic click
1786
+ and asks the viewport for the point. The provider exists before `connectDiagramJs`, so it gets a
1787
+ function and reads the viewport only when the entry fires:
1788
+
1789
+ ```ts
1790
+ // the renderer's palette provider, given freeRectCenter: () => viewport.freeRectCenter()
1791
+ const create = (event: Event) => {
1792
+ const shape = elementFactory.createShape({ type: "domain-event" });
1793
+ const keyboard = event.type === "click" && (event as MouseEvent).detail === 0;
1794
+ const center = keyboard ? freeRectCenter() : undefined;
1795
+ if (!center) return createTool.start(event, shape); // a pointer: the shape follows it
1796
+ const created = modeling.createShape(shape, center, canvas.getRootElement());
1797
+ selection.select(created);
1798
+ canvas.focus();
1799
+ directEditing.activate(created); // where the notation has labels
1800
+ };
1801
+ entries["create.domain-event"] = {
1802
+ group: "events",
1803
+ title: "Domain event",
1804
+ shortcut: "E",
1805
+ action: { click: create, dragstart: create },
1806
+ };
1807
+ ```
1808
+
1809
+ When the centre is already taken, the provider steps 24px diagonally until it is free (§5.5); a
1810
+ shared helper for that is a later candidate. The kit's `create` case presses Enter on the first
1811
+ creating entry and expects exactly one new, selected shape inside the free rectangle.
1812
+
1813
+ ## `./vscode/extension`
1814
+
1815
+ ```ts
1816
+ interface DisposableLike {
1817
+ dispose(): unknown;
1818
+ }
1819
+ interface ColorThemeLike {
1820
+ readonly kind: number;
1821
+ }
1822
+ interface VsCodeLike {
1823
+ readonly window: {
1824
+ readonly activeColorTheme: ColorThemeLike;
1825
+ onDidChangeActiveColorTheme(listener: (theme: ColorThemeLike) => unknown): DisposableLike;
1826
+ };
1827
+ readonly workspace: {
1828
+ getConfiguration(section?: string): { get<T>(key: string): T | undefined };
1829
+ onDidChangeConfiguration(
1830
+ listener: (event: { affectsConfiguration(section: string): boolean }) => unknown,
1831
+ ): DisposableLike;
1832
+ };
1833
+ }
1834
+ interface WebviewPanelLike {
1835
+ readonly webview: { postMessage(message: unknown): unknown };
1836
+ readonly visible: boolean;
1837
+ onDidChangeViewState(
1838
+ listener: (event: { readonly webviewPanel: { readonly visible: boolean } }) => unknown,
1839
+ ): DisposableLike;
1840
+ onDidDispose(listener: () => unknown): DisposableLike;
1841
+ }
1842
+
1843
+ interface WebviewHtmlOptions {
1844
+ cspSource: string;
1845
+ scriptUri: string;
1846
+ styleUris?: readonly string[];
1847
+ title: string;
1848
+ lang: string;
1849
+ appearance: Appearance;
1850
+ body?: string;
1851
+ nonce?: string;
1852
+ inlineStyles?: boolean; // default true: style-src … 'unsafe-inline'
1853
+ }
1854
+ function createNonce(): string;
1855
+ function renderWebviewHtml(options: WebviewHtmlOptions): string;
1856
+
1857
+ interface ThemeBridgeOptions {
1858
+ themes: readonly Theme[];
1859
+ settingKey?: string;
1860
+ }
1861
+ function currentAppearance(vscode: VsCodeLike, options: ThemeBridgeOptions): Appearance;
1862
+ function connectThemeBridge(
1863
+ vscode: VsCodeLike,
1864
+ panel: WebviewPanelLike,
1865
+ options: ThemeBridgeOptions,
1866
+ ): DisposableLike;
1867
+
1868
+ function revealFileLabel(platform: string): string;
1869
+
1870
+ const APPEARANCE_MESSAGE_TYPE = "modelerShell.appearance";
1871
+ interface AppearanceMessage {
1872
+ type: typeof APPEARANCE_MESSAGE_TYPE;
1873
+ appearance: Appearance;
1874
+ }
1875
+ function appearanceMessage(appearance: Appearance): AppearanceMessage;
1876
+ function isAppearanceMessage(value: unknown): value is AppearanceMessage;
1877
+ ```
1878
+
1879
+ - The real `vscode` namespace is assignable to `VsCodeLike` and a `WebviewPanel` to
1880
+ `WebviewPanelLike` (pinned by a type test against `@types/vscode` 1.96).
1881
+ - The entry needs a global `crypto` (Node 20 or later, so VS Code 1.90 or later; the types are tested
1882
+ against 1.96). A modeler declares `engines.vscode` accordingly and targets `node20` in its
1883
+ extension-host bundle. There is no `node:crypto` fallback: the entry imports nothing, so a web
1884
+ extension host (vscode.dev) can bundle it as well, and `createNonce` stays synchronous.
1885
+ - `createNonce()`: 32 characters `[A-Za-z0-9]` from `crypto.getRandomValues`, unbiased.
1886
+ - `renderWebviewHtml` writes the appearance attributes on `<html>` before the first paint, always
1887
+ (also `light`), and the CSP (`default-src 'none'`, the nonce'd script, fonts from `cspSource` and
1888
+ `data:`). Its `style-src` allows `'unsafe-inline'` by default because renderers write
1889
+ `style` attributes into markup (palette entries, SVG strings); the shell and diagram-js themselves
1890
+ style only through the CSSOM and run without it, so a modeler whose renderer does too passes
1891
+ `inlineStyles: false`. `lang` is the tool's UI language: English modelers pass `"en"`, not
1892
+ `vscode.env.language`. Title and attribute values are escaped, `body` is inserted as markup; the
1893
+ script is a classic `<script nonce src>` (an IIFE bundle). It throws for a `nonce` that is not a
1894
+ CSP base64 value.
1895
+
1896
+ ```text
1897
+ <html lang="en" class="cd-shell-document" data-cd-theme="light" data-cd-kontrast="hoch">
1898
+ Content-Security-Policy: default-src 'none'; img-src {cspSource} data: blob:;
1899
+ style-src {cspSource} 'unsafe-inline'; font-src {cspSource} data:; script-src 'nonce-{nonce}'
1900
+ ```
1901
+
1902
+ - `currentAppearance` resolves `requestedAppearance(kind, setting)` against `themes`; a setting value
1903
+ outside `automatic | light | dark` counts as `automatic`.
1904
+ - `connectThemeBridge` posts nothing on connect (the HTML carries the appearance) and then
1905
+ `appearanceMessage(currentAppearance(…))` on `onDidChangeActiveColorTheme`, on
1906
+ `onDidChangeConfiguration` affecting `settingKey` (no configuration listener without one), and on
1907
+ `onDidChangeViewState` when the panel goes from hidden to visible (not on active or column
1908
+ changes). It disposes itself with the panel (`onDidDispose`) and throws for empty `themes`. The
1909
+ returned disposable only ends it earlier (twice is safe), or goes into a panel-scoped list that is
1910
+ disposed with the panel; never into `context.subscriptions`, which keeps one dead bridge per
1911
+ opened editor until the extension deactivates.
1912
+ - `revealFileLabel(process.platform)`: "Reveal in Finder" (`darwin`), "Reveal in File Explorer"
1913
+ (`win32`), "Open Containing Folder" (other), for the export notification (§13.5); English, the
1914
+ labels of VS Code itself (a localized modeler translates them with `vscode.l10n`).
1915
+
1916
+ What the webview bundle needs under this CSP:
1917
+
1918
+ - **Fonts as files.** Bundle Geist and Geist Mono as files next to the bundle, not as data URLs:
1919
+ esbuild `loader: { ".woff2": "file", ".woff": "file" }` with `assetNames: "fonts/[name]-[hash]"`.
1920
+ The browser then fetches only the subsets a page's `unicode-range` needs (Geist Mono alone brings
1921
+ six, which inlined roughly double the webview CSS), `font-src {cspSource}` allows them, and `dist`
1922
+ must be in the panel's `localResourceRoots`. `data:` stays allowed for renderers that inline
1923
+ fonts.
1924
+ - **zod 4 without eval.** `script-src` admits only the nonce'd script, so eval and `new Function` are
1925
+ blocked (correctly, there is no `'unsafe-eval'`). zod 4 probes once for eval when it builds its
1926
+ first object schema and catches the error, but the browser still reports that `new Function` as a
1927
+ `securitypolicyviolation`; under `jitless` zod skips the probe. A webview that bundles zod 4 runs
1928
+ `z.config({ jitless: true })` in a module it imports before any schema module (Event Storming:
1929
+ `import "./zod-jitless"`, the first line of `main.ts`).
1930
+ - **A smoke test of the production bundle.** A test page served by Vite runs the webview from source
1931
+ without a CSP, so a loader or CSP mistake of the shipped bundle shows only behind the real HTML.
1932
+ Build the bundle as shipped, serve it under a fake origin behind `renderWebviewHtml`, and fail on
1933
+ any CSP violation, page error or missing font:
1934
+
1935
+ ```ts
1936
+ const ORIGIN = "https://webview.test";
1937
+ const NONCE = "c21va2V0ZXN0bm9uY2Uw"; // fixed, so the stub's script can carry it
1938
+ await page.route(`${ORIGIN}/**`, async (route) => {
1939
+ const { pathname } = new URL(route.request().url());
1940
+ if (pathname === "/") {
1941
+ const html = renderWebviewHtml({
1942
+ cspSource: ORIGIN,
1943
+ nonce: NONCE,
1944
+ scriptUri: `${ORIGIN}/webview.js`,
1945
+ styleUris: [`${ORIGIN}/webview.css`],
1946
+ title: "Event Storming Board",
1947
+ lang: "en",
1948
+ appearance: { theme: "light" },
1949
+ // runs before the webview: installs the VS Code stub and records every violation
1950
+ body: `<div id="app"></div><script nonce="${NONCE}" src="${ORIGIN}/stub.js"></script>`,
1951
+ });
1952
+ return route.fulfill({ contentType: "text/html", body: html });
1953
+ }
1954
+ if (pathname === "/stub.js") return route.fulfill({ contentType: "text/javascript", body: stubIife });
1955
+ return route.fulfill({ path: join(dist, pathname) }); // webview.js, webview.css, fonts/
1956
+ });
1957
+ await page.goto(`${ORIGIN}/`);
1958
+ // post the document's init message, wait for the first shape and open the menu, then:
1959
+ expect(await page.evaluate(() => window.cspViolations)).toEqual([]);
1960
+ ```
1961
+
1962
+ `stubIife` is the esbuild IIFE bundle of a small entry that adds a `securitypolicyviolation`
1963
+ listener on `document` (pushing `violatedDirective` and `blockedURI` into `window.cspViolations`)
1964
+ and then calls `installVsCodeWebviewStub(…)`.
1965
+
1966
+ ## `./vscode/webview`
1967
+
1968
+ ```ts
1969
+ function applyAppearance(appearance: Appearance, element?: HTMLElement): void; // default <html>
1970
+ interface AppearanceListenerOptions {
1971
+ themes: readonly Theme[];
1972
+ }
1973
+ function connectAppearance(options: AppearanceListenerOptions): () => void;
1974
+ function connectReducedMotion(): () => void;
1975
+
1976
+ interface VsCodeShellOptions
1977
+ extends Omit<ShellOptions, "host" | "keyScope" | "themes">, AppearanceListenerOptions {}
1978
+ function mountVsCodeShell(root: HTMLElement, options: VsCodeShellOptions): ShellHandle;
1979
+
1980
+ interface WebviewStateApi {
1981
+ getState(): unknown;
1982
+ setState(state: unknown): void;
1983
+ }
1984
+ interface ShellWebviewState {
1985
+ legendOpen?: boolean;
1986
+ view?: ViewportState;
1987
+ }
1988
+ const SHELL_STATE_KEY = "modelerShell";
1989
+ function readShellState(api: WebviewStateApi): ShellWebviewState;
1990
+ function writeShellState(api: WebviewStateApi, patch: ShellWebviewState): void;
1991
+ interface PersistableView {
1992
+ onChange(listener: () => void): () => void;
1993
+ getState(): ViewportState | undefined;
1994
+ }
1995
+ interface PersistViewOptions {
1996
+ delayMs?: number; // default 250
1997
+ }
1998
+ function persistView(
1999
+ api: WebviewStateApi,
2000
+ viewport: PersistableView,
2001
+ options?: PersistViewOptions,
2002
+ ): () => void;
2003
+
2004
+ // re-exported from the shared protocol, as in ./vscode/extension:
2005
+ // APPEARANCE_MESSAGE_TYPE, AppearanceMessage, appearanceMessage, isAppearanceMessage
2006
+ ```
2007
+
2008
+ - `applyAppearance` sets `data-cd-theme` and `data-cd-kontrast` in one task, with
2009
+ `cd-shell-no-transition` on `<html>` until a nested `requestAnimationFrame` (no colour fades; after
2010
+ several quick switches only the last one lifts it).
2011
+ - `connectAppearance` applies every `AppearanceMessage` arriving on `window`, resolved against its own
2012
+ `themes` once more (what this bundle can show wins over what the extension passed); a message that
2013
+ changes nothing leaves `<html>` alone. A page whose `<html>` has no `data-cd-theme` at connect (no
2014
+ `renderWebviewHtml`) falls back to the body classes until the first message: a `MutationObserver`
2015
+ applies `resolveAppearance(appearanceFromBodyClasses(…), themes)`, at once and on every change.
2016
+ With the attributes of `renderWebviewHtml` there is no fallback: the bridge knows the `colorTheme`
2017
+ setting, the body classes do not, and VS Code changes them before the bridge's message arrives from
2018
+ the extension host, which would flash the mode without the setting. The body classes are never
2019
+ used for styling. Throws for empty `themes`.
2020
+ - `connectReducedMotion` mirrors the body class `vscode-reduce-motion` (VS Code's
2021
+ `workbench.reduceMotion`, kept up to date by VS Code) as `REDUCED_MOTION_CLASS` on `<html>` for the
2022
+ whole session; its disconnect removes the class.
2023
+ - `mountVsCodeShell` is `mountShell` with `host: "ide"` and `keyScope: "document"` (both always win)
2024
+ plus `connectAppearance(options)` and `connectReducedMotion()`, connected first, so empty `themes`
2025
+ throw before anything mounts (and disconnected again if `mountShell` throws). It returns a handle
2026
+ that passes every member through and whose `destroy()` disconnects both too. This wires the IDE
2027
+ key interception: handled keys are stopped on `document`, before VS Code's listener on `window`
2028
+ forwards them to the workbench.
2029
+ - `readShellState`/`writeShellState` keep the shell's part (legend state, view) under
2030
+ `SHELL_STATE_KEY` of `vscode.getState()`. `readShellState` returns `{}` for a missing or malformed
2031
+ part and only valid fields (a view with finite `x`/`y`, positive width and height and a boolean
2032
+ `userMoved`), as copies. `writeShellState` merges: every other top-level key and unknown keys in its
2033
+ own part stay, an `undefined` or malformed value leaves the stored one alone (so
2034
+ `{ view: viewport.getState() }` before the first import erases nothing), and nothing is written
2035
+ when nothing changed. The modeler owns `acquireVsCodeApi()` (callable once) and passes its API
2036
+ object.
2037
+ - `persistView(api, viewport)` writes `{ view: viewport.getState() }` once the view has rested for
2038
+ `delayMs` (trailing), instead of one `vscode.setState` IPC message per frame of a pan, a wheel zoom
2039
+ or a fit animation; the returned stop function writes a pending change at once. The legend state
2040
+ is written from `legend.onToggle` (see the usage below).
2041
+ - The custom editor is registered with `webviewOptions: { retainContextWhenHidden: true }`: a hidden
2042
+ tab keeps document, view and selection, reports a 0 × 0 container and neither fits nor measures.
2043
+
2044
+ ## `./testing`
2045
+
2046
+ ```ts
2047
+ // Playwright's Page satisfies it
2048
+ interface PageLike {
2049
+ evaluate<R, Arg>(pageFunction: (arg: Arg) => R | Promise<R>, arg: Arg): Promise<R>;
2050
+ setViewportSize(size: { width: number; height: number }): Promise<void>;
2051
+ keyboard: { press(key: string): Promise<void> };
2052
+ mouse: {
2053
+ click(x: number, y: number): Promise<void>;
2054
+ dblclick(x: number, y: number): Promise<void>;
2055
+ move(x: number, y: number): Promise<void>;
2056
+ down(): Promise<void>;
2057
+ up(): Promise<void>;
2058
+ };
2059
+ waitForTimeout(timeout: number): Promise<void>;
2060
+ // 0.2: Playwright's page.emulateMedia; the web host's appearance needs it (else a skip, hook "open")
2061
+ emulateMedia?(options: {
2062
+ colorScheme?: "light" | "dark" | "no-preference" | null;
2063
+ contrast?: "more" | "no-preference" | null;
2064
+ reducedMotion?: "reduce" | "no-preference" | null;
2065
+ forcedColors?: "active" | "none" | null;
2066
+ }): Promise<void>;
2067
+ }
2068
+
2069
+ interface OpenContext {
2070
+ size: Size; // the shell container's size (grow the page by any frame around an embedded root)
2071
+ requested: Appearance; // what the host requests; the harness opens the page with it (embedded, web: see below)
2072
+ }
2073
+ interface ShowHooks<P extends PageLike = PageLike> {
2074
+ problems?(page: P): Promise<void>; // web: with items, so the popover has rows
2075
+ status?(page: P): Promise<void>;
2076
+ legend?(page: P): Promise<void>;
2077
+ inspector?(page: P): Promise<void>;
2078
+ invalid?(page: P): Promise<void>; // the invalid-file state, caused the way the host causes it
2079
+ emptyState?(page: P): Promise<void>; // 0.2, web: the start screen of a fresh visit
2080
+ toast?(page: P, toast: ToastFixture): Promise<void>; // 0.2, web and embedded: through shell.toast
2081
+ }
2082
+ type ShowHook = keyof ShowHooks; // … | "emptyState" | "toast"
2083
+ // "problemSelection": the modeler's problems never point at an element (only at lines), so no row selects
2084
+ type AbsentFeature = ShowHook | "drop" | "problemSelection";
2085
+ interface ToastFixture {
2086
+ status: ToastStatus;
2087
+ text: string;
2088
+ actionLabel?: string; // the hook passes { label: actionLabel, onSelect() {} } as action
2089
+ }
2090
+ interface DropFixture {
2091
+ name: string;
2092
+ type: string;
2093
+ text: string; // the kit builds new File([text], name, { type }) in the page
2094
+ }
2095
+ interface DropFixtures {
2096
+ accepted: DropFixture; // dropped on the start screen, it opens (the card goes)
2097
+ rejected?: DropFixture; // opens nothing, shows a danger toast (or the modeler's onReject)
2098
+ multiple?: boolean; // the modeler's DropZoneOptions.multiple; default false: two files are forbidden
2099
+ }
2100
+ interface ShellConformanceHarness<P extends PageLike = PageLike> {
2101
+ host: ShellHost;
2102
+ themes: readonly Theme[];
2103
+ open(page: P, context: OpenContext): Promise<void>;
2104
+ destroy?(page: P): Promise<void>;
2105
+ root?: string; // default ".cd-shell"
2106
+ show?: ShowHooks<P>;
2107
+ drop?: DropFixtures; // 0.2, web: the files the dropZone case drops
2108
+ absent?: readonly AbsentFeature[]; // islands, states and the drop zone the modeler does not have
2109
+ sizes?: readonly Size[]; // default 1280 × 900, 720 × 700, 420 × 700
2110
+ }
2111
+ interface ShellSkip {
2112
+ hook: ShowHook | "drop" | "destroy" | "open"; // the harness field that would feed the skipped part
2113
+ reason: string;
2114
+ }
2115
+ interface ShellCaseOutcome {
2116
+ skipped?: readonly ShellSkip[];
2117
+ }
2118
+ interface ShellConformanceCase {
2119
+ name: string;
2120
+ run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>): Promise<ShellCaseOutcome | void>;
2121
+ }
2122
+ function shellConformanceCases(): ShellConformanceCase[];
2123
+ type ShellConformanceTest<P extends PageLike = PageLike> = (
2124
+ name: string,
2125
+ body: (fixtures: { page: P }) => Promise<void>,
2126
+ ) => unknown;
2127
+ interface RegisterShellConformanceOptions {
2128
+ onSkip?(caseName: string, skips: readonly ShellSkip[]): void; // default: console.warn, joined by "; "
2129
+ }
2130
+ function registerShellConformance<P extends PageLike>(
2131
+ harness: ShellConformanceHarness<P>,
2132
+ test: ShellConformanceTest<P>,
2133
+ options?: RegisterShellConformanceOptions,
2134
+ ): void;
2135
+ // "theme=dark&kontrast=hoch[&colorTheme=…][&reduceMotion]" for a page with the VS Code stub
2136
+ function vsCodeStubSearch(
2137
+ requested: Appearance,
2138
+ extra?: { colorTheme?: ColorThemeSetting; reduceMotion?: boolean },
2139
+ ): string;
2140
+
2141
+ const GEOMETRY_TOLERANCE_PX = 1;
2142
+ interface IslandBox {
2143
+ left: number;
2144
+ top: number;
2145
+ width: number;
2146
+ height: number;
2147
+ }
2148
+ interface ChromeMeasurement {
2149
+ container: Size;
2150
+ paletteRow: PaletteRow;
2151
+ islands: Partial<Record<ChromeIsland, IslandBox>>;
2152
+ open: {
2153
+ legend: boolean;
2154
+ inspector: boolean;
2155
+ problems: boolean;
2156
+ emptyState: boolean; // 0.2: data-empty-state="shown"
2157
+ primaryCollapsed: boolean; // 0.2: data-primary="collapsed"
2158
+ zoomRaised: boolean; // 0.2: data-zoom-row="raised"
2159
+ };
2160
+ toastCardWidth?: number; // 0.2: the widest visible toast card; absent without a toast
2161
+ }
2162
+ interface ExpectedBox {
2163
+ left?: number;
2164
+ top?: number;
2165
+ right?: number;
2166
+ bottom?: number;
2167
+ width?: number;
2168
+ height?: number;
2169
+ centerX?: number;
2170
+ centerY?: number; // 0.2
2171
+ }
2172
+ function measureChrome(page: PageLike, root?: string): Promise<ChromeMeasurement>;
2173
+ function expectedChromeBoxes(
2174
+ measurement: ChromeMeasurement,
2175
+ host: ShellHost,
2176
+ ): Partial<Record<ChromeIsland, ExpectedBox>>;
2177
+ ```
2178
+
2179
+ `IslandBox` and `ExpectedBox` are relative to the root's padding box (`getBoundingClientRect()` minus
2180
+ the root's box and border), where the islands anchor.
2181
+
2182
+ Cases (each named `shell conformance: <name>`; failures throw plain `Error`s listing every problem):
2183
+
2184
+ | Case | Checks |
2185
+ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2186
+ | `geometry` | per size, as opened and after each show hook in turn (legend, problems, status, inspector): every visible `data-chrome` island against the geometry table, ±1px; the palette row decision (second row up to 760px, 16px clearance); the guide's limits (legend ≥ 184px, status ≤ `min(560px, 100cqw − 24px)`, side inspector ≥ 64px above the bottom, sheet ≤ 45 %, second-row palette ≤ `100cqw − 24px`); root state attributes against what is visible; zoom, pill and legend hidden under a bottom sheet; the dropdown at 60/12 and 248 to `min(320px, 100cqw − 24px)` wide; tooltips on the menu trigger, a palette entry and zoom in at 60, 112 and bottom 60, centred and 12px from the sides; an `absent` island is never visible |
2187
+ | `aria` | trigger `aria-haspopup`/`-expanded`/`-controls` → `role="menu"`; the menu keyboard (Enter and ↓ open on the first entry, ↑ on the last, wrap, Home/End, Escape returns focus), whose keys never reach a `keydown` listener on `document`; named items, `aria-checked`, labelled radio groups, dialog entries ending in "…"; palette `role="toolbar"` with roving tabindex driven by ←/→/Home/End, `aria-pressed` on tools and `aria-keyshortcuts` on every entry whose tooltip shows a shortcut (each entry focused by keyboard); Tab from the menu trigger lands on the palette's one stop, then on the canvas `<svg>`, nothing in between; the canvas `<svg>` has an accessible name; zoom `role="group"`, readout `aria-label` with the shown percentage, `aria-keyshortcuts` for the page's platform, `aria-disabled` (never native `disabled`) at 400 % and 20 % reached by clicking; roles of pill, status, legend and inspector (an `absent` one is never visible); DOM order; no positive `tabindex`; no native `title`; `lang` on `<html>` or on an embedded root |
2188
+ | `keys` | on the canvas, each chrome key changes the zoom exactly once (Ctrl, Meta, Shift and numpad variants 100 % → 135 % → 100 %, 0 and Numpad0 to 100 %, Shift+1 fits at most 100 %); unmodified `+`/`-` and Alt combinations do nothing; Escape without menu or dialog passes untouched; input, textarea and editable-label stand-ins block the zoom keys and keep undo/redo (stopped, not prevented), modal and popover stand-ins block, Escape with the open menu closes it; Control+A and Meta+A on the focused canvas are handled by the canvas (select all, or at least prevented). `ide`: a `keydown` listener on `window` never sees a handled or blocked key (select all included) while F1 still arrives, a `document` listener registered after the shell's sees handled keys prevented and blocked ones (Cmd/Ctrl+Z in a text field) not, and Cmd/Ctrl+Z, Shift+Z and Ctrl+Y on the canvas reach `window` once and unprevented (the document's history undoes, §13.5); other hosts: every key reaches `window`, the handled ones prevented |
2189
+ | `create` | §5.5: the palette focused by keyboard and walked with →/Home to the first entry that is not a tool (outside group `tools`, `data-action` not ending in `-tool`), then Enter: exactly one new `.djs-shape[data-element-id]`, selected, its centre inside the free rectangle (the container minus the fit insets recomputed from the measured chrome), the focus on the canvas (its `<svg>` or a label-editor field); Escape afterwards. A notation without a creating entry passes with nothing to verify |
2190
+ | `readOnly` | §13.5, after `show.invalid` and `INVALID_STATUS_DELAY_MS`: `data-readonly` on the root, the status box visible with `role="alert"`; every palette entry but `lasso-tool` and `hand-tool` `aria-disabled`, those two not, and the disabled tool entries and their icons in `--cd-ui-text-leise`; Enter on a creating entry adds no shape; the shell's Legend checkbox and every Export and Help entry that was enabled before stay enabled; on a shape a pointer reaches: no `.djs-context-pad` entry after a click, a 40px drag changes neither its position nor its size, a double-click opens no text field inside `.djs-container`, Delete and Backspace on the canvas keep it. Skips with `hook: "invalid"` without the hook (unless `absent` lists it) and with `hook: "open"` when the opened document shows no such shape |
2191
+ | `appearance` | the harness opens with dark, dark + high contrast and light + high contrast requested. Own document: the mode on `<html>` is `resolveAppearance(requested, themes)`, recomputed by the kit (a light-only modeler stays light, `hoch` passes). Embedded: the kit puts the foreign page's `<html>` in the requested mode, and the root carries its own `data-cd-theme="<themes[0]>"` with the page's `data-cd-kontrast` (§13.3). Both: no themed descendant; `cd-shell-document` on `<html>` in `ide`/`web`, never in a foreign page; tokens loaded; `color-scheme` and four `--cd-ui-*` roles equal on root, canvas, every island, the open dropdown and the visible tooltip; the painted surround, the first non-transparent `background-color` from `.djs-container` out through `.cd-shell-canvas` to the root, equals `--cd-ui-grund` (§10.1); every palette tool entry (group `tools` or `data-action` ending in `-tool`) and its icon (`<svg>` or `::before`) compute to `--cd-ui-text` at rest and pressed and to `--cd-ui-interaktion` hovered, each tool hovered in turn and Lasso armed by a click, which sets `aria-pressed="true"`; notation previews are exempt (§5.5). Never half-dark |
2192
+ | `cleanup` | with a tooltip timer pending: after `destroy` and 800ms, a resize and chrome keys that must arrive unprevented, no `cd-shell*` class or element, no `data-chrome`, no root state attribute (embedded: also no `data-cd-theme`/`data-cd-kontrast`) and no page error is left; skips with `hook: "destroy"` without a `destroy` |
2193
+
2194
+ **0.2, the web column.** The 0.1 cases learn the web host's islands, and five cases join them after
2195
+ `cleanup` (`createWebCases` in `src/testing/web-cases.ts`). Each runs in every host; outside the
2196
+ hosts it applies to it asserts that nothing of it shows and passes.
2197
+
2198
+ | Case / extension | Checks |
2199
+ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2200
+ | `geometry` (extended) | web: `primary` at 12/12, 40 high, 40 wide while collapsed; the palette keeping 16px to the primary action in the top row; `legal` at 12/12, 20 high per line and at most `100cqw − 24px` wide; pill, legend and zoom panel per `open.zoomRaised` on the legal line's measured lines; the popover above the pill (after a click on it); toasts at `toastStackBottom` recomputed from the measured bottom row and the widest card (`toastCardWidth`); `empty-state` centred after `show.emptyState`; IDE and embedded: `primary`, `legal`, `empty-state`, `drop-zone` never visible |
2201
+ | `aria` (extended) | web: tab order menu trigger, primary action, palette, canvas; the primary action's `aria-label` in both states; the legal `<nav aria-label>`, Impressum and Datenschutz with `lang="de"`, `rel="noopener noreferrer"`; the File group first with its ids and icons, the Appearance group (dark-capable only) between Export and Help with `aria-checked`; the pill's `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls` |
2202
+ | `appearance` (extended) | web, with `emulateMedia`: light-only: `<html data-cd-theme="light">` under a dark system, no Appearance group, `data-cd-kontrast="hoch"` under `prefers-contrast: more`; dark-capable: `system` follows the system, choosing Dark writes `miragon.darstellung=dark`, `data-cd-theme="dark"`, `color-scheme` and both `theme-color` metas in one frame, System removes the entry; never half-dark |
2203
+ | `webChrome` | primary action: label collapse before the palette moves (widths stepped from wide to narrow), tooltip only while collapsed, `onSelect` on click and on Enter (so a real app runs its own handler, a Share without clipboard permission shows its danger toast meanwhile); legal line content and links, at 320px every link whole inside the wrapped line (WCAG 1.4.10); the raised zoom row where legal line and zoom panel meet; File entries run their handlers and close the menu, never locked under `show.invalid` |
2204
+ | `emptyState` | after `show.emptyState`: the card centred, `min(400px, 100cqw − 24px)`, Komet 64px with `alt=""`, `<h1>`, primary "New diagram" before ghost "Show example", both 36px (a text link in the hint is no action); the work chrome `visibility: hidden`, the canvas `inert`, Tab reaching only the card and the legal links, chrome keys reaching the browser unprevented without zooming the diagram; with `show.toast`, closing a focused toast puts the focus on the card; "New diagram" hides it, shows the chrome and focuses the canvas `<svg>` |
2205
+ | `toasts` | through `show.toast`: a `role="status"` and a `role="alert"` live region in the root before the first toast, the toast's text arriving in the one of its status, no live role on the card; Close named "Close", at most three (the fourth evicts the oldest without action), the newest at the bottom, a repeated text not stacked, closing after `toastLifetime` (with a margin) unless hovered or focused, a toast with an action never; web: shown again in the top layer after a `showModal()` stand-in opened (everything outside a modal is inert, so the kit checks the stacking, not a click); IDE: no hook and no toast element ever |
2206
+ | `problemsList` | web (embedded when the pill opens it): after `show.problems`, a click on the pill opens `role="dialog"` titled "Problems" with the rows, Escape returns the focus to the pill, an outside click closes without moving it; Enter on the pill and then ↓ to the last row, Home and End reach every row, text rows included, each scrolled into the list (WCAG 2.1.1); a row with an element closes it and focuses the canvas (skipped with `hook: "problems"` when no item has an `elementId`, unless `absent` lists `"problemSelection"`); geometry left 12, 8px above the pill, at most 320 wide and high |
2207
+ | `dropZone` | web with `harness.drop`: synthetic file drags (a `DataTransfer` built in the page) show `data-drop="over"` with the §9 colours and the 12px inset; two files (unless `drop.multiple`) and a modal stand-in show `forbidden`; a drag without files changes nothing; dropping `accepted` on the start screen opens it, `rejected` opens nothing and shows a danger toast; skips with `hook: "drop"` without the fixtures unless `absent` lists `"drop"` |
2208
+
2209
+ - **Skips.** A case the harness cannot feed reports what it skipped as `ShellSkip`s, one per missing
2210
+ harness field, through `onSkip` (default `console.warn`). A hook that is neither in `show` nor in
2211
+ `absent` is skipped with `hook` set to its name, a missing `destroy` with `hook: "destroy"`, and a
2212
+ document that lacks what a case needs (a shape a pointer reaches, for `readOnly`) with
2213
+ `hook: "open"`. `absent` lists what the modeler does not have (an island or the `invalid` state),
2214
+ which is then never reported, while `geometry` and `aria` assert that the island never shows. Listing a hook in both `show` and `absent` fails every case that uses
2215
+ it. A suite that lists what it lacks can fail on every remaining skip:
2216
+ `onSkip(name, skips) { throw new Error(…) }`.
2217
+ - **`show.invalid`** makes the file invalid the way the host does (in a webview: a document update
2218
+ the parser rejects), not by calling `shell.setReadOnly` alone: the `readOnly` case also checks the
2219
+ modeler's own canvas lock, which its invalid-import path sets. The case waits
2220
+ `INVALID_STATUS_DELAY_MS` and a margin itself.
2221
+ - For the `embedded` host the kit writes `data-cd-theme` and `data-cd-kontrast` on the page's
2222
+ `<html>` itself after `open`: every consumer suite then proves that the modeler stays in its own
2223
+ mode inside a dark host page, the case a foreign page meets in practice (designIQ, a web app on
2224
+ `system` under a dark OS).
2225
+ - For the `web` host (0.2) the kit calls `page.emulateMedia({ colorScheme, contrast })` from
2226
+ `requested` before `open`, and `open` loads the page without a stored `miragon.darstellung`; the
2227
+ `appearance` case then sets the choice through the Appearance group itself. Without
2228
+ `emulateMedia` the web part of `appearance` skips with `hook: "open"`.
2229
+ - The second-row tooltip of the `geometry` case may stand centred on its entry or in the container:
2230
+ guide §5.15 says "mittig im Container", and which of the two it means is an open question; the
2231
+ shell centres it on its entry.
2232
+ - The kit never imports Playwright; a modeler's suite passes its `page` and `test`
2233
+ (`registerShellConformance(harness, test)` from `@playwright/test`). It installs one page global,
2234
+ `__modelerShellConformance`, with `keydown` recorders on `window` and `document`, and briefly
2235
+ injects stand-ins marked `data-modeler-shell-conformance` (an input, a textarea, an editable label,
2236
+ a modal dialog, a popover, a focus target) into the shell root.
2237
+ - `geometry` takes about 10s per host; wrap the registration in
2238
+ `test.describe.configure({ timeout: 60_000 })`.
2239
+ - `registerShellConformance` calls `test` inside the kit, so a runner reports every case at the
2240
+ kit's source (the package ships `src/`, so that location resolves). For locations in the suite's
2241
+ own spec file, register the cases there:
2242
+
2243
+ ```ts
2244
+ for (const conformanceCase of shellConformanceCases()) {
2245
+ test(`shell conformance: ${conformanceCase.name}`, async ({ page }) => {
2246
+ const outcome = await conformanceCase.run(page, harness);
2247
+ if (outcome?.skipped?.length) onSkip(conformanceCase.name, outcome.skipped);
2248
+ });
2249
+ }
2250
+ ```
2251
+
2252
+ ### `./testing/vscode-stub` — the `ide` host without VS Code
2253
+
2254
+ ```ts
2255
+ type ColorThemeKind = 1 | 2 | 3 | 4;
2256
+ function colorThemeKindOf(appearance: Appearance): ColorThemeKind;
2257
+ const STUB_SETTING_KEY = "miragon.modeler.colorTheme";
2258
+ interface VsCodeWebviewStubOptions {
2259
+ requested: Appearance; // VS Code's color theme
2260
+ themes: readonly Theme[]; // what the modeler's extension passes to the bridge
2261
+ lang?: string; // default "en"
2262
+ colorTheme?: ColorThemeSetting;
2263
+ settingKey?: string; // default STUB_SETTING_KEY
2264
+ reduceMotion?: boolean; // workbench.reduceMotion
2265
+ state?: unknown; // what acquireVsCodeApi().getState() returns at first
2266
+ }
2267
+ interface StubbedVsCodeApi {
2268
+ getState(): unknown;
2269
+ setState(state: unknown): void;
2270
+ postMessage(message: unknown): void;
2271
+ }
2272
+ interface VsCodeWebviewStub {
2273
+ readonly kind: ColorThemeKind;
2274
+ setColorThemeKind(kind: ColorThemeKind): void; // body classes first, then the bridge posts
2275
+ setColorTheme(setting: ColorThemeSetting | undefined): void;
2276
+ setReduceMotion(reduceMotion: boolean): void;
2277
+ pendingMessages(): number; // posted by the bridge, not yet delivered
2278
+ readonly posted: readonly unknown[]; // what the webview posted
2279
+ state(): unknown;
2280
+ }
2281
+ type StubWindow = Window & {
2282
+ __modelerShellVsCodeStub?: VsCodeWebviewStub;
2283
+ acquireVsCodeApi?: () => StubbedVsCodeApi;
2284
+ };
2285
+ function stubOptionsFromSearch(
2286
+ search: string,
2287
+ ): Pick<VsCodeWebviewStubOptions, "requested" | "colorTheme" | "reduceMotion">;
2288
+ function installVsCodeWebviewStub(options: VsCodeWebviewStubOptions): VsCodeWebviewStub;
2289
+ ```
2290
+
2291
+ Runs in a test page, before the modeler's webview code: VS Code's default webview stylesheet in
2292
+ `@layer vscode-default` (with foreign values, so a leak shows), `<html>` with the attributes
2293
+ `renderWebviewHtml` writes, `<body>` with the host page's theme and reduce-motion classes,
2294
+ `acquireVsCodeApi()` (once, as in VS Code) and the real `connectThemeBridge` against a fake `vscode`
2295
+ namespace whose panel posts to the window. The stub is also `window.__modelerShellVsCodeStub`, so a
2296
+ Playwright suite switches the color theme or the setting at runtime.
2297
+
2298
+ A modeler's e2e suite serves `e2e/webview/` as the root of a Vite server, with the test page
2299
+ `e2e/webview/webview-test.html` (`<div id="app">` and `<script type="module" src="./webview-test.ts">`)
2300
+ and this entry:
2301
+
2302
+ ```ts
2303
+ // e2e/webview/webview-test.ts
2304
+ import { installVsCodeWebviewStub, stubOptionsFromSearch } from "@miragon/modeler-shell/testing/vscode-stub";
2305
+
2306
+ // before the webview runs, which calls acquireVsCodeApi() and mounts the shell
2307
+ installVsCodeWebviewStub({ ...stubOptionsFromSearch(location.search), themes: ["light"] });
2308
+ const { webview } = await import("../../src/webview/main"); // exports { shell, modeler, connection, ready }
2309
+
2310
+ // for the harness only: the shipped bundle publishes nothing on window
2311
+ window.shell = webview.shell;
2312
+ window.modeler = webview.modeler;
2313
+ window.connection = webview.connection;
2314
+ void webview.ready.then(() => (window.webviewReady = true)); // the first import settled
2315
+
2316
+ // there is no extension host: post the document the way the extension would
2317
+ window.postMessage({ type: "init", text: EXAMPLE_BOARD }, "*");
2318
+ ```
2319
+
2320
+ The harness opens it with ``page.goto(`/webview-test.html?${vsCodeStubSearch(requested)}`)`` and waits
2321
+ for `window.webviewReady`. `ready`, `webviewReady` and the window globals are the modeler's, not the
2322
+ shell's: its webview module exports `ready`, resolved once the first `init` import settled, and the
2323
+ test page turns it into the flag (the Event Storming `main.ts` below exports it).
2324
+
2325
+ ## `./shell.css`
2326
+
2327
+ One stylesheet for everything above, loaded last. Containment: `.cd-shell { container: cd-shell /
2328
+ size }`; breakpoints are `@container cd-shell (max-width: 760px)`, `(max-width: 440px)` with literal
2329
+ values (custom properties are not allowed in container conditions), sizes inside are `cqw`/`cqh`,
2330
+ never viewport units, and no element inside gets a `container-type` of its own. It covers glass
2331
+ islands (`color-mix(in srgb, var(--cd-ui-flaeche-hoch) var(--cd-ui-chrome-deckkraft), transparent)`
2332
+ with `backdrop-filter: blur(8px)`), opaque popups, focus rings (inset in dense containers),
2333
+ `[data-cd-kontrast="hoch"]` (no shadows, 3px focus), `@media (forced-colors: active)` (rings in
2334
+ `Highlight`, status bar and island borders in `CanvasText`), `prefers-reduced-motion` and
2335
+ `html.cd-shell-reduce-motion`, `html.cd-shell-no-transition` (no transitions for the frame of a mode
2336
+ switch), the canvas `<svg>`'s focus ring (2px, 3px in high contrast, inside the slot), and under
2337
+ `html.cd-shell-document` the own-document reset: no body padding, `--cd-ui-grund` background, Geist
2338
+ 13px/1.5, own `kbd`, `code`, links, focus and scrollbars, never `var(--vscode-*)`. The reset is
2339
+ unlayered, so it wins over VS Code's `@layer vscode-default`; its button and field font reset has
2340
+ zero specificity, so the chrome's own rules (the readout's 13px/600) win over it. In a foreign page
2341
+ the chrome's own islands, controls and texts reset font, colour, letter spacing and text transform
2342
+ at class specificity, so the page's element rules (`button { text-transform: uppercase }`,
2343
+ `p { font-size: 18px }`) do not reach them. The canvas slot's layout (`.cd-shell >
2344
+ .cd-shell-canvas`: absolute, `inset: 0`, layer 0, `isolation: isolate`) wins over a renderer's own
2345
+ container class, its paint (`background-color: var(--cd-ui-grund)`) has zero specificity, so a
2346
+ renderer's paper (Event Storming's dot grid) shows. The paper's colour stays `--cd-ui-grund` in
2347
+ every mode, the dot grid being a background image in `--cd-ui-linie` (§10.1); the kit's `appearance`
2348
+ case checks the painted colour. Palette tool entries set their colour in every
2349
+ state (rest and pressed `--cd-ui-text`, hovered `--cd-ui-interaktion`, read-only
2350
+ `--cd-ui-text-leise`), and their icons (`> svg`, `::before`) inherit it, so a renderer's or
2351
+ diagram-js's rule on the icon or on the stale `highlighted-entry` does not leak in; notation
2352
+ previews keep their own colours (§5.5). Private helper properties are named `--modeler-shell-*`.
2353
+
2354
+ It is assembled from `dom/shell-dom.css` (0.1 chrome), `dom/buttons.css` (0.2: the buttons of the
2355
+ web chrome, the one place with fixed palette tokens, see [Buttons](#buttons)), `dom/web/web.css`
2356
+ (0.2: primary action, legal line, empty state, drop zone, raised zoom row) and
2357
+ `dom/feedback/feedback.css` (0.2: toasts and the problems popover; they render in a foreign page too,
2358
+ so they reset the page's element rules like the 0.1 islands), then `diagram-js/palette.css`. A test
2359
+ pins that `shell.css` imports every stylesheet of `src`.
2360
+
2361
+ The shell bundles no font: the modeler loads Geist and Geist Mono (`@fontsource-variable/geist`,
2362
+ `@fontsource-variable/geist-mono`), as the stylesheet order above shows, as font files (see
2363
+ `./vscode/extension`).
2364
+
2365
+ ## Usage: Event Storming in VS Code
2366
+
2367
+ The Event Storming extension, the pilot of 0.1. Extension host
2368
+ (`EventStormingEditorProvider.resolveCustomTextEditor`):
2369
+
2370
+ ```ts
2371
+ import * as vscode from "vscode";
2372
+ import {
2373
+ connectThemeBridge,
2374
+ currentAppearance,
2375
+ renderWebviewHtml,
2376
+ revealFileLabel,
2377
+ } from "@miragon/modeler-shell/vscode/extension";
2378
+
2379
+ const THEMES = ["light"] as const; // until the shared dark-mode release of all modelers
2380
+
2381
+ // activate(): a hidden tab keeps document, view and selection (and reports 0 × 0 meanwhile)
2382
+ vscode.window.registerCustomEditorProvider(viewType, provider, {
2383
+ webviewOptions: { retainContextWhenHidden: true },
2384
+ });
2385
+
2386
+ // resolveCustomTextEditor():
2387
+ const dist = vscode.Uri.joinPath(extensionUri, "dist");
2388
+ webview.options = { enableScripts: true, localResourceRoots: [dist] }; // webview.js, webview.css, fonts/
2389
+ const asset = (file: string) => webview.asWebviewUri(vscode.Uri.joinPath(dist, file)).toString();
2390
+ webview.html = renderWebviewHtml({
2391
+ cspSource: webview.cspSource,
2392
+ scriptUri: asset("webview.js"),
2393
+ styleUris: [asset("webview.css")],
2394
+ title: "Event Storming Board",
2395
+ lang: "en",
2396
+ appearance: currentAppearance(vscode, { themes: THEMES }),
2397
+ });
2398
+ connectThemeBridge(vscode, webviewPanel, { themes: THEMES }); // disposes itself with the panel
2399
+
2400
+ // after an export (exportImage.ts):
2401
+ const reveal = revealFileLabel(process.platform);
2402
+ if ((await vscode.window.showInformationMessage(`Exported ${name}.`, reveal)) === reveal) {
2403
+ void vscode.commands.executeCommand("revealFileInOS", target);
2404
+ }
2405
+ ```
2406
+
2407
+ The two bundles (`esbuild.config.mjs`):
2408
+
2409
+ ```js
2410
+ const host = {
2411
+ entryPoints: ["src/extension.ts"],
2412
+ outfile: "dist/extension.cjs",
2413
+ bundle: true,
2414
+ platform: "node",
2415
+ format: "cjs",
2416
+ target: "node20", // VS Code 1.90 or later: the global crypto of createNonce
2417
+ external: ["vscode"],
2418
+ };
2419
+ const web = {
2420
+ entryPoints: ["src/webview/main.ts"],
2421
+ outfile: "dist/webview.js", // and dist/webview.css
2422
+ bundle: true,
2423
+ platform: "browser",
2424
+ format: "iife", // renderWebviewHtml writes a classic <script nonce>
2425
+ loader: { ".woff2": "file", ".woff": "file" }, // font files: a page loads only the subsets it needs
2426
+ assetNames: "fonts/[name]-[hash]",
2427
+ };
2428
+ ```
2429
+
2430
+ Webview (`src/webview/main.ts`):
2431
+
2432
+ ```ts
2433
+ import "./zod-jitless"; // first: z.config({ jitless: true }) before any schema module (CSP)
2434
+ import "@fontsource-variable/geist/wght.css";
2435
+ import "@fontsource-variable/geist-mono/wght.css";
2436
+ // the renderer's stylesheet brings diagram-js.css and the tokens, so they are not imported again
2437
+ import {
2438
+ createCollaborativeModeler,
2439
+ type BoardBounds,
2440
+ type EventStormingReadOnly,
2441
+ type EventStormingViewOptions,
2442
+ } from "@miragon/event-storming-renderer";
2443
+ import "./style.css"; // the modeler's own content: the "Keyboard shortcuts…" dialog
2444
+ import "@miragon/modeler-shell/shell.css";
2445
+ import { INVALID_STATUS_DELAY_MS } from "@miragon/modeler-shell";
2446
+ import { connectDiagramJs, shellModule } from "@miragon/modeler-shell/diagram-js";
2447
+ import { mountVsCodeShell, persistView, readShellState } from "@miragon/modeler-shell/vscode/webview";
2448
+ import { createShortcutsDialog } from "./shortcutsDialog";
2449
+
2450
+ const vscode = acquireVsCodeApi();
2451
+ const stored = readShellState(vscode);
2452
+
2453
+ // 1. The diagram first: Event Storming's keyboard listens on document and must run before the
2454
+ // shell's, and the renderer builds its diagram lazily, so get() a service. width/height: the
2455
+ // shell's slot sizes it.
2456
+ const collaborative = createCollaborativeModeler({
2457
+ additionalModules: [shellModule],
2458
+ // VS Code's document history owns Cmd/Ctrl+Z (§13.5); the kit's keys case checks it
2459
+ undoRedoKeys: false,
2460
+ // where Enter or Space on a creating palette entry places the element (§5.5): the renderer takes
2461
+ // the fit insets and computes the centre of the free rectangle itself, the same point as
2462
+ // viewport.freeRectCenter(); a renderer may take that function instead
2463
+ freeRectInsets: () => shell.fitInsets(),
2464
+ });
2465
+ const modeler = collaborative.diagram;
2466
+ modeler.get("canvas");
2467
+ const viewOptions = modeler.get<EventStormingViewOptions>("eventStormingViewOptions");
2468
+ const levels = modeler.get<{ getLevel(): string; setLevel(level: string): void }>("eventStormingModeling");
2469
+ // the canvas lock of an invalid file: context pad, move, resize, delete, paste, label editor
2470
+ const canvasLock = modeler.get<EventStormingReadOnly>("eventStormingReadOnly");
2471
+ const boardBounds = modeler.get<BoardBounds>("boardBounds");
2472
+
2473
+ // 2. The chrome around it.
2474
+ const shell = mountVsCodeShell(document.getElementById("app")!, {
2475
+ themes: ["light"],
2476
+ canvas: modeler.container,
2477
+ messages: { canvas: "Event Storming board" },
2478
+ menu: {
2479
+ model: [
2480
+ {
2481
+ kind: "radio",
2482
+ id: "level",
2483
+ label: "Level",
2484
+ options: [
2485
+ { value: "big-picture", label: "Big picture" },
2486
+ { value: "process", label: "Process modeling" },
2487
+ { value: "design", label: "Software design" },
2488
+ ],
2489
+ value: () => levels.getLevel(),
2490
+ onSelect: (level) => levels.setLevel(level),
2491
+ },
2492
+ {
2493
+ kind: "checkbox",
2494
+ id: "type-captions",
2495
+ label: "Type captions",
2496
+ keepEnabledWhenReadOnly: true, // a view toggle, not board content: usable while read-only
2497
+ checked: () => viewOptions.typeCaptionsVisible(),
2498
+ onToggle: (visible) => viewOptions.setTypeCaptionsVisible(visible),
2499
+ },
2500
+ ],
2501
+ export: [
2502
+ {
2503
+ kind: "action",
2504
+ id: "export-png",
2505
+ label: "Export as PNG",
2506
+ icon: "file-image",
2507
+ onSelect: () => void exportPng(),
2508
+ },
2509
+ {
2510
+ kind: "action",
2511
+ id: "export-svg",
2512
+ label: "Export as SVG",
2513
+ icon: "file-code-corner",
2514
+ onSelect: () => void exportSvg(),
2515
+ },
2516
+ ],
2517
+ help: [
2518
+ {
2519
+ kind: "dialog",
2520
+ id: "keyboard-shortcuts",
2521
+ label: "Keyboard shortcuts…",
2522
+ icon: "keyboard",
2523
+ onSelect: () => shortcutsDialog.open(),
2524
+ },
2525
+ ],
2526
+ },
2527
+ problems: { onActivate: () => vscode.postMessage({ type: "showProblems" }) },
2528
+ emptyHint: { onInsertExample: () => vscode.postMessage({ type: "insertExample" }) },
2529
+ });
2530
+ // The modeler's own dialog in shell.root (see Modeler dialogs); it goes with the diagram.
2531
+ const shortcutsDialog = createShortcutsDialog(shell);
2532
+ modeler.on("diagram.destroy", () => shortcutsDialog.destroy());
2533
+
2534
+ // 3. Viewport, palette, keys and the empty-file hint. Cheap tests only: canFit() is cached, but
2535
+ // exportMap() validates the whole board.
2536
+ const registry = modeler.get<{ getAll(): Array<{ parent?: unknown; waypoints?: unknown }> }>(
2537
+ "elementRegistry",
2538
+ );
2539
+ const isEmpty = () => !registry.getAll().some((element) => element.parent && !element.waypoints); // §13.5
2540
+ const connection = connectDiagramJs(shell, modeler, {
2541
+ isEmpty,
2542
+ // no padding of its own (the default pads by 120px): the fit insets are the only margin (§5.9)
2543
+ contentBounds: () => (isEmpty() ? undefined : boardBounds.contentBounds(0)),
2544
+ });
2545
+ const { viewport } = connection;
2546
+ persistView(vscode, viewport); // vscode.setState once the view rested, not on every frame
2547
+ modeler.on("commandStack.changed", () => shell.menu.refresh()); // level undo/redo
2548
+
2549
+ // 4. Every import from the document (init and external updates), strictly one after another.
2550
+ async function importText(text: string, first: boolean): Promise<void> {
2551
+ const keep = first ? undefined : viewport.getViewbox();
2552
+ try {
2553
+ await collaborative.importText(text); // silent: no change event comes from an import
2554
+ canvasLock.setReadOnly(false);
2555
+ shell.setReadOnly(false);
2556
+ shell.setStatus(null);
2557
+ if (keep) viewport.restore(keep);
2558
+ else if (first && stored.view) viewport.setState(stored.view);
2559
+ viewport.afterImport(); // first content fits, an empty file shows 100 %, the hint follows import.done
2560
+ shell.menu.refresh();
2561
+ } catch {
2562
+ // locked at once, the canvas included; marks and status box show after 800ms without a valid import
2563
+ canvasLock.setReadOnly(true);
2564
+ shell.setReadOnly(true, { delayMs: INVALID_STATUS_DELAY_MS });
2565
+ shell.setStatus(
2566
+ { severity: "danger", text: shell.messages.invalidFile("Event Storming board") },
2567
+ { delayMs: INVALID_STATUS_DELAY_MS },
2568
+ );
2569
+ }
2570
+ }
2571
+ let imports = Promise.resolve();
2572
+ const enqueueImport = (text: string, first = false) =>
2573
+ (imports = imports.then(() => importText(text, first)));
2574
+
2575
+ // 5. Messages from the extension: the document, the diagnostics and the Command Palette commands,
2576
+ // which run the same handlers as the menu.
2577
+ let firstImportSettled = () => {};
2578
+ const ready = new Promise<void>((resolve) => (firstImportSettled = resolve));
2579
+ window.addEventListener("message", (event) => {
2580
+ const message = event.data;
2581
+ if (message?.type === "init") void enqueueImport(message.text, true).then(firstImportSettled);
2582
+ if (message?.type === "update") void enqueueImport(message.text);
2583
+ if (message?.type === "problems") shell.setProblems({ count: message.count, errors: message.errors });
2584
+ if (message?.type === "command" && message.name === "fitView") viewport.fit();
2585
+ });
2586
+ vscode.postMessage({ type: "ready" });
2587
+
2588
+ // For the e2e test page (see ./testing/vscode-stub); the bundle publishes nothing on window.
2589
+ export const webview = { shell, modeler: collaborative, connection, ready };
2590
+ ```
2591
+
2592
+ `zod-jitless.ts` is one line, `config({ jitless: true })` from `zod`, in a module of its own so that
2593
+ it runs before any module that builds a schema.
2594
+
2595
+ A modeler with a legend persists it the same way: `legend: { render, open: stored.legendOpen,
2596
+ onToggle: (open) => writeShellState(vscode, { legendOpen: open }) }`.
2597
+
2598
+ What the Event Storming renderer changes for the shell (the kit's `aria`, `keys`, `create` and
2599
+ `readOnly` cases check each one):
2600
+
2601
+ - palette entries carry `title` and `shortcut` separately (`title: "Domain event", shortcut: "E"`),
2602
+ not `"Domain event — E"`;
2603
+ - Enter or Space on a creating entry (a click with `detail === 0`) creates the sticky at the
2604
+ centre of the free rectangle (from `freeRectInsets`, the point `viewport.freeRectCenter()`
2605
+ returns), 24px further diagonally while that spot is taken, selects it, focuses the canvas and
2606
+ opens the label editor;
2607
+ - no `tabindex` on `canvas.getContainer()` (an extra, unnamed tab stop before the palette); its
2608
+ keyboard listens on `document` anyway;
2609
+ - the document keyboard returns early on `event.defaultPrevented` and for targets outside the canvas
2610
+ container (the zoom panel's buttons, the inspector), selects every element on Cmd/Ctrl+A, and in
2611
+ the webview binds no Cmd/Ctrl+Z, Shift+Z or Y (`undoRedoKeys: false`): VS Code's document history
2612
+ owns undo (§13.5), the shell only keeps diagram-js's `KeyboardBindings` off them;
2613
+ - `eventStormingReadOnly` locks every edit rule (move, resize, create, connect, delete), and the
2614
+ keyboard, context pad, label editor and drawing handles consult it;
2615
+ - its own overlays inside the canvas (the note toolbar, the colour picker) stay at `z-index` 100 or
2616
+ below, so the palette at 110 covers them (see Layers above);
2617
+ - `boardBounds.contentBounds(0)`: the tight bounds of the board, since the fit insets are the margin.
2618
+
2619
+ Document sync, echo suppression, minimal edits and undo stay in the modeler for 0.1 (see below).
2620
+
2621
+ ## Usage: Event Storming in the web app
2622
+
2623
+ The Event Storming web app (`apps/webapp`), the pilot of 0.2. It is light-only until the shared
2624
+ dark-mode release, so its `index.html` is static and its head carries the contrast line only:
2625
+
2626
+ ```html
2627
+ <!doctype html>
2628
+ <html lang="en" data-cd-theme="light">
2629
+ <head>
2630
+ <meta charset="UTF-8" />
2631
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
2632
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
2633
+ <!-- miragon-mode: webHeadMarkup({ themes: ["light"], themeColors }) through transformIndexHtml -->
2634
+ <title>Event Storming Modeler</title>
2635
+ </head>
2636
+ <body>
2637
+ <div id="app"></div>
2638
+ <script type="module" src="/src/main.ts"></script>
2639
+ </body>
2640
+ </html>
2641
+ ```
2642
+
2643
+ `src/main.ts`, shortened to what touches the shell (the real file is
2644
+ `apps/webapp/src/main.ts` in Miragon/event-storming-modeler):
2645
+
2646
+ ```ts
2647
+ import "@fontsource-variable/geist/wght.css";
2648
+ import "@fontsource-variable/geist-mono/wght.css";
2649
+ import "@miragon/event-storming-renderer/assets/event-storming.css"; // the tokens, diagram-js.css
2650
+ import "@miragon/modeler-shell/shell.css"; // last
2651
+ import {
2652
+ Modeler,
2653
+ boardMenu,
2654
+ createShortcutsDialog,
2655
+ importWarningText,
2656
+ type ImportWarning,
2657
+ } from "@miragon/event-storming-renderer";
2658
+ import {
2659
+ commandKeyLabel,
2660
+ defaultMessages,
2661
+ detectPlatform,
2662
+ mountShell,
2663
+ problemSummary,
2664
+ } from "@miragon/modeler-shell";
2665
+ import { connectDiagramJs, shellModule } from "@miragon/modeler-shell/diagram-js";
2666
+ import { copyTextWhenReady } from "./clipboard"; // ClipboardItem with a promise: Safari keeps the gesture
2667
+ import { hashHoldsBoard, readHashMap, shareUrl, writeHashMap } from "./share";
2668
+ import { THEMES } from "./themes"; // ["light"]
2669
+
2670
+ // 1. The diagram first (its keyboard listens on document); the web app keeps the canvas undo keys.
2671
+ const modeler = new Modeler({
2672
+ width: "100%",
2673
+ height: "100%",
2674
+ additionalModules: [shellModule],
2675
+ freeRectInsets: () => shell.fitInsets(), // keyboard creation lands in the free rectangle
2676
+ });
2677
+ modeler.get("canvas");
2678
+ const platform = detectPlatform(navigator);
2679
+ const openKey = commandKeyLabel("O", platform, defaultMessages.keyNames); // "⌘O" / "Ctrl+O"
2680
+ const saveKey = commandKeyLabel("S", platform, defaultMessages.keyNames);
2681
+
2682
+ // 2. The chrome, the whole web column.
2683
+ const shell = mountShell(document.getElementById("app")!, {
2684
+ host: "web",
2685
+ canvas: modeler.container,
2686
+ lang: "en",
2687
+ platform,
2688
+ themes: THEMES,
2689
+ messages: { canvas: "Event Storming board" },
2690
+ primary: { onSelect: () => share() }, // "Share", share-2
2691
+ legal: { links: [{ label: "GitHub", href: "https://github.com/Miragon/event-storming-modeler" }] },
2692
+ file: {
2693
+ newDiagram: { onSelect: () => void newDiagram() },
2694
+ open: { onSelect: () => pickFile(), shortcut: openKey }, // also the start screen's "Open a file…"
2695
+ showExample: { onSelect: () => void showExample() },
2696
+ },
2697
+ // Model (level, type captions), Export and Help: the same entries the VS Code webview shows.
2698
+ menu: boardMenu({
2699
+ levels,
2700
+ typeCaptions,
2701
+ exports: {
2702
+ png: { onSelect: () => void exportPicture("png") },
2703
+ svg: { onSelect: () => void exportPicture("svg") },
2704
+ transparentPng: { onSelect: () => void exportPicture("png-transparent") },
2705
+ json: { onSelect: () => exportBoardText("json") },
2706
+ storm: { onSelect: () => exportBoardText("storm"), shortcut: saveKey },
2707
+ },
2708
+ showKeyboardShortcuts: () => shortcutsDialog.open(),
2709
+ }),
2710
+ problems: { onSelectItem: (item) => selectElement(item.elementId) },
2711
+ emptyState: {
2712
+ title: "Event Storming Modeler",
2713
+ text: "Start a new diagram, or explore the example.",
2714
+ komet: "/miragon-komet-blau.svg", // vendored unchanged from corporate-identity
2715
+ onNewDiagram: () => void newDiagram(),
2716
+ onShowExample: () => void showExample(),
2717
+ dropHint: "Or drop a .storm, .json, or .txt file here", // the types the reject toast names
2718
+ },
2719
+ dropZone: {
2720
+ accept: [".storm", ".json", ".txt", "application/json", "text/plain"],
2721
+ onDrop: ([file]) => file && void openBoardFile(file),
2722
+ },
2723
+ });
2724
+ const shortcutsDialog = createShortcutsDialog(shell, { fileKeys: true });
2725
+
2726
+ // 3. Viewport, palette, keys.
2727
+ const { viewport } = connectDiagramJs(shell, modeler, {
2728
+ isEmpty,
2729
+ contentBounds: () => (isEmpty() ? undefined : boardBounds.contentBounds(0)),
2730
+ });
2731
+
2732
+ // 4. Every new document opens like the first (§5.9); the start screen goes with it.
2733
+ async function openDocument(load: () => Promise<readonly ImportWarning[]>, announce: boolean) {
2734
+ viewport.newDocument();
2735
+ let warnings: readonly ImportWarning[];
2736
+ try {
2737
+ warnings = await load();
2738
+ } finally {
2739
+ viewport.afterImport();
2740
+ }
2741
+ shell.setEmptyState(false);
2742
+ shell.menu.refresh();
2743
+ updateDocumentTitle(); // "Order Checkout · Event Storming Modeler" (§5.4)
2744
+ // The parser names lines: "Line 4 · A -> Nowhere" below the message, no element to select.
2745
+ shell.setProblems(problemSummary(problemItems(warnings)));
2746
+ for (const warning of warnings) console.warn(importWarningText(warning));
2747
+ // A toast only after an import the user started, with the count (§9), never on a restore.
2748
+ if (announce && warnings.length > 0) {
2749
+ const pill = shell.messages.problems(warnings.length, String(warnings.length));
2750
+ shell.toast({
2751
+ status: "warning",
2752
+ text: `Imported with ${warnings.length} warnings. Details under “${pill}” bottom left.`,
2753
+ });
2754
+ }
2755
+ }
2756
+
2757
+ // Replacing a board clears the canvas history, so instead of confirm() it offers the board back
2758
+ // (§9 asks for a confirmation; the undo toast is the open guide question): "Board replaced · Undo",
2759
+ // also on Cmd/Ctrl+Z while the canvas has nothing else to undo. An edit withdraws the offer.
2760
+ async function replaceBoard(load: () => Promise<readonly ImportWarning[]>) {
2761
+ const previous = boardIsEmpty() ? undefined : modeler.exportDSL();
2762
+ await openDocument(load, true);
2763
+ withdrawReplaceUndo();
2764
+ if (previous === undefined || previous === modeler.exportDSL()) return;
2765
+ const undo = () => void openDocument(async () => (await modeler.importDSL(previous)).warnings, false);
2766
+ replaceUndo = {
2767
+ toast: shell.toast({ status: "info", text: "Board replaced", action: { label: "Undo", onSelect: undo } }),
2768
+ undo,
2769
+ };
2770
+ }
2771
+
2772
+ // Copy inside the click: Safari drops the user activation at the first await before writeText.
2773
+ function share(): void {
2774
+ const dsl = modeler.exportDSL();
2775
+ const copied = copyTextWhenReady(shareUrl(dsl));
2776
+ void (async () => {
2777
+ try {
2778
+ await writeHashMap(dsl);
2779
+ await copied;
2780
+ shell.toast({ status: "success", text: "Link copied" });
2781
+ } catch {
2782
+ shell.toast({
2783
+ status: "danger",
2784
+ text: "Couldn't copy the link. Copy it from the address bar instead.",
2785
+ });
2786
+ }
2787
+ })();
2788
+ }
2789
+
2790
+ // The app's own keys: Cmd/Ctrl+O opens a file, Cmd/Ctrl+S exports the .storm file (never the
2791
+ // browser's dialogs, also not over a modal dialog; on the start screen there is nothing to save).
2792
+ window.addEventListener("keydown", onAppKey);
2793
+
2794
+ modeler.on("commandStack.changed", (event: { trigger?: string }) => {
2795
+ if (event.trigger !== "clear") {
2796
+ shell.setEmptyState(false); // the first edit is a start as well
2797
+ withdrawReplaceUndo(); // an edit makes the undo offer stale
2798
+ }
2799
+ shell.menu.refresh(); // the level is undoable
2800
+ updateDocumentTitle();
2801
+ });
2802
+
2803
+ // 5. Start: the board from the link, else the start screen on an empty board.
2804
+ const linked = await readHashMap();
2805
+ if (linked !== null) await openDocument(async () => (await modeler.importDSL(linked)).warnings, true);
2806
+ else {
2807
+ await modeler.importMap(createEmptyBoard("New board"));
2808
+ viewport.afterImport(); // an empty board at 100 %
2809
+ shell.setEmptyState(true);
2810
+ if (hashHoldsBoard())
2811
+ shell.toast({
2812
+ status: "danger",
2813
+ text: "Couldn't read the board in this link. Check that you copied the whole link.",
2814
+ });
2815
+ }
2816
+ ```
2817
+
2818
+ `openBoardFile(file)` reads the file before the canvas is touched and then runs `replaceBoard`; a
2819
+ parse error shows the danger toast "Couldn't read “name”. Check that it contains a valid Event
2820
+ Storming board." and keeps the board. `pickFile` opens the hidden file input with the same
2821
+ `accept`. The JSON export writes `JSON.stringify(modeler.exportMap(), null, 2)`, the board's own
2822
+ element order (the importer stacks overlapping stickies by it). The web app's own `style.css`,
2823
+ `toast.ts` and the hand-written menu, zoom, fit and legal markup go away.
2824
+
2825
+ ## Out of scope for 0.2
2826
+
2827
+ Later candidates unless noted:
2828
+
2829
+ - **VS Code document plumbing**: two-way sync with echo suppression, minimal `WorkspaceEdit`s,
2830
+ undo/redo through the document's history, "Insert example" as one edit, diagnostics to the
2831
+ Problems view (the pill only means something with a `DiagnosticCollection`), shared webview→host
2832
+ messages for the pill ("Show in Problems view") and "Insert example" with their host handlers,
2833
+ export save dialog. The modelers keep their own code; the shell keeps diagram-js's
2834
+ `KeyboardBindings` off undo/redo in the IDE host, stops undo/redo keys in text fields and stops
2835
+ keys the canvas handled.
2836
+ - An ES-module webview bundle (`renderWebviewHtml` writes a classic script; a `scriptType` option).
2837
+ - **More of the web column**: further top-right actions left of the primary action (§5.7), the
2838
+ loading state (§9 "Laden": spinner or skeleton during asynchronous work), share-link helpers
2839
+ (each modeler keeps its own hash encoding), the legend's state across sessions (a modeler persists
2840
+ it from `legend.onToggle` in `localStorage`, §5.10).
2841
+ - **Dialog component and the shared shortcuts dialog** (§5.4, §8; also the confirmation of §9
2842
+ "Destruktiv" where an undo toast does not fit): an exported `cd-shell-dialog`
2843
+ class (panel, header with Close, body, backdrop with `grayscale(1)`, the high-contrast and
2844
+ forced-colours rules, the top layer through `showModal()`, focus trap and focus return), and on
2845
+ it `shell.showShortcutsDialog(groups: ShortcutGroup[])` with one table per group and the chips of
2846
+ the tooltip's `kbd`. The shell fills the View group itself from the chrome keys. New
2847
+ `ShellMessages`: `shortcutsTitle` ("Keyboard shortcuts") and the group names
2848
+ "View", "Edit" and "Tools". Popovers (§5.4) follow on the same class. Until then a modeler meets
2849
+ the rules of [Modeler dialogs](#modeler-dialogs) itself; one component lets every modeler migrate
2850
+ once.
2851
+ - **Placement helper** for palette keyboard creation: the 24px diagonal steps when the centre of the
2852
+ free rectangle is taken (§5.5), next to `Viewport.freeRectCenter()`.
2853
+ - the `appearance` surface of the modeler-api contract (a later 1.x minor) for embedded hosts and
2854
+ the `miragon.<modeler>.colorTheme` setting UI (the bridge already honours `settingKey`).
2855
+ - **Kit**:
2856
+ - cases for reduced motion and forced colours on every host (`PageLike.emulateMedia` exists since
2857
+ 0.2, but only the web `appearance` case uses it); the shell's CSS covers both, its own e2e
2858
+ suite checks VS Code's reduce motion and spot-checks forced colours on the web chrome;
2859
+ - a CSP helper: serve the production bundle under `renderWebviewHtml`'s CSP inside the VS Code
2860
+ stub, record every `securitypolicyviolation` and fail on any (until then the recipe under
2861
+ `./vscode/extension`);
2862
+ - a harness field `documents?: readonly string[]` that hands `open` a document key (for example
2863
+ `["example", "style-dark", "empty"]`), so `appearance` runs per document; until then a modeler
2864
+ registers a second harness whose `open` loads the variant;
2865
+ - on those documents, a check that an empty one opens at 100 % with the empty-file hint;
2866
+ - a check that selects an element and reports positioned descendants of `.djs-container` above
2867
+ `z-index` 100 that are neither the palette, diagram-js's popup menu nor `.djs-hover-tooltip`.
2868
+ - IntelliJ/JCEF hosts, Electron, Tauri, Theia.
2869
+ - Canvas theming (`cd-canvas.css`, `cd-c-*` classes), light-only export, the token files themselves.