@miragon/modeler-shell 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/API.md +2869 -0
  2. package/LICENSE +21 -0
  3. package/README.md +566 -2
  4. package/THIRD_PARTY_NOTICES.md +52 -0
  5. package/dist/appearance-BBaM_4bD.d.ts +323 -0
  6. package/dist/appearance-D3FUcbu6.d.cts +53 -0
  7. package/dist/appearance-D3FUcbu6.d.ts +53 -0
  8. package/dist/appearance-XX3JC3pI.d.cts +323 -0
  9. package/dist/chunk-66HCSJNY.js +2792 -0
  10. package/dist/chunk-66HCSJNY.js.map +1 -0
  11. package/dist/chunk-KYDG5DMX.js +136 -0
  12. package/dist/chunk-KYDG5DMX.js.map +1 -0
  13. package/dist/chunk-N4QXFF3A.js +23 -0
  14. package/dist/chunk-N4QXFF3A.js.map +1 -0
  15. package/dist/chunk-OADW3GPA.js +785 -0
  16. package/dist/chunk-OADW3GPA.js.map +1 -0
  17. package/dist/core.cjs +873 -0
  18. package/dist/core.cjs.map +1 -0
  19. package/dist/core.d.cts +246 -0
  20. package/dist/core.d.ts +246 -0
  21. package/dist/core.js +131 -0
  22. package/dist/core.js.map +1 -0
  23. package/dist/diagram-js.cjs +1147 -0
  24. package/dist/diagram-js.cjs.map +1 -0
  25. package/dist/diagram-js.d.cts +170 -0
  26. package/dist/diagram-js.d.ts +170 -0
  27. package/dist/diagram-js.js +879 -0
  28. package/dist/diagram-js.js.map +1 -0
  29. package/dist/index.cjs +3632 -0
  30. package/dist/index.cjs.map +1 -0
  31. package/dist/index.d.cts +29 -0
  32. package/dist/index.d.ts +29 -0
  33. package/dist/index.js +143 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/protocol-DhYBakWL.d.cts +15 -0
  36. package/dist/protocol-IeW8bf4f.d.ts +15 -0
  37. package/dist/shell.css +1603 -0
  38. package/dist/testing-vscode-stub.cjs +332 -0
  39. package/dist/testing-vscode-stub.cjs.map +1 -0
  40. package/dist/testing-vscode-stub.d.cts +65 -0
  41. package/dist/testing-vscode-stub.d.ts +65 -0
  42. package/dist/testing-vscode-stub.js +154 -0
  43. package/dist/testing-vscode-stub.js.map +1 -0
  44. package/dist/testing.cjs +4663 -0
  45. package/dist/testing.cjs.map +1 -0
  46. package/dist/testing.d.cts +231 -0
  47. package/dist/testing.d.ts +231 -0
  48. package/dist/testing.js +4483 -0
  49. package/dist/testing.js.map +1 -0
  50. package/dist/toasts-Bp0REqdq.d.cts +142 -0
  51. package/dist/toasts-Bp0REqdq.d.ts +142 -0
  52. package/dist/types-CnRrINRs.d.cts +445 -0
  53. package/dist/types-PuDZczzy.d.ts +445 -0
  54. package/dist/vscode-extension.cjs +215 -0
  55. package/dist/vscode-extension.cjs.map +1 -0
  56. package/dist/vscode-extension.d.cts +103 -0
  57. package/dist/vscode-extension.d.ts +103 -0
  58. package/dist/vscode-extension.js +24 -0
  59. package/dist/vscode-extension.js.map +1 -0
  60. package/dist/vscode-webview.cjs +3561 -0
  61. package/dist/vscode-webview.cjs.map +1 -0
  62. package/dist/vscode-webview.d.cts +84 -0
  63. package/dist/vscode-webview.d.ts +84 -0
  64. package/dist/vscode-webview.js +259 -0
  65. package/dist/vscode-webview.js.map +1 -0
  66. package/package.json +145 -4
  67. package/src/core/appearance.ts +104 -0
  68. package/src/core/feedback/appearance.ts +141 -0
  69. package/src/core/feedback/problems.ts +23 -0
  70. package/src/core/feedback/toasts.ts +118 -0
  71. package/src/core/fit.ts +220 -0
  72. package/src/core/index.ts +127 -0
  73. package/src/core/keymap.ts +295 -0
  74. package/src/core/layout.ts +69 -0
  75. package/src/core/messages.ts +159 -0
  76. package/src/core/sizes.ts +85 -0
  77. package/src/core/types.ts +106 -0
  78. package/src/core/web-chrome/bottom-row.ts +37 -0
  79. package/src/core/web-chrome/drop.ts +78 -0
  80. package/src/core/web-chrome/top-row.ts +66 -0
  81. package/src/core/zoom.ts +74 -0
  82. package/src/core.ts +2 -0
  83. package/src/diagram-js/connect.ts +75 -0
  84. package/src/diagram-js/index.ts +16 -0
  85. package/src/diagram-js/module.ts +20 -0
  86. package/src/diagram-js/palette-skin.ts +267 -0
  87. package/src/diagram-js/palette.css +269 -0
  88. package/src/diagram-js/service.ts +243 -0
  89. package/src/diagram-js/types.ts +67 -0
  90. package/src/diagram-js/viewport.ts +583 -0
  91. package/src/diagram-js.ts +2 -0
  92. package/src/dom/buttons.css +124 -0
  93. package/src/dom/element.ts +51 -0
  94. package/src/dom/feedback/appearance.ts +184 -0
  95. package/src/dom/feedback/feedback.css +435 -0
  96. package/src/dom/feedback/problems-popover.ts +287 -0
  97. package/src/dom/feedback/toasts.ts +431 -0
  98. package/src/dom/hint.ts +51 -0
  99. package/src/dom/icons.ts +309 -0
  100. package/src/dom/index.ts +42 -0
  101. package/src/dom/inspector.ts +35 -0
  102. package/src/dom/keys.ts +67 -0
  103. package/src/dom/ledger.ts +87 -0
  104. package/src/dom/legend.ts +85 -0
  105. package/src/dom/menu.ts +404 -0
  106. package/src/dom/mount.ts +731 -0
  107. package/src/dom/problems.ts +73 -0
  108. package/src/dom/shell-dom.css +969 -0
  109. package/src/dom/status.ts +72 -0
  110. package/src/dom/tooltip.ts +237 -0
  111. package/src/dom/types.ts +459 -0
  112. package/src/dom/web/drop-zone.ts +181 -0
  113. package/src/dom/web/empty-state.ts +144 -0
  114. package/src/dom/web/legal.ts +75 -0
  115. package/src/dom/web/menu-groups.ts +69 -0
  116. package/src/dom/web/primary.ts +124 -0
  117. package/src/dom/web/web.css +386 -0
  118. package/src/dom/zoom-panel.ts +154 -0
  119. package/src/index.ts +3 -0
  120. package/src/shell.css +9 -0
  121. package/src/testing/case-tools.ts +164 -0
  122. package/src/testing/cases.ts +1077 -0
  123. package/src/testing/checks.ts +868 -0
  124. package/src/testing/facts.ts +641 -0
  125. package/src/testing/geometry.ts +652 -0
  126. package/src/testing/harness.ts +187 -0
  127. package/src/testing/index.ts +19 -0
  128. package/src/testing/page-tools.ts +457 -0
  129. package/src/testing/page.ts +27 -0
  130. package/src/testing/vscode-stub.ts +237 -0
  131. package/src/testing/web-cases.ts +913 -0
  132. package/src/testing/web-checks.ts +1049 -0
  133. package/src/testing/web-facts.ts +713 -0
  134. package/src/testing-vscode-stub.ts +2 -0
  135. package/src/testing.ts +2 -0
  136. package/src/vscode/extension/html.ts +107 -0
  137. package/src/vscode/extension/index.ts +8 -0
  138. package/src/vscode/extension/reveal.ts +9 -0
  139. package/src/vscode/extension/theme-bridge.ts +85 -0
  140. package/src/vscode/extension/types.ts +36 -0
  141. package/src/vscode/protocol.ts +31 -0
  142. package/src/vscode/webview/appearance.ts +141 -0
  143. package/src/vscode/webview/index.ts +8 -0
  144. package/src/vscode/webview/mount.ts +88 -0
  145. package/src/vscode/webview/state.ts +116 -0
  146. package/src/vscode-extension.ts +2 -0
  147. package/src/vscode-webview.ts +2 -0
