@miragon/modeler-shell 0.0.0-stage → 0.1.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 (126) hide show
  1. package/API.md +1902 -0
  2. package/LICENSE +21 -0
  3. package/README.md +435 -2
  4. package/THIRD_PARTY_NOTICES.md +52 -0
  5. package/dist/appearance-D3FUcbu6.d.cts +53 -0
  6. package/dist/appearance-D3FUcbu6.d.ts +53 -0
  7. package/dist/chunk-5JSZ4VGA.js +551 -0
  8. package/dist/chunk-5JSZ4VGA.js.map +1 -0
  9. package/dist/chunk-6LQOFACK.js +1576 -0
  10. package/dist/chunk-6LQOFACK.js.map +1 -0
  11. package/dist/chunk-N4QXFF3A.js +23 -0
  12. package/dist/chunk-N4QXFF3A.js.map +1 -0
  13. package/dist/chunk-RJRFR6N3.js +136 -0
  14. package/dist/chunk-RJRFR6N3.js.map +1 -0
  15. package/dist/core.cjs +619 -0
  16. package/dist/core.cjs.map +1 -0
  17. package/dist/core.d.cts +171 -0
  18. package/dist/core.d.ts +171 -0
  19. package/dist/core.js +91 -0
  20. package/dist/core.js.map +1 -0
  21. package/dist/diagram-js.cjs +1115 -0
  22. package/dist/diagram-js.cjs.map +1 -0
  23. package/dist/diagram-js.d.cts +163 -0
  24. package/dist/diagram-js.d.ts +163 -0
  25. package/dist/diagram-js.js +867 -0
  26. package/dist/diagram-js.js.map +1 -0
  27. package/dist/index.cjs +2175 -0
  28. package/dist/index.cjs.map +1 -0
  29. package/dist/index.d.cts +20 -0
  30. package/dist/index.d.ts +20 -0
  31. package/dist/index.js +103 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/keymap-CObQE6q6.d.cts +151 -0
  34. package/dist/keymap-DORzHnK-.d.ts +151 -0
  35. package/dist/protocol-DhYBakWL.d.cts +15 -0
  36. package/dist/protocol-IeW8bf4f.d.ts +15 -0
  37. package/dist/shell.css +914 -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 +2364 -0
  45. package/dist/testing.cjs.map +1 -0
  46. package/dist/testing.d.cts +165 -0
  47. package/dist/testing.d.ts +165 -0
  48. package/dist/testing.js +2297 -0
  49. package/dist/testing.js.map +1 -0
  50. package/dist/types-B6NfkiUG.d.cts +66 -0
  51. package/dist/types-B6NfkiUG.d.ts +66 -0
  52. package/dist/types-DnZn9CPw.d.ts +284 -0
  53. package/dist/types-OuFlqZp0.d.cts +284 -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 +2198 -0
  61. package/dist/vscode-webview.cjs.map +1 -0
  62. package/dist/vscode-webview.d.cts +82 -0
  63. package/dist/vscode-webview.d.ts +82 -0
  64. package/dist/vscode-webview.js +250 -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/fit.ts +205 -0
  69. package/src/core/index.ts +84 -0
  70. package/src/core/keymap.ts +275 -0
  71. package/src/core/layout.ts +69 -0
  72. package/src/core/messages.ts +88 -0
  73. package/src/core/sizes.ts +85 -0
  74. package/src/core/types.ts +84 -0
  75. package/src/core/zoom.ts +74 -0
  76. package/src/core.ts +2 -0
  77. package/src/diagram-js/connect.ts +75 -0
  78. package/src/diagram-js/index.ts +16 -0
  79. package/src/diagram-js/module.ts +20 -0
  80. package/src/diagram-js/palette-skin.ts +267 -0
  81. package/src/diagram-js/palette.css +269 -0
  82. package/src/diagram-js/service.ts +240 -0
  83. package/src/diagram-js/types.ts +67 -0
  84. package/src/diagram-js/viewport.ts +564 -0
  85. package/src/diagram-js.ts +2 -0
  86. package/src/dom/element.ts +51 -0
  87. package/src/dom/hint.ts +51 -0
  88. package/src/dom/icons.ts +309 -0
  89. package/src/dom/index.ts +29 -0
  90. package/src/dom/inspector.ts +35 -0
  91. package/src/dom/keys.ts +59 -0
  92. package/src/dom/ledger.ts +87 -0
  93. package/src/dom/legend.ts +85 -0
  94. package/src/dom/menu.ts +393 -0
  95. package/src/dom/mount.ts +461 -0
  96. package/src/dom/problems.ts +72 -0
  97. package/src/dom/shell-dom.css +966 -0
  98. package/src/dom/status.ts +72 -0
  99. package/src/dom/tooltip.ts +237 -0
  100. package/src/dom/types.ts +277 -0
  101. package/src/dom/zoom-panel.ts +154 -0
  102. package/src/index.ts +3 -0
  103. package/src/shell.css +6 -0
  104. package/src/testing/cases.ts +1012 -0
  105. package/src/testing/checks.ts +832 -0
  106. package/src/testing/facts.ts +587 -0
  107. package/src/testing/geometry.ts +472 -0
  108. package/src/testing/harness.ts +138 -0
  109. package/src/testing/index.ts +15 -0
  110. package/src/testing/page-tools.ts +434 -0
  111. package/src/testing/page.ts +17 -0
  112. package/src/testing/vscode-stub.ts +237 -0
  113. package/src/testing-vscode-stub.ts +2 -0
  114. package/src/testing.ts +2 -0
  115. package/src/vscode/extension/html.ts +107 -0
  116. package/src/vscode/extension/index.ts +8 -0
  117. package/src/vscode/extension/reveal.ts +9 -0
  118. package/src/vscode/extension/theme-bridge.ts +85 -0
  119. package/src/vscode/extension/types.ts +36 -0
  120. package/src/vscode/protocol.ts +31 -0
  121. package/src/vscode/webview/appearance.ts +141 -0
  122. package/src/vscode/webview/index.ts +8 -0
  123. package/src/vscode/webview/mount.ts +76 -0
  124. package/src/vscode/webview/state.ts +116 -0
  125. package/src/vscode-extension.ts +2 -0
  126. package/src/vscode-webview.ts +2 -0
