@miragon/modeler-shell 0.1.0 → 0.2.1

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 (98) hide show
  1. package/API.md +1051 -84
  2. package/README.md +162 -31
  3. package/dist/appearance-BBaM_4bD.d.ts +323 -0
  4. package/dist/appearance-XX3JC3pI.d.cts +323 -0
  5. package/dist/{chunk-6LQOFACK.js → chunk-A4YGKH4Y.js} +1252 -31
  6. package/dist/chunk-A4YGKH4Y.js.map +1 -0
  7. package/dist/{chunk-RJRFR6N3.js → chunk-KYDG5DMX.js} +2 -2
  8. package/dist/{chunk-5JSZ4VGA.js → chunk-OADW3GPA.js} +240 -6
  9. package/dist/chunk-OADW3GPA.js.map +1 -0
  10. package/dist/core.cjs +260 -6
  11. package/dist/core.cjs.map +1 -1
  12. package/dist/core.d.cts +81 -6
  13. package/dist/core.d.ts +81 -6
  14. package/dist/core.js +43 -3
  15. package/dist/diagram-js.cjs +34 -2
  16. package/dist/diagram-js.cjs.map +1 -1
  17. package/dist/diagram-js.d.cts +10 -3
  18. package/dist/diagram-js.d.ts +10 -3
  19. package/dist/diagram-js.js +14 -2
  20. package/dist/diagram-js.js.map +1 -1
  21. package/dist/index.cjs +1658 -196
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +18 -9
  24. package/dist/index.d.ts +18 -9
  25. package/dist/index.js +44 -4
  26. package/dist/shell.css +692 -3
  27. package/dist/testing-vscode-stub.js +2 -2
  28. package/dist/testing.cjs +2849 -550
  29. package/dist/testing.cjs.map +1 -1
  30. package/dist/testing.d.cts +75 -9
  31. package/dist/testing.d.ts +75 -9
  32. package/dist/testing.js +2744 -558
  33. package/dist/testing.js.map +1 -1
  34. package/dist/toasts-Bp0REqdq.d.cts +142 -0
  35. package/dist/toasts-Bp0REqdq.d.ts +142 -0
  36. package/dist/{types-OuFlqZp0.d.cts → types-CnRrINRs.d.cts} +170 -9
  37. package/dist/{types-DnZn9CPw.d.ts → types-PuDZczzy.d.ts} +170 -9
  38. package/dist/vscode-extension.js +2 -2
  39. package/dist/vscode-webview.cjs +1546 -178
  40. package/dist/vscode-webview.cjs.map +1 -1
  41. package/dist/vscode-webview.d.cts +6 -4
  42. package/dist/vscode-webview.d.ts +6 -4
  43. package/dist/vscode-webview.js +11 -2
  44. package/dist/vscode-webview.js.map +1 -1
  45. package/package.json +2 -2
  46. package/src/core/feedback/appearance.ts +141 -0
  47. package/src/core/feedback/problems.ts +23 -0
  48. package/src/core/feedback/toasts.ts +118 -0
  49. package/src/core/fit.ts +18 -3
  50. package/src/core/index.ts +43 -0
  51. package/src/core/keymap.ts +20 -0
  52. package/src/core/messages.ts +71 -0
  53. package/src/core/types.ts +24 -2
  54. package/src/core/web-chrome/bottom-row.ts +37 -0
  55. package/src/core/web-chrome/drop.ts +78 -0
  56. package/src/core/web-chrome/top-row.ts +66 -0
  57. package/src/diagram-js/service.ts +4 -1
  58. package/src/diagram-js/viewport.ts +19 -0
  59. package/src/dom/buttons.css +124 -0
  60. package/src/dom/feedback/appearance.ts +184 -0
  61. package/src/dom/feedback/feedback.css +435 -0
  62. package/src/dom/feedback/problems-popover.ts +287 -0
  63. package/src/dom/feedback/toasts.ts +431 -0
  64. package/src/dom/index.ts +13 -0
  65. package/src/dom/keys.ts +8 -0
  66. package/src/dom/menu.ts +28 -17
  67. package/src/dom/mount.ts +290 -20
  68. package/src/dom/problems.ts +5 -4
  69. package/src/dom/shell-dom.css +7 -4
  70. package/src/dom/tooltip.ts +10 -0
  71. package/src/dom/types.ts +188 -6
  72. package/src/dom/web/drop-zone.ts +181 -0
  73. package/src/dom/web/empty-state.ts +144 -0
  74. package/src/dom/web/legal.ts +75 -0
  75. package/src/dom/web/menu-groups.ts +69 -0
  76. package/src/dom/web/primary.ts +124 -0
  77. package/src/dom/web/web.css +386 -0
  78. package/src/shell.css +3 -0
  79. package/src/testing/case-tools.ts +164 -0
  80. package/src/testing/cases.ts +209 -144
  81. package/src/testing/checks.ts +51 -15
  82. package/src/testing/facts.ts +56 -2
  83. package/src/testing/geometry.ts +206 -26
  84. package/src/testing/harness.ts +56 -7
  85. package/src/testing/index.ts +4 -0
  86. package/src/testing/page-tools.ts +24 -1
  87. package/src/testing/page.ts +10 -0
  88. package/src/testing/web-cases.ts +913 -0
  89. package/src/testing/web-checks.ts +1049 -0
  90. package/src/testing/web-facts.ts +713 -0
  91. package/src/vscode/webview/mount.ts +13 -1
  92. package/dist/chunk-5JSZ4VGA.js.map +0 -1
  93. package/dist/chunk-6LQOFACK.js.map +0 -1
  94. package/dist/keymap-CObQE6q6.d.cts +0 -151
  95. package/dist/keymap-DORzHnK-.d.ts +0 -151
  96. package/dist/types-B6NfkiUG.d.cts +0 -66
  97. package/dist/types-B6NfkiUG.d.ts +0 -66
  98. /package/dist/{chunk-RJRFR6N3.js.map → chunk-KYDG5DMX.js.map} +0 -0
package/API.md CHANGED
@@ -1,14 +1,17 @@
1
- # @miragon/modeler-shell 0.1 — public API
1
+ # @miragon/modeler-shell 0.2 — public API
2
2
 
3
3
  The shared, framework-free implementation of everything around a Miragon diagram-js modeler: menu