@@ -0,0 +1,713 @@
1
+ import type { Size } from "../core/index.js";
2
+ import type { DropFixture } from "./harness.js";
3
+ import type { ModeChange, RelativeBox, ToolWindow } from "./page-tools.js";
4
+
5
+ /*
6
+ * In-page probes of the web column (0.2): mode and meta tags, top row, start screen, toasts,
7
+ * problems list and drop zone. Like facts.ts, every probe runs in the page and stays
8
+ * self-contained; the judgements are in web-checks.ts.
9
+ */
10
+
11
+ type Point = { x: number; y: number };
12
+
13
+ /** `<html>`'s mode, its meta tags and the stored choice of the web app (guide §13.3). */
14
+ export interface WebModeFacts {
15
+ theme: string | null;
16
+ kontrast: string | null;
17
+ /** `content` of `<meta name="color-scheme">`; `null` without one. */
18
+ colorScheme: string | null;
19
+ themeColors: Array<{ content: string; media: string | null }>;
20
+ /** `localStorage[key]`; `null` without an entry. */
21
+ stored: string | null;
22
+ storageBlocked: boolean;
23
+ noTransition: boolean;
24
+ }
25
+
26
+ export const webModeProbe = ({ key }: { key: string }): WebModeFacts => {
27
+ const html = document.documentElement;
28
+ let stored: string | null = null;
29
+ let storageBlocked = false;
30
+ try {
31
+ stored = localStorage.getItem(key);
32
+ } catch {
33
+ storageBlocked = true;
34
+ }
35
+ return {
36
+ theme: html.getAttribute("data-cd-theme"),
37
+ kontrast: html.getAttribute("data-cd-kontrast"),
38
+ colorScheme: document.querySelector('meta[name="color-scheme"]')?.getAttribute("content") ?? null,
39
+ themeColors: [...document.querySelectorAll('meta[name="theme-color"]')].map((meta) => ({
40
+ content: meta.getAttribute("content") ?? "",
41
+ media: meta.getAttribute("media"),
42
+ })),
43
+ stored,
44
+ storageBlocked,
45
+ noTransition: html.classList.contains("cd-shell-no-transition"),
46
+ };
47
+ };
48
+
49
+ /**
50
+ * Records every change of `<html data-cd-theme>`, `data-cd-kontrast`, `cd-shell-no-transition` and
51
+ * the `color-scheme` and `theme-color` metas, with the animation frame each landed in, until
52
+ * `modeChangesProbe` reads them.
53
+ */
54
+ export const watchModeProbe = (_: null): void => {
55
+ const tools = (window as ToolWindow).__modelerShellConformance!;
56
+ tools.modeWatch?.stop();
57
+ const html = document.documentElement;
58
+ const changes: ModeChange[] = [];
59
+ let frame = 0;
60
+ let running = true;
61
+ const tick = () => {
62
+ if (!running) return;
63
+ frame += 1;
64
+ requestAnimationFrame(tick);
65
+ };
66
+ requestAnimationFrame(tick);
67
+ const observer = new MutationObserver((records) => {
68
+ for (const record of records) {
69
+ const target = record.target as Element;
70
+ if (target === html) {
71
+ if (record.attributeName === "class") {
72
+ const had = (record.oldValue ?? "").split(/\s+/).includes("cd-shell-no-transition");
73
+ const has = html.classList.contains("cd-shell-no-transition");
74
+ if (had !== has) changes.push({ part: "no-transition", value: has ? "on" : "off", frame });
75
+ } else if (record.attributeName === "data-cd-theme") {
76
+ changes.push({ part: "theme", value: html.getAttribute("data-cd-theme"), frame });
77
+ } else if (record.attributeName === "data-cd-kontrast") {
78
+ changes.push({ part: "kontrast", value: html.getAttribute("data-cd-kontrast"), frame });
79
+ }
80
+ } else if (target.localName === "meta") {
81
+ const name = target.getAttribute("name");
82
+ const media = target.getAttribute("media");
83
+ changes.push({
84
+ part: name === "color-scheme" ? "color-scheme" : "theme-color",
85
+ value: `${target.getAttribute("content") ?? ""}${media ? ` ${media}` : ""}`,
86
+ frame,
87
+ });
88
+ }
89
+ }
90
+ });
91
+ observer.observe(html, {
92
+ attributes: true,
93
+ attributeOldValue: true,
94
+ attributeFilter: ["data-cd-theme", "data-cd-kontrast", "class"],
95
+ });
96
+ for (const meta of document.querySelectorAll('meta[name="color-scheme"], meta[name="theme-color"]')) {
97
+ observer.observe(meta, { attributes: true, attributeFilter: ["content"] });
98
+ }
99
+ tools.modeWatch = {
100
+ changes,
101
+ stop() {
102
+ running = false;
103
+ observer.disconnect();
104
+ },
105
+ };
106
+ };
107
+
108
+ /** The changes `watchModeProbe` recorded since it started; stops the recording. */
109
+ export const modeChangesProbe = (_: null): ModeChange[] => {
110
+ const tools = (window as ToolWindow).__modelerShellConformance!;
111
+ const watch = tools.modeWatch;
112
+ if (!watch) return [];
113
+ watch.stop();
114
+ tools.modeWatch = undefined;
115
+ return [...watch.changes];
116
+ };
117
+
118
+ /** What the top row decision of guide §5.15 reads, as the shell measures it. */
119
+ export interface TopRowFacts {
120
+ container: Size;
121
+ paletteRow: "top" | "second-row";
122
+ /** The palette's intrinsic border-box width (`offsetWidth − clientWidth + scrollWidth`). */
123
+ paletteWidth: number | null;
124
+ /** Right edge of the menu trigger, relative to the root. */
125
+ menuRight: number | null;
126
+ primary: {
127
+ box: RelativeBox;
128
+ client: Point;
129
+ /** The root's `data-primary="collapsed"`. */
130
+ collapsed: boolean;
131
+ ariaLabel: string | null;
132
+ describedBy: string | null;
133
+ /** Text of the visible label; `null` while no label shows. */
134
+ label: string | null;
135
+ } | null;
136
+ }
137
+
138
+ /** The legal line and each of its links, relative to the root (guide §5.10). */
139
+ export interface LegalLineFacts {
140
+ container: { width: number; height: number };
141
+ legal: RelativeBox | null;
142
+ links: Array<{ text: string; box: RelativeBox }>;
143
+ }
144
+
145
+ export const legalLineProbe = ({ root }: { root: string }): LegalLineFacts | null => {
146
+ const tools = (window as ToolWindow).__modelerShellConformance!;
147
+ const rootElement = document.querySelector<HTMLElement>(root);
148
+ if (!rootElement) return null;
149
+ const legal = rootElement.querySelector('[data-chrome="legal"]');
150
+ return {
151
+ container: tools.container(rootElement),
152
+ legal: legal && tools.visible(legal) ? tools.box(legal, rootElement) : null,
153
+ links: [...(legal?.querySelectorAll("a") ?? [])].map((link) => ({
154
+ text: (link.textContent ?? "").replace(/\s+/g, " ").trim(),
155
+ box: tools.box(link, rootElement),
156
+ })),
157
+ };
158
+ };
159
+
160
+ export const topRowProbe = ({ root }: { root: string }): TopRowFacts | null => {
161
+ const tools = (window as ToolWindow).__modelerShellConformance!;
162
+ const rootElement = document.querySelector<HTMLElement>(root);
163
+ if (!rootElement) return null;
164
+ const palette = rootElement.querySelector<HTMLElement>('[data-chrome="palette"]');
165
+ const menu = rootElement.querySelector('[data-chrome="menu"]');
166
+ const primary = rootElement.querySelector('[data-chrome="primary"]');
167
+ const menuBox = menu && tools.visible(menu) ? tools.box(menu, rootElement) : null;
168
+ let primaryFacts: TopRowFacts["primary"] = null;
169
+ if (primary && tools.visible(primary)) {
170
+ const rect = primary.getBoundingClientRect();
171
+ const labels = [...primary.querySelectorAll("span")].filter(
172
+ (span) => tools.visible(span) && (span.textContent ?? "").trim() !== "",
173
+ );
174
+ primaryFacts = {
175
+ box: tools.box(primary, rootElement),
176
+ client: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
177
+ collapsed: rootElement.getAttribute("data-primary") === "collapsed",
178
+ ariaLabel: primary.getAttribute("aria-label"),
179
+ describedBy: primary.getAttribute("aria-describedby"),
180
+ label: labels.length > 0 ? labels.map((span) => (span.textContent ?? "").trim()).join(" ") : null,
181
+ };
182
+ }
183
+ return {
184
+ container: tools.container(rootElement),
185
+ paletteRow: rootElement.getAttribute("data-palette") === "second-row" ? "second-row" : "top",
186
+ paletteWidth: palette ? palette.offsetWidth - palette.clientWidth + palette.scrollWidth : null,
187
+ menuRight: menuBox ? menuBox.left + menuBox.width : null,
188
+ primary: primaryFacts,
189
+ };
190
+ };
191
+
192
+ /** Counts the `click` events that reach the first match of `selector` (removed with the stand-ins). */
193
+ export const countClicksProbe = ({ root, selector }: { root: string; selector: string }): boolean => {
194
+ const tools = (window as ToolWindow).__modelerShellConformance!;
195
+ const element = document.querySelector(root)?.querySelector(selector);
196
+ if (!element) return false;
197
+ tools.clicks = 0;
198
+ const listener = () => {
199
+ tools.clicks += 1;
200
+ };
201
+ element.addEventListener("click", listener, true);
202
+ tools.cleanups.push(() => element.removeEventListener("click", listener, true));
203
+ return true;
204
+ };
205
+
206
+ export const clicksProbe = (_: null): number => (window as ToolWindow).__modelerShellConformance!.clicks;
207
+
208
+ /** The web app's start screen and what it hides (guide §8). */
209
+ export interface EmptyStateFacts {
210
+ /** The root's `data-empty-state="shown"`. */
211
+ shown: boolean;
212
+ card: { visible: boolean; box: RelativeBox; tag: string; labelledBy: string | null } | null;
213
+ komet: { tag: string; alt: string | null; width: number; height: number } | null;
214
+ heading: { tag: string; text: string } | null;
215
+ buttons: Array<{ variant: string | null; name: string; height: number; client: Point }>;
216
+ hint: string | null;
217
+ /** Each island of the work chrome in the DOM, with its computed visibility. */
218
+ work: Array<{ name: string; visibility: string; visible: boolean }>;
219
+ /** The canvas slot (`.cd-shell-canvas`) is `inert`. */
220
+ canvasInert: boolean;
221
+ legalVisible: boolean;
222
+ }
223
+
224
+ export const emptyStateProbe = ({
225
+ root,
226
+ work,
227
+ }: {
228
+ root: string;
229
+ work: readonly string[];
230
+ }): EmptyStateFacts | null => {
231
+ const tools = (window as ToolWindow).__modelerShellConformance!;
232
+ const rootElement = document.querySelector<HTMLElement>(root);
233
+ if (!rootElement) return null;
234
+ const card = rootElement.querySelector('[data-chrome="empty-state"]');
235
+ const image = card?.querySelector("img") ?? null;
236
+ const heading = card?.querySelector("h1, h2, h3, h4, h5, h6") ?? null;
237
+ const labelledBy = card?.getAttribute("aria-labelledby");
238
+ const parts: Array<[string, Element | null]> = work.map((name) => [
239
+ name,
240
+ rootElement.querySelector(`[data-chrome="${name}"]`),
241
+ ]);
242
+ parts.push(["hint", rootElement.querySelector(".cd-shell-hint")]);
243
+ const legal = rootElement.querySelector('[data-chrome="legal"]');
244
+ return {
245
+ shown: rootElement.getAttribute("data-empty-state") === "shown",
246
+ card: card
247
+ ? {
248
+ visible: tools.visible(card),
249
+ box: tools.box(card, rootElement),
250
+ tag: card.tagName.toLowerCase(),
251
+ labelledBy: labelledBy
252
+ ? labelledBy
253
+ .split(/\s+/)
254
+ .map((id) => document.getElementById(id)?.textContent?.trim() ?? "")
255
+ .join(" ")
256
+ .trim()
257
+ : null,
258
+ }
259
+ : null,
260
+ komet: image
261
+ ? {
262
+ tag: image.tagName.toLowerCase(),
263
+ alt: image.getAttribute("alt"),
264
+ width: image.getBoundingClientRect().width,
265
+ height: image.getBoundingClientRect().height,
266
+ }
267
+ : null,
268
+ heading: heading
269
+ ? { tag: heading.tagName.toLowerCase(), text: (heading.textContent ?? "").replace(/\s+/g, " ").trim() }
270
+ : null,
271
+ // The card's actions; a text link inside the hint paragraph is no action (guide §8).
272
+ buttons: [...(card?.querySelectorAll("button") ?? [])]
273
+ .filter((button) => tools.visible(button) && !button.closest("p"))
274
+ .map((button) => {
275
+ const rect = button.getBoundingClientRect();
276
+ return {
277
+ variant: button.getAttribute("data-variant"),
278
+ name: (button.getAttribute("aria-label") ?? button.textContent ?? "").replace(/\s+/g, " ").trim(),
279
+ height: rect.height,
280
+ client: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
281
+ };
282
+ }),
283
+ hint:
284
+ [...(card?.querySelectorAll("p") ?? [])]
285
+ .map((paragraph) => (paragraph.textContent ?? "").trim())
286
+ .filter(Boolean)
287
+ .at(-1) ?? null,
288
+ work: parts
289
+ .filter((part): part is [string, Element] => part[1] !== null)
290
+ .map(([name, element]) => ({
291
+ name,
292
+ visibility: getComputedStyle(element).visibility,
293
+ visible: tools.visible(element),
294
+ })),
295
+ canvasInert: rootElement.querySelector(".cd-shell-canvas")?.hasAttribute("inert") ?? false,
296
+ legalVisible: legal ? tools.visible(legal) : false,
297
+ };
298
+ };
299
+
300
+ /** Where the focus is: the island (`data-chrome`) around it, the canvas, `<body>` or outside the root. */
301
+ export const focusOwnerProbe = ({ root }: { root: string }): { owner: string; description: string } => {
302
+ const tools = (window as ToolWindow).__modelerShellConformance!;
303
+ const active = document.activeElement;
304
+ const rootElement = document.querySelector(root);
305
+ if (!active || active === document.body || active === document.documentElement) {
306
+ return { owner: "body", description: "<body>" };
307
+ }
308
+ const description = tools.describe(active);
309
+ if (!rootElement?.contains(active)) return { owner: "outside", description };
310
+ if (active.closest(".djs-container")) return { owner: "canvas", description };
311
+ const island = active.closest("[data-chrome]")?.getAttribute("data-chrome");
312
+ return { owner: island ?? "other", description };
313
+ };
314
+
315
+ /** Removes the focus, so the next Tab starts at the beginning of the document. */
316
+ export const blurProbe = (_: null): void => {
317
+ (document.activeElement as HTMLElement | null)?.blur?.();
318
+ };
319
+
320
+ /** One toast of the stack (guide §8). */
321
+ export interface ToastFacts {
322
+ text: string;
323
+ role: string | null;
324
+ /** `data-status` of the card. */
325
+ status: string | null;
326
+ box: RelativeBox;
327
+ client: Point;
328
+ /** Its buttons: the action (with text) and Close (an icon with `aria-label`). */
329
+ buttons: Array<{ name: string; text: string; client: Point }>;
330
+ /** Whether the card is what a pointer at its centre reaches (nothing in the top layer above it). */
331
+ topmost: boolean;
332
+ focused: boolean;
333
+ }
334
+
335
+ /** One of the toasts' live regions (`.cd-shell-live [role]`), rendered whether a toast shows or not. */
336
+ export interface LiveRegionFacts {
337
+ role: string | null;
338
+ live: string | null;
339
+ text: string;
340
+ }
341
+
342
+ export interface ToastsFacts {
343
+ /** `[data-chrome="toasts"]`; `null` where there is none (the IDE host). */
344
+ stack: { visible: boolean; box: RelativeBox; popover: string | null; popoverOpen: boolean } | null;
345
+ /** The visible toasts (`[data-status]` cards of the stack), in DOM order (the newest last). */
346
+ toasts: ToastFacts[];
347
+ /** Toast cards (`.cd-shell-toast`) visible anywhere in the document outside the stack. */
348
+ strays: string[];
349
+ /** The live regions in the root that announce the toasts. */
350
+ live: LiveRegionFacts[];
351
+ }
352
+
353
+ export const toastsProbe = ({ root }: { root: string }): ToastsFacts | null => {
354
+ const tools = (window as ToolWindow).__modelerShellConformance!;
355
+ const rootElement = document.querySelector<HTMLElement>(root);
356
+ if (!rootElement) return null;
357
+ const stack = rootElement.querySelector<HTMLElement>('[data-chrome="toasts"]');
358
+ const open = (element: HTMLElement): boolean => {
359
+ try {
360
+ return element.matches(":popover-open");
361
+ } catch {
362
+ return false;
363
+ }
364
+ };
365
+ const toasts = stack
366
+ ? [...stack.querySelectorAll<HTMLElement>(":scope > [data-status]")].filter((toast) =>
367
+ tools.visible(toast),
368
+ )
369
+ : [];
370
+ const name = (element: Element) =>
371
+ (element.getAttribute("aria-label") ?? element.textContent ?? "").replace(/\s+/g, " ").trim();
372
+ const centre = (element: Element): Point => {
373
+ const rect = element.getBoundingClientRect();
374
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
375
+ };
376
+ return {
377
+ stack: stack
378
+ ? {
379
+ visible: tools.visible(stack),
380
+ box: tools.box(stack, rootElement),
381
+ popover: stack.getAttribute("popover"),
382
+ popoverOpen: open(stack),
383
+ }
384
+ : null,
385
+ toasts: toasts.map((toast) => {
386
+ const point = centre(toast);
387
+ const hit = document.elementFromPoint(point.x, point.y);
388
+ return {
389
+ text: (toast.textContent ?? "").replace(/\s+/g, " ").trim(),
390
+ role: toast.getAttribute("role"),
391
+ status: toast.getAttribute("data-status"),
392
+ box: tools.box(toast, rootElement),
393
+ client: point,
394
+ buttons: [...toast.querySelectorAll("button")]
395
+ .filter((button) => tools.visible(button))
396
+ .map((button) => ({
397
+ name: name(button),
398
+ text: (button.textContent ?? "").replace(/\s+/g, " ").trim(),
399
+ client: centre(button),
400
+ })),
401
+ topmost: hit !== null && toast.contains(hit),
402
+ focused: toast.contains(document.activeElement),
403
+ };
404
+ }),
405
+ strays: [...document.querySelectorAll(".cd-shell-toast")]
406
+ .filter((element) => !stack?.contains(element) && tools.visible(element))
407
+ .map((element) => tools.describe(element)),
408
+ live: [...rootElement.querySelectorAll(".cd-shell-live [role], .cd-shell-live [aria-live]")].map(
409
+ (region) => ({
410
+ role: region.getAttribute("role"),
411
+ live: region.getAttribute("aria-live"),
412
+ text: (region.textContent ?? "").replace(/\s+/g, " ").trim(),
413
+ }),
414
+ ),
415
+ };
416
+ };
417
+
418
+ /** Focuses the button named `name` in the toast whose text contains `text`. */
419
+ export const focusToastButtonProbe = ({
420
+ root,
421
+ text,
422
+ name,
423
+ }: {
424
+ root: string;
425
+ text: string;
426
+ name: string;
427
+ }): boolean => {
428
+ const stack = document.querySelector(root)?.querySelector('[data-chrome="toasts"]');
429
+ const toast = [...(stack?.querySelectorAll<HTMLElement>(":scope > [data-status]") ?? [])].find(
430
+ (candidate) => (candidate.textContent ?? "").includes(text),
431
+ );
432
+ const button = [...(toast?.querySelectorAll("button") ?? [])].find(
433
+ (candidate) => (candidate.getAttribute("aria-label") ?? candidate.textContent ?? "").trim() === name,
434
+ );
435
+ if (!button) return false;
436
+ button.focus();
437
+ return document.activeElement === button;
438
+ };
439
+
440
+ /**
441
+ * Records the toast stack's popover toggles from now on (`beforetoggle`), so the kit sees whether a
442
+ * toast shown over a modal dialog raised the stack in the top layer again.
443
+ */
444
+ export const watchToastStackProbe = ({ root }: { root: string }): boolean => {
445
+ const tools = (window as ToolWindow).__modelerShellConformance!;
446
+ const stack = document.querySelector(root)?.querySelector('[data-chrome="toasts"]');
447
+ if (!stack) return false;
448
+ const seen: string[] = [];
449
+ const listener = (event: Event) => seen.push((event as ToggleEvent).newState);
450
+ stack.addEventListener("beforetoggle", listener);
451
+ tools.cleanups.push(() => stack.removeEventListener("beforetoggle", listener));
452
+ tools.toastToggles = seen;
453
+ return true;
454
+ };
455
+
456
+ /** The toggles `watchToastStackProbe` recorded, and whether the stack is shown in the top layer now. */
457
+ export const toastStackTogglesProbe = ({ root }: { root: string }): { toggles: string[]; open: boolean } => {
458
+ const tools = (window as ToolWindow).__modelerShellConformance!;
459
+ const stack = document.querySelector(root)?.querySelector('[data-chrome="toasts"]');
460
+ let open = false;
461
+ try {
462
+ open = stack?.matches(":popover-open") ?? false;
463
+ } catch {
464
+ open = false;
465
+ }
466
+ return { toggles: [...(tools.toastToggles ?? [])], open };
467
+ };
468
+
469
+ /** A `<dialog>` opened with `showModal()` in the top layer, as a modeler's dialog (removed with the stand-ins). */
470
+ export const showModalProbe = (_: null): boolean => {
471
+ const tools = (window as ToolWindow).__modelerShellConformance!;
472
+ const dialog = document.createElement("dialog");
473
+ dialog.setAttribute("data-modeler-shell-conformance", "show-modal");
474
+ dialog.setAttribute("aria-label", "Conformance stand-in");
475
+ dialog.style.cssText = "width:60vw;height:60vh;max-width:none;max-height:none;";
476
+ document.body.append(dialog);
477
+ tools.injected.push(dialog);
478
+ dialog.showModal();
479
+ try {
480
+ return dialog.matches(":modal");
481
+ } catch {
482
+ return dialog.open;
483
+ }
484
+ };
485
+
486
+ /** The pill, the web app's problems list it opens, its rows and where the focus is (guide §5.10). */
487
+ export interface PopoverFacts {
488
+ pill: { attributes: Record<string, string>; box: RelativeBox; client: Point; visible: boolean } | null;
489
+ popover: {
490
+ attributes: Record<string, string>;
491
+ box: RelativeBox;
492
+ visible: boolean;
493
+ /** Text of its `aria-labelledby` targets. */
494
+ title: string | null;
495
+ } | null;
496
+ rows: Array<{ button: boolean; height: number; icon: boolean; text: string; client: Point }>;
497
+ focus: "pill" | "popover" | "canvas" | "body" | "other";
498
+ focused: string;
499
+ /**
500
+ * The row holding the focus: its index among `rows`, and whether it lies inside the visible part
501
+ * of the scrolling list; `null` while no row holds it.
502
+ */
503
+ focusedRow: { index: number; inView: boolean } | null;
504
+ }
505
+
506
+ export const popoverProbe = ({ root }: { root: string }): PopoverFacts | null => {
507
+ const tools = (window as ToolWindow).__modelerShellConformance!;
508
+ const rootElement = document.querySelector<HTMLElement>(root);
509
+ if (!rootElement) return null;
510
+ const pill = rootElement.querySelector('[data-chrome="problems"]');
511
+ const controls = pill?.getAttribute("aria-controls");
512
+ const popover =
513
+ (controls ? document.getElementById(controls) : null) ??
514
+ rootElement.querySelector('[data-chrome="problems-popover"]');
515
+ const attributes = (element: Element) => {
516
+ const all: Record<string, string> = {};
517
+ for (const name of element.getAttributeNames()) all[name] = element.getAttribute(name) ?? "";
518
+ return all;
519
+ };
520
+ const labelledBy = popover?.getAttribute("aria-labelledby");
521
+ const active = document.activeElement;
522
+ const rows = popover
523
+ ? [...popover.querySelectorAll(".cd-shell-problem")].filter((row) => tools.visible(row))
524
+ : [];
525
+ const pillRect = pill?.getBoundingClientRect();
526
+ return {
527
+ pill:
528
+ pill && pillRect
529
+ ? {
530
+ attributes: attributes(pill),
531
+ box: tools.box(pill, rootElement),
532
+ client: { x: pillRect.left + pillRect.width / 2, y: pillRect.top + pillRect.height / 2 },
533
+ visible: tools.visible(pill),
534
+ }
535
+ : null,
536
+ popover: popover
537
+ ? {
538
+ attributes: attributes(popover),
539
+ box: tools.box(popover, rootElement),
540
+ visible: tools.visible(popover),
541
+ title: labelledBy
542
+ ? labelledBy
543
+ .split(/\s+/)
544
+ .map((id) => document.getElementById(id)?.textContent?.trim() ?? "")
545
+ .join(" ")
546
+ .trim()
547
+ : null,
548
+ }
549
+ : null,
550
+ rows: rows.map((row) => {
551
+ const target = row.matches("button") ? row : row.querySelector("button");
552
+ const rect = (target ?? row).getBoundingClientRect();
553
+ return {
554
+ button: target !== null,
555
+ height: row.getBoundingClientRect().height,
556
+ icon: row.querySelector("svg") !== null,
557
+ text: (row.textContent ?? "").replace(/\s+/g, " ").trim(),
558
+ client: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
559
+ };
560
+ }),
561
+ focus:
562
+ !active || active === document.body
563
+ ? "body"
564
+ : pill && active === pill
565
+ ? "pill"
566
+ : popover?.contains(active)
567
+ ? "popover"
568
+ : active.closest(".djs-container")
569
+ ? "canvas"
570
+ : "other",
571
+ focused: !active || active === document.body ? "<body>" : tools.describe(active),
572
+ focusedRow: (() => {
573
+ const index = active ? rows.findIndex((row) => row === active || row.contains(active)) : -1;
574
+ const row = rows[index];
575
+ if (!row) return null;
576
+ let scroller: Element | null = row.parentElement;
577
+ while (scroller && scroller !== popover && scroller.scrollHeight <= scroller.clientHeight) {
578
+ scroller = scroller.parentElement;
579
+ }
580
+ const view = (scroller ?? popover ?? row).getBoundingClientRect();
581
+ const box = row.getBoundingClientRect();
582
+ return { index, inView: box.top >= view.top - 1 && box.bottom <= view.bottom + 1 };
583
+ })(),
584
+ };
585
+ };
586
+
587
+ /** A synthetic drag the kit dispatches: files built in the page, or plain strings (a text drag). */
588
+ export interface DragSpec {
589
+ root: string;
590
+ files: readonly DropFixture[];
591
+ strings?: readonly string[];
592
+ steps: ReadonlyArray<"dragenter" | "dragover" | "dragleave" | "drop">;
593
+ }
594
+
595
+ /** The drop zone and the root's state after a drag step (guide §9). */
596
+ export interface DropZoneFacts {
597
+ /** The root's `data-drop`. */
598
+ state: string | null;
599
+ /** Per dispatched step: `defaultPrevented` and the `dropEffect` the page set. */
600
+ events: Array<{ type: string; defaultPrevented: boolean; dropEffect: string }>;
601
+ zone: {
602
+ visible: boolean;
603
+ box: RelativeBox;
604
+ ariaHidden: string | null;
605
+ background: string;
606
+ borderStyle: string;
607
+ borderWidth: string;
608
+ borderColor: string;
609
+ radius: string;
610
+ cursor: string;
611
+ } | null;
612
+ chip: { text: string; background: string } | null;
613
+ /** The roles of guide §9 resolved to computed colours inside the root. */
614
+ roles: { hover: string; interaktion: string; dangerFlaeche: string; danger: string; flaecheHoch: string };
615
+ }
616
+
617
+ export const dragProbe = async (spec: DragSpec): Promise<DropZoneFacts | null> => {
618
+ const tools = (window as ToolWindow).__modelerShellConformance!;
619
+ const rootElement = document.querySelector<HTMLElement>(spec.root);
620
+ if (!rootElement) return null;
621
+ const transfer = new DataTransfer();
622
+ for (const file of spec.files) {
623
+ transfer.items.add(new File([file.text], file.name, { type: file.type }));
624
+ }
625
+ for (const text of spec.strings ?? []) transfer.items.add(text, "text/plain");
626
+ const rect = rootElement.getBoundingClientRect();
627
+ const clientX = rect.left + rect.width / 2;
628
+ const clientY = rect.top + rect.height / 2;
629
+ const target = document.elementFromPoint(clientX, clientY) ?? rootElement;
630
+ const events: DropZoneFacts["events"] = [];
631
+ for (const type of spec.steps) {
632
+ const event = new DragEvent(type, {
633
+ bubbles: true,
634
+ cancelable: true,
635
+ composed: true,
636
+ dataTransfer: transfer,
637
+ clientX,
638
+ clientY,
639
+ });
640
+ // Chromium ignores dropEffect on a constructed DataTransfer; the kit records what the page sets.
641
+ const effects: Record<string, string> = { dropEffect: "none", effectAllowed: "all" };
642
+ const recorded = new Proxy(transfer, {
643
+ get(target, key) {
644
+ if (key === "dropEffect" || key === "effectAllowed") return effects[key];
645
+ const value = Reflect.get(target, key, target) as unknown;
646
+ return typeof value === "function" ? (value as (...args: unknown[]) => unknown).bind(target) : value;
647
+ },
648
+ set(target, key, value) {
649
+ if (key === "dropEffect" || key === "effectAllowed") {
650
+ effects[key] = String(value);
651
+ return true;
652
+ }
653
+ return Reflect.set(target, key, value, target);
654
+ },
655
+ });
656
+ Object.defineProperty(event, "dataTransfer", { configurable: true, value: recorded });
657
+ target.dispatchEvent(event);
658
+ events.push({ type, defaultPrevented: event.defaultPrevented, dropEffect: effects.dropEffect ?? "none" });
659
+ }
660
+ await tools.settle();
661
+ const resolve = (role: string): string => {
662
+ const probe = document.createElement("span");
663
+ probe.setAttribute("data-modeler-shell-conformance", "role-probe");
664
+ probe.style.cssText = `position:absolute;visibility:hidden;background-color:var(${role});`;
665
+ rootElement.append(probe);
666
+ const color = getComputedStyle(probe).backgroundColor;
667
+ probe.remove();
668
+ return color;
669
+ };
670
+ const resolveColor = (role: string): string => {
671
+ const probe = document.createElement("span");
672
+ probe.setAttribute("data-modeler-shell-conformance", "role-probe");
673
+ probe.style.cssText = `position:absolute;visibility:hidden;color:var(${role});`;
674
+ rootElement.append(probe);
675
+ const color = getComputedStyle(probe).color;
676
+ probe.remove();
677
+ return color;
678
+ };
679
+ const zone = rootElement.querySelector('[data-chrome="drop-zone"]');
680
+ const chip = zone?.querySelector(".cd-shell-drop-zone-chip") ?? null;
681
+ const zoneStyle = zone ? getComputedStyle(zone) : null;
682
+ return {
683
+ state: rootElement.getAttribute("data-drop"),
684
+ events,
685
+ zone:
686
+ zone && zoneStyle
687
+ ? {
688
+ visible: tools.visible(zone),
689
+ box: tools.box(zone, rootElement),
690
+ ariaHidden: zone.getAttribute("aria-hidden"),
691
+ background: zoneStyle.backgroundColor,
692
+ borderStyle: zoneStyle.borderTopStyle,
693
+ borderWidth: zoneStyle.borderTopWidth,
694
+ borderColor: zoneStyle.borderTopColor,
695
+ radius: zoneStyle.borderTopLeftRadius,
696
+ cursor: zoneStyle.cursor,
697
+ }
698
+ : null,
699
+ chip: chip
700
+ ? {
701
+ text: (chip.textContent ?? "").replace(/\s+/g, " ").trim(),
702
+ background: getComputedStyle(chip).backgroundColor,
703
+ }
704
+ : null,
705
+ roles: {
706
+ hover: resolve("--cd-ui-hover"),
707
+ interaktion: resolveColor("--cd-ui-interaktion"),
708
+ dangerFlaeche: resolve("--cd-ui-danger-flaeche"),
709
+ danger: resolveColor("--cd-ui-danger"),
710
+ flaecheHoch: resolve("--cd-ui-flaeche-hoch"),
711
+ },
712
+ };
713
+ };