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