4
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.
5
+ problems pill, status box, empty-file hint, legend and inspector slots, the plumbing to a VS Code
6
+ webview, and (0.2) the web-app chrome: primary action, legal line, File and Appearance groups,
7
+ problems popover, toasts, empty-state card, drop zone and the web app's mode. Planned in
8
+ [Miragon/modeler-api#4](https://github.com/Miragon/modeler-api/issues/4) (Phase 4); the first
9
+ consumer is the Event Storming modeler, in VS Code (0.1) and in its web app (0.2, see
10
+ [Usage: Event Storming in the web app](#usage-event-storming-in-the-web-app)).
8
11
 
9
12
  - **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.
13
+ (skill `miragon-brand:modeler-tool-design`, design system 1.13.0), §2, §5.1 to §5.15, §6, §7, §8,
14
+ §9, §11, §12 and §13.3 to §13.5. Section numbers below (§x.y) refer to it.
12
15
  - **Mechanics** (diagram-js services, VS Code quirks) are in
13
16
  [`docs/shell/implementation-notes.md`](../../docs/shell/implementation-notes.md).
14
17
  - **Zero runtime dependencies.** The shell never imports `diagram-js`, `didi`, `vscode`, Lucide or
@@ -17,22 +20,27 @@ webview. Planned in [Miragon/modeler-api#4](https://github.com/Miragon/modeler-a
17
20
  - **Tokens are the consumer's.** The modeler vendors `cd-tokens.generated.css` unchanged; the shell
18
21
  CSS reads only `var(--cd-ui-*)` roles, `var(--cd-shell-*)` sizes and the mode-independent
19
22
  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`, …).
23
+ It contains no colour literal and no fixed palette token (`--cd-blau`, `--cd-schwarz`, …), with
24
+ one exception the guide itself makes: the primary button's fill is `--cd-blau` with `--cd-weiss`
25
+ text in both modes, hover `--cd-blau-link` (§8: "Feste Werte nimmst du nur für die Fläche von
26
+ Primär- und Destruktiv-Button"). Those three tokens appear in `src/dom/buttons.css` only, and the
27
+ stylesheet test allows them nowhere else (see [Buttons](#buttons)). Values the shell needs in
28
+ script (the two `theme-color`s) come from the modeler's vendored JSON as well.
21
29
  - **English by default.** Every text comes from one messages table (`ShellMessages`), every number
22
30
  goes through `Intl` in the UI language.
23
31
 
24
32
  ## Entry points
25
33
 
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 | — |
34
+ | Entry | For | Needs a DOM |
35
+ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------ |
36
+ | `@miragon/modeler-shell` | the chrome (`mountShell`, web chrome included), icons, plus all of `./core` | yes (at call time) |
37
+ | `@miragon/modeler-shell/core` | fit math, keymap, zoom readout, appearance, sizes, messages, the web host's decisions and head script | no |
38
+ | `@miragon/modeler-shell/diagram-js` | `shellModule`, viewport, `connectDiagramJs` | yes |
39
+ | `@miragon/modeler-shell/vscode/extension` | webview HTML, nonce, theme bridge (extension host) | no |
40
+ | `@miragon/modeler-shell/vscode/webview` | `mountVsCodeShell`, appearance handler, webview state | yes |
41
+ | `@miragon/modeler-shell/testing` | Playwright-driven shell conformance kit | runs in Node, drives a browser |
42
+ | `@miragon/modeler-shell/testing/vscode-stub` | a stand-in for VS Code around a webview test page | yes (in the test page) |
43
+ | `@miragon/modeler-shell/shell.css` | every style of the shell | — |
36
44
 
37
45
  All JavaScript entries ship as ESM and CJS with types (`typesVersions` covers `moduleResolution:
38
46
  node10`); importing any of them has no side effects. The package also ships `src/` without its tests,
@@ -91,8 +99,22 @@ interface Insets {
91
99
  }
92
100
 
93
101
  type ShellHost = "ide" | "web" | "embedded";
94
- type ChromeIsland = "menu" | "palette" | "primary" | "zoom" | "problems" | "legend" | "inspector" | "status";
95
- const CHROME_ISLANDS: readonly ChromeIsland[];
102
+ type ChromeIsland =
103
+ | "menu"
104
+ | "palette"
105
+ | "primary"
106
+ | "zoom"
107
+ | "problems"
108
+ | "legend"
109
+ | "inspector"
110
+ | "status"
111
+ // 0.2, web column (toasts and problems-popover also embedded)
112
+ | "legal"
113
+ | "toasts"
114
+ | "empty-state"
115
+ | "drop-zone"
116
+ | "problems-popover";
117
+ const CHROME_ISLANDS: readonly ChromeIsland[]; // in this order
96
118
  type PaletteRow = "top" | "second-row";
97
119
 
98
120
  interface ZoomTarget {
@@ -182,6 +204,8 @@ interface FitLayout {
182
204
  host: ShellHost;
183
205
  paletteRow: PaletteRow;
184
206
  problemsVisible: boolean;
207
+ zoomRaised?: boolean; // web: the zoom panel stands above the legal line (zoomRowRaised)
208
+ legalHeight?: number; // web: the legal line's measured height, 20 per line (default one line)
185
209
  legendWidth?: number; // open legend's rendered width, undefined when closed/absent
186
210
  inspector?: { sheetTop?: number }; // present = open; sheetTop measured when it is a bottom sheet
187
211
  }
@@ -204,7 +228,9 @@ function easeStandard(progress: number): number; // cubic-bezier(0.4, 0, 0.2, 1)
204
228
  the other dimension. An axis whose insets leave no room (a container lower or narrower than its
205
229
  chrome) centres in the whole container.
206
230
  - `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
231
+ with a visible pill or with the zoom panel raised above the legal line (`zoomRaised`) 92, plus 20
232
+ for every extra line of a wrapped legal line (`legalHeight`); right 324 (12 + 288 + 24) with an
233
+ open side inspector (container wider
208
234
  than 760); left 12 + legend width + 24 with an open legend in containers wider than 440
209
235
  (`legendWidth <= 0` counts as closed); bottom `container.height − sheetTop + 12` with an open
210
236
  bottom sheet (up to 760). The sheet hides zoom panel, pill and legend (§5.11), so while it is open
@@ -263,6 +289,7 @@ interface KeyContext {
263
289
  dialogOpen: boolean; // any shown [role="dialog"], [role="alertdialog"] or <dialog open>, modal or not
264
290
  modalOpen: boolean; // one of them with aria-modal="true" or :modal
265
291
  defaultPrevented: boolean; // the canvas or the modeler handled the key
292
+ startScreen?: boolean; // web: the start screen shows; the chrome keys go to the browser
266
293
  }
267
294
  type KeyAction = ChromeKey | "closeMenu";
268
295
  interface KeyDecision {
@@ -281,6 +308,7 @@ function detectPlatform(navigator?: NavigatorLike): Platform;
281
308
  function shortcutLabel(key: ChromeKey, platform: Platform, keyNames: ShellMessages["keyNames"]): string;
282
309
  function ariaKeyShortcuts(key: ChromeKey, platform: Platform): string;
283
310
  function ariaKeyShortcutsFromLabel(label: string): string | undefined;
311
+ function commandKeyLabel(key: string, platform: Platform, keyNames: { ctrl: string }): string; // "⌘O", "Ctrl+O"
284
312
  ```
285
313
 
286
314
  `matchChromeKey` (§5.8) — Cmd/Ctrl is `metaKey || ctrlKey` on every platform, nothing matches with
@@ -300,6 +328,7 @@ it. `stopPropagation` is only ever `true` in the `ide` host.
300
328
  | --------------------------------------------------------- | ----------- | -------------- | ----------------------- |
301
329
  | Escape (no modifier), menu open, not composing | `closeMenu` | yes | yes |
302
330
  | Escape, popover or dialog open, or menu open while IME | — | no | yes |
331
+ | zoom key or Shift+1 behind the web app's start screen | — | no | — (web only) |
303
332
  | zoom key, not composing, not in a text field, no modal | the key | yes | yes |
304
333
  | zoom key while composing, in a text field or with a modal | — | no | yes |
305
334
  | Shift+1, not composing, not in a text field, no modal | `fit` | yes | yes |
@@ -386,6 +415,29 @@ interface ShellMessages {
386
415
  invalidFile(diagram: string): string;
387
416
  // "This file isn't a valid <diagram> right now. The diagram shows the last valid version and is read-only until you fix the text."
388
417
  keyNames: { ctrl: string; shift: string; plus: string; minus: string }; // Ctrl, Shift, Plus, Minus
418
+ // 0.2, web host (guide §2 shared terms)
419
+ share: string; // "Share": default label of the primary action
420
+ legal: string; // "Legal": aria-label of the legal line's <nav>
421
+ copyright(year: number): string; // "© 2026 Miragon GmbH"
422
+ opensInNewTab: string; // "Opens in a new tab": the legal links' description for screen readers
423
+ newDiagram: string; // "New diagram": File group and the empty state's primary button
424
+ openFile: string; // "Open…"
425
+ showExample: string; // "Show example": File group and the empty state's ghost button
426
+ importFile: string; // "Import…"
427
+ appearance: string; // "Appearance"
428
+ appearanceSystem: string; // "System"
429
+ appearanceLight: string; // "Light"
430
+ appearanceDark: string; // "Dark"
431
+ problemsTitle: string; // "Problems": title of the problems popover
432
+ problemSeverity(severity: ProblemSeverity): string; // "Error", "Warning": a popover row's icon, for screen readers
433
+ close: string; // "Close": a toast's Close button
434
+ dropFile: string; // "Drop file here"
435
+ dropForbidden(reason: DropForbiddenReason): string;
436
+ // "Drop one file at a time", "This modeler can't open this file type", "Close the dialog to drop a file"
437
+ dropHint: string; // "Or drop a file here"
438
+ openFileLink: string; // "Open a file…": the start screen's link to the File group's Open…
439
+ cannotOpenFile(name: string, accepted: string): string;
440
+ // "Couldn't open “map.png”. Drop a .storm, .json, or .txt file." (accepted: Intl.ListFormat disjunction)
389
441
  }
390
442
  // every key optional; an undefined value keeps the default
391
443
  type ShellMessageOverrides = {
@@ -396,6 +448,8 @@ function resolveMessages(overrides?: ShellMessageOverrides): ShellMessages;
396
448
  ```
397
449
 
398
450
  A localized modeler overrides the whole table (guide §2: one language per tool) and passes `lang`.
451
+ "Impressum" and "Datenschutz" are not in the table: they stay German with `lang="de"` in every tool
452
+ language (§2).
399
453
 
400
454
  ### Layout decisions
401
455
 
@@ -429,6 +483,15 @@ borders), so the decision does not flip once the palette scrolls in the second r
429
483
  row 112), above one in the lower half ending 8px above it (bottom row: `bottom: 60px`), centred on
430
484
  `anchor`, shifted to keep 12px to the sides.
431
485
 
486
+ ### Web host decisions (0.2)
487
+
488
+ The web column's DOM-free parts are in `./core` as well, documented with their islands under
489
+ [Web host](#web-host-02): `decideTopRow` (label collapse before the second row), `zoomRowRaised`
490
+ (legal line and zoom panel), `dragVerdict` and `acceptsFile` (drop zone), `toastLifetime`,
491
+ `toastToEvict`, `toastStackWidth`, `toastStackBottom` and their constants (toasts),
492
+ `parseAppearanceChoice`, `documentMode`, `webModeScript` and `webHeadMarkup` (mode; they run in a
493
+ Vite config or a build script), `problemSummary` (problems popover).
494
+
432
495
  ## `.` — the chrome
433
496
 
434
497
  Exports everything of `./core` plus:
@@ -443,12 +506,19 @@ interface ShellOptions {
443
506
  messages?: ShellMessageOverrides;
444
507
  platform?: Platform; // default detectPlatform(navigator)
445
508
  keyScope?: "document" | "root"; // default "document", "root" for embedded
446
- themes?: readonly Theme[]; // default ["light"]; read in the embedded host only, see below
509
+ themes?: readonly Theme[]; // default ["light"]; read in the embedded and web hosts, see below
447
510
  menu?: MenuGroups;
448
511
  problems?: ProblemsOptions;
449
512
  legend?: LegendOptions;
450
513
  inspector?: InspectorOptions;
451
514
  emptyHint?: EmptyHintOptions;
515
+ // 0.2, read in the web host only (see Web host below)
516
+ primary?: PrimaryActionOptions;
517
+ legal?: LegalLineOptions;
518
+ file?: FileMenuOptions;
519
+ appearance?: WebAppearanceOptions; // required when themes includes "dark"
520
+ emptyState?: EmptyStateOptions;
521
+ dropZone?: DropZoneOptions;
452
522
  }
453
523
  ```
454
524
 
@@ -474,9 +544,10 @@ surface, planned for a later 1.x minor of `@miragon/modeler-api`, there is no ch
474
544
  embedded modeler for another mode, so it stays in its first one), and mirrors the host's
475
545
  `data-cd-kontrast` from the nearest mode carrier above it for as long as it lives (a
476
546
  `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.
547
+ `data-cd-theme` would otherwise drop the host's high contrast. In the `ide` host the
548
+ host writes the mode on `<html>` and `themes` is not read; in the `web` host the page's head script
549
+ writes it before the first paint and `themes` decides whether the shell renders the Appearance
550
+ group (see [Mode](#mode-133)). An empty `themes` throws before anything is mounted.
480
551
 
481
552
  **Order with document listeners.** Listeners on one target run in registration order. A modeler
482
553
  whose own keyboard listens on `document` (Event Storming does) constructs its diagram **before**
@@ -497,6 +568,7 @@ interface MenuGroups {
497
568
  export?: readonly MenuEntry[];
498
569
  help?: readonly MenuEntry[];
499
570
  }
571
+ type MenuGroupName = "file" | "model" | "export" | "appearance" | "help"; // data-menu-group
500
572
  type MenuEntry = MenuActionEntry | MenuDialogEntry | MenuCheckboxEntry | MenuRadioGroup;
501
573
 
502
574
  interface MenuItemBase {
@@ -515,6 +587,8 @@ interface MenuActionEntry extends MenuItemBase {
515
587
  }
516
588
  interface MenuDialogEntry extends MenuItemBase {
517
589
  kind: "dialog";
590
+ shortcut?: string; // 0.2: the chip of a dialog entry ("?" for "Keyboard shortcuts…")
591
+ ariaKeyShortcuts?: string; // as for an action
518
592
  onSelect(): void;
519
593
  }
520
594
  interface MenuCheckboxEntry extends MenuItemBase {
@@ -538,22 +612,26 @@ interface MenuRadioOption {
538
612
  }
539
613
  ```
540
614
 
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.
615
+ - Groups render in the fixed order File, Model, Export, Appearance, Help with separators; empty
616
+ groups collapse. File and Appearance exist in the web host only and the shell builds them
617
+ (`ShellOptions.file`, the Appearance group of a dark-capable modeler, see
618
+ [File and Appearance groups](#file-and-appearance-groups-54-133)); Model, Export and Help come
619
+ from `MenuGroups`. With a legend (and `legend.menuEntry !== false`) the shell appends the checkbox
620
+ "Legend" to the Model group.
544
621
  - `action` and `dialog`: the menu closes, focus returns to the trigger, then `onSelect` runs (a host
545
622
  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
623
+ label ends with "…" (U+2026), and in 0.2 the modeler renders the dialog itself (see
547
624
  [Modeler dialogs](#modeler-dialogs)).
548
625
  - `checkbox` and `radio` toggle and keep the menu open. State is read through `checked()`, `value()`
549
626
  and `disabled()` whenever the menu opens and on `menu.refresh()`.
550
627
  - 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`.
628
+ without an icon. An action or dialog entry with a `shortcut` shows its chip and carries
629
+ `aria-keyshortcuts`.
552
630
  - On a read-only canvas (`setReadOnly(true)`, §13.5) every Model entry is `aria-disabled` unless it
553
631
  sets `keepEnabledWhenReadOnly`; disabled entries stay focusable and do nothing. View toggles stay
554
632
  usable on a read-only canvas: the shell's Legend checkbox carries `keepEnabledWhenReadOnly` itself,
555
633
  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.
634
+ entry that changes the document (Level, "Map size…"). File, Export, Appearance and Help entries are never locked.
557
635
  Guide §13.5 (design system 1.13.0): only Model entries that change the document are `aria-disabled`.
558
636
  - The trigger is `hidden` while no group has an entry (no menu groups and no Legend entry).
559
637
  - Keys: Enter, Space and ↓ on the trigger open on the first entry, ↑ on the last; ↑/↓ wrap, Home/End
@@ -568,8 +646,8 @@ interface MenuRadioOption {
568
646
 
569
647
  ### Modeler dialogs
570
648
 
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
649
+ 0.2 has no dialog component (a later candidate, see [Out of scope for 0.2](#out-of-scope-for-02)), so
650
+ a modeler renders its dialogs, the "Keyboard shortcuts…" dialog of the Help group (§5.4) first among
573
651
  them, and meets §8 on its own:
574
652
 
575
653
  - **Layer.** `z-index: var(--cd-shell-ebene-dialog)` (50) applies only to a dialog outside the top
@@ -598,7 +676,8 @@ them, and meets §8 on its own:
598
676
 
599
677
  ```ts
600
678
  interface ProblemsOptions {
601
- onActivate(): void; // the IDE host opens its Problems view
679
+ onActivate?(): void; // the IDE host opens its Problems view (give it there); ignored in the web host
680
+ onSelectItem?(item: ProblemItem): void; // 0.2: a row of the problems popover, after it closed
602
681
  }
603
682
  interface LegendOptions {
604
683
  render(body: HTMLElement): void; // once; a throw unwinds the whole mount
@@ -628,6 +707,8 @@ interface ShellHandle {
628
707
  readonly tooltip: TooltipHandle;
629
708
  readonly legend: LegendHandle | undefined;
630
709
  readonly inspector: InspectorHandle | undefined;
710
+ readonly primary: PrimaryActionHandle | undefined; // 0.2, web host with `primary`
711
+ readonly appearance: AppearanceHandle | undefined; // 0.2, web host with "dark" in `themes`
631
712
  setZoomTarget(target: ZoomTarget | null): void;
632
713
  fitInsets(): Insets;
633
714
  registerPalette(palette: PaletteRegistration): () => void;
@@ -636,6 +717,9 @@ interface ShellHandle {
636
717
  setEmptyHint(visible: boolean): void;
637
718
  setReadOnly(readOnly: boolean, options?: { delayMs?: number }): void;
638
719
  isReadOnly(): boolean;
720
+ setEmptyState(shown: boolean): void; // 0.2, web host with `emptyState`
721
+ isEmptyStateShown(): boolean;
722
+ toast(request: ToastRequest): ToastHandle; // 0.2, web and embedded; inert in the ide host
639
723
  destroy(): void;
640
724
  }
641
725
  interface MenuHandle {
@@ -680,6 +764,7 @@ interface PaletteReadOnlyState {
680
764
  interface ProblemSummary {
681
765
  count: number;
682
766
  errors: number;
767
+ items?: readonly ProblemItem[]; // 0.2: the popover's rows; the ide host ignores them
683
768
  }
684
769
  type StatusSeverity = "danger" | "warning";
685
770
  interface StatusMessage {
@@ -700,7 +785,9 @@ interface StatusMessage {
700
785
  earlier one, and the returned unregister function is safe to call twice and after `destroy()`.
701
786
  - `setProblems`: the pill shows for `count > 0` with `Intl` numbers in `lang` and the icon by severity;
702
787
  its tooltip "Show in Problems view" exists only in the `ide` host (`aria-describedby`, since it
703
- says more than the label).
788
+ says more than the label). A click calls `onActivate` in the `ide` host and in an `embedded` host
789
+ that gives it; in the `web` host (and an `embedded` one without `onActivate`) it opens the
790
+ problems popover with `summary.items` (see [Problems popover](#problems-popover-510)).
704
791
  - `setStatus(status, { delayMs })`: shows after `delayMs` (an invalid file: `INVALID_STATUS_DELAY_MS`,
705
792
  so typing does not flicker and `role="alert"` does not re-announce), hides at once with `null`.
706
793
  Repeated calls while the delay runs keep the first timer (the box appears once the text stayed
@@ -823,40 +910,55 @@ Feather notices of `THIRD_PARTY_NOTICES.md` itself.
823
910
  ```
824
911
 
825
912
  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
913
+ pill, legend, zoom panel; the web host adds the primary action after the menu trigger, the
914
+ empty-state card after the hint and the legal line before the zoom panel, see
915
+ [Web host](#web-host-02)); nothing has a positive `tabindex`, and a renderer adds no tab stop of its
827
916
  own around the canvas (no `tabindex` on `.djs-container`). Islands that are not shown are `hidden`.
828
917
 
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 |
918
+ | `data-chrome` | Element | Notes |
919
+ | ------------------ | ----------------------------- | ------------------------------------------ |
920
+ | `menu` | menu trigger | icon only, Lucide `menu` |
921
+ | `palette` | diagram-js `.djs-palette` | set by `shellModule` |
922
+ | `primary` | primary action (web) | only with `primary`, see Web host |
923
+ | `zoom` | zoom panel | `[−][NN%][+] \| [Fit]` |
924
+ | `problems` | problems pill | `hidden` at 0 |
925
+ | `legend` | legend card | only with `legend` |
926
+ | `inspector` | inspector `<aside>` | only with `inspector` |
927
+ | `status` | status box under the palette | `role="alert"` (danger) or `role="status"` |
928
+ | `legal` | legal line (web) | always in the web host |
929
+ | `toasts` | toast stack (web, embedded) | always present, no box while empty |
930
+ | `empty-state` | start screen card (web) | only with `emptyState` |
931
+ | `drop-zone` | drop overlay (web) | only with `dropZone`, during a file drag |
932
+ | `problems-popover` | problems list (web, embedded) | in the layer, opened by the pill |
933
+
934
+ | Class | Part |
935
+ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
936
+ | `cd-shell`, `cd-shell-document`, `cd-shell-canvas`, `cd-shell-layer` | root, own document, canvas slot, layer |
937
+ | `cd-shell-island`, `cd-shell-target` | glass island, 32px target in an island |
938
+ | `cd-shell-menu-trigger`, `cd-shell-menu`, `cd-shell-menu-item`, `cd-shell-menu-item-icon`, `cd-shell-menu-item-label`, `cd-shell-menu-item-end`, `cd-shell-menu-separator`, `cd-shell-menu-heading`, `cd-shell-menu-group` | menu |
939
+ | `cd-shell-menu-shortcut`, `cd-shell-menu-check` | shortcut text and check mark of an entry |
940
+ | `cd-shell-tooltip`, `cd-shell-tooltip-label`, `cd-shell-kbd` | tooltip and shortcut chip |
941
+ | `cd-shell-zoom`, `cd-shell-zoom-readout`, `cd-shell-zoom-separator` | zoom panel |
942
+ | `cd-shell-problems`, `cd-shell-problems-icon`, `cd-shell-problems-text` | pill |
943
+ | `cd-shell-status`, `cd-shell-status-icon`, `cd-shell-status-text` | status box (bar as `::before`) |
944
+ | `cd-shell-hint`, `cd-shell-hint-text`, `cd-shell-hint-action` | empty-file hint |
945
+ | `cd-shell-legend`, `cd-shell-legend-header`, `cd-shell-legend-title`, `cd-shell-legend-close`, `cd-shell-legend-body` | legend card |
946
+ | `cd-shell-legend-row`, `cd-shell-legend-preview`, `cd-shell-legend-separator` | rows the modeler renders into the body |
947
+ | `cd-shell-inspector` | inspector |
948
+ | `cd-shell-no-transition` | on `<html>` for one frame of a mode switch |
949
+ | `cd-shell-button` (`data-variant="primary"` / `"ghost"`) | buttons of the web chrome (0.2) |
950
+ | `cd-shell-primary`, `cd-shell-primary-label` | primary action (0.2) |
951
+ | `cd-shell-legal`, `cd-shell-legal-item` | legal line, a separator with its link (0.2) |
952
+ | `cd-shell-empty-state`, `cd-shell-empty-state-mark`, `cd-shell-empty-state-title`, `cd-shell-empty-state-text`, `cd-shell-empty-state-actions`, `cd-shell-empty-state-hint`, `cd-shell-empty-state-open-item`, `cd-shell-empty-state-open`, `cd-shell-empty-state-key` | empty-state card (0.2) |
953
+ | `cd-shell-drop-zone`, `cd-shell-drop-zone-chip` | drop zone (0.2) |
954
+ | `cd-shell-toasts`, `cd-shell-toast`, `cd-shell-toast-dot`, `cd-shell-toast-text`, `cd-shell-toast-action`, `cd-shell-toast-close` | toasts (0.2) |
955
+ | `cd-shell-live`, `cd-shell-live-region` (`data-live="polite"` / `"assertive"`) | the toasts' live regions (0.2) |
956
+ | `cd-shell-problems-popover`, `cd-shell-problems-popover-title`, `cd-shell-problems-list`, `cd-shell-problem`, `cd-shell-problem-icon`, `cd-shell-problem-message`, `cd-shell-problem-location` | problems popover (0.2) |
855
957
 
856
958
  Internal attributes: `data-severity` on pill and status box, `data-danger` on a dangerous menu
857
959
  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,
960
+ so a modeler does not use the id `legend`), `data-menu-group` (`file`, `model`, `export`, `appearance`,
961
+ `help`) on entries and radio groups, which the kit's `readOnly` case reads, `data-menu-value` on radio options,
860
962
  `data-zoom` on the zoom buttons.
861
963
 
862
964
  Modelers style only their own content (legend rows, inspector fields); every other class is
@@ -879,9 +981,25 @@ Relative to the shell root's padding box (a border a foreign page gives the root
879
981
  | dropdown | `top: 60px; left: 12px`, at least 248px wide |
880
982
  | tooltip | top row `top: 60px`, second row `top: 112px`, bottom row `bottom: 60px` |
881
983
 
984
+ The web column adds (see [Web host](#web-host-02)):
985
+
986
+ | Island | Box (web host) |
987
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
988
+ | `primary` | 40 high at `top: 12px; right: 12px`; 40 wide while `data-primary="collapsed"`, else icon, gap and label with padding `0 16px` |
989
+ | `legal` | 20 high at `left: 12px; bottom: 12px`, at most `100cqw − 24px` wide; wrapped 20 per line, the stack above rising with it |
990
+ | `problems` | `left: 12px; bottom: 40px` (0.1) |
991
+ | `legend` | `bottom: 40px`, with pill `88px` (0.1); up to 440px width at least `60px`, `88px` with `data-zoom-row="raised"` |
992
+ | `zoom` | `right: 12px; bottom: 12px`; `bottom: 40px` with `data-zoom-row="raised"` |
993
+ | `problems-popover` | `left: 12px`, its bottom 8px above the pill's top (`bottom: 88px`), `min(320px, 100cqw − 24px)` wide, at most 320 high |
994
+ | `toasts` | centred, `min(480px, 100cqw − 24px)` wide, `bottom: toastStackBottom(…)` (12px unless its widest card meets the bottom row) |
995
+ | `empty-state` | centred in the container (`centerX`, `centerY`), `min(400px, 100cqw − 24px)` wide |
996
+ | `drop-zone` | `inset: 12px` while a file drag is on the page |
997
+
882
998
  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()`
999
+ context pad and label editor at 100); islands `--cd-shell-ebene-inseln` (20, the legal line and the
1000
+ empty-state card included), inspector 25, menu trigger and primary action 30, layer 40 (dropdown,
1001
+ tooltip, problems popover, drop zone), modeler dialogs 50, toasts 60 (in the web host a manual
1002
+ popover in the top layer, see [Toasts](#toasts-8-9)). The 50 holds only outside the top layer; a `showModal()`
885
1003
  `<dialog>` or a popover-API element lies above everything (see [Modeler dialogs](#modeler-dialogs)).
886
1004
 
887
1005
  Inside the canvas slot, persistent overlays a modeler adds (element toolbars, its own context pad,
@@ -890,6 +1008,578 @@ opens from them (diagram-js's popup menu at 200, a colour picker) may cover the
890
1008
  open. All of them stay inside `.djs-container`, so they never cover an island. This is the shell's
891
1009
  rule of guide §5.13 (design system 1.13.0).
892
1010
 
1011
+ ## Web host (0.2)
1012
+
1013
+ `host: "web"` is a Miragon web app that owns its page: the web column of guide §5.2. 0.1 covered its
1014
+ keys (the chrome keys prevent the browser's page zoom), fit insets and the pill and legend offsets;
1015
+ 0.2 renders the rest of the column. Everything in this section is read in the `web` host only and
1016
+ ignored by `ide` and `embedded` hosts, except toasts and the problems popover, which an embedded
1017
+ modeler shows as well (§5.2: until the contract has a channel for messages to the host).
1018
+ `mountVsCodeShell` does not accept these options at all; the IDE host is unchanged.
1019
+
1020
+ | Anchor (§5.2) | Web app | Option → handle | `data-chrome` |
1021
+ | ------------- | -------------------------------------------------------- | --------------------------------------- | ------------------------------------------------- |
1022
+ | top left | menu trigger: File, Model, Export, Appearance, Help | `file`, `menu`, `themes` + `appearance` | `menu` |
1023
+ | top centre | palette | `shellModule` | `palette` |
1024
+ | top right | primary action, label collapses first (§5.15) | `primary` → `shell.primary` | `primary` |
1025
+ | bottom left | legal line; above it the pill (with its list) and legend | `legal`, `problems`, `legend` | `legal`, `problems`, `problems-popover`, `legend` |
1026
+ | bottom right | zoom panel, raised above the legal line where they meet | — | `zoom` |
1027
+ | bottom centre | toasts | `shell.toast()` | `toasts` |
1028
+ | centre | empty-state card on a fresh start | `emptyState` → `shell.setEmptyState()` | `empty-state` |
1029
+ | whole page | drop zone for files | `dropZone` | `drop-zone` |
1030
+
1031
+ ```html
1032
+ <html lang="en" class="cd-shell-document" data-cd-theme="light">
1033
+ <!-- static, see Mode -->
1034
+ <div
1035
+ class="cd-shell"
1036
+ data-cd-shell-host="web"
1037
+ data-palette="top"
1038
+ data-primary="collapsed"
1039
+ data-zoom-row="raised"
1040
+ data-empty-state="shown"
1041
+ data-drop="over|forbidden"
1042
+ data-problems="shown"
1043
+ >
1044
+ <!-- state attributes only while true -->
1045
+ <button class="cd-shell-menu-trigger" data-chrome="menu" …>…</button>
1046
+ <button
1047
+ class="cd-shell-primary cd-shell-button"
1048
+ data-variant="primary"
1049
+ data-chrome="primary"
1050
+ aria-label="Share"
1051
+ >
1052
+ <svg … /><span class="cd-shell-primary-label">Share</span>
1053
+ </button>
1054
+ <div class="cd-shell-canvas" role="main" [inert]>…</div>
1055
+ <!-- the page's main landmark; inert behind the start screen -->
1056
+ <div class="cd-shell-status" data-chrome="status" …>…</div>
1057
+ <div class="cd-shell-hint">…</div>
1058
+ <section
1059
+ class="cd-shell-empty-state"
1060
+ data-chrome="empty-state"
1061
+ aria-labelledby="cd-shell-empty-state-title-1"
1062
+ >
1063
+ …
1064
+ </section>
1065
+ <aside class="cd-shell-inspector" data-chrome="inspector" …>…</aside>
1066
+ <button
1067
+ class="cd-shell-problems"
1068
+ data-chrome="problems"
1069
+ aria-haspopup="dialog"
1070
+ aria-expanded="false"
1071
+ aria-controls="cd-shell-problems-1"
1072
+ …
1073
+ >
1074
+ …
1075
+ </button>
1076
+ <section class="cd-shell-legend" data-chrome="legend" …>…</section>
1077
+ <nav class="cd-shell-legal" data-chrome="legal" aria-label="Legal">…</nav>
1078
+ <div class="cd-shell-zoom" data-chrome="zoom" …>…</div>
1079
+ <div class="cd-shell-layer">
1080
+ <!-- z 40 -->
1081
+ <div class="cd-shell-menu" role="menu" …>…</div>
1082
+ <div class="cd-shell-tooltip" role="tooltip">…</div>
1083
+ <div
1084
+ class="cd-shell-problems-popover"
1085
+ data-chrome="problems-popover"
1086
+ role="dialog"
1087
+ id="cd-shell-problems-1"
1088
+ aria-labelledby="cd-shell-problems-title-1"
1089
+ hidden
1090
+ >
1091
+ …
1092
+ </div>
1093
+ <div class="cd-shell-drop-zone" data-chrome="drop-zone" aria-hidden="true" hidden>…</div>
1094
+ </div>
1095
+ <div class="cd-shell-live">
1096
+ <!-- visually hidden, always rendered: the toasts' live regions -->
1097
+ <div class="cd-shell-live-region" role="status" aria-live="polite" aria-atomic="true"></div>
1098
+ <div class="cd-shell-live-region" role="alert" aria-live="assertive" aria-atomic="true"></div>
1099
+ </div>
1100
+ <div class="cd-shell-toasts" data-chrome="toasts" popover="manual">…</div>
1101
+ <!-- z 60; embedded: no popover -->
1102
+ </div>
1103
+ </html>
1104
+ ```
1105
+
1106
+ Tab order (§5.14): menu trigger, primary action, palette, canvas, status box, hint, empty-state card,
1107
+ inspector, pill, legend, legal line, zoom panel, then the toasts' buttons. Each 0.2 island has its
1108
+ `data-chrome` so the kit measures it; ids count on the page-global mount counter as in 0.1. The
1109
+ canvas slot is the page's `main` landmark (`role="main"`, unless it already has a role; the web host
1110
+ only: a foreign page has its own).
1111
+
1112
+ ### Primary action (§5.7, §5.15)
1113
+
1114
+ ```ts
1115
+ interface PrimaryActionOptions {
1116
+ label?: string; // default messages.share ("Share"); also the aria-label
1117
+ icon?: ShellIconName; // default "share-2"
1118
+ onSelect(): void;
1119
+ disabled?: () => boolean; // read on mount and on primary.refresh(); aria-disabled, stays focusable
1120
+ }
1121
+ interface PrimaryActionHandle {
1122
+ readonly element: HTMLButtonElement;
1123
+ isCollapsed(): boolean; // icon only right now
1124
+ refresh(): void;
1125
+ }
1126
+
1127
+ // ./core
1128
+ interface PrimaryWidths {
1129
+ withLabel: number;
1130
+ iconOnly: number; // 40
1131
+ }
1132
+ interface TopRowInput {
1133
+ containerWidth: number;
1134
+ paletteWidth: number;
1135
+ menuRight: number;
1136
+ primary?: PrimaryWidths;
1137
+ }
1138
+ interface TopRow {
1139
+ paletteRow: PaletteRow;
1140
+ primaryLabel: "shown" | "collapsed";
1141
+ }
1142
+ function decideTopRow(input: TopRowInput): TopRow;
1143
+ ```
1144
+
1145
+ - A blue primary button (§8, [Buttons](#buttons)) at `top: 12px; right: 12px`, 40px high, layer 30
1146
+ (`--cd-shell-ebene-reihe`), padding `0 16px`, Lucide icon 16px, 8px gap, label 13px/600. Hover and
1147
+ active `--cd-blau-link`, `shadow-2`, lift `translateY(-1px)`; disabled opaque and neutral, no lift.
1148
+ - **Label collapse** (§5.15: "Wird es oben eng, verliert in der Web-App zuerst die Primäraktion ihr
1149
+ Label, erst danach rutscht die Palette"): the layout pass (root and palette resize) runs
1150
+ `decideTopRow` with the measured widths. With the label the palette must keep 16px to both the
1151
+ menu trigger and the primary action; failing that, the icon-only width is tried; failing that, the
1152
+ palette moves to the second row and the label comes back if it fits next to the menu trigger.
1153
+ Collapsed: 40 × 40, the label not rendered, `aria-label` unchanged, the chrome tooltip shows the
1154
+ label (no `aria-describedby`, it repeats the name); the root carries `data-primary="collapsed"`.
1155
+ The labelled width is measured afresh while the label shows (a late web font counts) and, while
1156
+ collapsed, in one synchronous layout with the label that is never painted.
1157
+ - `disabled()` maps to `aria-disabled` (read on mount and on `primary.refresh()`); a click on a
1158
+ disabled primary action does nothing.
1159
+ - One primary action in 0.2; the further actions "links daneben mit 8px Lücke" (§5.7) are a later
1160
+ candidate. Hidden on the start screen, never locked by `setReadOnly`.
1161
+
1162
+ ### Legal line (§5.10, §2)
1163
+
1164
+ ```ts
1165
+ interface LegalLink {
1166
+ label: string;
1167
+ href: string;
1168
+ lang?: string;
1169
+ }
1170
+ interface LegalLineOptions {
1171
+ links?: readonly LegalLink[]; // after Impressum and Datenschutz, e.g. GitHub
1172
+ year?: number; // default the current year at mount
1173
+ }
1174
+ ```
1175
+
1176
+ ```html
1177
+ <nav class="cd-shell-legal" data-chrome="legal" aria-label="Legal">
1178
+ <a href="https://www.miragon.io/" … aria-describedby="cd-shell-legal-1-new-tab">© 2026 Miragon GmbH</a>
1179
+ <span class="cd-shell-legal-item">
1180
+ <span aria-hidden="true">·</span>
1181
+ <a href="https://www.miragon.io/impressum/" lang="de" target="_blank" rel="noopener noreferrer" …
1182
+ >Impressum</a
1183
+ >
1184
+ </span>
1185
+ <span class="cd-shell-legal-item">
1186
+ <span aria-hidden="true">·</span>
1187
+ <a href="https://www.miragon.io/datenschutz/" lang="de" …>Datenschutz</a>
1188
+ </span>
1189
+ <span class="cd-shell-legal-item">
1190
+ <span aria-hidden="true">·</span>
1191
+ <a href="https://github.com/Miragon/event-storming-modeler" …>GitHub</a>
1192
+ </span>
1193
+ <span id="cd-shell-legal-1-new-tab" hidden>Opens in a new tab</span>
1194
+ </nav>
1195
+ ```
1196
+
1197
+ - Always rendered in the web host, also on the start screen ("die Zeile Rechtliches, die eine
1198
+ öffentliche Seite immer zeigt", §8). Copyright, URLs and the two German labels are those of the
1199
+ corporate-identity docs-site kit (`miragon/legal.ts`); `messages.copyright` and `messages.legal`
1200
+ translate, the URLs and "Impressum"/"Datenschutz" never do.
1201
+ - One line, 13px, line height 20px, at `left: 12px; bottom: 12px`, glass (§5.3) without border and
1202
+ shadow, padding `0 8px`, radius `sm`, links `--cd-ui-link` (underlined on hover and focus, always
1203
+ in high contrast and forced colours), separators `--cd-ui-text-leise`, layer 20. Pill and legend
1204
+ stand above it (0.1: pill `bottom: 40px`, legend 40/88px). Every link opens a new tab and says so
1205
+ to screen readers through `aria-describedby` (`messages.opensInNewTab`, "Opens in a new tab").
1206
+ - **Narrow containers.** The line is at most `100cqw − 24px` wide; where it does not fit (with a
1207
+ GitHub link it is about 391px wide, so below about 415px) it wraps between its entries, each
1208
+ separator staying with the link after it, and grows upwards from `bottom: 12px`. Nothing is cut
1209
+ off or scrolled away (Datenschutz must stay reachable; WCAG 1.4.10 at 320 CSS px). The shell
1210
+ measures the line's height in whole lines (`legalLineHeight`) into `--modeler-shell-legal-height`
1211
+ on the root, and everything stacked on it rises with it: the pill and the problems popover, the
1212
+ legend, the raised zoom panel and the fit's bottom (`FitLayout.legalHeight`; 112 instead of 92 on
1213
+ two lines).
1214
+ - **Bottom-row collision.** In a narrow container the legal line and the zoom panel meet:
1215
+ `zoomRowRaised` (`./core`) is true when the zoom panel at `right: 12px` would keep less than 8px to
1216
+ the legal line's measured right edge. The root then carries `data-zoom-row="raised"`, the zoom
1217
+ panel stands above the legal line at `bottom: 40px` like the pill (8px above a wrapped line), the
1218
+ fit keeps 92px at the bottom (`FitLayout.zoomRaised`), and up to 440px the legend's lowest position
1219
+ is 88px. §5.10 and §5.15 do not settle this case; it is the shell's rule until the guide does.
1220
+
1221
+ ```ts
1222
+ // ./core
1223
+ const LEGAL_LINE_HEIGHT = 20; // one line of the legal line
1224
+ function legalLineHeight(measured: number | undefined): number; // whole lines, at least one: 20, 40, …
1225
+ interface BottomRowInput {
1226
+ containerWidth: number;
1227
+ legalRight?: number; // measured, relative to the root; absent while not shown
1228
+ zoomWidth?: number; // measured; absent while not shown
1229
+ }
1230
+ function zoomRowRaised(input: BottomRowInput): boolean;
1231
+ ```
1232
+
1233
+ ### File and Appearance groups (§5.4, §13.3)
1234
+
1235
+ ```ts
1236
+ interface FileMenuAction {
1237
+ onSelect(): void;
1238
+ shortcut?: string; // chip, e.g. commandKeyLabel("O", platform, keyNames); the web app binds the key itself
1239
+ dialog?: boolean; // kind "dialog": it opens an in-page dialog, not the browser's file picker
1240
+ disabled?: () => boolean;
1241
+ }
1242
+ interface FileMenuOptions {
1243
+ newDiagram?: FileMenuAction;
1244
+ open?: FileMenuAction;
1245
+ showExample?: FileMenuAction;
1246
+ import?: FileMenuAction;
1247
+ }
1248
+ ```
1249
+
1250
+ | Option | Entry (`messages`) | Icon | `data-menu-id` |
1251
+ | ------------- | ------------------ | ------------- | -------------- |
1252
+ | `newDiagram` | "New diagram" | `file-plus` | `new-diagram` |
1253
+ | `open` | "Open…" | `folder-open` | `open` |
1254
+ | `showExample` | "Show example" | none (§5.14) | `show-example` |
1255
+ | `import` | "Import…" | `import` | `import` |
1256
+
1257
+ - The File group comes first, the shell builds it in this order from what the modeler gives; labels
1258
+ and icons are the shared terms of §2 and the common set of §5.14, not the modeler's. Entries are
1259
+ `action`s (`dialog` with `FileMenuAction.dialog`), never locked on a read-only canvas. `open` also
1260
+ becomes the start screen's text link (see [Empty state](#empty-state-8)).
1261
+ - The chip of a Cmd/Ctrl key the app binds itself comes from `./core`, so it reads the same in every
1262
+ modeler and on every platform:
1263
+
1264
+ ```ts
1265
+ function commandKeyLabel(key: string, platform: Platform, keyNames: { ctrl: string }): string;
1266
+ // commandKeyLabel("O", "mac", messages.keyNames) → "⌘O"; "other" → "Ctrl+O" (German "Strg+O")
1267
+ ```
1268
+
1269
+ - The **Appearance** group is a radio group (`data-menu-id="appearance"`, heading and `aria-label`
1270
+ "Appearance", options `system`, `light`, `dark`: "System", "Light", "Dark") between Export and
1271
+ Help. The shell renders it only when `themes` includes `"dark"`; a light-only modeler has no
1272
+ Appearance group (§13.3 "Nur Hell, bis der Modeler Dunkel kann"). Its value is
1273
+ `shell.appearance.choice()`, selecting runs `shell.appearance.setChoice()` and keeps the menu open.
1274
+ - Neither group exists in the IDE host (native, §13.5) or the embedded host (§5.2).
1275
+
1276
+ ### Mode (§13.3)
1277
+
1278
+ ```ts
1279
+ // ./core
1280
+ type AppearanceChoice = "system" | "light" | "dark";
1281
+ const APPEARANCE_STORAGE_KEY = "miragon.darstellung";
1282
+ interface ThemeColors {
1283
+ light: string; // the app's theme-color: fest["--cd-blau"] of the vendored JSON
1284
+ dark: string; // dunkel["--cd-ui-grund"]
1285
+ }
1286
+ function parseAppearanceChoice(stored: unknown): AppearanceChoice; // "light" | "dark", else "system"
1287
+ interface DocumentMode {
1288
+ theme: "system" | Theme; // data-cd-theme on <html>
1289
+ colorScheme: "light dark" | Theme; // <meta name="color-scheme">
1290
+ themeColor: ThemeColors; // the two theme-color metas
1291
+ }
1292
+ function documentMode(choice: AppearanceChoice, themes: readonly Theme[], colors: ThemeColors): DocumentMode;
1293
+ interface WebModeScriptOptions {
1294
+ themes: readonly Theme[];
1295
+ themeColors: ThemeColors;
1296
+ }
1297
+ function webModeScript(options: WebModeScriptOptions): string; // body of the inline head <script>
1298
+ function webHeadMarkup(options: WebModeScriptOptions): string; // metas + <script>, for index.html
1299
+
1300
+ // .
1301
+ interface StorageLike {
1302
+ getItem(key: string): string | null;
1303
+ setItem(key: string, value: string): void;
1304
+ removeItem(key: string): void;
1305
+ }
1306
+ interface WebAppearanceOptions {
1307
+ themeColors: ThemeColors;
1308
+ storage?: StorageLike; // default globalThis.localStorage
1309
+ }
1310
+ interface AppearanceHandle {
1311
+ choice(): AppearanceChoice;
1312
+ setChoice(choice: AppearanceChoice): void;
1313
+ }
1314
+ ```
1315
+
1316
+ - **Before the first paint** the page itself decides (§13.3, §13.5: one place, the host). A
1317
+ light-only modeler writes `<html lang="en" data-cd-theme="light">` statically, one
1318
+ `<meta name="theme-color">` without `media`, and of the guide's script only the contrast line;
1319
+ `webHeadMarkup({ themes: ["light"], themeColors })` returns exactly that. A dark-capable modeler
1320
+ writes `data-cd-theme="system"` statically and the guide's whole script with both `theme-color`s:
1321
+ `webHeadMarkup({ themes: ["light", "dark"], themeColors })`. The markup goes into `<head>` before
1322
+ the first stylesheet, for example through a Vite `transformIndexHtml` hook:
1323
+
1324
+ ```ts
1325
+ // apps/webapp/vite.config.ts
1326
+ import { webHeadMarkup } from "@miragon/modeler-shell/core";
1327
+ import tokens from "./src/theme/cd-tokens.generated.json" with { type: "json" };
1328
+ import { THEMES } from "./src/themes";
1329
+
1330
+ const themeColors = { light: tokens.fest["--cd-blau"], dark: tokens.dunkel["--cd-ui-grund"] };
1331
+ const modePlugin = {
1332
+ name: "miragon-mode",
1333
+ transformIndexHtml: (html: string) =>
1334
+ html.replace("<!-- miragon-mode -->", webHeadMarkup({ themes: THEMES, themeColors })),
1335
+ };
1336
+ ```
1337
+
1338
+ - **At runtime** `mountShell` (web host) follows `(prefers-contrast: more)` changes on `<html>`
1339
+ (`data-cd-kontrast="hoch"`), for every modeler. With `"dark"` in `themes` it needs
1340
+ `appearance.themeColors` (it throws without them, before anything mounts) and offers
1341
+ `shell.appearance`: `setChoice` stores the choice (`"system"` removes the entry; a blocked storage
1342
+ is caught and the choice applies for the page), applies `documentMode` on `<html data-cd-theme>`,
1343
+ `color-scheme` and both `theme-color` metas (created when missing) in one task with
1344
+ `cd-shell-no-transition` for one frame (§7), and a `storage` event from another tab applies the
1345
+ same way (a cleared storage means `system`). `choice()` is read from storage once at mount and then
1346
+ kept in memory, so a choice a blocked storage refused still holds for the page; mounting does not
1347
+ re-apply the mode, the head script owns the first paint. Nothing refits (§5.9). Light-only:
1348
+ `shell.appearance` is `undefined`.
1349
+ - `webModeScript` is the guide's script as ES5 in an immediately invoked function; it reads the
1350
+ storage inside `try`/`catch`, tolerates a missing `color-scheme` meta and JSON-escapes every
1351
+ literal (with `<`, U+2028 and U+2029), so no value ends the `<script>` element.
1352
+ - The kit's `appearance` case drives the web host through `PageLike.emulateMedia` and the stored
1353
+ choice (see [`./testing`](#testing)).
1354
+
1355
+ ### Problems popover (§5.10)
1356
+
1357
+ ```ts
1358
+ // ./core
1359
+ type ProblemSeverity = "error" | "warning";
1360
+ interface ProblemItem {
1361
+ severity: ProblemSeverity;
1362
+ message: string; // 13px --cd-ui-text
1363
+ location?: string; // "Line 12", "“Order Placed”": 13px --cd-ui-text-leise below the message
1364
+ elementId?: string; // the element a click on the row selects
1365
+ }
1366
+ function problemSummary(items: readonly ProblemItem[]): {
1367
+ count: number;
1368
+ errors: number;
1369
+ items: readonly ProblemItem[];
1370
+ };
1371
+ ```
1372
+
1373
+ - In the web host the pill (0.1) is the trigger of a non-modal dialog: `aria-haspopup="dialog"`,
1374
+ `aria-expanded`, `aria-controls` on the pill; the popover `role="dialog"` without `aria-modal`,
1375
+ `aria-labelledby` its title "Problems" (13px/600). An embedded shell shows it too unless the
1376
+ modeler gives `problems.onActivate` (an embedding host with a list of its own); the IDE host never.
1377
+ - Above the pill, left-aligned with it: `left: 12px`, its bottom 8px above the pill's top, opaque
1378
+ (§5.3), 1px `--cd-ui-linie`, radius `md`, `shadow-3`, `min(320px, 100cqw − 24px)` wide, at most
1379
+ 320px high with a scrolling list, layer 40, pop-in 150ms (§5.14). Rows at least 36px: the pill's
1380
+ icon 16px (`circle-x` in `--cd-ui-danger`, `triangle-alert` in `--cd-ui-warning`), the message,
1381
+ below it the location.
1382
+ - A row with an `elementId` is a button: a click closes the popover, puts the focus on the canvas and
1383
+ then calls `problems.onSelectItem(item)`, where the modeler selects the element. Rows without one
1384
+ are text that still takes the focus (`<div tabindex="-1">`), so the keyboard reads every row of a
1385
+ list longer than its 320px (WCAG 2.1.1); Enter and Space act on row buttons only. The icon carries
1386
+ the severity's name for screen readers (`role="img"`, `messages.problemSeverity`), so the shape and
1387
+ colour are not its only signal (§12).
1388
+ - Opening focuses the first row (else the popover, with an inset focus ring); ↑/↓/Home/End move
1389
+ between all rows and scroll the focused one into the list (only the list scrolls, never the
1390
+ root). Escape and Tab out close it with the focus back on the pill (Tab from the last row then
1391
+ moves on from the pill, Shift+Tab from the first stays on it); an outside `pointerdown` and the
1392
+ window's blur close it without moving the focus, as for the menu. Not modal: the zoom keys keep
1393
+ working. Like the menu it stops Enter, Space, Delete, Backspace and printable keys, so a modeler's
1394
+ keyboard on `document` never acts on them.
1395
+ - `setProblems` re-renders the rows while it is open and keeps the focused problem; `count` 0 hides
1396
+ pill and popover, and a focus in the list goes to the canvas (else the menu trigger).
1397
+
1398
+ ### Toasts (§8, §9)
1399
+
1400
+ ```ts
1401
+ // ./core
1402
+ type ToastStatus = "success" | "info" | "warning" | "danger";
1403
+ interface ToastAction {
1404
+ label: string;
1405
+ onSelect(): void; // runs after the toast closed
1406
+ }
1407
+ interface ToastRequest {
1408
+ status: ToastStatus;
1409
+ text: string;
1410
+ action?: ToastAction;
1411
+ durationMs?: number; // only to lengthen; ignored with an action
1412
+ }
1413
+ const MAX_TOASTS = 3;
1414
+ const TOAST_DURATION_MS: Readonly<Record<ToastStatus, number>>; // success, info 2800; warning, danger 5000
1415
+ const TOAST_MAX_WIDTH = 480;
1416
+ function toastLifetime(request: ToastRequest): number | undefined; // undefined with an action
1417
+ interface ToastSlot {
1418
+ id: number;
1419
+ hasAction: boolean;
1420
+ engaged: boolean; // hovered or focused
1421
+ }
1422
+ function toastToEvict(stack: readonly ToastSlot[]): number | undefined; // oldest first
1423
+ interface BottomObstacle {
1424
+ left: number;
1425
+ right: number;
1426
+ top: number; // px from the container's bottom to its top edge
1427
+ }
1428
+ interface ToastStackInput {
1429
+ containerWidth: number;
1430
+ obstacles: readonly BottomObstacle[];
1431
+ cardWidth?: number; // the widest card's measured width; absent = the whole stack width
1432
+ }
1433
+ function toastStackWidth(containerWidth: number): number; // min(480, width − 24)
1434
+ function toastStackBottom(input: ToastStackInput): number;
1435
+
1436
+ // .
1437
+ interface ToastHandle {
1438
+ dismiss(): void; // idempotent
1439
+ }
1440
+ ```
1441
+
1442
+ - `shell.toast(request)` in the web and embedded hosts; in the IDE host it shows nothing and returns
1443
+ an inert handle (§13.5: the extension shows the host's notification). An import that runs by itself
1444
+ never shows a toast (§9); that is the modeler's discipline, the shell does not know imports.
1445
+ - **Card** (§8): opaque `--cd-ui-flaeche-hoch`, 1px `--cd-ui-linie`, a straight 4px bar and an 8px
1446
+ dot in `--cd-ui-<status>` (a dot, not an icon: the common set has no success or info icon, §5.14),
1447
+ text 13px/1.5 `--cd-ui-text`, radius `md`, `shadow-3`, at least 40px high, padding `4px 4px 4px
1448
+ 16px`, 8px gap; the action as a 32px ghost button, then Close as a 32 × 32 ghost icon button
1449
+ (Lucide `x`, `aria-label` "Close", `aria-describedby` the toast's text, so three Close buttons
1450
+ are told apart). Slide and fade in from `translateY(8px)` over 250ms, out the same way; none under
1451
+ reduced motion. A card is as wide as its content, at most the stack's width, as in the Wardley and
1452
+ Team Topologies web apps (§8's `min(480px, 100% − 24px)` read as the maximum).
1453
+ - **Screen readers** (§8, §12): two live regions in the root, rendered from mount on and visually
1454
+ hidden (`.cd-shell-live`): `role="status"` with `aria-live="polite"`, and `role="alert"`. A toast's
1455
+ text goes into the alert region for danger, else into the status region, 100ms after the region
1456
+ was emptied (`TOAST_ANNOUNCE_DELAY_MS`), so a repeated toast ("Link copied" twice) is announced
1457
+ again; the region empties when its toast closes. A live region created together with its text is
1458
+ often not announced, which is why the regions never come and go with the stack. The cards carry no
1459
+ live role, so nothing is read twice.
1460
+ - **Stack**: centred, `toastStackWidth`, column with 8px gap, the newest at the bottom, at most three;
1461
+ a fourth evicts per `toastToEvict` (the oldest without action that is not engaged first). A
1462
+ request equal to a shown toast (status and text) restarts that toast's time instead of stacking,
1463
+ and takes the newer request's action and lifetime (a repeated "Board replaced · Undo" undoes the
1464
+ latest replacement). Layer 60; in the web host the stack is a `popover="manual"`, shown with the
1465
+ first toast and shown again with a toast that arrives while a modal dialog is open, so it stays
1466
+ above a `showModal()` dialog in the top layer (visible, but inert like everything outside a modal
1467
+ until the dialog closes); without a modal it is not hidden and re-shown, which would rebuild it for
1468
+ screen readers. In the embedded host it is absolute inside the root and never leaves its box.
1469
+ - **Time**: `toastLifetime` (about 2.8s, errors about 5s, §8; a warning, a sentence to act on, gets
1470
+ the error's 5s), paused while hovered or focused and resumed with at least 1.5s left; a toast with
1471
+ an action never closes by itself. A closing toast that held the focus hands it to the next
1472
+ toast's Close button, else to the canvas; on the start screen to its "New diagram" (the canvas and
1473
+ the menu trigger cannot take it there), never silently to `<body>`.
1474
+ - **Bottom row**: the stack sits at `bottom: 12px` (§8) unless the centred column of its widest card
1475
+ comes closer than 8px horizontally to a visible zoom panel, legal line or pill; then it stands 8px
1476
+ above the highest of those (`toastStackBottom` with `cardWidth`, recomputed in the layout pass,
1477
+ whenever a toast comes or goes, and whenever the pill, the inspector or the start screen changes;
1478
+ a bottom sheet hides zoom panel and pill). At 1280px with a GitHub link the legal line ends 7.5px
1479
+ before the 480px column, so "Link copied" (146px) stays at 12 while a full-width card rises to 40. §8 does not settle this case; it is the shell's rule until the guide does.
1480
+
1481
+ ### Empty state (§8)
1482
+
1483
+ ```ts
1484
+ interface EmptyStateOptions {
1485
+ title: string; // the modeler's name: "Event Storming Modeler"
1486
+ text: string; // one line of help: "Start a new diagram, or explore the example."
1487
+ komet: string; // URL of the vendored, unchanged miragon-komet-blau.svg (§2, §11)
1488
+ onNewDiagram(): void; // "New diagram", blue primary button; the shell hides the card first
1489
+ onShowExample?(): void; // "Show example", ghost button; the shell hides the card first
1490
+ dropHint?: string; // shown with a dropZone only; default messages.dropHint
1491
+ }
1492
+ ```
1493
+
1494
+ - `shell.setEmptyState(true)` shows the card; the modeler calls it on a fresh start (no document in
1495
+ the URL, nothing restored). While it shows (`data-empty-state="shown"` on the root) the work chrome
1496
+ is hidden (`visibility: hidden`): menu trigger, primary action, palette, zoom panel, pill, legend,
1497
+ status box, hint and inspector; the canvas slot is `inert`, menu and popover close. The chrome keys
1498
+ (Cmd/Ctrl + `+`/`−`/`0`, Shift+1) go to the browser unprevented, which zooms the page: there is no
1499
+ diagram to zoom, and a low-vision user's Ctrl+ must not be swallowed (WCAG 1.4.4); the shell's
1500
+ diagram-js guard returns `false` for them meanwhile, so diagram-js zooms nothing either. Visible:
1501
+ the card, the legal line, toasts and the drop zone (§8: "sichtbar sind nur die Karte und die Zeile
1502
+ Rechtliches").
1503
+ - The card: centred in the container, `min(400px, 100cqw − 24px)` wide, glass (§5.3), `shadow-2`,
1504
+ radius `lg`, padding `32px 40px`, content centred with 16px gaps: the Komet at 64px (`alt=""`), the
1505
+ title as `<h1>` (21px/600), the help line (16px/1.6, `--cd-ui-text-leise`), the two 36px buttons
1506
+ side by side with 8px gap, primary first, then the drop hint (13px, `--cd-ui-text-leise`). Layer 20.
1507
+ In high contrast its border is `--cd-ui-linie` through the role like every other island (§5.3).
1508
+ - **Open on the start screen.** The start screen hides the menu, and a drop needs a pointer; so with
1509
+ `file.open` the hint ends in a text link "Open a file…" (`messages.openFileLink`, `--cd-ui-link`,
1510
+ `aria-keyshortcuts` and a Geist Mono chip from `file.open.shortcut`, the two never parted by a
1511
+ line break): "Or drop a .storm file here
1512
+ · Open a file… ⌘O". It is part of the hint, not a third action (§8 keeps exactly two buttons), and
1513
+ runs `file.open.onSelect` without leaving the start screen: the modeler ends it once the file has
1514
+ loaded.
1515
+ - Either button hides the card (`setEmptyState(false)`), moves the focus to the canvas `<svg>` and
1516
+ then runs its handler. `setEmptyState(true)` while the focus is in the work chrome or the canvas
1517
+ moves it to "New diagram"; a focus on the legal line or a toast stays. The Komet is not draggable
1518
+ (an image drag can carry a file and would open the drop zone). Any other start (Open…, a dropped
1519
+ file, a shared link, the first edit) is the modeler's to end with `setEmptyState(false)`; the card
1520
+ does not come back in the session.
1521
+ - The IDE and embedded hosts have no start screen; they use the empty-file hint (§13.5).
1522
+
1523
+ ### Drop zone (§9)
1524
+
1525
+ ```ts
1526
+ interface DropZoneOptions {
1527
+ accept: readonly string[]; // ".storm", "application/json", "text/*"; empty: any file
1528
+ multiple?: boolean; // default false
1529
+ onDrop(files: readonly File[]): void; // the accepted files
1530
+ onReject?(files: readonly File[]): void; // default: a danger toast messages.cannotOpenFile
1531
+ }
1532
+
1533
+ // ./core
1534
+ type DropForbiddenReason = "multiple" | "type" | "dialog";
1535
+ interface DragItemLike {
1536
+ kind: string;
1537
+ type: string;
1538
+ }
1539
+ interface DragContext {
1540
+ accept: readonly string[];
1541
+ multiple: boolean;
1542
+ dialogOpen: boolean;
1543
+ }
1544
+ type DragVerdict =
1545
+ { state: "none" } | { state: "over" } | { state: "forbidden"; reason: DropForbiddenReason };
1546
+ function dragVerdict(items: readonly DragItemLike[], context: DragContext): DragVerdict;
1547
+ function acceptsFile(file: { name: string; type: string }, accept: readonly string[]): boolean;
1548
+ ```
1549
+
1550
+ - Page-wide (`window` listeners) and for drags that carry files only, so palette drags and text
1551
+ dragged into the label editor stay alone; every file drag is `preventDefault`-ed, so the browser
1552
+ never navigates to the file. `dragVerdict` decides: a modal dialog open → forbidden (`dialog`);
1553
+ more than one file without `multiple` → forbidden (`multiple`); every file of a MIME type that none
1554
+ of `accept`'s MIME entries matches → forbidden (`type`; extensions are unreadable before the drop,
1555
+ so list the MIME types as well; an empty type never forbids); else over. Only items of kind
1556
+ `"file"` count. `accept` entries are trimmed and lower-cased (extensions match the name's suffix
1557
+ case-insensitively, MIME types exactly or as `type/*`), blank ones dropped; a list without a usable
1558
+ entry accepts every file, like `<input accept>`.
1559
+ - **Drag-over**: `data-drop="over"` on the root, the overlay at `inset: 12px`, fill `--cd-ui-hover`,
1560
+ 2px dashed `--cd-ui-interaktion`, radius `lg`, the chip "Drop file here" centred on
1561
+ `--cd-ui-flaeche-hoch`, `dropEffect = "copy"`. **Forbidden**: `data-drop="forbidden"`, fill
1562
+ `--cd-ui-danger-flaeche`, dashed `--cd-ui-danger`, the chip `messages.dropForbidden(reason)`,
1563
+ `dropEffect = "none"` (the cursor shows not allowed). Layer 40, `aria-hidden`.
1564
+ - **Drop**: `acceptsFile` splits the files; `onDrop(accepted)` when there are any, `onReject(rest)`
1565
+ for the others (default: one danger toast per file, "Couldn't open “map.png”. Drop a .storm,
1566
+ .json, or .txt file.", the extensions of `accept` joined by `Intl.ListFormat` in `lang`, its MIME
1567
+ entries where it lists no extension). A drop forbidden for `dialog` or `multiple` opens nothing and
1568
+ shows no toast. After a `type` verdict a browser fires no `drop` (`dropEffect = "none"`); a
1569
+ synthetic one is split by `acceptsFile` like any other. Whether the drop replaces the open document is the modeler's
1570
+ (see the usage below for an undo toast instead of `confirm()`).
1571
+
1572
+ ### Buttons
1573
+
1574
+ `src/dom/buttons.css` styles `.cd-shell-button` for the web chrome (§8): `data-variant="primary"`
1575
+ (`--cd-blau` with `--cd-weiss` text in both modes, `shadow-1`, hover and active `--cd-blau-link`,
1576
+ `shadow-2` and a 1px lift, no lift under reduced motion; disabled `--cd-ui-hover` with
1577
+ `--cd-ui-linie` border, muted text, no lift) and `data-variant="ghost"` (transparent, hover
1578
+ `--cd-ui-hover`); 36px high, padding `0 16px`,
1579
+ 13px/600, radius `md`; focus ring 2px `--cd-ui-fokus` with 2px offset (3px in high contrast, which
1580
+ also drops the shadow); under forced colours borders in `ButtonText`, rings in `Highlight`. It is
1581
+ the only stylesheet that reads fixed palette tokens, and only these three.
1582
+
893
1583
  ## `./diagram-js`
894
1584
 
895
1585
  ```ts
@@ -960,6 +1650,7 @@ interface Viewport extends ZoomTarget {
960
1650
  fit(options?: { animate?: boolean }): void;
961
1651
  freeRectCenter(): Point | undefined; // diagram coordinates; undefined at 0 × 0 and after destroy
962
1652
  afterImport(): void;
1653
+ newDocument(): void; // 0.2, web app: the next afterImport() counts as the first again
963
1654
  restore(viewbox: Bounds): void;
964
1655
  getViewbox(): Bounds | undefined;
965
1656
  isAutomatic(): boolean;
@@ -976,7 +1667,10 @@ registers one internal didi service named `modelerShell` (a modeler does not reu
976
1667
  - `paletteTooltip: ["value", null]` replaces diagram-js's (and a renderer's) palette tooltip; the
977
1668
  chrome tooltip takes over with `title` and `shortcut` from `palette.getEntries()`.
978
1669
  - 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
1670
+ of the canvas `<svg>` (`canvas.getContainer()`'s child), so tab order puts the palette first. A
1671
+ renderer's export (`saveSVG`) must therefore take the container's own `<svg>` child, not the
1672
+ first `<svg>` it finds (`container.querySelector("svg")` now gets a palette icon), and strip the
1673
+ `tabindex`, `role="application"` and `aria-label` the live canvas carries from its copy. After
980
1674
  every `palette.changed` it re-applies `role="toolbar"` and `aria-label` (`messages.tools`) on
981
1675
  `.djs-palette-entries`, `role="button"` on every entry, `aria-keyshortcuts` from the entry's
982
1676
  `ariaKeyShortcuts` or `ariaKeyShortcutsFromLabel(shortcut)` (§5.6; split the shortcut off the
@@ -1002,7 +1696,8 @@ registers one internal didi service named `modelerShell` (a modeler does not reu
1002
1696
  `KeyboardBindings` and the modeler's keyboard modules (priority 1000) never act on them; the
1003
1697
  shell's `keydown` listener executes them exactly once. It returns `false` (stops them without
1004
1698
  `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
1699
+ for a chrome key while the web app's start screen shows (the browser zooms the page), and, while
1700
+ the attached shell's host is `ide`, for undo and redo (`isUndoRedoKey`): the canvas
1006
1701
  `commandStack` never takes them, VS Code undoes the document (§13.5). A modeler's own DOM undo
1007
1702
  binding must go in the IDE host as well; the kit's `keys` case checks it.
1008
1703
  - Keys the canvas uses beyond the chrome keys are the modeler's (§13.5). Cmd/Ctrl+A on the focused
@@ -1054,6 +1749,10 @@ insets are the only margin (§5.9), and a padding would add to them.
1054
1749
  1 around the centre of the free rectangle (at 0 × 0 once the container has a size). That write is
1055
1750
  the viewport's own, not a user move, so the first content still fits; later re-imports and
1056
1751
  `restore`s of the empty file keep the view (guide §5.9 and §13.5, design system 1.13.0).
1752
+ - `newDocument()` (0.2): in the web app every new document (New diagram, Open…, Import…, Show
1753
+ example, a shared link, a dropped file) opens like the first (§5.9). Called before the import, it
1754
+ forgets the user's moves, a stored view and a pending restore and stops a fit animation, so the
1755
+ next `afterImport()` jump-fits non-empty content or shows an empty document at 100 %.
1057
1756
  - `freeRectCenter()` is the centre of the free rectangle (the container minus `insets()`, as fit
1058
1757
  uses them) in diagram coordinates, for the current viewbox; `undefined` while the container is
1059
1758
  0 × 0 and after `destroy`. It is where keyboard creation places an element.
@@ -1108,7 +1807,7 @@ entries["create.domain-event"] = {
1108
1807
  ```
1109
1808
 
1110
1809
  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
1810
+ shared helper for that is a later candidate. The kit's `create` case presses Enter on the first
1112
1811
  creating entry and expects exactly one new, selected shape inside the free rectangle.
1113
1812
 
1114
1813
  ## `./vscode/extension`
@@ -1358,20 +2057,46 @@ interface PageLike {
1358
2057
  up(): Promise<void>;
1359
2058
  };
1360
2059
  waitForTimeout(timeout: number): Promise<void>;
2060
+ // 0.2: Playwright's page.emulateMedia; the web host's appearance needs it (else a skip, hook "open")
2061
+ emulateMedia?(options: {
2062
+ colorScheme?: "light" | "dark" | "no-preference" | null;
2063
+ contrast?: "more" | "no-preference" | null;
2064
+ reducedMotion?: "reduce" | "no-preference" | null;
2065
+ forcedColors?: "active" | "none" | null;
2066
+ }): Promise<void>;
1361
2067
  }
1362
2068
 
1363
2069
  interface OpenContext {
1364
2070
  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)
2071
+ requested: Appearance; // what the host requests; the harness opens the page with it (embedded, web: see below)
1366
2072
  }
1367
2073
  interface ShowHooks<P extends PageLike = PageLike> {
1368
- problems?(page: P): Promise<void>;
2074
+ problems?(page: P): Promise<void>; // web: with items, so the popover has rows
1369
2075
  status?(page: P): Promise<void>;
1370
2076
  legend?(page: P): Promise<void>;
1371
2077
  inspector?(page: P): Promise<void>;
1372
2078
  invalid?(page: P): Promise<void>; // the invalid-file state, caused the way the host causes it
2079
+ emptyState?(page: P): Promise<void>; // 0.2, web: the start screen of a fresh visit
2080
+ toast?(page: P, toast: ToastFixture): Promise<void>; // 0.2, web and embedded: through shell.toast
2081
+ }
2082
+ type ShowHook = keyof ShowHooks; // … | "emptyState" | "toast"
2083
+ // "problemSelection": the modeler's problems never point at an element (only at lines), so no row selects
2084
+ type AbsentFeature = ShowHook | "drop" | "problemSelection";
2085
+ interface ToastFixture {
2086
+ status: ToastStatus;
2087
+ text: string;
2088
+ actionLabel?: string; // the hook passes { label: actionLabel, onSelect() {} } as action
2089
+ }
2090
+ interface DropFixture {
2091
+ name: string;
2092
+ type: string;
2093
+ text: string; // the kit builds new File([text], name, { type }) in the page
2094
+ }
2095
+ interface DropFixtures {
2096
+ accepted: DropFixture; // dropped on the start screen, it opens (the card goes)
2097
+ rejected?: DropFixture; // opens nothing, shows a danger toast (or the modeler's onReject)
2098
+ multiple?: boolean; // the modeler's DropZoneOptions.multiple; default false: two files are forbidden
1373
2099
  }
1374
- type ShowHook = keyof ShowHooks; // "problems" | "status" | "legend" | "inspector" | "invalid"
1375
2100
  interface ShellConformanceHarness<P extends PageLike = PageLike> {
1376
2101
  host: ShellHost;
1377
2102
  themes: readonly Theme[];
@@ -1379,11 +2104,12 @@ interface ShellConformanceHarness<P extends PageLike = PageLike> {
1379
2104
  destroy?(page: P): Promise<void>;
1380
2105
  root?: string; // default ".cd-shell"
1381
2106
  show?: ShowHooks<P>;
1382
- absent?: readonly ShowHook[]; // islands and states the modeler does not have
2107
+ drop?: DropFixtures; // 0.2, web: the files the dropZone case drops
2108
+ absent?: readonly AbsentFeature[]; // islands, states and the drop zone the modeler does not have
1383
2109
  sizes?: readonly Size[]; // default 1280 × 900, 720 × 700, 420 × 700
1384
2110
  }
1385
2111
  interface ShellSkip {
1386
- hook: ShowHook | "destroy" | "open"; // the harness field that would feed the skipped part
2112
+ hook: ShowHook | "drop" | "destroy" | "open"; // the harness field that would feed the skipped part
1387
2113
  reason: string;
1388
2114
  }
1389
2115
  interface ShellCaseOutcome {
@@ -1423,7 +2149,15 @@ interface ChromeMeasurement {
1423
2149
  container: Size;
1424
2150
  paletteRow: PaletteRow;
1425
2151
  islands: Partial<Record<ChromeIsland, IslandBox>>;
1426
- open: { legend: boolean; inspector: boolean; problems: boolean };
2152
+ open: {
2153
+ legend: boolean;
2154
+ inspector: boolean;
2155
+ problems: boolean;
2156
+ emptyState: boolean; // 0.2: data-empty-state="shown"
2157
+ primaryCollapsed: boolean; // 0.2: data-primary="collapsed"
2158
+ zoomRaised: boolean; // 0.2: data-zoom-row="raised"
2159
+ };
2160
+ toastCardWidth?: number; // 0.2: the widest visible toast card; absent without a toast
1427
2161
  }
1428
2162
  interface ExpectedBox {
1429
2163
  left?: number;
@@ -1433,6 +2167,7 @@ interface ExpectedBox {
1433
2167
  width?: number;
1434
2168
  height?: number;
1435
2169
  centerX?: number;
2170
+ centerY?: number; // 0.2
1436
2171
  }
1437
2172
  function measureChrome(page: PageLike, root?: string): Promise<ChromeMeasurement>;
1438
2173
  function expectedChromeBoxes(
@@ -1456,6 +2191,21 @@ Cases (each named `shell conformance: <name>`; failures throw plain `Error`s lis
1456
2191
  | `appearance` | the harness opens with dark, dark + high contrast and light + high contrast requested. Own document: the mode on `<html>` is `resolveAppearance(requested, themes)`, recomputed by the kit (a light-only modeler stays light, `hoch` passes). Embedded: the kit puts the foreign page's `<html>` in the requested mode, and the root carries its own `data-cd-theme="<themes[0]>"` with the page's `data-cd-kontrast` (§13.3). Both: no themed descendant; `cd-shell-document` on `<html>` in `ide`/`web`, never in a foreign page; tokens loaded; `color-scheme` and four `--cd-ui-*` roles equal on root, canvas, every island, the open dropdown and the visible tooltip; the painted surround, the first non-transparent `background-color` from `.djs-container` out through `.cd-shell-canvas` to the root, equals `--cd-ui-grund` (§10.1); every palette tool entry (group `tools` or `data-action` ending in `-tool`) and its icon (`<svg>` or `::before`) compute to `--cd-ui-text` at rest and pressed and to `--cd-ui-interaktion` hovered, each tool hovered in turn and Lasso armed by a click, which sets `aria-pressed="true"`; notation previews are exempt (§5.5). Never half-dark |
1457
2192
  | `cleanup` | with a tooltip timer pending: after `destroy` and 800ms, a resize and chrome keys that must arrive unprevented, no `cd-shell*` class or element, no `data-chrome`, no root state attribute (embedded: also no `data-cd-theme`/`data-cd-kontrast`) and no page error is left; skips with `hook: "destroy"` without a `destroy` |
1458
2193
 
2194
+ **0.2, the web column.** The 0.1 cases learn the web host's islands, and five cases join them after
2195
+ `cleanup` (`createWebCases` in `src/testing/web-cases.ts`). Each runs in every host; outside the
2196
+ hosts it applies to it asserts that nothing of it shows and passes.
2197
+
2198
+ | Case / extension | Checks |
2199
+ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2200
+ | `geometry` (extended) | web: `primary` at 12/12, 40 high, 40 wide while collapsed; the palette keeping 16px to the primary action in the top row; `legal` at 12/12, 20 high per line and at most `100cqw − 24px` wide; pill, legend and zoom panel per `open.zoomRaised` on the legal line's measured lines; the popover above the pill (after a click on it); toasts at `toastStackBottom` recomputed from the measured bottom row and the widest card (`toastCardWidth`); `empty-state` centred after `show.emptyState`; IDE and embedded: `primary`, `legal`, `empty-state`, `drop-zone` never visible |
2201
+ | `aria` (extended) | web: tab order menu trigger, primary action, palette, canvas; the primary action's `aria-label` in both states; the legal `<nav aria-label>`, Impressum and Datenschutz with `lang="de"`, `rel="noopener noreferrer"`; the File group first with its ids and icons, the Appearance group (dark-capable only) between Export and Help with `aria-checked`; the pill's `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls` |
2202
+ | `appearance` (extended) | web, with `emulateMedia`: light-only: `<html data-cd-theme="light">` under a dark system, no Appearance group, `data-cd-kontrast="hoch"` under `prefers-contrast: more`; dark-capable: `system` follows the system, choosing Dark writes `miragon.darstellung=dark`, `data-cd-theme="dark"`, `color-scheme` and both `theme-color` metas in one frame, System removes the entry; never half-dark |
2203
+ | `webChrome` | primary action: label collapse before the palette moves (widths stepped from wide to narrow), tooltip only while collapsed, `onSelect` on click and on Enter (so a real app runs its own handler, a Share without clipboard permission shows its danger toast meanwhile); legal line content and links, at 320px every link whole inside the wrapped line (WCAG 1.4.10); the raised zoom row where legal line and zoom panel meet; File entries run their handlers and close the menu, never locked under `show.invalid` |
2204
+ | `emptyState` | after `show.emptyState`: the card centred, `min(400px, 100cqw − 24px)`, Komet 64px with `alt=""`, `<h1>`, primary "New diagram" before ghost "Show example", both 36px (a text link in the hint is no action); the work chrome `visibility: hidden`, the canvas `inert`, Tab reaching only the card and the legal links, chrome keys reaching the browser unprevented without zooming the diagram; with `show.toast`, closing a focused toast puts the focus on the card; "New diagram" hides it, shows the chrome and focuses the canvas `<svg>` |
2205
+ | `toasts` | through `show.toast`: a `role="status"` and a `role="alert"` live region in the root before the first toast, the toast's text arriving in the one of its status, no live role on the card; Close named "Close", at most three (the fourth evicts the oldest without action), the newest at the bottom, a repeated text not stacked, closing after `toastLifetime` (with a margin) unless hovered or focused, a toast with an action never; web: shown again in the top layer after a `showModal()` stand-in opened (everything outside a modal is inert, so the kit checks the stacking, not a click); IDE: no hook and no toast element ever |
2206
+ | `problemsList` | web (embedded when the pill opens it): after `show.problems`, a click on the pill opens `role="dialog"` titled "Problems" with the rows, Escape returns the focus to the pill, an outside click closes without moving it; Enter on the pill and then ↓ to the last row, Home and End reach every row, text rows included, each scrolled into the list (WCAG 2.1.1); a row with an element closes it and focuses the canvas (skipped with `hook: "problems"` when no item has an `elementId`, unless `absent` lists `"problemSelection"`); geometry left 12, 8px above the pill, at most 320 wide and high |
2207
+ | `dropZone` | web with `harness.drop`: synthetic file drags (a `DataTransfer` built in the page) show `data-drop="over"` with the §9 colours and the 12px inset; two files (unless `drop.multiple`) and a modal stand-in show `forbidden`; a drag without files changes nothing; dropping `accepted` on the start screen opens it, `rejected` opens nothing and shows a danger toast; skips with `hook: "drop"` without the fixtures unless `absent` lists `"drop"` |
2208
+
1459
2209
  - **Skips.** A case the harness cannot feed reports what it skipped as `ShellSkip`s, one per missing
1460
2210
  harness field, through `onSkip` (default `console.warn`). A hook that is neither in `show` nor in
1461
2211
  `absent` is skipped with `hook` set to its name, a missing `destroy` with `hook: "destroy"`, and a
@@ -1472,6 +2222,10 @@ Cases (each named `shell conformance: <name>`; failures throw plain `Error`s lis
1472
2222
  `<html>` itself after `open`: every consumer suite then proves that the modeler stays in its own
1473
2223
  mode inside a dark host page, the case a foreign page meets in practice (designIQ, a web app on
1474
2224
  `system` under a dark OS).
2225
+ - For the `web` host (0.2) the kit calls `page.emulateMedia({ colorScheme, contrast })` from
2226
+ `requested` before `open`, and `open` loads the page without a stored `miragon.darstellung`; the
2227
+ `appearance` case then sets the choice through the Appearance group itself. Without
2228
+ `emulateMedia` the web part of `appearance` skips with `hook: "open"`.
1475
2229
  - The second-row tooltip of the `geometry` case may stand centred on its entry or in the container:
1476
2230
  guide §5.15 says "mittig im Container", and which of the two it means is an open question; the
1477
2231
  shell centres it on its entry.
@@ -1597,6 +2351,13 @@ state (rest and pressed `--cd-ui-text`, hovered `--cd-ui-interaktion`, read-only
1597
2351
  diagram-js's rule on the icon or on the stale `highlighted-entry` does not leak in; notation
1598
2352
  previews keep their own colours (§5.5). Private helper properties are named `--modeler-shell-*`.
1599
2353
 
2354
+ It is assembled from `dom/shell-dom.css` (0.1 chrome), `dom/buttons.css` (0.2: the buttons of the
2355
+ web chrome, the one place with fixed palette tokens, see [Buttons](#buttons)), `dom/web/web.css`
2356
+ (0.2: primary action, legal line, empty state, drop zone, raised zoom row) and
2357
+ `dom/feedback/feedback.css` (0.2: toasts and the problems popover; they render in a foreign page too,
2358
+ so they reset the page's element rules like the 0.1 islands), then `diagram-js/palette.css`. A test
2359
+ pins that `shell.css` imports every stylesheet of `src`.
2360
+
1600
2361
  The shell bundles no font: the modeler loads Geist and Geist Mono (`@fontsource-variable/geist`,
1601
2362
  `@fontsource-variable/geist-mono`), as the stylesheet order above shows, as font files (see
1602
2363
  `./vscode/extension`).
@@ -1857,9 +2618,213 @@ What the Event Storming renderer changes for the shell (the kit's `aria`, `keys`
1857
2618
 
1858
2619
  Document sync, echo suppression, minimal edits and undo stay in the modeler for 0.1 (see below).
1859
2620
 
1860
- ## Out of scope for 0.1
2621
+ ## Usage: Event Storming in the web app
2622
+
2623
+ The Event Storming web app (`apps/webapp`), the pilot of 0.2. It is light-only until the shared
2624
+ dark-mode release, so its `index.html` is static and its head carries the contrast line only:
2625
+
2626
+ ```html
2627
+ <!doctype html>
2628
+ <html lang="en" data-cd-theme="light">
2629
+ <head>
2630
+ <meta charset="UTF-8" />
2631
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
2632
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
2633
+ <!-- miragon-mode: webHeadMarkup({ themes: ["light"], themeColors }) through transformIndexHtml -->
2634
+ <title>Event Storming Modeler</title>
2635
+ </head>
2636
+ <body>
2637
+ <div id="app"></div>
2638
+ <script type="module" src="/src/main.ts"></script>
2639
+ </body>
2640
+ </html>
2641
+ ```
2642
+
2643
+ `src/main.ts`, shortened to what touches the shell (the real file is
2644
+ `apps/webapp/src/main.ts` in Miragon/event-storming-modeler):
2645
+
2646
+ ```ts
2647
+ import "@fontsource-variable/geist/wght.css";
2648
+ import "@fontsource-variable/geist-mono/wght.css";
2649
+ import "@miragon/event-storming-renderer/assets/event-storming.css"; // the tokens, diagram-js.css
2650
+ import "@miragon/modeler-shell/shell.css"; // last
2651
+ import {
2652
+ Modeler,
2653
+ boardMenu,
2654
+ createShortcutsDialog,
2655
+ importWarningText,
2656
+ type ImportWarning,
2657
+ } from "@miragon/event-storming-renderer";
2658
+ import {
2659
+ commandKeyLabel,
2660
+ defaultMessages,
2661
+ detectPlatform,
2662
+ mountShell,
2663
+ problemSummary,
2664
+ } from "@miragon/modeler-shell";
2665
+ import { connectDiagramJs, shellModule } from "@miragon/modeler-shell/diagram-js";
2666
+ import { copyTextWhenReady } from "./clipboard"; // ClipboardItem with a promise: Safari keeps the gesture
2667
+ import { hashHoldsBoard, readHashMap, shareUrl, writeHashMap } from "./share";
2668
+ import { THEMES } from "./themes"; // ["light"]
2669
+
2670
+ // 1. The diagram first (its keyboard listens on document); the web app keeps the canvas undo keys.
2671
+ const modeler = new Modeler({
2672
+ width: "100%",
2673
+ height: "100%",
2674
+ additionalModules: [shellModule],
2675
+ freeRectInsets: () => shell.fitInsets(), // keyboard creation lands in the free rectangle
2676
+ });
2677
+ modeler.get("canvas");
2678
+ const platform = detectPlatform(navigator);
2679
+ const openKey = commandKeyLabel("O", platform, defaultMessages.keyNames); // "⌘O" / "Ctrl+O"
2680
+ const saveKey = commandKeyLabel("S", platform, defaultMessages.keyNames);
2681
+
2682
+ // 2. The chrome, the whole web column.
2683
+ const shell = mountShell(document.getElementById("app")!, {
2684
+ host: "web",
2685
+ canvas: modeler.container,
2686
+ lang: "en",
2687
+ platform,
2688
+ themes: THEMES,
2689
+ messages: { canvas: "Event Storming board" },
2690
+ primary: { onSelect: () => share() }, // "Share", share-2
2691
+ legal: { links: [{ label: "GitHub", href: "https://github.com/Miragon/event-storming-modeler" }] },
2692
+ file: {
2693
+ newDiagram: { onSelect: () => void newDiagram() },
2694
+ open: { onSelect: () => pickFile(), shortcut: openKey }, // also the start screen's "Open a file…"
2695
+ showExample: { onSelect: () => void showExample() },
2696
+ },
2697
+ // Model (level, type captions), Export and Help: the same entries the VS Code webview shows.
2698
+ menu: boardMenu({
2699
+ levels,
2700
+ typeCaptions,
2701
+ exports: {
2702
+ png: { onSelect: () => void exportPicture("png") },
2703
+ svg: { onSelect: () => void exportPicture("svg") },
2704
+ transparentPng: { onSelect: () => void exportPicture("png-transparent") },
2705
+ json: { onSelect: () => exportBoardText("json") },
2706
+ storm: { onSelect: () => exportBoardText("storm"), shortcut: saveKey },
2707
+ },
2708
+ showKeyboardShortcuts: () => shortcutsDialog.open(),
2709
+ }),
2710
+ problems: { onSelectItem: (item) => selectElement(item.elementId) },
2711
+ emptyState: {
2712
+ title: "Event Storming Modeler",
2713
+ text: "Start a new diagram, or explore the example.",
2714
+ komet: "/miragon-komet-blau.svg", // vendored unchanged from corporate-identity
2715
+ onNewDiagram: () => void newDiagram(),
2716
+ onShowExample: () => void showExample(),
2717
+ dropHint: "Or drop a .storm, .json, or .txt file here", // the types the reject toast names
2718
+ },
2719
+ dropZone: {
2720
+ accept: [".storm", ".json", ".txt", "application/json", "text/plain"],
2721
+ onDrop: ([file]) => file && void openBoardFile(file),
2722
+ },
2723
+ });
2724
+ const shortcutsDialog = createShortcutsDialog(shell, { fileKeys: true });
2725
+
2726
+ // 3. Viewport, palette, keys.
2727
+ const { viewport } = connectDiagramJs(shell, modeler, {
2728
+ isEmpty,
2729
+ contentBounds: () => (isEmpty() ? undefined : boardBounds.contentBounds(0)),
2730
+ });
2731
+
2732
+ // 4. Every new document opens like the first (§5.9); the start screen goes with it.
2733
+ async function openDocument(load: () => Promise<readonly ImportWarning[]>, announce: boolean) {
2734
+ viewport.newDocument();
2735
+ let warnings: readonly ImportWarning[];
2736
+ try {
2737
+ warnings = await load();
2738
+ } finally {
2739
+ viewport.afterImport();
2740
+ }
2741
+ shell.setEmptyState(false);
2742
+ shell.menu.refresh();
2743
+ updateDocumentTitle(); // "Order Checkout · Event Storming Modeler" (§5.4)
2744
+ // The parser names lines: "Line 4 · A -> Nowhere" below the message, no element to select.
2745
+ shell.setProblems(problemSummary(problemItems(warnings)));
2746
+ for (const warning of warnings) console.warn(importWarningText(warning));
2747
+ // A toast only after an import the user started, with the count (§9), never on a restore.
2748
+ if (announce && warnings.length > 0) {
2749
+ const pill = shell.messages.problems(warnings.length, String(warnings.length));
2750
+ shell.toast({
2751
+ status: "warning",
2752
+ text: `Imported with ${warnings.length} warnings. Details under “${pill}” bottom left.`,
2753
+ });
2754
+ }
2755
+ }
2756
+
2757
+ // Replacing a board clears the canvas history, so instead of confirm() it offers the board back
2758
+ // (§9 asks for a confirmation; the undo toast is the open guide question): "Board replaced · Undo",
2759
+ // also on Cmd/Ctrl+Z while the canvas has nothing else to undo. An edit withdraws the offer.
2760
+ async function replaceBoard(load: () => Promise<readonly ImportWarning[]>) {
2761
+ const previous = boardIsEmpty() ? undefined : modeler.exportDSL();
2762
+ await openDocument(load, true);
2763
+ withdrawReplaceUndo();
2764
+ if (previous === undefined || previous === modeler.exportDSL()) return;
2765
+ const undo = () => void openDocument(async () => (await modeler.importDSL(previous)).warnings, false);
2766
+ replaceUndo = {
2767
+ toast: shell.toast({ status: "info", text: "Board replaced", action: { label: "Undo", onSelect: undo } }),
2768
+ undo,
2769
+ };
2770
+ }
2771
+
2772
+ // Copy inside the click: Safari drops the user activation at the first await before writeText.
2773
+ function share(): void {
2774
+ const dsl = modeler.exportDSL();
2775
+ const copied = copyTextWhenReady(shareUrl(dsl));
2776
+ void (async () => {
2777
+ try {
2778
+ await writeHashMap(dsl);
2779
+ await copied;
2780
+ shell.toast({ status: "success", text: "Link copied" });
2781
+ } catch {
2782
+ shell.toast({
2783
+ status: "danger",
2784
+ text: "Couldn't copy the link. Copy it from the address bar instead.",
2785
+ });
2786
+ }
2787
+ })();
2788
+ }
2789
+
2790
+ // The app's own keys: Cmd/Ctrl+O opens a file, Cmd/Ctrl+S exports the .storm file (never the
2791
+ // browser's dialogs, also not over a modal dialog; on the start screen there is nothing to save).
2792
+ window.addEventListener("keydown", onAppKey);
2793
+
2794
+ modeler.on("commandStack.changed", (event: { trigger?: string }) => {
2795
+ if (event.trigger !== "clear") {
2796
+ shell.setEmptyState(false); // the first edit is a start as well
2797
+ withdrawReplaceUndo(); // an edit makes the undo offer stale
2798
+ }
2799
+ shell.menu.refresh(); // the level is undoable
2800
+ updateDocumentTitle();
2801
+ });
2802
+
2803
+ // 5. Start: the board from the link, else the start screen on an empty board.
2804
+ const linked = await readHashMap();
2805
+ if (linked !== null) await openDocument(async () => (await modeler.importDSL(linked)).warnings, true);
2806
+ else {
2807
+ await modeler.importMap(createEmptyBoard("New board"));
2808
+ viewport.afterImport(); // an empty board at 100 %
2809
+ shell.setEmptyState(true);
2810
+ if (hashHoldsBoard())
2811
+ shell.toast({
2812
+ status: "danger",
2813
+ text: "Couldn't read the board in this link. Check that you copied the whole link.",
2814
+ });
2815
+ }
2816
+ ```
2817
+
2818
+ `openBoardFile(file)` reads the file before the canvas is touched and then runs `replaceBoard`; a
2819
+ parse error shows the danger toast "Couldn't read “name”. Check that it contains a valid Event
2820
+ Storming board." and keeps the board. `pickFile` opens the hidden file input with the same
2821
+ `accept`. The JSON export writes `JSON.stringify(modeler.exportMap(), null, 2)`, the board's own
2822
+ element order (the importer stacks overlapping stickies by it). The web app's own `style.css`,
2823
+ `toast.ts` and the hand-written menu, zoom, fit and legal markup go away.
2824
+
2825
+ ## Out of scope for 0.2
1861
2826
 
1862
- 0.2 candidates unless noted:
2827
+ Later candidates unless noted:
1863
2828
 
1864
2829
  - **VS Code document plumbing**: two-way sync with echo suppression, minimal `WorkspaceEdit`s,
1865
2830
  undo/redo through the document's history, "Insert example" as one edit, diagnostics to the
@@ -1869,16 +2834,17 @@ Document sync, echo suppression, minimal edits and undo stay in the modeler for
1869
2834
  `KeyboardBindings` off undo/redo in the IDE host, stops undo/redo keys in text fields and stops
1870
2835
  keys the canvas handled.
1871
2836
  - 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`
2837
+ - **More of the web column**: further top-right actions left of the primary action (§5.7), the
2838
+ loading state (§9 "Laden": spinner or skeleton during asynchronous work), share-link helpers
2839
+ (each modeler keeps its own hash encoding), the legend's state across sessions (a modeler persists
2840
+ it from `legend.onToggle` in `localStorage`, §5.10).
2841
+ - **Dialog component and the shared shortcuts dialog** (§5.4, §8; also the confirmation of §9
2842
+ "Destruktiv" where an undo toast does not fit): an exported `cd-shell-dialog`
1877
2843
  class (panel, header with Close, body, backdrop with `grayscale(1)`, the high-contrast and
1878
2844
  forced-colours rules, the top layer through `showModal()`, focus trap and focus return), and on
1879
2845
  it `shell.showShortcutsDialog(groups: ShortcutGroup[])` with one table per group and the chips of
1880
2846
  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
2847
+ `ShellMessages`: `shortcutsTitle` ("Keyboard shortcuts") and the group names
1882
2848
  "View", "Edit" and "Tools". Popovers (§5.4) follow on the same class. Until then a modeler meets
1883
2849
  the rules of [Modeler dialogs](#modeler-dialogs) itself; one component lets every modeler migrate
1884
2850
  once.
@@ -1887,8 +2853,9 @@ Document sync, echo suppression, minimal edits and undo stay in the modeler for
1887
2853
  - the `appearance` surface of the modeler-api contract (a later 1.x minor) for embedded hosts and
1888
2854
  the `miragon.<modeler>.colorTheme` setting UI (the bridge already honours `settingKey`).
1889
2855
  - **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;
2856
+ - cases for reduced motion and forced colours on every host (`PageLike.emulateMedia` exists since
2857
+ 0.2, but only the web `appearance` case uses it); the shell's CSS covers both, its own e2e
2858
+ suite checks VS Code's reduce motion and spot-checks forced colours on the web chrome;
1892
2859
  - a CSP helper: serve the production bundle under `renderWebviewHtml`'s CSP inside the VS Code
1893
2860
  stub, record every `securitypolicyviolation` and fail on any (until then the recipe under
1894
2861
  `./vscode/extension`);