package/API.md ADDED
@@ -0,0 +1,1902 @@
1
+ # @miragon/modeler-shell 0.1 — 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, and the plumbing to a VS Code
6
+ webview. Planned in [Miragon/modeler-api#4](https://github.com/Miragon/modeler-api/issues/4)
7
+ (Phase 4); the first consumer is the Event Storming modeler.
8
+
9
+ - **Look and behaviour** come from the Miragon design system, guide `modeler-design-system.md`
10
+ (skill `miragon-brand:modeler-tool-design`), §5.1 to §5.15, §6, §8, §9, §11, §12 and §13.3 to
11
+ §13.5. Section numbers below (§x.y) refer to it.
12
+ - **Mechanics** (diagram-js services, VS Code quirks) are in
13
+ [`docs/shell/implementation-notes.md`](../../docs/shell/implementation-notes.md).
14
+ - **Zero runtime dependencies.** The shell never imports `diagram-js`, `didi`, `vscode`, Lucide or
15
+ React, not even for types: diagram-js services are typed structurally, the `vscode` namespace and
16
+ Playwright's `page`/`test` are passed in.
17
+ - **Tokens are the consumer's.** The modeler vendors `cd-tokens.generated.css` unchanged; the shell
18
+ CSS reads only `var(--cd-ui-*)` roles, `var(--cd-shell-*)` sizes and the mode-independent
19
+ non-colour tokens `--cd-radius-*`, `--cd-shadow-*`, `--cd-motion-*`, `--cd-ease`, `--cd-ease-out`.
20
+ It contains no colour literal and never a fixed palette token (`--cd-blau`, `--cd-schwarz`, …).
21
+ - **English by default.** Every text comes from one messages table (`ShellMessages`), every number
22
+ goes through `Intl` in the UI language.
23
+
24
+ ## Entry points
25
+
26
+ | Entry | For | Needs a DOM |
27
+ | -------------------------------------------- | ----------------------------------------------------------- | ------------------------------ |
28
+ | `@miragon/modeler-shell` | the chrome (`mountShell`), icons, plus all of `./core` | yes (at call time) |
29
+ | `@miragon/modeler-shell/core` | fit math, keymap, zoom readout, appearance, sizes, messages | no |
30
+ | `@miragon/modeler-shell/diagram-js` | `shellModule`, viewport, `connectDiagramJs` | yes |
31
+ | `@miragon/modeler-shell/vscode/extension` | webview HTML, nonce, theme bridge (extension host) | no |
32
+ | `@miragon/modeler-shell/vscode/webview` | `mountVsCodeShell`, appearance handler, webview state | yes |
33
+ | `@miragon/modeler-shell/testing` | Playwright-driven shell conformance kit | runs in Node, drives a browser |
34
+ | `@miragon/modeler-shell/testing/vscode-stub` | a stand-in for VS Code around a webview test page | yes (in the test page) |
35
+ | `@miragon/modeler-shell/shell.css` | every style of the shell | — |
36
+
37
+ All JavaScript entries ship as ESM and CJS with types (`typesVersions` covers `moduleResolution:
38
+ node10`); importing any of them has no side effects. The package also ships `src/` without its tests,
39
+ the files the sourcemaps point at, so stack traces and test locations resolve to real files. Every
40
+ optional input field also accepts an explicit `undefined`, so consumers compiling with
41
+ `exactOptionalPropertyTypes` can forward optional values (`{ view: viewport.getState() }`,
42
+ `{ open: stored.legendOpen }`); the shell compiles with it too.
43
+
44
+ ### Stylesheet order
45
+
46
+ The tokens come first, unless the renderer's stylesheet already imports them (Event Storming's
47
+ `event-storming.css` does, in a layer), and are loaded exactly once:
48
+
49
+ ```ts
50
+ import "./theme/cd-tokens.generated.css"; // vendored, unchanged, first; left out when the renderer CSS brings it
51
+ import "@fontsource-variable/geist/wght.css";
52
+ import "@fontsource-variable/geist-mono/wght.css"; // shortcut chips
53
+ import "diagram-js/assets/diagram-js.css"; // usually through the renderer's CSS
54
+ import "@miragon/<notation>-renderer/…css"; // the renderer
55
+ import "@miragon/modeler-shell/shell.css"; // last
56
+ ```
57
+
58
+ A bundle ships the fonts as files, not as data URLs (see `./vscode/extension`).
59
+
60
+ ## `./core` — DOM-free
61
+
62
+ Compiles without the DOM library (`tsconfig.core.json`), usable in the extension host and in workers.
63
+
64
+ ### Geometry and shared types
65
+
66
+ ```ts
67
+ interface Point {
68
+ x: number;
69
+ y: number;
70
+ }
71
+ interface Size {
72
+ width: number;
73
+ height: number;
74
+ }
75
+ // content bounds and viewboxes are in diagram coordinates
76
+ interface Bounds {
77
+ x: number;
78
+ y: number;
79
+ width: number;
80
+ height: number;
81
+ }
82
+ interface ScaledViewbox extends Bounds {
83
+ scale: number;
84
+ }
85
+ // px from the edges of the modeler's container
86
+ interface Insets {
87
+ top: number;
88
+ right: number;
89
+ bottom: number;
90
+ left: number;
91
+ }
92
+
93
+ type ShellHost = "ide" | "web" | "embedded";
94
+ type ChromeIsland = "menu" | "palette" | "primary" | "zoom" | "problems" | "legend" | "inspector" | "status";
95
+ const CHROME_ISLANDS: readonly ChromeIsland[];
96
+ type PaletteRow = "top" | "second-row";
97
+
98
+ interface ZoomTarget {
99
+ zoom(): number; // 1 = 100 %
100
+ stepZoom(direction: 1 | -1): void; // zoomScroll raster, around the container centre
101
+ zoomToActual(): void; // 100 %, a user move
102
+ fit(): void; // explicit fit: animated unless reduced motion; view becomes automatic
103
+ canFit(): boolean; // false while the content bounds are empty
104
+ onChange(listener: () => void): () => void; // every viewbox change and canFit change; unsubscribe
105
+ }
106
+
107
+ // JSON-serializable, for VS Code setState
108
+ interface ViewportState {
109
+ viewbox: Bounds;
110
+ userMoved: boolean; // zoomed or panned since the last fit: a resize no longer refits
111
+ }
112
+ ```
113
+
114
+ `ShellHost` is the host column of §5.2: `ide` (VS Code webview, later IntelliJ), `web` (a Miragon web
115
+ app owning its page), `embedded` (a foreign page such as designIQ; geometry like `ide`).
116
+
117
+ ### Sizes
118
+
119
+ ```ts
120
+ const SHELL_TOKENS: {
121
+ edge: "--cd-shell-rand";
122
+ gap: "--cd-shell-luecke";
123
+ row: "--cd-shell-zeile";
124
+ target: "--cd-shell-ziel";
125
+ entry: "--cd-shell-eintrag";
126
+ free: "--cd-shell-frei";
127
+ menuMinWidth: "--cd-shell-menue-breite";
128
+ inspectorWidth: "--cd-shell-inspector-breite";
129
+ fitInsetTop: "--cd-shell-einpassen-oben";
130
+ fitInsetTopSecondRow: "--cd-shell-einpassen-oben-schmal";
131
+ fitInsetSide: "--cd-shell-einpassen-seite";
132
+ fitInsetBottom: "--cd-shell-einpassen-unten";
133
+ fitInsetBottomWeb: "--cd-shell-einpassen-unten-web";
134
+ narrow: "--cd-shell-schmal";
135
+ veryNarrow: "--cd-shell-sehr-schmal";
136
+ low: "--cd-shell-niedrig";
137
+ layerIslands: "--cd-shell-ebene-inseln";
138
+ layerInspector: "--cd-shell-ebene-inspector";
139
+ layerTopRow: "--cd-shell-ebene-reihe";
140
+ layerPopups: "--cd-shell-ebene-aufklapper";
141
+ layerDialog: "--cd-shell-ebene-dialog";
142
+ layerToast: "--cd-shell-ebene-toast";
143
+ };
144
+ type ShellSizeName = keyof typeof SHELL_TOKENS;
145
+ type ShellSizes = Readonly<Record<ShellSizeName, number>>;
146
+ const SHELL: ShellSizes; // edge 12, gap 8, row 40, target 32, entry 36, free 64, menuMinWidth 248,
147
+ // inspectorWidth 288, fitInsetTop 88, fitInsetTopSecondRow 140, fitInsetSide 24, fitInsetBottom 64,
148
+ // fitInsetBottomWeb 92, narrow 760, veryNarrow 440, low 820, layers 20, 25, 30, 40, 50, 60
149
+ function shellSizesFromTokens(shellGroup: Readonly<Record<string, string>>): ShellSizes;
150
+ ```
151
+
152
+ `SHELL` equals `parseFloat` of the `shell` group of `cd-tokens.generated.json`; a drift test pins it
153
+ to the token fixture. `shellSizesFromTokens` reads a vendored JSON's `shell` group and throws, naming
154
+ the token, when a size is missing or neither `"<n>px"` nor a plain number (so `"12em"` fails instead
155
+ of silently becoming 12); unknown tokens are ignored.
156
+
157
+ A modeler pins its own vendored JSON the same way, in a unit test (Vitest or `node:test`), never in
158
+ its e2e suite: the check is pure data. Both expectations, because the fit math reads `SHELL` and the
159
+ stylesheet reads the tokens:
160
+
161
+ ```ts
162
+ expect(SHELL).toEqual(shellSizesFromTokens(vendoredTokens.shell));
163
+ expect(Object.keys(vendoredTokens.shell)).toEqual(expect.arrayContaining(Object.values(SHELL_TOKENS)));
164
+ ```
165
+
166
+ ### Fit
167
+
168
+ ```ts
169
+ const MAX_FIT_ZOOM = 1;
170
+ const FIT_ANIMATION_MS = 250;
171
+ const VIEW_TOLERANCE = 1e-3;
172
+
173
+ function fitViewbox(
174
+ bounds: Bounds,
175
+ container: Size,
176
+ insets: Insets,
177
+ maxZoom?: number,
178
+ ): ScaledViewbox | undefined;
179
+
180
+ interface FitLayout {
181
+ container: Size;
182
+ host: ShellHost;
183
+ paletteRow: PaletteRow;
184
+ problemsVisible: boolean;
185
+ legendWidth?: number; // open legend's rendered width, undefined when closed/absent
186
+ inspector?: { sheetTop?: number }; // present = open; sheetTop measured when it is a bottom sheet
187
+ }
188
+ function fitInsets(layout: FitLayout): Insets;
189
+ function freeRectCenter(container: Size, insets: Insets): Point | undefined; // px from the container's top left
190
+
191
+ function sameView(
192
+ a: Pick<ScaledViewbox, "x" | "y" | "scale">,
193
+ b: Pick<ScaledViewbox, "x" | "y" | "scale">,
194
+ tolerance?: number,
195
+ ): boolean;
196
+ function interpolateViewbox(from: ScaledViewbox, to: ScaledViewbox, progress: number): ScaledViewbox;
197
+ function easeStandard(progress: number): number; // cubic-bezier(0.4, 0, 0.2, 1)
198
+ ```
199
+
200
+ - `fitViewbox` centres `bounds` in the free rectangle (container minus insets) at the largest scale
201
+ that fits, capped at `maxZoom`, and returns a box with the container's aspect ratio, so
202
+ `canvas.viewbox(box)` shows exactly that view (§5.9). `undefined` for a container with a 0 side,
203
+ for bounds without width and height, or for a non-finite result; a zero width or height scales by
204
+ the other dimension. An axis whose insets leave no room (a container lower or narrower than its
205
+ chrome) centres in the whole container.
206
+ - `fitInsets` (§5.9): top 88, 140 in the second row; left and right 24; bottom 64, in the `web` host
207
+ with a visible pill 92; right 324 (12 + 288 + 24) with an open side inspector (container wider
208
+ than 760); left 12 + legend width + 24 with an open legend in containers wider than 440
209
+ (`legendWidth <= 0` counts as closed); bottom `container.height − sheetTop + 12` with an open
210
+ bottom sheet (up to 760). The sheet hides zoom panel, pill and legend (§5.11), so while it is open
211
+ neither the legend nor the web pill counts; without a measured `sheetTop` the bottom stays 64.
212
+ - `freeRectCenter` is the centre of the free rectangle, where `fitViewbox` centres the content and
213
+ keyboard creation places an element (§5.5), in pixels from the container's top left; an axis whose
214
+ insets leave no room centres in the whole container, as in `fitViewbox`. `undefined` for a
215
+ container with a 0 side. `Viewport.freeRectCenter()` turns it into diagram coordinates.
216
+ - `sameView` compares `scale` and the screen translation (`x · scale`, `y · scale`) within
217
+ `tolerance` relative to `max(1, |value|)`; width and height never count, so a resize is no move.
218
+ - `interpolateViewbox` interpolates the scale in log space and the centre linearly.
219
+
220
+ ### Zoom readout
221
+
222
+ ```ts
223
+ const ZOOM_MIN = 0.2;
224
+ const ZOOM_MAX = 4;
225
+ function canZoomOut(zoom: number): boolean; // zoom > 0.2 · 1.001
226
+ function canZoomIn(zoom: number): boolean; // zoom < 4 · 0.999
227
+ function formatPercent(zoom: number, lang: string): string; // "86%", German "86 %" (U+00A0)
228
+ function formatNumber(value: number, lang: string): string; // a tag Intl rejects ("") formats as "en"
229
+ interface ZoomReadout {
230
+ text: string;
231
+ ariaLabel: string;
232
+ canZoomOut: boolean;
233
+ canZoomIn: boolean;
234
+ }
235
+ function zoomReadout(
236
+ zoom: number,
237
+ lang: string,
238
+ messages: Pick<ShellMessages, "zoomToActualNow">,
239
+ ): ZoomReadout;
240
+ ```
241
+
242
+ ### Keymap
243
+
244
+ ```ts
245
+ type ChromeKey = "zoomIn" | "zoomOut" | "zoomToActual" | "fit";
246
+ interface KeyLike {
247
+ key: string;
248
+ code: string;
249
+ metaKey: boolean;
250
+ ctrlKey: boolean;
251
+ shiftKey: boolean;
252
+ altKey: boolean;
253
+ isComposing?: boolean;
254
+ }
255
+ function matchChromeKey(event: KeyLike): ChromeKey | undefined;
256
+ function isUndoRedoKey(event: KeyLike): boolean;
257
+ const TEXT_FIELD_SELECTOR = "input, textarea, select, [contenteditable]:not([contenteditable='false'])";
258
+
259
+ interface KeyContext {
260
+ host: ShellHost;
261
+ inTextField: boolean; // target matches TEXT_FIELD_SELECTOR or isContentEditable
262
+ menuOpen: boolean; // the shell's dropdown
263
+ dialogOpen: boolean; // any shown [role="dialog"], [role="alertdialog"] or <dialog open>, modal or not
264
+ modalOpen: boolean; // one of them with aria-modal="true" or :modal
265
+ defaultPrevented: boolean; // the canvas or the modeler handled the key
266
+ }
267
+ type KeyAction = ChromeKey | "closeMenu";
268
+ interface KeyDecision {
269
+ action?: KeyAction;
270
+ preventDefault: boolean;
271
+ stopPropagation: boolean;
272
+ }
273
+ function decideKey(event: KeyLike, context: KeyContext): KeyDecision;
274
+
275
+ type Platform = "mac" | "other";
276
+ interface NavigatorLike {
277
+ platform?: string;
278
+ userAgentData?: { platform?: string };
279
+ }
280
+ function detectPlatform(navigator?: NavigatorLike): Platform;
281
+ function shortcutLabel(key: ChromeKey, platform: Platform, keyNames: ShellMessages["keyNames"]): string;
282
+ function ariaKeyShortcuts(key: ChromeKey, platform: Platform): string;
283
+ function ariaKeyShortcutsFromLabel(label: string): string | undefined;
284
+ ```
285
+
286
+ `matchChromeKey` (§5.8) — Cmd/Ctrl is `metaKey || ctrlKey` on every platform, nothing matches with
287
+ `altKey` (AltGr):
288
+
289
+ | Chrome key | Matches |
290
+ | -------------- | -------------------------------------------------------------------------- |
291
+ | `zoomIn` | Cmd/Ctrl + `key` `+` or `=` (with or without Shift), or `code` `NumpadAdd` |
292
+ | `zoomOut` | Cmd/Ctrl + `key` `-` or `_`, or `code` `NumpadSubtract` |
293
+ | `zoomToActual` | Cmd/Ctrl + `key` `0`, or `code` `Numpad0` (also with NumLock off) |
294
+ | `fit` | `code` `Digit1` with Shift, without Cmd, Ctrl and Alt (every layout) |
295
+
296
+ `decideKey` holds every key rule of §5.8 and §13.5; the shell's one `keydown` listener only applies
297
+ it. `stopPropagation` is only ever `true` in the `ide` host.
298
+
299
+ | Case | Action | preventDefault | stopPropagation (`ide`) |
300
+ | --------------------------------------------------------- | ----------- | -------------- | ----------------------- |
301
+ | Escape (no modifier), menu open, not composing | `closeMenu` | yes | yes |
302
+ | Escape, popover or dialog open, or menu open while IME | — | no | yes |
303
+ | zoom key, not composing, not in a text field, no modal | the key | yes | yes |
304
+ | zoom key while composing, in a text field or with a modal | — | no | yes |
305
+ | Shift+1, not composing, not in a text field, no modal | `fit` | yes | yes |
306
+ | Shift+1 otherwise | — | no | no (types "!") |
307
+ | undo/redo key in a text field | — | no | yes |
308
+ | any other key with `defaultPrevented` | — | no | yes |
309
+ | everything else | — | no | no |
310
+
311
+ - "No modifier" means no Meta, Ctrl, Alt or Shift. During IME composition Escape belongs to the IME.
312
+ - Zoom keys act whatever `defaultPrevented` says (diagram-js's keyboard prevents every key a listener
313
+ answered, the shell's own guard included); a blocked Shift+1 is never stopped.
314
+ - `isUndoRedoKey` (Cmd/Ctrl + Z, Shift+Z, Y) decides by the typed character when it is ASCII, so
315
+ QWERTZ and Dvorak work, and falls back to `code` `KeyZ`/`KeyY` only for non-ASCII layouts
316
+ (Cyrillic, Greek). Cmd/Ctrl+Shift+Y is not redo: VS Code toggles its Debug Console with it.
317
+
318
+ Shortcut labels (§5.8): macOS `⌘+`, `⌘−` (U+2212), `⌘0`, `⇧1`; elsewhere `Ctrl+Plus`, `Ctrl+Minus`,
319
+ `Ctrl+0`, `Shift+1` from `keyNames`. `aria-keyshortcuts`: `Meta+= Meta+Plus`, `Meta+-`, `Meta+0`,
320
+ `Shift+1`; outside macOS `Control` for `Meta`. The platform is read in the page itself
321
+ (`detectPlatform(navigator)`, `userAgentData.platform || platform`, so an empty `userAgentData`
322
+ platform falls back), never in the extension host (VS Code Remote).
323
+
324
+ `ariaKeyShortcutsFromLabel` turns a shortcut chip label into `aria-keyshortcuts` (§5.6) for palette
325
+ entries and menu actions: `"L"` → `"L"`, `"⇧L"` → `"Shift+L"`, `"⌘⇧Z"` → `"Meta+Shift+Z"`,
326
+ `"Ctrl+Shift+E"` → `"Control+Shift+E"`, `"Del"` → `"Delete"`, `"Ctrl++"` → `"Control+Plus"`; a label
327
+ that is not one key combination (`"G then D"`, `"1–9"`) gives `undefined`.
328
+
329
+ ### Appearance
330
+
331
+ ```ts
332
+ type Theme = "light" | "dark";
333
+ type Kontrast = "hoch";
334
+ interface Appearance {
335
+ theme: Theme;
336
+ kontrast?: Kontrast;
337
+ }
338
+ function resolveAppearance(requested: Appearance, themes: readonly Theme[]): Appearance;
339
+ function appearanceFromColorThemeKind(kind: number): Appearance;
340
+ type ColorThemeSetting = "automatic" | "light" | "dark";
341
+ function requestedAppearance(kind: number, setting?: ColorThemeSetting): Appearance;
342
+ function appearanceFromBodyClasses(classes: Iterable<string>): Appearance | undefined;
343
+ function reducedMotionFromBodyClasses(classes: Iterable<string>): boolean; // vscode-reduce-motion
344
+ const REDUCED_MOTION_CLASS = "cd-shell-reduce-motion"; // on <html>, mirrored by mountVsCodeShell
345
+ interface AppearanceAttributes {
346
+ "data-cd-theme": Theme;
347
+ "data-cd-kontrast"?: Kontrast;
348
+ }
349
+ function appearanceAttributes(appearance: Appearance): AppearanceAttributes;
350
+ ```
351
+
352
+ - `resolveAppearance` is the one decision of §13.5: a theme outside `themes` becomes `themes[0]`, so
353
+ a modeler declaring `["light"]` is never half-dark; `kontrast: "hoch"` always passes (VS Code High
354
+ Contrast on a light-only modeler gives `{ theme: "light", kontrast: "hoch" }`), any other value is
355
+ dropped, since appearances arrive by `postMessage`. Throws for empty `themes`. `themes` is a static
356
+ value of the modeler package, known before the first paint.
357
+ - `appearanceFromColorThemeKind`: 1 → light, 2 → dark, 3 → dark + hoch, 4 → light + hoch, other →
358
+ light. `requestedAppearance` lets the setting's theme win unless it is `automatic`; the contrast
359
+ always comes from the kind.
360
+ - `appearanceFromBodyClasses` checks `vscode-high-contrast-light` before `vscode-high-contrast`.
361
+ - `reducedMotionFromBodyClasses` reads VS Code's `workbench.reduceMotion` from the body class
362
+ `vscode-reduce-motion`; while `<html>` carries `REDUCED_MOTION_CLASS`, the shell's animations stop
363
+ and an explicit fit jumps, as under `prefers-reduced-motion`.
364
+
365
+ ### Messages
366
+
367
+ ```ts
368
+ interface ShellMessages {
369
+ menu: string; // "Menu"
370
+ tools: string; // "Tools"
371
+ canvas: string; // "Diagram": aria-label of the focusable canvas <svg>, name the notation
372
+ zoom: string; // "Zoom"
373
+ zoomOut: string; // "Zoom out"
374
+ zoomIn: string; // "Zoom in"
375
+ zoomToActual(percent: string): string; // "Zoom to 100%"
376
+ zoomToActualNow(percent: string, now: string): string; // "Zoom to 100% (now 86%)"
377
+ fit: string; // "Fit to view"
378
+ legend: string; // "Legend"
379
+ closeLegend: string; // "Close legend"
380
+ problems(count: number, formattedCount: string): string; // "1 problem", "2 problems"
381
+ problemsLabel(count: number, errors: number, formattedCount: string, formattedErrors: string): string;
382
+ // "2 problems, 1 error", "2 problems, no errors"
383
+ showProblems: string; // "Show in Problems view"
384
+ emptyHint: string; // "Pick a tool from the palette to start."
385
+ insertExample: string; // "Insert example"
386
+ invalidFile(diagram: string): string;
387
+ // "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."
388
+ keyNames: { ctrl: string; shift: string; plus: string; minus: string }; // Ctrl, Shift, Plus, Minus
389
+ }
390
+ // every key optional; an undefined value keeps the default
391
+ type ShellMessageOverrides = {
392
+ [Key in Exclude<keyof ShellMessages, "keyNames">]?: ShellMessages[Key] | undefined;
393
+ } & { keyNames?: { [Key in keyof ShellMessages["keyNames"]]?: string | undefined } | undefined };
394
+ const defaultMessages: ShellMessages;
395
+ function resolveMessages(overrides?: ShellMessageOverrides): ShellMessages;
396
+ ```
397
+
398
+ A localized modeler overrides the whole table (guide §2: one language per tool) and passes `lang`.
399
+
400
+ ### Layout decisions
401
+
402
+ ```ts
403
+ const PALETTE_CLEARANCE = 16;
404
+ const TOOLTIP_DELAY_MS = 300;
405
+ const INVALID_STATUS_DELAY_MS = 800;
406
+ interface PaletteRowInput {
407
+ containerWidth: number;
408
+ paletteWidth: number;
409
+ menuRight: number;
410
+ primaryLeft?: number;
411
+ }
412
+ function decidePaletteRow(input: PaletteRowInput): PaletteRow;
413
+ function legendStartsOpen(containerHeight: number): boolean; // > 820
414
+ interface TooltipPlacementInput {
415
+ anchor: Bounds;
416
+ island: Bounds;
417
+ tooltip: Size;
418
+ container: Size;
419
+ }
420
+ function placeTooltip(input: TooltipPlacementInput): Point;
421
+ ```
422
+
423
+ `decidePaletteRow` (§5.15): `second-row` up to 760px container width, or when the centred palette
424
+ would keep less than 16px to the menu trigger (or to the primary action); `paletteWidth` is the
425
+ intrinsic border-box width (`offsetWidth − clientWidth + scrollWidth`: the content including the
426
+ borders), so the decision does not flip once the palette scrolls in the second row.
427
+
428
+ `placeTooltip` (§5.6): below an island in the upper half at `island.bottom + 8` (top row 60, second
429
+ row 112), above one in the lower half ending 8px above it (bottom row: `bottom: 60px`), centred on
430
+ `anchor`, shifted to keep 12px to the sides.
431
+
432
+ ## `.` — the chrome
433
+
434
+ Exports everything of `./core` plus:
435
+
436
+ ```ts
437
+ function mountShell(root: HTMLElement, options: ShellOptions): ShellHandle;
438
+
439
+ interface ShellOptions {
440
+ host: ShellHost;
441
+ canvas?: HTMLElement; // diagram-js's container; omitted: the shell creates it (handle.canvas)
442
+ lang?: string; // see below; the embedded root carries lang itself
443
+ messages?: ShellMessageOverrides;
444
+ platform?: Platform; // default detectPlatform(navigator)
445
+ keyScope?: "document" | "root"; // default "document", "root" for embedded
446
+ themes?: readonly Theme[]; // default ["light"]; read in the embedded host only, see below
447
+ menu?: MenuGroups;
448
+ problems?: ProblemsOptions;
449
+ legend?: LegendOptions;
450
+ inspector?: InspectorOptions;
451
+ emptyHint?: EmptyHintOptions;
452
+ }
453
+ ```
454
+
455
+ `mountShell` turns `root` into the shell: class `cd-shell`, `container: cd-shell / size`,
456
+ `isolation: isolate`. `root` must be sized from outside, otherwise size containment collapses it to 0:
457
+ in an own document a root that is a direct child of `<body>` gets `height: 100%` from `shell.css`
458
+ (a zero-specificity rule), any other root and an embedded one need an explicit size. In the `ide` and
459
+ `web` hosts `<html>` gets `cd-shell-document` (own-document reset). The shell observes the root and
460
+ the registered palette with a `ResizeObserver` (palette row, legend start state; 0 × 0 is skipped)
461
+ and installs exactly one `keydown` listener (bubble phase) on `document` or on the root.
462
+
463
+ `lang` is the tool's UI language for `Intl` (guide §2: one language per tool). Default in an own
464
+ document (`ide`, `web`): the closest `[lang]` of the root (the `<html lang>` the host wrote), else
465
+ `"en"`. In the `embedded` host the page's language is not the tool's, so the default is the root's
466
+ own `lang` attribute, else `"en"`; the embedded root carries `lang` itself. A localized modeler
467
+ passes `lang` together with its messages.
468
+
469
+ **The embedded root's mode** (§13.3). The token CSS resolves the roles on the nearest
470
+ `[data-cd-theme]`, so a root without one would take the mode of a themed host page (designIQ, a web
471
+ app on `system` under a dark OS) and be half-dark against its light-drawn diagram. The `embedded`
472
+ root therefore carries `data-cd-theme="<themes[0]>"` itself (until the contract's `appearance`
473
+ surface, planned for a later 1.x minor of `@miragon/modeler-api`, there is no channel to ask an
474
+ embedded modeler for another mode, so it stays in its first one), and mirrors the host's
475
+ `data-cd-kontrast` from the nearest mode carrier above it for as long as it lives (a
476
+ `MutationObserver` on the document's `data-cd-theme`/`data-cd-kontrast` attributes), because its own
477
+ `data-cd-theme` would otherwise drop the host's high contrast. In an own document (`ide`, `web`) the
478
+ host writes the mode on `<html>` and `themes` is not read. An empty `themes` throws before anything
479
+ is mounted.
480
+
481
+ **Order with document listeners.** Listeners on one target run in registration order. A modeler
482
+ whose own keyboard listens on `document` (Event Storming does) constructs its diagram **before**
483
+ `mountShell`, so the shell's listener runs after it and sees `defaultPrevented`; a renderer that
484
+ creates its diagram lazily (Event Storming's `Modeler` on the first `get`) calls
485
+ `modeler.get("canvas")` first. Listeners on the canvas `<svg>` (diagram-js `keyboard`) have no
486
+ ordering constraint. Such a document keyboard must leave alone what is not meant for the canvas:
487
+ keys with `defaultPrevented` and keys whose target lies outside the canvas container (the menu
488
+ stops the keys it consumes, the palette its own, but the zoom panel's buttons do not); in the IDE
489
+ host it binds no undo or redo, and on the canvas it handles Cmd/Ctrl+A (see `./diagram-js`,
490
+ Keyboard).
491
+
492
+ ### Menu (§5.4)
493
+
494
+ ```ts
495
+ interface MenuGroups {
496
+ model?: readonly MenuEntry[];
497
+ export?: readonly MenuEntry[];
498
+ help?: readonly MenuEntry[];
499
+ }
500
+ type MenuEntry = MenuActionEntry | MenuDialogEntry | MenuCheckboxEntry | MenuRadioGroup;
501
+
502
+ interface MenuItemBase {
503
+ id: string;
504
+ label: string;
505
+ icon?: ShellIconName;
506
+ disabled?: () => boolean;
507
+ keepEnabledWhenReadOnly?: boolean; // a Model entry that is pure view state; default false
508
+ }
509
+ interface MenuActionEntry extends MenuItemBase {
510
+ kind: "action";
511
+ shortcut?: string;
512
+ ariaKeyShortcuts?: string; // default ariaKeyShortcutsFromLabel(shortcut); "" sets none
513
+ danger?: boolean;
514
+ onSelect(): void;
515
+ }
516
+ interface MenuDialogEntry extends MenuItemBase {
517
+ kind: "dialog";
518
+ onSelect(): void;
519
+ }
520
+ interface MenuCheckboxEntry extends MenuItemBase {
521
+ kind: "checkbox";
522
+ checked(): boolean;
523
+ onToggle(checked: boolean): void;
524
+ }
525
+ interface MenuRadioGroup {
526
+ kind: "radio";
527
+ id: string;
528
+ label: string;
529
+ options: readonly MenuRadioOption[];
530
+ value(): string | undefined;
531
+ onSelect(value: string): void;
532
+ disabled?: () => boolean;
533
+ keepEnabledWhenReadOnly?: boolean;
534
+ }
535
+ interface MenuRadioOption {
536
+ value: string;
537
+ label: string;
538
+ }
539
+ ```
540
+
541
+ - Groups render in the fixed order Model, Export, Help with separators; empty groups collapse. The
542
+ File and Appearance groups of the web app are not part of 0.1. With a legend (and
543
+ `legend.menuEntry !== false`) the shell appends the checkbox "Legend" to the Model group.
544
+ - `action` and `dialog`: the menu closes, focus returns to the trigger, then `onSelect` runs (a host
545
+ dialog then hands focus back to a visible control). `dialog` adds `aria-haspopup="dialog"`; its
546
+ label ends with "…" (U+2026), and in 0.1 the modeler renders the dialog itself (see
547
+ [Modeler dialogs](#modeler-dialogs)).
548
+ - `checkbox` and `radio` toggle and keep the menu open. State is read through `checked()`, `value()`
549
+ and `disabled()` whenever the menu opens and on `menu.refresh()`.
550
+ - Icons only from the common set and only where the guide gives one; the icon column stays 16px wide
551
+ without an icon. An action with a `shortcut` carries `aria-keyshortcuts`.
552
+ - On a read-only canvas (`setReadOnly(true)`, §13.5) every Model entry is `aria-disabled` unless it
553
+ sets `keepEnabledWhenReadOnly`; disabled entries stay focusable and do nothing. View toggles stay
554
+ usable on a read-only canvas: the shell's Legend checkbox carries `keepEnabledWhenReadOnly` itself,
555
+ and a modeler sets it on its own pure view state (Event Storming's "Type captions"), never on an
556
+ entry that changes the document (Level, "Map size…"). Export and Help entries are never locked.
557
+ Guide §13.5 (design system 1.13.0): only Model entries that change the document are `aria-disabled`.
558
+ - The trigger is `hidden` while no group has an entry (no menu groups and no Legend entry).
559
+ - Keys: Enter, Space and ↓ on the trigger open on the first entry, ↑ on the last; ↑/↓ wrap, Home/End
560
+ jump, a letter jumps to the next entry starting with it; Escape and Tab close (Escape returns focus
561
+ to the trigger). Every key the menu consumes (those, plus Enter, Space, Delete and Backspace in the
562
+ dropdown) is stopped at the menu in every host, so a modeler's keyboard on `document` never acts
563
+ on it (Event Storming would move the selection on ↓); Escape, Tab, modified keys and the chrome
564
+ keys go on (Shift+1 fits, Cmd/Ctrl + `+` zooms with the menu open).
565
+ - A `pointerdown` outside (capture phase on `document`) closes without moving focus and without
566
+ stopping the event, so it still acts on its target. So does the window losing the focus (a click
567
+ into the VS Code workbench or another iframe).
568
+
569
+ ### Modeler dialogs
570
+
571
+ 0.1 has no dialog component (a 0.2 candidate, see [Out of scope for 0.1](#out-of-scope-for-01)), so a
572
+ modeler renders its dialogs, the "Keyboard shortcuts…" dialog of the Help group (§5.4) first among
573
+ them, and meets §8 on its own:
574
+
575
+ - **Layer.** `z-index: var(--cd-shell-ebene-dialog)` (50) applies only to a dialog outside the top
576
+ layer, inside `shell.root`. A `<dialog>` opened with `showModal()` or an element of the popover API
577
+ lies in the browser's top layer above everything, whatever its `z-index`. The shell's keys count it
578
+ either way: any shown `<dialog>`, `[role="dialog"]` or `[role="alertdialog"]` in the document
579
+ (embedded: in the root) is open, and a modal one blocks the zoom keys.
580
+ - **Panel and backdrop** (§8): panel `--cd-ui-flaeche-hoch` with 1px `--cd-ui-linie`, radius `md`,
581
+ `shadow-3`, `width: min(560px, 100cqw - 32px)`, `max-height: calc(100cqh - 32px)` with a scrolling
582
+ body; header (padding `16px 16px 8px 24px`, title 21px/600) with Close as a 32 × 32 ghost icon
583
+ button (Lucide `x` 16px, `aria-label` "Close"); backdrop
584
+ `background: var(--cd-ui-backdrop); backdrop-filter: blur(3px) grayscale(1)` in both modes. The
585
+ shortcut chips look like the tooltip's (§5.6).
586
+ - **High contrast** (`[data-cd-kontrast="hoch"]`, §6, §5.14): no shadow, border `--cd-ui-kontur`,
587
+ focus ring 3px.
588
+ - **Forced colours** (`@media (forced-colors: active)`): border `CanvasText`, focus ring `Highlight`.
589
+ The shell's own high-contrast and forced-colours rules match only `cd-shell-*` classes, so they
590
+ do not reach a modeler's dialog.
591
+ - **Focus** (§8): it moves into the dialog and stays there, Escape and a click on the backdrop close
592
+ it, and it returns to the control that opened it (the menu trigger after a menu entry).
593
+ - **Read-only:** the shortcuts dialog stays usable on a read-only canvas (§13.5).
594
+ - **Teardown:** the dialog is the modeler's content in `shell.root`, removed by the modeler (see
595
+ `destroy()` below).
596
+
597
+ ### Problems, legend, inspector, empty-file hint
598
+
599
+ ```ts
600
+ interface ProblemsOptions {
601
+ onActivate(): void; // the IDE host opens its Problems view
602
+ }
603
+ interface LegendOptions {
604
+ render(body: HTMLElement): void; // once; a throw unwinds the whole mount
605
+ open?: boolean; // remembered start state; default (also undefined) legendStartsOpen(container height)
606
+ onToggle?(open: boolean): void; // every change (close button, checkbox, setOpen, toggle); persist it
607
+ menuEntry?: boolean; // default true
608
+ }
609
+ interface InspectorOptions {
610
+ label: string; // aria-label of the <aside>
611
+ }
612
+ interface EmptyHintOptions {
613
+ onInsertExample?(): void; // shows "Insert example"; the modeler writes the example as one edit
614
+ }
615
+ ```
616
+
617
+ ### Handle
618
+
619
+ ```ts
620
+ interface ShellHandle {
621
+ readonly root: HTMLElement;
622
+ readonly canvas: HTMLElement;
623
+ readonly host: ShellHost;
624
+ readonly lang: string;
625
+ readonly platform: Platform;
626
+ readonly messages: ShellMessages;
627
+ readonly menu: MenuHandle;
628
+ readonly tooltip: TooltipHandle;
629
+ readonly legend: LegendHandle | undefined;
630
+ readonly inspector: InspectorHandle | undefined;
631
+ setZoomTarget(target: ZoomTarget | null): void;
632
+ fitInsets(): Insets;
633
+ registerPalette(palette: PaletteRegistration): () => void;
634
+ setProblems(summary: ProblemSummary): void;
635
+ setStatus(status: StatusMessage | null, options?: { delayMs?: number }): void;
636
+ setEmptyHint(visible: boolean): void;
637
+ setReadOnly(readOnly: boolean, options?: { delayMs?: number }): void;
638
+ isReadOnly(): boolean;
639
+ destroy(): void;
640
+ }
641
+ interface MenuHandle {
642
+ readonly trigger: HTMLButtonElement;
643
+ readonly dropdown: HTMLElement;
644
+ isOpen(): boolean;
645
+ open(focus?: "first" | "last"): void;
646
+ close(options?: { restoreFocus?: boolean }): void;
647
+ refresh(): void;
648
+ setGroups(groups: MenuGroups): void;
649
+ }
650
+ interface TooltipContent {
651
+ label: string;
652
+ shortcut?: string;
653
+ }
654
+ interface TooltipHandle {
655
+ attach(control: HTMLElement, content: TooltipContent | (() => TooltipContent | null)): () => void;
656
+ hide(): void;
657
+ }
658
+ interface LegendHandle {
659
+ readonly element: HTMLElement;
660
+ readonly body: HTMLElement;
661
+ isOpen(): boolean;
662
+ setOpen(open: boolean): void;
663
+ toggle(): void;
664
+ }
665
+ interface InspectorHandle {
666
+ readonly element: HTMLElement;
667
+ isOpen(): boolean;
668
+ open(): void;
669
+ close(): void;
670
+ }
671
+ interface PaletteRegistration {
672
+ element: HTMLElement; // .djs-palette
673
+ entryTooltip(entry: HTMLElement): TooltipContent | null;
674
+ setReadOnly(state: PaletteReadOnlyState): void; // on registration and on every change
675
+ }
676
+ interface PaletteReadOnlyState {
677
+ locked: boolean; // veto every entry but Lasso and Hand, from the invalid import on
678
+ shown: boolean; // mark them aria-disabled and muted, once the delay has passed
679
+ }
680
+ interface ProblemSummary {
681
+ count: number;
682
+ errors: number;
683
+ }
684
+ type StatusSeverity = "danger" | "warning";
685
+ interface StatusMessage {
686
+ severity: StatusSeverity;
687
+ text: string;
688
+ }
689
+ ```
690
+
691
+ - `setZoomTarget` binds zoom panel, readout and chrome keys; the readout reads `zoom()` at most once
692
+ per animation frame after `onChange`. With `null` the three buttons are `aria-disabled`; the readout
693
+ never is (§5.8), its click does nothing then. Buttons and keys never call `stepZoom` past a limit
694
+ (zoom out at or below 20 % never zooms in), never `zoomToActual` at exactly 100 % and never `fit`
695
+ while `canFit()` is false.
696
+ - `fitInsets()` is `fitInsets(layout)` of `./core` for what is open and measured right now (palette
697
+ row, pill, legend width, side inspector or measured sheet top).
698
+ - `registerPalette` is the adapter's API (`connectDiagramJs` calls it, possibly from inside
699
+ diagram-js's `palette.create`, before any entry exists); a later registration replaces the
700
+ earlier one, and the returned unregister function is safe to call twice and after `destroy()`.
701
+ - `setProblems`: the pill shows for `count > 0` with `Intl` numbers in `lang` and the icon by severity;
702
+ its tooltip "Show in Problems view" exists only in the `ide` host (`aria-describedby`, since it
703
+ says more than the label).
704
+ - `setStatus(status, { delayMs })`: shows after `delayMs` (an invalid file: `INVALID_STATUS_DELAY_MS`,
705
+ so typing does not flicker and `role="alert"` does not re-announce), hides at once with `null`.
706
+ Repeated calls while the delay runs keep the first timer (the box appears once the text stayed
707
+ invalid for 800ms), `null` drops it, and an unchanged text is not written again.
708
+ - `setReadOnly(true, { delayMs })` (invalid file, §13.5) locks at once: `isReadOnly()`, the palette
709
+ veto (every entry but Lasso and Hand) and the Model entries of the menu without
710
+ `keepEnabledWhenReadOnly`. The marks follow after `delayMs` (pass `INVALID_STATUS_DELAY_MS`, as for
711
+ the status box): `data-readonly` on the root and the palette entries `aria-disabled`, muted,
712
+ previews in `grayscale(1)`. Repeated calls keep the first timer; `setReadOnly(false)` unlocks and
713
+ hides at once. The legend is a view toggle: its Legend checkbox and its close button keep working
714
+ under the lock (guide §13.5, design system 1.13.0).
715
+ - The canvas is the modeler's to lock, from the same invalid import on (§13.5, what the read-only
716
+ state blocks): no context pad, no move, resize, delete or paste, and no label editor (also not on
717
+ a double-click). Selection, zoom, pan, the export of the last valid state and the "Keyboard
718
+ shortcuts…" dialog stay. The modeler reads `isReadOnly()` or keeps a lock of its own next to
719
+ `setReadOnly` (Event Storming's `eventStormingReadOnly` service); the kit's `readOnly` case checks
720
+ each of these.
721
+ - `setEmptyHint` shows or hides the hint; `connectDiagramJs` keeps it in sync with the diagram. When
722
+ the hint (its "Insert example") or the closing inspector holds the focus, the focus moves to the
723
+ canvas `<svg>` (else the menu trigger) instead of falling to `<body>`.
724
+ - `tooltip.attach` shows the chrome tooltip after `TOOLTIP_DELAY_MS` hover or at once on keyboard
725
+ focus (`:focus-visible`, also on `aria-disabled` buttons); after a hover-hide a neighbour shows at
726
+ once for the same delay. It hides on click (until the pointer leaves), Escape, leave, blur and menu
727
+ open, never on touch. It sets `aria-describedby` only when its text says more than the control's
728
+ name, which a shortcut chip does unless the control names its keys in `aria-keyshortcuts`. The
729
+ shell attaches it to its own buttons; the palette gets it through `registerPalette`.
730
+ - The legend's start state comes from `legend.open` or, at the first non-zero container size, from
731
+ `legendStartsOpen`; that automatic decision does not call `onToggle`. Its close button returns the
732
+ focus to the menu trigger.
733
+ - `destroy()` removes every element, class, attribute, listener, observer and timer the shell added
734
+ and restores what was there before the mount: a passed `canvas` stays where it is, without
735
+ `cd-shell-canvas`, and a `cd-shell-document` class or a `lang` the page already had stays (the
736
+ class `renderWebviewHtml` writes on `<html>` included). A second call and every later handle call
737
+ are no-ops. A mount that throws (a modeler's `legend.render`) undoes the same before the error
738
+ propagates. Content a modeler adds to `shell.root` (a dialog, a popover) is the modeler's: it
739
+ removes it in its own teardown, before or with `shell.destroy()`, for example on `diagram.destroy`,
740
+ because the shell removes only what it added.
741
+ - Element ids (`cd-shell-menu-N`, `cd-shell-tooltip-N`, `cd-shell-legend-title-N`) count on one
742
+ page-global counter (`Symbol.for("@miragon/modeler-shell.mounts")`), so two modelers that each
743
+ bundle the shell never share an id on one foreign page.
744
+
745
+ ### Icons
746
+
747
+ ```ts
748
+ type IconNode = ReadonlyArray<readonly [tag: string, attributes: Readonly<Record<string, string>>]>;
749
+ type ShellIconName =
750
+ | "menu"
751
+ | "minus"
752
+ | "plus"
753
+ | "maximize"
754
+ | "check"
755
+ | "x"
756
+ | "file-plus"
757
+ | "folder-open"
758
+ | "import"
759
+ | "file-image"
760
+ | "file-code-corner"
761
+ | "keyboard"
762
+ | "share-2"
763
+ | "circle-x"
764
+ | "triangle-alert"
765
+ | "square-dashed-mouse-pointer"
766
+ | "hand"
767
+ | "line-segment"
768
+ | "unfold-horizontal"
769
+ | "workflow";
770
+ const ICONS: Readonly<Record<ShellIconName, IconNode>>; // lucide-static 1.59.0, unchanged (ISC); line-segment = spline with a straight segment
771
+ interface IconOptions {
772
+ size?: number; // default 16
773
+ strokeWidth?: number; // default 2.5
774
+ className?: string;
775
+ }
776
+ const PALETTE_TOOL_ICON: Readonly<IconOptions>; // { size: 20, strokeWidth: 2 }
777
+ function iconMarkup(icon: ShellIconName | IconNode, options?: IconOptions): string;
778
+ function iconElement(icon: ShellIconName | IconNode, options?: IconOptions): SVGSVGElement;
779
+ ```
780
+
781
+ `iconMarkup` renders decorative SVG (`aria-hidden="true"`, `focusable="false"`, `fill="none"`,
782
+ `stroke="currentColor"`, round caps and joins) and lets only Lucide's elements and geometry
783
+ attributes pass, so palette providers can put it into an entry's `html`.
784
+
785
+ The geometry is Lucide's (ISC, © Lucide Icons and Contributors); `check`, `circle-x`, `maximize`,
786
+ `minus`, `plus`, `triangle-alert` and `x` derive from Feather (MIT, © Cole Bemis), as Lucide's
787
+ LICENSE lists them (two by their Feather names, `x-circle` and `alert-triangle`). `src/dom/icons.ts`
788
+ opens with a legal comment (`/*! … @license … */`) naming both, which esbuild keeps in the `dist`
789
+ chunk that carries the icons (ESM and CJS). A consumer's bundler may drop or move legal comments (its
790
+ `legalComments` setting), so a VSIX or a web bundle that contains the shell carries the Lucide and
791
+ Feather notices of `THIRD_PARTY_NOTICES.md` itself.
792
+
793
+ ### DOM, `data-chrome` and classes
794
+
795
+ ```html
796
+ <html class="cd-shell-document" lang="en" data-cd-theme="light"> <!-- own document: ide, web -->
797
+ <div class="cd-shell" data-cd-shell-host="ide" data-palette="top"
798
+ data-legend="open" data-inspector="open" data-problems="shown" data-readonly> <!-- state attributes only while true -->
799
+ <!-- embedded: lang="en" data-cd-theme="light" [data-cd-kontrast="hoch"] on the root itself -->
800
+ <button class="cd-shell-menu-trigger" data-chrome="menu" aria-label="Menu" aria-haspopup="menu"
801
+ aria-expanded="false" aria-controls="cd-shell-menu-1">…</button>
802
+ <div class="cd-shell-canvas"> <!-- diagram-js container -->
803
+ <div class="djs-container">
804
+ <div class="djs-palette" data-chrome="palette"> <!-- moved before the <svg> -->
805
+ <div class="djs-palette-entries" role="toolbar" aria-label="Tools">
806
+ <div class="group" data-group="tools">
807
+ <div class="entry" data-action="lasso-tool" role="button" aria-label="…" tabindex="0" aria-pressed="false" aria-keyshortcuts="L">…</div>
808
+ <svg tabindex="0" role="application" aria-label="Diagram">…</svg>
809
+ <div class="cd-shell-status" data-chrome="status" data-severity="danger" role="alert">…</div>
810
+ <div class="cd-shell-hint"><p class="cd-shell-hint-text">…</p><button class="cd-shell-hint-action">…</button></div>
811
+ <aside class="cd-shell-inspector" data-chrome="inspector" aria-label="…">…</aside>
812
+ <button class="cd-shell-problems" data-chrome="problems" aria-label="2 problems, 1 error">…</button>
813
+ <section class="cd-shell-legend" data-chrome="legend" aria-labelledby="…">…</section>
814
+ <div class="cd-shell-zoom" data-chrome="zoom" role="group" aria-label="Zoom">
815
+ <button data-zoom="out"> <button class="cd-shell-zoom-readout" data-zoom="actual"> <button data-zoom="in">
816
+ <span class="cd-shell-zoom-separator"> <button data-zoom="fit">
817
+ </div>
818
+ <div class="cd-shell-layer"> <!-- z 40: dropdown and tooltip -->
819
+ <div class="cd-shell-menu" id="cd-shell-menu-1" role="menu" aria-label="Menu">…</div>
820
+ <div class="cd-shell-tooltip" role="tooltip"><span class="cd-shell-tooltip-label">Zoom in</span><kbd class="cd-shell-kbd">⌘+</kbd></div>
821
+ </div>
822
+ </div>
823
+ ```
824
+
825
+ The DOM order is the tab order of §5.14 (menu trigger, palette, canvas, status box, hint, inspector,
826
+ pill, legend, zoom panel); nothing has a positive `tabindex`, and a renderer adds no tab stop of its
827
+ own around the canvas (no `tabindex` on `.djs-container`). Islands that are not shown are `hidden`.
828
+
829
+ | `data-chrome` | Element | Notes |
830
+ | ------------- | ------------------------------ | ------------------------------------------ |
831
+ | `menu` | menu trigger | icon only, Lucide `menu` |
832
+ | `palette` | diagram-js `.djs-palette` | set by `shellModule` |
833
+ | `primary` | reserved for the web app (0.2) | never rendered in 0.1 |
834
+ | `zoom` | zoom panel | `[−][NN%][+] \| [Fit]` |
835
+ | `problems` | problems pill | `hidden` at 0 |
836
+ | `legend` | legend card | only with `legend` |
837
+ | `inspector` | inspector `<aside>` | only with `inspector` |
838
+ | `status` | status box under the palette | `role="alert"` (danger) or `role="status"` |
839
+
840
+ | Class | Part |
841
+ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
842
+ | `cd-shell`, `cd-shell-document`, `cd-shell-canvas`, `cd-shell-layer` | root, own document, canvas slot, layer |
843
+ | `cd-shell-island`, `cd-shell-target` | glass island, 32px target in an island |
844
+ | `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 |
845
+ | `cd-shell-menu-shortcut`, `cd-shell-menu-check` | shortcut text and check mark of an entry |
846
+ | `cd-shell-tooltip`, `cd-shell-tooltip-label`, `cd-shell-kbd` | tooltip and shortcut chip |
847
+ | `cd-shell-zoom`, `cd-shell-zoom-readout`, `cd-shell-zoom-separator` | zoom panel |
848
+ | `cd-shell-problems`, `cd-shell-problems-icon`, `cd-shell-problems-text` | pill |
849
+ | `cd-shell-status`, `cd-shell-status-icon`, `cd-shell-status-text` | status box (bar as `::before`) |
850
+ | `cd-shell-hint`, `cd-shell-hint-text`, `cd-shell-hint-action` | empty-file hint |
851
+ | `cd-shell-legend`, `cd-shell-legend-header`, `cd-shell-legend-title`, `cd-shell-legend-close`, `cd-shell-legend-body` | legend card |
852
+ | `cd-shell-legend-row`, `cd-shell-legend-preview`, `cd-shell-legend-separator` | rows the modeler renders into the body |
853
+ | `cd-shell-inspector` | inspector |
854
+ | `cd-shell-no-transition` | on `<html>` for one frame of a mode switch |
855
+
856
+ Internal attributes: `data-severity` on pill and status box, `data-danger` on a dangerous menu
857
+ entry, `data-menu-id` on entries and radio groups (the shell's Legend entry is `data-menu-id="legend"`,
858
+ so a modeler does not use the id `legend`), `data-menu-group` (`model`, `export`, `help`) on entries
859
+ and radio groups, which the kit's `readOnly` case reads, `data-menu-value` on radio options,
860
+ `data-zoom` on the zoom buttons.
861
+
862
+ Modelers style only their own content (legend rows, inspector fields); every other class is
863
+ internal to the shell CSS and may change in a minor release.
864
+
865
+ ### Geometry
866
+
867
+ Relative to the shell root's padding box (a border a foreign page gives the root does not count),
868
+ ±1px (§5.2 to §5.15). `ide` and `embedded` share the IDE column.
869
+
870
+ | Island | Box |
871
+ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
872
+ | `menu` | 40 × 40 at `top: 12px; left: 12px` |
873
+ | `palette` | 40 high at `top: 12px`, horizontally centred; second row `top: 64px`, at most `100cqw − 24px` wide |
874
+ | `zoom` | 173 × 40 (56px readout) at `right: 12px; bottom: 12px` |
875
+ | `problems` | 40 high at `left: 12px; bottom: 12px` (web `40px`) |
876
+ | `legend` | `left: 12px`, `bottom: 12px` without pill, `60px` with pill (web `40px`, `88px`); up to 440px width at least `60px` |
877
+ | `inspector` | 288 wide at `top: 64px; right: 12px`; up to 760px width a bottom sheet with 12px left, right and bottom |
878
+ | `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 |
879
+ | dropdown | `top: 60px; left: 12px`, at least 248px wide |
880
+ | tooltip | top row `top: 60px`, second row `top: 112px`, bottom row `bottom: 60px` |
881
+
882
+ Layers (§5.13): the canvas slot is layer 0 with `isolation: isolate` (palette `z-index: 110` above
883
+ context pad and label editor at 100); islands `--cd-shell-ebene-inseln` (20), inspector 25, menu
884
+ trigger 30, layer 40, modeler dialogs 50. The 50 holds only outside the top layer; a `showModal()`
885
+ `<dialog>` or a popover-API element lies above everything (see [Modeler dialogs](#modeler-dialogs)).
886
+
887
+ Inside the canvas slot, persistent overlays a modeler adds (element toolbars, its own context pad,
888
+ the label editor) stay at `z-index` 100 or below, under the palette at 110. Transient popups the user
889
+ opens from them (diagram-js's popup menu at 200, a colour picker) may cover the palette while they are
890
+ open. All of them stay inside `.djs-container`, so they never cover an island. This is the shell's
891
+ rule of guide §5.13 (design system 1.13.0).
892
+
893
+ ## `./diagram-js`
894
+
895
+ ```ts
896
+ // a Diagram, a renderer's Modeler, a didi injector
897
+ interface InjectorLike {
898
+ get<T>(name: string): T;
899
+ }
900
+ interface ModuleDeclarationLike {
901
+ __init__?: string[];
902
+ __depends__?: ModuleDeclarationLike[];
903
+ [name: string]: unknown;
904
+ }
905
+ type DiagramEventListener = (event: never) => unknown;
906
+ interface EventBusLike {
907
+ on(events: string | string[], priority: number, listener: DiagramEventListener): void;
908
+ on(events: string | string[], listener: DiagramEventListener): void;
909
+ off(events: string | string[], listener?: DiagramEventListener): void;
910
+ }
911
+ interface DiagramViewbox extends ScaledViewbox {
912
+ inner: Bounds;
913
+ outer: Size;
914
+ }
915
+ interface CanvasLike {
916
+ getContainer(): HTMLElement;
917
+ getSize(): Size;
918
+ viewbox(): DiagramViewbox;
919
+ viewbox(box: Bounds): void;
920
+ zoom(): number;
921
+ zoom(scale: number, center?: Point): number;
922
+ resized(): void;
923
+ }
924
+ interface ZoomScrollLike {
925
+ stepZoom(delta: number, position?: Point): void;
926
+ }
927
+ interface KeyboardLike {
928
+ addListener(priority: number, listener: (context: { keyEvent: KeyboardEvent }) => unknown): void;
929
+ }
930
+ interface PaletteLike {
931
+ getEntries(): Record<
932
+ string,
933
+ { title?: string; shortcut?: string; ariaKeyShortcuts?: string; group?: string }
934
+ >;
935
+ }
936
+
937
+ const shellModule: ModuleDeclarationLike;
938
+
939
+ interface ConnectOptions {
940
+ contentBounds(): Bounds | undefined; // tight, no padding: the fit insets are the only margin (§5.9)
941
+ isEmpty?(): boolean; // §13.5 empty file (no element); default: the content bounds are empty
942
+ prefersReducedMotion?(): boolean;
943
+ }
944
+ interface DiagramJsConnection {
945
+ readonly viewport: Viewport;
946
+ destroy(): void;
947
+ }
948
+ function connectDiagramJs(
949
+ shell: ShellHandle,
950
+ diagram: InjectorLike,
951
+ options: ConnectOptions,
952
+ ): DiagramJsConnection;
953
+
954
+ interface ViewportOptions {
955
+ contentBounds(): Bounds | undefined;
956
+ insets(): Insets;
957
+ prefersReducedMotion?(): boolean;
958
+ }
959
+ interface Viewport extends ZoomTarget {
960
+ fit(options?: { animate?: boolean }): void;
961
+ freeRectCenter(): Point | undefined; // diagram coordinates; undefined at 0 × 0 and after destroy
962
+ afterImport(): void;
963
+ restore(viewbox: Bounds): void;
964
+ getViewbox(): Bounds | undefined;
965
+ isAutomatic(): boolean;
966
+ getState(): ViewportState | undefined;
967
+ setState(state: ViewportState): void;
968
+ destroy(): void;
969
+ }
970
+ function createViewport(diagram: InjectorLike, options: ViewportOptions): Viewport;
971
+ ```
972
+
973
+ **`shellModule`** goes into the modeler's modules after the renderer's own (`additionalModules`). It
974
+ registers one internal didi service named `modelerShell` (a modeler does not reuse the name):
975
+
976
+ - `paletteTooltip: ["value", null]` replaces diagram-js's (and a renderer's) palette tooltip; the
977
+ chrome tooltip takes over with `title` and `shortcut` from `palette.getEntries()`.
978
+ - Palette skin: on `palette.create` it sets `data-chrome="palette"` and moves the container in front
979
+ of the canvas `<svg>` (`canvas.getContainer()`'s child), so tab order puts the palette first. After
980
+ every `palette.changed` it re-applies `role="toolbar"` and `aria-label` (`messages.tools`) on
981
+ `.djs-palette-entries`, `role="button"` on every entry, `aria-keyshortcuts` from the entry's
982
+ `ariaKeyShortcuts` or `ariaKeyShortcutsFromLabel(shortcut)` (§5.6; split the shortcut off the
983
+ title: `title: "Domain event", shortcut: "E"`), roving `tabindex` (the last focused entry keeps the
984
+ tab stop across rebuilds) and `aria-pressed` for the tool of the last
985
+ `tool-manager.update` (group `tools`, `data-action="<tool>-tool"`), styled through
986
+ `[aria-pressed="true"]`: diagram-js's `highlighted-entry` goes stale after a rebuild, and its
987
+ `two-column` class is ignored. `separator` entries become vertical rules.
988
+ - Palette keys, without modifiers: ← and → move and wrap, Home and End jump, Enter and Space trigger
989
+ the entry once (repeats ignored). They are stopped at the palette, so a modeler's document listener
990
+ and VS Code never see them; chrome keys still bubble to the shell.
991
+ - Enter and Space trigger through a synthetic `click`, and that click is a contract: a `MouseEvent`
992
+ with `detail === 0` (a pointer click has 1 or more), `clientX`/`clientY` at the entry's centre,
993
+ bubbling and cancelable, dispatched on the entry. It goes through diagram-js's click delegate and
994
+ `palette.trigger` like a pointer click, so under the read-only lock it is vetoed the same way. A
995
+ provider tells the keyboard apart by `detail === 0` (see Palette keyboard creation below).
996
+ - Read-only (§13.5): only `lasso-tool` and `hand-tool` stay active. Every other entry, also tools of
997
+ group `tools` such as draw, space or connect, is vetoed on `palette.trigger` from the lock on (a
998
+ vetoed `dragstart` is `preventDefault`-ed) and marked `aria-disabled` once the shell shows the
999
+ read-only state; an entry's own `aria-disabled` comes back afterwards. Without an entry element
1000
+ in the event only group `tools` passes.
1001
+ - Keyboard: `keyboard.addListener(2000, …)` returns `true` for every `matchChromeKey` hit, so
1002
+ `KeyboardBindings` and the modeler's keyboard modules (priority 1000) never act on them; the
1003
+ shell's `keydown` listener executes them exactly once. It returns `false` (stops them without
1004
+ `preventDefault`) for a chrome key from a text field inside the `<svg>`, which stays the field's,
1005
+ and, while the attached shell's host is `ide`, for undo and redo (`isUndoRedoKey`): the canvas
1006
+ `commandStack` never takes them, VS Code undoes the document (§13.5). A modeler's own DOM undo
1007
+ binding must go in the IDE host as well; the kit's `keys` case checks it.
1008
+ - Keys the canvas uses beyond the chrome keys are the modeler's (§13.5). Cmd/Ctrl+A on the focused
1009
+ canvas selects every element, or is at least `preventDefault`-ed: diagram-js 15 binds no select
1010
+ all, and an unprevented key reaches VS Code, whose Select All then selects the chrome's text. The
1011
+ shell stops a prevented key in the IDE host; the kit's `keys` case presses Control+A and Meta+A on
1012
+ the canvas.
1013
+ - While a shell is attached, the canvas `<svg>` is `role="application"` with `aria-label`
1014
+ `messages.canvas` (WCAG 4.1.2; screen readers switch to focus mode, so single-key commands reach the
1015
+ canvas); a role or name the modeler set stays, and both are restored on detach.
1016
+ - It buffers the palette until `connectDiagramJs`, so the diagram may exist before the shell.
1017
+
1018
+ **`connectDiagramJs`** creates the viewport with `insets: shell.fitInsets`, binds it
1019
+ (`shell.setZoomTarget`), registers the palette and calls `canvas.resized()` (the canvas element has
1020
+ moved). After every `import.done` and `elements.changed` it calls `shell.setEmptyHint(isEmpty())`,
1021
+ so the first element the user creates on the canvas hides the hint (and its "Insert example").
1022
+ `isEmpty` defaults to empty content bounds; a notation whose bounds are never empty (Wardley's plot)
1023
+ passes its own. It throws when `shellModule` is missing. A later connection to another shell takes
1024
+ the palette over; `destroy()` (idempotent, also run on `diagram.destroy`) unregisters the palette,
1025
+ unbinds the zoom target and the hint sync and destroys the viewport, while the keyboard guard stays
1026
+ until `diagram.destroy`. `contentBounds` returns the tight bounds of the content, without a padding
1027
+ of its own (Event Storming: `boardBounds.contentBounds(0)`, not its default of 120px): the shell's fit
1028
+ insets are the only margin (§5.9), and a padding would add to them.
1029
+
1030
+ **`Viewport`** (§5.8, §5.9):
1031
+
1032
+ - `stepZoom` is `zoomScroll.stepZoom(±1)` (the wheel's raster, snapping to the nearest step);
1033
+ `zoomToActual` is `canvas.zoom(1)`; both around the container centre. No rounding of its own. A
1034
+ step past a limit is refused (`canZoomOut`/`canZoomIn`), so zoom out never zooms in, whatever the
1035
+ caller checks; without a `zoomScroll` service it steps on the same raster itself.
1036
+ - `fit()` uses `fitViewbox(contentBounds(), canvas.getSize(), insets())` and `canvas.viewbox(box)`,
1037
+ never `canvas.zoom("fit-viewport")`; animated over 250ms in `requestAnimationFrame` with
1038
+ `easeStandard` unless `animate: false` or reduced motion (`prefers-reduced-motion`, or
1039
+ `REDUCED_MOTION_CLASS` on `<html>`); a `wheel`, `pointerdown` or zoom key cancels the animation. A
1040
+ no-op when the bounds are empty.
1041
+ - `canFit()` reads `contentBounds()` once per content change (`elements.changed`, `shape.added`,
1042
+ `shape.removed`, `connection.added`, `connection.removed`, `diagram.clear`, `import.done` and the
1043
+ viewport's own `afterImport`, `restore`, `setState`), not on every frame the zoom panel renders.
1044
+ - `afterImport()` jump-fits the first import whose bounds are non-empty (also after an empty file got
1045
+ its first content), unless the user moved the view or a stored view was set with `setState` while
1046
+ there was content. `restore` does not block it: a modeler restores on every re-import, and an
1047
+ empty file must still fit its first content ("Insert example"). Content the user creates on the
1048
+ canvas does (§5.9: an element created on the canvas never fits): an `elements.changed` outside an
1049
+ import window after the first import, with content, ends the wait for the first fit, so the next
1050
+ external change keeps the view. A renderer that changes elements through the command stack while
1051
+ it imports therefore fires `import.parse.start` or `import.render.start` first.
1052
+ - An empty file shows 100 %, whatever zoom the renderer's import left: on the first import with
1053
+ empty bounds, without a stored view (`setState`) and without a user move, `afterImport()` zooms to
1054
+ 1 around the centre of the free rectangle (at 0 × 0 once the container has a size). That write is
1055
+ the viewport's own, not a user move, so the first content still fits; later re-imports and
1056
+ `restore`s of the empty file keep the view (guide §5.9 and §13.5, design system 1.13.0).
1057
+ - `freeRectCenter()` is the centre of the free rectangle (the container minus `insets()`, as fit
1058
+ uses them) in diagram coordinates, for the current viewbox; `undefined` while the container is
1059
+ 0 × 0 and after `destroy`. It is where keyboard creation places an element.
1060
+ - A `ResizeObserver` on `canvas.getContainer()`, coalesced per frame, skips 0 × 0 (hidden tab) and
1061
+ compares with the size the view was last set for, so a tab coming back at the same size neither
1062
+ refits nor calls `resized()`. On a real change it calls `canvas.resized()` and refits while the view
1063
+ is automatic. A `restore` or `setState` made at 0 × 0 waits for a size and is not refitted then.
1064
+ - User moves: every `canvas.viewbox.changed` (and `canvas.viewbox.changing` for the readout under
1065
+ `deferUpdate`) is compared with the last view the viewport set (`sameView`; during an animation
1066
+ also with the last written frame); a difference is a user move. Changes between
1067
+ `import.parse.start`/`import.render.start` and `import.done` are not (renderers set the viewbox
1068
+ while importing); that window also ends on `afterImport`, `restore`, `setState` and user input.
1069
+ Fit makes the view automatic again; `restore` updates the reference without resetting it.
1070
+ - `getViewbox()` takes width and height from the fresh container size; `getState()` is `undefined`
1071
+ before the first `afterImport`, `restore` or `setState`. `onChange` fires on
1072
+ `canvas.viewbox.changing`/`.changed`, `elements.changed` (so Fit enables with the first element),
1073
+ `import.done` and the viewport's own calls.
1074
+ - The viewport destroys itself on `diagram.destroy`; afterwards `zoom()` returns the last zoom,
1075
+ `canFit()` false and `getViewbox()`/`getState()` `undefined`, so a shell still bound to it never
1076
+ calls into a destroyed canvas.
1077
+ - diagram-js events used: `canvas.init`, `palette.create`, `palette.changed`, `tool-manager.update`,
1078
+ `palette.trigger`, `keyboard.keydown` (through `keyboard.addListener`), `canvas.viewbox.changing`,
1079
+ `canvas.viewbox.changed`, `elements.changed`, `shape.added`, `shape.removed`, `connection.added`,
1080
+ `connection.removed`, `diagram.clear`, `import.parse.start`, `import.render.start`, `import.done`,
1081
+ `diagram.destroy`. The shell fires no events of its own.
1082
+
1083
+ **Palette keyboard creation** (§5.5). Enter or Space on an entry that creates something places the
1084
+ element in the centre of the free rectangle, selects it, opens the label editor where the notation
1085
+ has labels and puts the focus on the canvas; tool entries only switch the tool. diagram-js's create
1086
+ tool follows the pointer and never ends without one, so the provider branches on the synthetic click
1087
+ and asks the viewport for the point. The provider exists before `connectDiagramJs`, so it gets a
1088
+ function and reads the viewport only when the entry fires:
1089
+
1090
+ ```ts
1091
+ // the renderer's palette provider, given freeRectCenter: () => viewport.freeRectCenter()
1092
+ const create = (event: Event) => {
1093
+ const shape = elementFactory.createShape({ type: "domain-event" });
1094
+ const keyboard = event.type === "click" && (event as MouseEvent).detail === 0;
1095
+ const center = keyboard ? freeRectCenter() : undefined;
1096
+ if (!center) return createTool.start(event, shape); // a pointer: the shape follows it
1097
+ const created = modeling.createShape(shape, center, canvas.getRootElement());
1098
+ selection.select(created);
1099
+ canvas.focus();
1100
+ directEditing.activate(created); // where the notation has labels
1101
+ };
1102
+ entries["create.domain-event"] = {
1103
+ group: "events",
1104
+ title: "Domain event",
1105
+ shortcut: "E",
1106
+ action: { click: create, dragstart: create },
1107
+ };
1108
+ ```
1109
+
1110
+ When the centre is already taken, the provider steps 24px diagonally until it is free (§5.5); a
1111
+ shared helper for that is a 0.2 candidate. The kit's `create` case presses Enter on the first
1112
+ creating entry and expects exactly one new, selected shape inside the free rectangle.
1113
+
1114
+ ## `./vscode/extension`
1115
+
1116
+ ```ts
1117
+ interface DisposableLike {
1118
+ dispose(): unknown;
1119
+ }
1120
+ interface ColorThemeLike {
1121
+ readonly kind: number;
1122
+ }
1123
+ interface VsCodeLike {
1124
+ readonly window: {
1125
+ readonly activeColorTheme: ColorThemeLike;
1126
+ onDidChangeActiveColorTheme(listener: (theme: ColorThemeLike) => unknown): DisposableLike;
1127
+ };
1128
+ readonly workspace: {
1129
+ getConfiguration(section?: string): { get<T>(key: string): T | undefined };
1130
+ onDidChangeConfiguration(
1131
+ listener: (event: { affectsConfiguration(section: string): boolean }) => unknown,
1132
+ ): DisposableLike;
1133
+ };
1134
+ }
1135
+ interface WebviewPanelLike {
1136
+ readonly webview: { postMessage(message: unknown): unknown };
1137
+ readonly visible: boolean;
1138
+ onDidChangeViewState(
1139
+ listener: (event: { readonly webviewPanel: { readonly visible: boolean } }) => unknown,
1140
+ ): DisposableLike;
1141
+ onDidDispose(listener: () => unknown): DisposableLike;
1142
+ }
1143
+
1144
+ interface WebviewHtmlOptions {
1145
+ cspSource: string;
1146
+ scriptUri: string;
1147
+ styleUris?: readonly string[];
1148
+ title: string;
1149
+ lang: string;
1150
+ appearance: Appearance;
1151
+ body?: string;
1152
+ nonce?: string;
1153
+ inlineStyles?: boolean; // default true: style-src … 'unsafe-inline'
1154
+ }
1155
+ function createNonce(): string;
1156
+ function renderWebviewHtml(options: WebviewHtmlOptions): string;
1157
+
1158
+ interface ThemeBridgeOptions {
1159
+ themes: readonly Theme[];
1160
+ settingKey?: string;
1161
+ }
1162
+ function currentAppearance(vscode: VsCodeLike, options: ThemeBridgeOptions): Appearance;
1163
+ function connectThemeBridge(
1164
+ vscode: VsCodeLike,
1165
+ panel: WebviewPanelLike,
1166
+ options: ThemeBridgeOptions,
1167
+ ): DisposableLike;
1168
+
1169
+ function revealFileLabel(platform: string): string;
1170
+
1171
+ const APPEARANCE_MESSAGE_TYPE = "modelerShell.appearance";
1172
+ interface AppearanceMessage {
1173
+ type: typeof APPEARANCE_MESSAGE_TYPE;
1174
+ appearance: Appearance;
1175
+ }
1176
+ function appearanceMessage(appearance: Appearance): AppearanceMessage;
1177
+ function isAppearanceMessage(value: unknown): value is AppearanceMessage;
1178
+ ```
1179
+
1180
+ - The real `vscode` namespace is assignable to `VsCodeLike` and a `WebviewPanel` to
1181
+ `WebviewPanelLike` (pinned by a type test against `@types/vscode` 1.96).
1182
+ - The entry needs a global `crypto` (Node 20 or later, so VS Code 1.90 or later; the types are tested
1183
+ against 1.96). A modeler declares `engines.vscode` accordingly and targets `node20` in its
1184
+ extension-host bundle. There is no `node:crypto` fallback: the entry imports nothing, so a web
1185
+ extension host (vscode.dev) can bundle it as well, and `createNonce` stays synchronous.
1186
+ - `createNonce()`: 32 characters `[A-Za-z0-9]` from `crypto.getRandomValues`, unbiased.
1187
+ - `renderWebviewHtml` writes the appearance attributes on `<html>` before the first paint, always
1188
+ (also `light`), and the CSP (`default-src 'none'`, the nonce'd script, fonts from `cspSource` and
1189
+ `data:`). Its `style-src` allows `'unsafe-inline'` by default because renderers write
1190
+ `style` attributes into markup (palette entries, SVG strings); the shell and diagram-js themselves
1191
+ style only through the CSSOM and run without it, so a modeler whose renderer does too passes
1192
+ `inlineStyles: false`. `lang` is the tool's UI language: English modelers pass `"en"`, not
1193
+ `vscode.env.language`. Title and attribute values are escaped, `body` is inserted as markup; the
1194
+ script is a classic `<script nonce src>` (an IIFE bundle). It throws for a `nonce` that is not a
1195
+ CSP base64 value.
1196
+
1197
+ ```text
1198
+ <html lang="en" class="cd-shell-document" data-cd-theme="light" data-cd-kontrast="hoch">
1199
+ Content-Security-Policy: default-src 'none'; img-src {cspSource} data: blob:;
1200
+ style-src {cspSource} 'unsafe-inline'; font-src {cspSource} data:; script-src 'nonce-{nonce}'
1201
+ ```
1202
+
1203
+ - `currentAppearance` resolves `requestedAppearance(kind, setting)` against `themes`; a setting value
1204
+ outside `automatic | light | dark` counts as `automatic`.
1205
+ - `connectThemeBridge` posts nothing on connect (the HTML carries the appearance) and then
1206
+ `appearanceMessage(currentAppearance(…))` on `onDidChangeActiveColorTheme`, on
1207
+ `onDidChangeConfiguration` affecting `settingKey` (no configuration listener without one), and on
1208
+ `onDidChangeViewState` when the panel goes from hidden to visible (not on active or column
1209
+ changes). It disposes itself with the panel (`onDidDispose`) and throws for empty `themes`. The
1210
+ returned disposable only ends it earlier (twice is safe), or goes into a panel-scoped list that is
1211
+ disposed with the panel; never into `context.subscriptions`, which keeps one dead bridge per
1212
+ opened editor until the extension deactivates.
1213
+ - `revealFileLabel(process.platform)`: "Reveal in Finder" (`darwin`), "Reveal in File Explorer"
1214
+ (`win32`), "Open Containing Folder" (other), for the export notification (§13.5); English, the
1215
+ labels of VS Code itself (a localized modeler translates them with `vscode.l10n`).
1216
+
1217
+ What the webview bundle needs under this CSP:
1218
+
1219
+ - **Fonts as files.** Bundle Geist and Geist Mono as files next to the bundle, not as data URLs:
1220
+ esbuild `loader: { ".woff2": "file", ".woff": "file" }` with `assetNames: "fonts/[name]-[hash]"`.
1221
+ The browser then fetches only the subsets a page's `unicode-range` needs (Geist Mono alone brings
1222
+ six, which inlined roughly double the webview CSS), `font-src {cspSource}` allows them, and `dist`
1223
+ must be in the panel's `localResourceRoots`. `data:` stays allowed for renderers that inline
1224
+ fonts.
1225
+ - **zod 4 without eval.** `script-src` admits only the nonce'd script, so eval and `new Function` are
1226
+ blocked (correctly, there is no `'unsafe-eval'`). zod 4 probes once for eval when it builds its
1227
+ first object schema and catches the error, but the browser still reports that `new Function` as a
1228
+ `securitypolicyviolation`; under `jitless` zod skips the probe. A webview that bundles zod 4 runs
1229
+ `z.config({ jitless: true })` in a module it imports before any schema module (Event Storming:
1230
+ `import "./zod-jitless"`, the first line of `main.ts`).
1231
+ - **A smoke test of the production bundle.** A test page served by Vite runs the webview from source
1232
+ without a CSP, so a loader or CSP mistake of the shipped bundle shows only behind the real HTML.
1233
+ Build the bundle as shipped, serve it under a fake origin behind `renderWebviewHtml`, and fail on
1234
+ any CSP violation, page error or missing font:
1235
+
1236
+ ```ts
1237
+ const ORIGIN = "https://webview.test";
1238
+ const NONCE = "c21va2V0ZXN0bm9uY2Uw"; // fixed, so the stub's script can carry it
1239
+ await page.route(`${ORIGIN}/**`, async (route) => {
1240
+ const { pathname } = new URL(route.request().url());
1241
+ if (pathname === "/") {
1242
+ const html = renderWebviewHtml({
1243
+ cspSource: ORIGIN,
1244
+ nonce: NONCE,
1245
+ scriptUri: `${ORIGIN}/webview.js`,
1246
+ styleUris: [`${ORIGIN}/webview.css`],
1247
+ title: "Event Storming Board",
1248
+ lang: "en",
1249
+ appearance: { theme: "light" },
1250
+ // runs before the webview: installs the VS Code stub and records every violation
1251
+ body: `<div id="app"></div><script nonce="${NONCE}" src="${ORIGIN}/stub.js"></script>`,
1252
+ });
1253
+ return route.fulfill({ contentType: "text/html", body: html });
1254
+ }
1255
+ if (pathname === "/stub.js") return route.fulfill({ contentType: "text/javascript", body: stubIife });
1256
+ return route.fulfill({ path: join(dist, pathname) }); // webview.js, webview.css, fonts/
1257
+ });
1258
+ await page.goto(`${ORIGIN}/`);
1259
+ // post the document's init message, wait for the first shape and open the menu, then:
1260
+ expect(await page.evaluate(() => window.cspViolations)).toEqual([]);
1261
+ ```
1262
+
1263
+ `stubIife` is the esbuild IIFE bundle of a small entry that adds a `securitypolicyviolation`
1264
+ listener on `document` (pushing `violatedDirective` and `blockedURI` into `window.cspViolations`)
1265
+ and then calls `installVsCodeWebviewStub(…)`.
1266
+
1267
+ ## `./vscode/webview`
1268
+
1269
+ ```ts
1270
+ function applyAppearance(appearance: Appearance, element?: HTMLElement): void; // default <html>
1271
+ interface AppearanceListenerOptions {
1272
+ themes: readonly Theme[];
1273
+ }
1274
+ function connectAppearance(options: AppearanceListenerOptions): () => void;
1275
+ function connectReducedMotion(): () => void;
1276
+
1277
+ interface VsCodeShellOptions
1278
+ extends Omit<ShellOptions, "host" | "keyScope" | "themes">, AppearanceListenerOptions {}
1279
+ function mountVsCodeShell(root: HTMLElement, options: VsCodeShellOptions): ShellHandle;
1280
+
1281
+ interface WebviewStateApi {
1282
+ getState(): unknown;
1283
+ setState(state: unknown): void;
1284
+ }
1285
+ interface ShellWebviewState {
1286
+ legendOpen?: boolean;
1287
+ view?: ViewportState;
1288
+ }
1289
+ const SHELL_STATE_KEY = "modelerShell";
1290
+ function readShellState(api: WebviewStateApi): ShellWebviewState;
1291
+ function writeShellState(api: WebviewStateApi, patch: ShellWebviewState): void;
1292
+ interface PersistableView {
1293
+ onChange(listener: () => void): () => void;
1294
+ getState(): ViewportState | undefined;
1295
+ }
1296
+ interface PersistViewOptions {
1297
+ delayMs?: number; // default 250
1298
+ }
1299
+ function persistView(
1300
+ api: WebviewStateApi,
1301
+ viewport: PersistableView,
1302
+ options?: PersistViewOptions,
1303
+ ): () => void;
1304
+
1305
+ // re-exported from the shared protocol, as in ./vscode/extension:
1306
+ // APPEARANCE_MESSAGE_TYPE, AppearanceMessage, appearanceMessage, isAppearanceMessage
1307
+ ```
1308
+
1309
+ - `applyAppearance` sets `data-cd-theme` and `data-cd-kontrast` in one task, with
1310
+ `cd-shell-no-transition` on `<html>` until a nested `requestAnimationFrame` (no colour fades; after
1311
+ several quick switches only the last one lifts it).
1312
+ - `connectAppearance` applies every `AppearanceMessage` arriving on `window`, resolved against its own
1313
+ `themes` once more (what this bundle can show wins over what the extension passed); a message that
1314
+ changes nothing leaves `<html>` alone. A page whose `<html>` has no `data-cd-theme` at connect (no
1315
+ `renderWebviewHtml`) falls back to the body classes until the first message: a `MutationObserver`
1316
+ applies `resolveAppearance(appearanceFromBodyClasses(…), themes)`, at once and on every change.
1317
+ With the attributes of `renderWebviewHtml` there is no fallback: the bridge knows the `colorTheme`
1318
+ setting, the body classes do not, and VS Code changes them before the bridge's message arrives from
1319
+ the extension host, which would flash the mode without the setting. The body classes are never
1320
+ used for styling. Throws for empty `themes`.
1321
+ - `connectReducedMotion` mirrors the body class `vscode-reduce-motion` (VS Code's
1322
+ `workbench.reduceMotion`, kept up to date by VS Code) as `REDUCED_MOTION_CLASS` on `<html>` for the
1323
+ whole session; its disconnect removes the class.
1324
+ - `mountVsCodeShell` is `mountShell` with `host: "ide"` and `keyScope: "document"` (both always win)
1325
+ plus `connectAppearance(options)` and `connectReducedMotion()`, connected first, so empty `themes`
1326
+ throw before anything mounts (and disconnected again if `mountShell` throws). It returns a handle
1327
+ that passes every member through and whose `destroy()` disconnects both too. This wires the IDE
1328
+ key interception: handled keys are stopped on `document`, before VS Code's listener on `window`
1329
+ forwards them to the workbench.
1330
+ - `readShellState`/`writeShellState` keep the shell's part (legend state, view) under
1331
+ `SHELL_STATE_KEY` of `vscode.getState()`. `readShellState` returns `{}` for a missing or malformed
1332
+ part and only valid fields (a view with finite `x`/`y`, positive width and height and a boolean
1333
+ `userMoved`), as copies. `writeShellState` merges: every other top-level key and unknown keys in its
1334
+ own part stay, an `undefined` or malformed value leaves the stored one alone (so
1335
+ `{ view: viewport.getState() }` before the first import erases nothing), and nothing is written
1336
+ when nothing changed. The modeler owns `acquireVsCodeApi()` (callable once) and passes its API
1337
+ object.
1338
+ - `persistView(api, viewport)` writes `{ view: viewport.getState() }` once the view has rested for
1339
+ `delayMs` (trailing), instead of one `vscode.setState` IPC message per frame of a pan, a wheel zoom
1340
+ or a fit animation; the returned stop function writes a pending change at once. The legend state
1341
+ is written from `legend.onToggle` (see the usage below).
1342
+ - The custom editor is registered with `webviewOptions: { retainContextWhenHidden: true }`: a hidden
1343
+ tab keeps document, view and selection, reports a 0 × 0 container and neither fits nor measures.
1344
+
1345
+ ## `./testing`
1346
+
1347
+ ```ts
1348
+ // Playwright's Page satisfies it
1349
+ interface PageLike {
1350
+ evaluate<R, Arg>(pageFunction: (arg: Arg) => R | Promise<R>, arg: Arg): Promise<R>;
1351
+ setViewportSize(size: { width: number; height: number }): Promise<void>;
1352
+ keyboard: { press(key: string): Promise<void> };
1353
+ mouse: {
1354
+ click(x: number, y: number): Promise<void>;
1355
+ dblclick(x: number, y: number): Promise<void>;
1356
+ move(x: number, y: number): Promise<void>;
1357
+ down(): Promise<void>;
1358
+ up(): Promise<void>;
1359
+ };
1360
+ waitForTimeout(timeout: number): Promise<void>;
1361
+ }
1362
+
1363
+ interface OpenContext {
1364
+ size: Size; // the shell container's size (grow the page by any frame around an embedded root)
1365
+ requested: Appearance; // what the host requests; the harness opens the page with it (embedded: see below)
1366
+ }
1367
+ interface ShowHooks<P extends PageLike = PageLike> {
1368
+ problems?(page: P): Promise<void>;
1369
+ status?(page: P): Promise<void>;
1370
+ legend?(page: P): Promise<void>;
1371
+ inspector?(page: P): Promise<void>;
1372
+ invalid?(page: P): Promise<void>; // the invalid-file state, caused the way the host causes it
1373
+ }
1374
+ type ShowHook = keyof ShowHooks; // "problems" | "status" | "legend" | "inspector" | "invalid"
1375
+ interface ShellConformanceHarness<P extends PageLike = PageLike> {
1376
+ host: ShellHost;
1377
+ themes: readonly Theme[];
1378
+ open(page: P, context: OpenContext): Promise<void>;
1379
+ destroy?(page: P): Promise<void>;
1380
+ root?: string; // default ".cd-shell"
1381
+ show?: ShowHooks<P>;
1382
+ absent?: readonly ShowHook[]; // islands and states the modeler does not have
1383
+ sizes?: readonly Size[]; // default 1280 × 900, 720 × 700, 420 × 700
1384
+ }
1385
+ interface ShellSkip {
1386
+ hook: ShowHook | "destroy" | "open"; // the harness field that would feed the skipped part
1387
+ reason: string;
1388
+ }
1389
+ interface ShellCaseOutcome {
1390
+ skipped?: readonly ShellSkip[];
1391
+ }
1392
+ interface ShellConformanceCase {
1393
+ name: string;
1394
+ run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>): Promise<ShellCaseOutcome | void>;
1395
+ }
1396
+ function shellConformanceCases(): ShellConformanceCase[];
1397
+ type ShellConformanceTest<P extends PageLike = PageLike> = (
1398
+ name: string,
1399
+ body: (fixtures: { page: P }) => Promise<void>,
1400
+ ) => unknown;
1401
+ interface RegisterShellConformanceOptions {
1402
+ onSkip?(caseName: string, skips: readonly ShellSkip[]): void; // default: console.warn, joined by "; "
1403
+ }
1404
+ function registerShellConformance<P extends PageLike>(
1405
+ harness: ShellConformanceHarness<P>,
1406
+ test: ShellConformanceTest<P>,
1407
+ options?: RegisterShellConformanceOptions,
1408
+ ): void;
1409
+ // "theme=dark&kontrast=hoch[&colorTheme=…][&reduceMotion]" for a page with the VS Code stub
1410
+ function vsCodeStubSearch(
1411
+ requested: Appearance,
1412
+ extra?: { colorTheme?: ColorThemeSetting; reduceMotion?: boolean },
1413
+ ): string;
1414
+
1415
+ const GEOMETRY_TOLERANCE_PX = 1;
1416
+ interface IslandBox {
1417
+ left: number;
1418
+ top: number;
1419
+ width: number;
1420
+ height: number;
1421
+ }
1422
+ interface ChromeMeasurement {
1423
+ container: Size;
1424
+ paletteRow: PaletteRow;
1425
+ islands: Partial<Record<ChromeIsland, IslandBox>>;
1426
+ open: { legend: boolean; inspector: boolean; problems: boolean };
1427
+ }
1428
+ interface ExpectedBox {
1429
+ left?: number;
1430
+ top?: number;
1431
+ right?: number;
1432
+ bottom?: number;
1433
+ width?: number;
1434
+ height?: number;
1435
+ centerX?: number;
1436
+ }
1437
+ function measureChrome(page: PageLike, root?: string): Promise<ChromeMeasurement>;
1438
+ function expectedChromeBoxes(
1439
+ measurement: ChromeMeasurement,
1440
+ host: ShellHost,
1441
+ ): Partial<Record<ChromeIsland, ExpectedBox>>;
1442
+ ```
1443
+
1444
+ `IslandBox` and `ExpectedBox` are relative to the root's padding box (`getBoundingClientRect()` minus
1445
+ the root's box and border), where the islands anchor.
1446
+
1447
+ Cases (each named `shell conformance: <name>`; failures throw plain `Error`s listing every problem):
1448
+
1449
+ | Case | Checks |
1450
+ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1451
+ | `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 |
1452
+ | `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 |
1453
+ | `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 |
1454
+ | `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 |
1455
+ | `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 |
1456
+ | `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 |
1457
+ | `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` |
1458
+
1459
+ - **Skips.** A case the harness cannot feed reports what it skipped as `ShellSkip`s, one per missing
1460
+ harness field, through `onSkip` (default `console.warn`). A hook that is neither in `show` nor in
1461
+ `absent` is skipped with `hook` set to its name, a missing `destroy` with `hook: "destroy"`, and a
1462
+ document that lacks what a case needs (a shape a pointer reaches, for `readOnly`) with
1463
+ `hook: "open"`. `absent` lists what the modeler does not have (an island or the `invalid` state),
1464
+ 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
1465
+ it. A suite that lists what it lacks can fail on every remaining skip:
1466
+ `onSkip(name, skips) { throw new Error(…) }`.
1467
+ - **`show.invalid`** makes the file invalid the way the host does (in a webview: a document update
1468
+ the parser rejects), not by calling `shell.setReadOnly` alone: the `readOnly` case also checks the
1469
+ modeler's own canvas lock, which its invalid-import path sets. The case waits
1470
+ `INVALID_STATUS_DELAY_MS` and a margin itself.
1471
+ - For the `embedded` host the kit writes `data-cd-theme` and `data-cd-kontrast` on the page's
1472
+ `<html>` itself after `open`: every consumer suite then proves that the modeler stays in its own
1473
+ mode inside a dark host page, the case a foreign page meets in practice (designIQ, a web app on
1474
+ `system` under a dark OS).
1475
+ - The second-row tooltip of the `geometry` case may stand centred on its entry or in the container:
1476
+ guide §5.15 says "mittig im Container", and which of the two it means is an open question; the
1477
+ shell centres it on its entry.
1478
+ - The kit never imports Playwright; a modeler's suite passes its `page` and `test`
1479
+ (`registerShellConformance(harness, test)` from `@playwright/test`). It installs one page global,
1480
+ `__modelerShellConformance`, with `keydown` recorders on `window` and `document`, and briefly
1481
+ injects stand-ins marked `data-modeler-shell-conformance` (an input, a textarea, an editable label,
1482
+ a modal dialog, a popover, a focus target) into the shell root.
1483
+ - `geometry` takes about 10s per host; wrap the registration in
1484
+ `test.describe.configure({ timeout: 60_000 })`.
1485
+ - `registerShellConformance` calls `test` inside the kit, so a runner reports every case at the
1486
+ kit's source (the package ships `src/`, so that location resolves). For locations in the suite's
1487
+ own spec file, register the cases there:
1488
+
1489
+ ```ts
1490
+ for (const conformanceCase of shellConformanceCases()) {
1491
+ test(`shell conformance: ${conformanceCase.name}`, async ({ page }) => {
1492
+ const outcome = await conformanceCase.run(page, harness);
1493
+ if (outcome?.skipped?.length) onSkip(conformanceCase.name, outcome.skipped);
1494
+ });
1495
+ }
1496
+ ```
1497
+
1498
+ ### `./testing/vscode-stub` — the `ide` host without VS Code
1499
+
1500
+ ```ts
1501
+ type ColorThemeKind = 1 | 2 | 3 | 4;
1502
+ function colorThemeKindOf(appearance: Appearance): ColorThemeKind;
1503
+ const STUB_SETTING_KEY = "miragon.modeler.colorTheme";
1504
+ interface VsCodeWebviewStubOptions {
1505
+ requested: Appearance; // VS Code's color theme
1506
+ themes: readonly Theme[]; // what the modeler's extension passes to the bridge
1507
+ lang?: string; // default "en"
1508
+ colorTheme?: ColorThemeSetting;
1509
+ settingKey?: string; // default STUB_SETTING_KEY
1510
+ reduceMotion?: boolean; // workbench.reduceMotion
1511
+ state?: unknown; // what acquireVsCodeApi().getState() returns at first
1512
+ }
1513
+ interface StubbedVsCodeApi {
1514
+ getState(): unknown;
1515
+ setState(state: unknown): void;
1516
+ postMessage(message: unknown): void;
1517
+ }
1518
+ interface VsCodeWebviewStub {
1519
+ readonly kind: ColorThemeKind;
1520
+ setColorThemeKind(kind: ColorThemeKind): void; // body classes first, then the bridge posts
1521
+ setColorTheme(setting: ColorThemeSetting | undefined): void;
1522
+ setReduceMotion(reduceMotion: boolean): void;
1523
+ pendingMessages(): number; // posted by the bridge, not yet delivered
1524
+ readonly posted: readonly unknown[]; // what the webview posted
1525
+ state(): unknown;
1526
+ }
1527
+ type StubWindow = Window & {
1528
+ __modelerShellVsCodeStub?: VsCodeWebviewStub;
1529
+ acquireVsCodeApi?: () => StubbedVsCodeApi;
1530
+ };
1531
+ function stubOptionsFromSearch(
1532
+ search: string,
1533
+ ): Pick<VsCodeWebviewStubOptions, "requested" | "colorTheme" | "reduceMotion">;
1534
+ function installVsCodeWebviewStub(options: VsCodeWebviewStubOptions): VsCodeWebviewStub;
1535
+ ```
1536
+
1537
+ Runs in a test page, before the modeler's webview code: VS Code's default webview stylesheet in
1538
+ `@layer vscode-default` (with foreign values, so a leak shows), `<html>` with the attributes
1539
+ `renderWebviewHtml` writes, `<body>` with the host page's theme and reduce-motion classes,
1540
+ `acquireVsCodeApi()` (once, as in VS Code) and the real `connectThemeBridge` against a fake `vscode`
1541
+ namespace whose panel posts to the window. The stub is also `window.__modelerShellVsCodeStub`, so a
1542
+ Playwright suite switches the color theme or the setting at runtime.
1543
+
1544
+ A modeler's e2e suite serves `e2e/webview/` as the root of a Vite server, with the test page
1545
+ `e2e/webview/webview-test.html` (`<div id="app">` and `<script type="module" src="./webview-test.ts">`)
1546
+ and this entry:
1547
+
1548
+ ```ts
1549
+ // e2e/webview/webview-test.ts
1550
+ import { installVsCodeWebviewStub, stubOptionsFromSearch } from "@miragon/modeler-shell/testing/vscode-stub";
1551
+
1552
+ // before the webview runs, which calls acquireVsCodeApi() and mounts the shell
1553
+ installVsCodeWebviewStub({ ...stubOptionsFromSearch(location.search), themes: ["light"] });
1554
+ const { webview } = await import("../../src/webview/main"); // exports { shell, modeler, connection, ready }
1555
+
1556
+ // for the harness only: the shipped bundle publishes nothing on window
1557
+ window.shell = webview.shell;
1558
+ window.modeler = webview.modeler;
1559
+ window.connection = webview.connection;
1560
+ void webview.ready.then(() => (window.webviewReady = true)); // the first import settled
1561
+
1562
+ // there is no extension host: post the document the way the extension would
1563
+ window.postMessage({ type: "init", text: EXAMPLE_BOARD }, "*");
1564
+ ```
1565
+
1566
+ The harness opens it with ``page.goto(`/webview-test.html?${vsCodeStubSearch(requested)}`)`` and waits
1567
+ for `window.webviewReady`. `ready`, `webviewReady` and the window globals are the modeler's, not the
1568
+ shell's: its webview module exports `ready`, resolved once the first `init` import settled, and the
1569
+ test page turns it into the flag (the Event Storming `main.ts` below exports it).
1570
+
1571
+ ## `./shell.css`
1572
+
1573
+ One stylesheet for everything above, loaded last. Containment: `.cd-shell { container: cd-shell /
1574
+ size }`; breakpoints are `@container cd-shell (max-width: 760px)`, `(max-width: 440px)` with literal
1575
+ values (custom properties are not allowed in container conditions), sizes inside are `cqw`/`cqh`,
1576
+ never viewport units, and no element inside gets a `container-type` of its own. It covers glass
1577
+ islands (`color-mix(in srgb, var(--cd-ui-flaeche-hoch) var(--cd-ui-chrome-deckkraft), transparent)`
1578
+ with `backdrop-filter: blur(8px)`), opaque popups, focus rings (inset in dense containers),
1579
+ `[data-cd-kontrast="hoch"]` (no shadows, 3px focus), `@media (forced-colors: active)` (rings in
1580
+ `Highlight`, status bar and island borders in `CanvasText`), `prefers-reduced-motion` and
1581
+ `html.cd-shell-reduce-motion`, `html.cd-shell-no-transition` (no transitions for the frame of a mode
1582
+ switch), the canvas `<svg>`'s focus ring (2px, 3px in high contrast, inside the slot), and under
1583
+ `html.cd-shell-document` the own-document reset: no body padding, `--cd-ui-grund` background, Geist
1584
+ 13px/1.5, own `kbd`, `code`, links, focus and scrollbars, never `var(--vscode-*)`. The reset is
1585
+ unlayered, so it wins over VS Code's `@layer vscode-default`; its button and field font reset has
1586
+ zero specificity, so the chrome's own rules (the readout's 13px/600) win over it. In a foreign page
1587
+ the chrome's own islands, controls and texts reset font, colour, letter spacing and text transform
1588
+ at class specificity, so the page's element rules (`button { text-transform: uppercase }`,
1589
+ `p { font-size: 18px }`) do not reach them. The canvas slot's layout (`.cd-shell >
1590
+ .cd-shell-canvas`: absolute, `inset: 0`, layer 0, `isolation: isolate`) wins over a renderer's own
1591
+ container class, its paint (`background-color: var(--cd-ui-grund)`) has zero specificity, so a
1592
+ renderer's paper (Event Storming's dot grid) shows. The paper's colour stays `--cd-ui-grund` in
1593
+ every mode, the dot grid being a background image in `--cd-ui-linie` (§10.1); the kit's `appearance`
1594
+ case checks the painted colour. Palette tool entries set their colour in every
1595
+ state (rest and pressed `--cd-ui-text`, hovered `--cd-ui-interaktion`, read-only
1596
+ `--cd-ui-text-leise`), and their icons (`> svg`, `::before`) inherit it, so a renderer's or
1597
+ diagram-js's rule on the icon or on the stale `highlighted-entry` does not leak in; notation
1598
+ previews keep their own colours (§5.5). Private helper properties are named `--modeler-shell-*`.
1599
+
1600
+ The shell bundles no font: the modeler loads Geist and Geist Mono (`@fontsource-variable/geist`,
1601
+ `@fontsource-variable/geist-mono`), as the stylesheet order above shows, as font files (see
1602
+ `./vscode/extension`).
1603
+
1604
+ ## Usage: Event Storming in VS Code
1605
+
1606
+ The Event Storming extension, the pilot of 0.1. Extension host
1607
+ (`EventStormingEditorProvider.resolveCustomTextEditor`):
1608
+
1609
+ ```ts
1610
+ import * as vscode from "vscode";
1611
+ import {
1612
+ connectThemeBridge,
1613
+ currentAppearance,
1614
+ renderWebviewHtml,
1615
+ revealFileLabel,
1616
+ } from "@miragon/modeler-shell/vscode/extension";
1617
+
1618
+ const THEMES = ["light"] as const; // until the shared dark-mode release of all modelers
1619
+
1620
+ // activate(): a hidden tab keeps document, view and selection (and reports 0 × 0 meanwhile)
1621
+ vscode.window.registerCustomEditorProvider(viewType, provider, {
1622
+ webviewOptions: { retainContextWhenHidden: true },
1623
+ });
1624
+
1625
+ // resolveCustomTextEditor():
1626
+ const dist = vscode.Uri.joinPath(extensionUri, "dist");
1627
+ webview.options = { enableScripts: true, localResourceRoots: [dist] }; // webview.js, webview.css, fonts/
1628
+ const asset = (file: string) => webview.asWebviewUri(vscode.Uri.joinPath(dist, file)).toString();
1629
+ webview.html = renderWebviewHtml({
1630
+ cspSource: webview.cspSource,
1631
+ scriptUri: asset("webview.js"),
1632
+ styleUris: [asset("webview.css")],
1633
+ title: "Event Storming Board",
1634
+ lang: "en",
1635
+ appearance: currentAppearance(vscode, { themes: THEMES }),
1636
+ });
1637
+ connectThemeBridge(vscode, webviewPanel, { themes: THEMES }); // disposes itself with the panel
1638
+
1639
+ // after an export (exportImage.ts):
1640
+ const reveal = revealFileLabel(process.platform);
1641
+ if ((await vscode.window.showInformationMessage(`Exported ${name}.`, reveal)) === reveal) {
1642
+ void vscode.commands.executeCommand("revealFileInOS", target);
1643
+ }
1644
+ ```
1645
+
1646
+ The two bundles (`esbuild.config.mjs`):
1647
+
1648
+ ```js
1649
+ const host = {
1650
+ entryPoints: ["src/extension.ts"],
1651
+ outfile: "dist/extension.cjs",
1652
+ bundle: true,
1653
+ platform: "node",
1654
+ format: "cjs",
1655
+ target: "node20", // VS Code 1.90 or later: the global crypto of createNonce
1656
+ external: ["vscode"],
1657
+ };
1658
+ const web = {
1659
+ entryPoints: ["src/webview/main.ts"],
1660
+ outfile: "dist/webview.js", // and dist/webview.css
1661
+ bundle: true,
1662
+ platform: "browser",
1663
+ format: "iife", // renderWebviewHtml writes a classic <script nonce>
1664
+ loader: { ".woff2": "file", ".woff": "file" }, // font files: a page loads only the subsets it needs
1665
+ assetNames: "fonts/[name]-[hash]",
1666
+ };
1667
+ ```
1668
+
1669
+ Webview (`src/webview/main.ts`):
1670
+
1671
+ ```ts
1672
+ import "./zod-jitless"; // first: z.config({ jitless: true }) before any schema module (CSP)
1673
+ import "@fontsource-variable/geist/wght.css";
1674
+ import "@fontsource-variable/geist-mono/wght.css";
1675
+ // the renderer's stylesheet brings diagram-js.css and the tokens, so they are not imported again
1676
+ import {
1677
+ createCollaborativeModeler,
1678
+ type BoardBounds,
1679
+ type EventStormingReadOnly,
1680
+ type EventStormingViewOptions,
1681
+ } from "@miragon/event-storming-renderer";
1682
+ import "./style.css"; // the modeler's own content: the "Keyboard shortcuts…" dialog
1683
+ import "@miragon/modeler-shell/shell.css";
1684
+ import { INVALID_STATUS_DELAY_MS } from "@miragon/modeler-shell";
1685
+ import { connectDiagramJs, shellModule } from "@miragon/modeler-shell/diagram-js";
1686
+ import { mountVsCodeShell, persistView, readShellState } from "@miragon/modeler-shell/vscode/webview";
1687
+ import { createShortcutsDialog } from "./shortcutsDialog";
1688
+
1689
+ const vscode = acquireVsCodeApi();
1690
+ const stored = readShellState(vscode);
1691
+
1692
+ // 1. The diagram first: Event Storming's keyboard listens on document and must run before the
1693
+ // shell's, and the renderer builds its diagram lazily, so get() a service. width/height: the
1694
+ // shell's slot sizes it.
1695
+ const collaborative = createCollaborativeModeler({
1696
+ additionalModules: [shellModule],
1697
+ // VS Code's document history owns Cmd/Ctrl+Z (§13.5); the kit's keys case checks it
1698
+ undoRedoKeys: false,
1699
+ // where Enter or Space on a creating palette entry places the element (§5.5): the renderer takes
1700
+ // the fit insets and computes the centre of the free rectangle itself, the same point as
1701
+ // viewport.freeRectCenter(); a renderer may take that function instead
1702
+ freeRectInsets: () => shell.fitInsets(),
1703
+ });
1704
+ const modeler = collaborative.diagram;
1705
+ modeler.get("canvas");
1706
+ const viewOptions = modeler.get<EventStormingViewOptions>("eventStormingViewOptions");
1707
+ const levels = modeler.get<{ getLevel(): string; setLevel(level: string): void }>("eventStormingModeling");
1708
+ // the canvas lock of an invalid file: context pad, move, resize, delete, paste, label editor
1709
+ const canvasLock = modeler.get<EventStormingReadOnly>("eventStormingReadOnly");
1710
+ const boardBounds = modeler.get<BoardBounds>("boardBounds");
1711
+
1712
+ // 2. The chrome around it.
1713
+ const shell = mountVsCodeShell(document.getElementById("app")!, {
1714
+ themes: ["light"],
1715
+ canvas: modeler.container,
1716
+ messages: { canvas: "Event Storming board" },
1717
+ menu: {
1718
+ model: [
1719
+ {
1720
+ kind: "radio",
1721
+ id: "level",
1722
+ label: "Level",
1723
+ options: [
1724
+ { value: "big-picture", label: "Big picture" },
1725
+ { value: "process", label: "Process modeling" },
1726
+ { value: "design", label: "Software design" },
1727
+ ],
1728
+ value: () => levels.getLevel(),
1729
+ onSelect: (level) => levels.setLevel(level),
1730
+ },
1731
+ {
1732
+ kind: "checkbox",
1733
+ id: "type-captions",
1734
+ label: "Type captions",
1735
+ keepEnabledWhenReadOnly: true, // a view toggle, not board content: usable while read-only
1736
+ checked: () => viewOptions.typeCaptionsVisible(),
1737
+ onToggle: (visible) => viewOptions.setTypeCaptionsVisible(visible),
1738
+ },
1739
+ ],
1740
+ export: [
1741
+ {
1742
+ kind: "action",
1743
+ id: "export-png",
1744
+ label: "Export as PNG",
1745
+ icon: "file-image",
1746
+ onSelect: () => void exportPng(),
1747
+ },
1748
+ {
1749
+ kind: "action",
1750
+ id: "export-svg",
1751
+ label: "Export as SVG",
1752
+ icon: "file-code-corner",
1753
+ onSelect: () => void exportSvg(),
1754
+ },
1755
+ ],
1756
+ help: [
1757
+ {
1758
+ kind: "dialog",
1759
+ id: "keyboard-shortcuts",
1760
+ label: "Keyboard shortcuts…",
1761
+ icon: "keyboard",
1762
+ onSelect: () => shortcutsDialog.open(),
1763
+ },
1764
+ ],
1765
+ },
1766
+ problems: { onActivate: () => vscode.postMessage({ type: "showProblems" }) },
1767
+ emptyHint: { onInsertExample: () => vscode.postMessage({ type: "insertExample" }) },
1768
+ });
1769
+ // The modeler's own dialog in shell.root (see Modeler dialogs); it goes with the diagram.
1770
+ const shortcutsDialog = createShortcutsDialog(shell);
1771
+ modeler.on("diagram.destroy", () => shortcutsDialog.destroy());
1772
+
1773
+ // 3. Viewport, palette, keys and the empty-file hint. Cheap tests only: canFit() is cached, but
1774
+ // exportMap() validates the whole board.
1775
+ const registry = modeler.get<{ getAll(): Array<{ parent?: unknown; waypoints?: unknown }> }>(
1776
+ "elementRegistry",
1777
+ );
1778
+ const isEmpty = () => !registry.getAll().some((element) => element.parent && !element.waypoints); // §13.5
1779
+ const connection = connectDiagramJs(shell, modeler, {
1780
+ isEmpty,
1781
+ // no padding of its own (the default pads by 120px): the fit insets are the only margin (§5.9)
1782
+ contentBounds: () => (isEmpty() ? undefined : boardBounds.contentBounds(0)),
1783
+ });
1784
+ const { viewport } = connection;
1785
+ persistView(vscode, viewport); // vscode.setState once the view rested, not on every frame
1786
+ modeler.on("commandStack.changed", () => shell.menu.refresh()); // level undo/redo
1787
+
1788
+ // 4. Every import from the document (init and external updates), strictly one after another.
1789
+ async function importText(text: string, first: boolean): Promise<void> {
1790
+ const keep = first ? undefined : viewport.getViewbox();
1791
+ try {
1792
+ await collaborative.importText(text); // silent: no change event comes from an import
1793
+ canvasLock.setReadOnly(false);
1794
+ shell.setReadOnly(false);
1795
+ shell.setStatus(null);
1796
+ if (keep) viewport.restore(keep);
1797
+ else if (first && stored.view) viewport.setState(stored.view);
1798
+ viewport.afterImport(); // first content fits, an empty file shows 100 %, the hint follows import.done
1799
+ shell.menu.refresh();
1800
+ } catch {
1801
+ // locked at once, the canvas included; marks and status box show after 800ms without a valid import
1802
+ canvasLock.setReadOnly(true);
1803
+ shell.setReadOnly(true, { delayMs: INVALID_STATUS_DELAY_MS });
1804
+ shell.setStatus(
1805
+ { severity: "danger", text: shell.messages.invalidFile("Event Storming board") },
1806
+ { delayMs: INVALID_STATUS_DELAY_MS },
1807
+ );
1808
+ }
1809
+ }
1810
+ let imports = Promise.resolve();
1811
+ const enqueueImport = (text: string, first = false) =>
1812
+ (imports = imports.then(() => importText(text, first)));
1813
+
1814
+ // 5. Messages from the extension: the document, the diagnostics and the Command Palette commands,
1815
+ // which run the same handlers as the menu.
1816
+ let firstImportSettled = () => {};
1817
+ const ready = new Promise<void>((resolve) => (firstImportSettled = resolve));
1818
+ window.addEventListener("message", (event) => {
1819
+ const message = event.data;
1820
+ if (message?.type === "init") void enqueueImport(message.text, true).then(firstImportSettled);
1821
+ if (message?.type === "update") void enqueueImport(message.text);
1822
+ if (message?.type === "problems") shell.setProblems({ count: message.count, errors: message.errors });
1823
+ if (message?.type === "command" && message.name === "fitView") viewport.fit();
1824
+ });
1825
+ vscode.postMessage({ type: "ready" });
1826
+
1827
+ // For the e2e test page (see ./testing/vscode-stub); the bundle publishes nothing on window.
1828
+ export const webview = { shell, modeler: collaborative, connection, ready };
1829
+ ```
1830
+
1831
+ `zod-jitless.ts` is one line, `config({ jitless: true })` from `zod`, in a module of its own so that
1832
+ it runs before any module that builds a schema.
1833
+
1834
+ A modeler with a legend persists it the same way: `legend: { render, open: stored.legendOpen,
1835
+ onToggle: (open) => writeShellState(vscode, { legendOpen: open }) }`.
1836
+
1837
+ What the Event Storming renderer changes for the shell (the kit's `aria`, `keys`, `create` and
1838
+ `readOnly` cases check each one):
1839
+
1840
+ - palette entries carry `title` and `shortcut` separately (`title: "Domain event", shortcut: "E"`),
1841
+ not `"Domain event — E"`;
1842
+ - Enter or Space on a creating entry (a click with `detail === 0`) creates the sticky at the
1843
+ centre of the free rectangle (from `freeRectInsets`, the point `viewport.freeRectCenter()`
1844
+ returns), 24px further diagonally while that spot is taken, selects it, focuses the canvas and
1845
+ opens the label editor;
1846
+ - no `tabindex` on `canvas.getContainer()` (an extra, unnamed tab stop before the palette); its
1847
+ keyboard listens on `document` anyway;
1848
+ - the document keyboard returns early on `event.defaultPrevented` and for targets outside the canvas
1849
+ container (the zoom panel's buttons, the inspector), selects every element on Cmd/Ctrl+A, and in
1850
+ the webview binds no Cmd/Ctrl+Z, Shift+Z or Y (`undoRedoKeys: false`): VS Code's document history
1851
+ owns undo (§13.5), the shell only keeps diagram-js's `KeyboardBindings` off them;
1852
+ - `eventStormingReadOnly` locks every edit rule (move, resize, create, connect, delete), and the
1853
+ keyboard, context pad, label editor and drawing handles consult it;
1854
+ - its own overlays inside the canvas (the note toolbar, the colour picker) stay at `z-index` 100 or
1855
+ below, so the palette at 110 covers them (see Layers above);
1856
+ - `boardBounds.contentBounds(0)`: the tight bounds of the board, since the fit insets are the margin.
1857
+
1858
+ Document sync, echo suppression, minimal edits and undo stay in the modeler for 0.1 (see below).
1859
+
1860
+ ## Out of scope for 0.1
1861
+
1862
+ 0.2 candidates unless noted:
1863
+
1864
+ - **VS Code document plumbing**: two-way sync with echo suppression, minimal `WorkspaceEdit`s,
1865
+ undo/redo through the document's history, "Insert example" as one edit, diagnostics to the
1866
+ Problems view (the pill only means something with a `DiagnosticCollection`), shared webview→host
1867
+ messages for the pill ("Show in Problems view") and "Insert example" with their host handlers,
1868
+ export save dialog. The modelers keep their own code; the shell keeps diagram-js's
1869
+ `KeyboardBindings` off undo/redo in the IDE host, stops undo/redo keys in text fields and stops
1870
+ keys the canvas handled.
1871
+ - An ES-module webview bundle (`renderWebviewHtml` writes a classic script; a `scriptType` option).
1872
+ - **Web-app chrome**: primary action (`data-chrome="primary"`) with label collapse, legal line,
1873
+ File and Appearance menu groups, problems popover, toasts, empty-state card, drop zone, the
1874
+ `localStorage` mode script. `host: "web"` in 0.1 covers keys (page zoom), fit insets and the
1875
+ pill/legend offsets only.
1876
+ - **Dialog component and the shared shortcuts dialog** (§5.4, §8): an exported `cd-shell-dialog`
1877
+ class (panel, header with Close, body, backdrop with `grayscale(1)`, the high-contrast and
1878
+ forced-colours rules, the top layer through `showModal()`, focus trap and focus return), and on
1879
+ it `shell.showShortcutsDialog(groups: ShortcutGroup[])` with one table per group and the chips of
1880
+ the tooltip's `kbd`. The shell fills the View group itself from the chrome keys. New
1881
+ `ShellMessages`: `shortcutsTitle` ("Keyboard shortcuts"), `close` ("Close") and the group names
1882
+ "View", "Edit" and "Tools". Popovers (§5.4) follow on the same class. Until then a modeler meets
1883
+ the rules of [Modeler dialogs](#modeler-dialogs) itself; one component lets every modeler migrate
1884
+ once.
1885
+ - **Placement helper** for palette keyboard creation: the 24px diagonal steps when the centre of the
1886
+ free rectangle is taken (§5.5), next to `Viewport.freeRectCenter()`.
1887
+ - the `appearance` surface of the modeler-api contract (a later 1.x minor) for embedded hosts and
1888
+ the `miragon.<modeler>.colorTheme` setting UI (the bridge already honours `settingKey`).
1889
+ - **Kit**:
1890
+ - cases for reduced motion and forced colours (they need `page.emulateMedia`, which `PageLike`
1891
+ does not have yet); the shell's CSS covers both, the e2e suite checks VS Code's reduce motion;
1892
+ - a CSP helper: serve the production bundle under `renderWebviewHtml`'s CSP inside the VS Code
1893
+ stub, record every `securitypolicyviolation` and fail on any (until then the recipe under
1894
+ `./vscode/extension`);
1895
+ - a harness field `documents?: readonly string[]` that hands `open` a document key (for example
1896
+ `["example", "style-dark", "empty"]`), so `appearance` runs per document; until then a modeler
1897
+ registers a second harness whose `open` loads the variant;
1898
+ - on those documents, a check that an empty one opens at 100 % with the empty-file hint;
1899
+ - a check that selects an element and reports positioned descendants of `.djs-container` above
1900
+ `z-index` 100 that are neither the palette, diagram-js's popup menu nor `.djs-hover-tooltip`.
1901
+ - IntelliJ/JCEF hosts, Electron, Tauri, Theia.
1902
+ - Canvas theming (`cd-canvas.css`, `cd-c-*` classes), light-only export, the token files themselves.