@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,187 @@
1
+ import type { Appearance, ColorThemeSetting, ShellHost, Size, Theme, ToastStatus } from "../core/index.js";
2
+ import { createCases } from "./cases.js";
3
+ import type { PageLike } from "./page.js";
4
+
5
+ export interface OpenContext {
6
+ /** The page's viewport, which is the modeler's container in an own document. */
7
+ size: Size;
8
+ /**
9
+ * What the host requests before the modeler resolves it: in an IDE host the VS Code color theme
10
+ * (`vsCodeStubSearch` hands it to a page with the VS Code stub). For an embedded host the kit
11
+ * itself puts the foreign page's `<html>` in this mode once `open` resolved. In the web host the
12
+ * system's preference without a stored choice: the kit emulates `prefers-color-scheme` and
13
+ * `prefers-contrast` (`PageLike.emulateMedia`) before `open`, and `open` loads the page with an
14
+ * empty `localStorage` entry `miragon.darstellung`.
15
+ */
16
+ requested: Appearance;
17
+ }
18
+
19
+ /**
20
+ * How the kit makes the optional islands visible (`geometry`, `aria`) and puts the modeler into its
21
+ * invalid-file state (`readOnly`).
22
+ */
23
+ export interface ShowHooks<P extends PageLike = PageLike> {
24
+ problems?: ((page: P) => Promise<void>) | undefined;
25
+ status?: ((page: P) => Promise<void>) | undefined;
26
+ legend?: ((page: P) => Promise<void>) | undefined;
27
+ inspector?: ((page: P) => Promise<void>) | undefined;
28
+ /**
29
+ * Puts the modeler into its invalid-file state the way its host does (an invalid import): a
30
+ * read-only canvas and the status box. The kit waits `INVALID_STATUS_DELAY_MS` afterwards.
31
+ */
32
+ invalid?: ((page: P) => Promise<void>) | undefined;
33
+ /**
34
+ * Web host: puts the page on its start screen, the way a fresh visit without a document shows
35
+ * it (`shell.setEmptyState(true)`); `open` itself loads a document.
36
+ */
37
+ emptyState?: ((page: P) => Promise<void>) | undefined;
38
+ /**
39
+ * Web and embedded hosts: shows `toast` through the modeler's `shell.toast`, with
40
+ * `{ label: toast.actionLabel, onSelect() {} }` as its action when `actionLabel` is given.
41
+ */
42
+ toast?: ((page: P, toast: ToastFixture) => Promise<void>) | undefined;
43
+ }
44
+
45
+ /** A toast the kit asks the `toast` hook to show (cases `toasts`, `geometry`). */
46
+ export interface ToastFixture {
47
+ status: ToastStatus;
48
+ text: string;
49
+ actionLabel?: string | undefined;
50
+ }
51
+
52
+ /** A file the kit drops on the page (case `dropZone`), built in the page as `new File([text], name, { type })`. */
53
+ export interface DropFixture {
54
+ name: string;
55
+ type: string;
56
+ text: string;
57
+ }
58
+
59
+ export interface DropFixtures {
60
+ /** A file `DropZoneOptions.accept` takes; dropping it on the start screen opens it (the card goes). */
61
+ accepted: DropFixture;
62
+ /** A file outside `accept`; dropping it opens nothing and shows a danger toast (or the modeler's `onReject`). */
63
+ rejected?: DropFixture | undefined;
64
+ /** `DropZoneOptions.multiple` of the modeler; default `false`, so a drag of two files is forbidden. */
65
+ multiple?: boolean | undefined;
66
+ }
67
+
68
+ /**
69
+ * A show hook by name: the islands `legend`, `inspector`, `problems`, `status`, the state
70
+ * `invalid`, and in the web column `emptyState` and `toast`.
71
+ */
72
+ export type ShowHook = keyof ShowHooks;
73
+
74
+ /**
75
+ * What a harness can lack: a show hook, the web app's drop zone (`drop`), or problems that point at
76
+ * an element (`problemSelection`: the modeler's diagnostics only name lines, so no row selects).
77
+ */
78
+ export type AbsentFeature = ShowHook | "drop" | "problemSelection";
79
+
80
+ export interface ShellConformanceHarness<P extends PageLike = PageLike> {
81
+ host: ShellHost;
82
+ /** The modes the modeler declares (static); the appearance case requests dark from `["light"]`. */
83
+ themes: readonly Theme[];
84
+ /** Load the modeler with the shell at `size`, as the host would with `requested`; resolve once the first import is done. */
85
+ open(page: P, context: OpenContext): Promise<void>;
86
+ /** Tear modeler and shell down through the page's own path; `undefined` skips the cleanup case. */
87
+ destroy?: ((page: P) => Promise<void>) | undefined;
88
+ /** CSS selector of the shell root; default `.cd-shell`. */
89
+ root?: string | undefined;
90
+ /** Hooks for the islands and states the modeler has; one that is neither given nor `absent` is reported as skipped. */
91
+ show?: ShowHooks<P> | undefined;
92
+ /** Web host: the files the `dropZone` case drops; without them (and not `absent`) it is skipped with `hook: "drop"`. */
93
+ drop?: DropFixtures | undefined;
94
+ /**
95
+ * The islands and states the modeler does not have. An absent hook is not reported as skipped,
96
+ * and `geometry` and `aria` assert that its island is never visible. Listing a hook that `show`
97
+ * also gives fails every case that uses it.
98
+ */
99
+ absent?: readonly AbsentFeature[] | undefined;
100
+ /** Container sizes of the geometry case; default wide 1280 × 900, narrow 720 × 700, very narrow 420 × 700. */
101
+ sizes?: readonly Size[] | undefined;
102
+ }
103
+
104
+ /** One part of a case the harness could not feed. */
105
+ export interface ShellSkip {
106
+ /**
107
+ * The harness field that would feed it: a show hook, `destroy`, or `open` when the document it
108
+ * opens lacks what the case needs (a shape on the canvas).
109
+ */
110
+ hook: ShowHook | "drop" | "destroy" | "open";
111
+ reason: string;
112
+ }
113
+
114
+ export interface ShellCaseOutcome {
115
+ /** What the case could not (fully) run, one entry per missing harness field. */
116
+ skipped?: readonly ShellSkip[];
117
+ }
118
+
119
+ export interface ShellConformanceCase {
120
+ name: string;
121
+ run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>): Promise<ShellCaseOutcome | void>;
122
+ }
123
+
124
+ /**
125
+ * The cases: `geometry` (every `data-chrome` island ±1px, per host and breakpoint), `aria`
126
+ * (roles, names, `aria-expanded`/`-controls`/`-keyshortcuts`/`-disabled`, DOM order, no positive
127
+ * tabindex), `keys` (each chrome key acts exactly once; in the IDE host a `keydown` listener on
128
+ * `window` never fires for a handled key; unmodified `+`/`-` never zoom; select all acts on the
129
+ * canvas only), `create` (Enter on the first creating palette entry places one selected element in
130
+ * the free rectangle and focuses the canvas), `readOnly` (the invalid-file lock of guide §13.5),
131
+ * `appearance` (dark requested from a light-only modeler: nothing turns dark, `color-scheme` light
132
+ * everywhere, the canvas surround and the palette's tool colours from the roles; the web app's mode
133
+ * and its Appearance group) and `cleanup` (after `destroy` no shell element, class, attribute or
134
+ * key handling remains); then the web column's `webChrome`, `emptyState`, `toasts`,
135
+ * `problemsList` and `dropZone`, which assert in the other hosts that nothing of them shows.
136
+ */
137
+ export function shellConformanceCases(): ShellConformanceCase[] {
138
+ return createCases();
139
+ }
140
+
141
+ /** Playwright's `test` (or any runner whose body receives `{ page }`). */
142
+ export type ShellConformanceTest<P extends PageLike = PageLike> = (
143
+ name: string,
144
+ body: (fixtures: { page: P }) => Promise<void>,
145
+ ) => unknown;
146
+
147
+ export interface RegisterShellConformanceOptions {
148
+ /** Default: `console.warn`, one line per case with its skips joined by "; ". */
149
+ onSkip?: ((caseName: string, skips: readonly ShellSkip[]) => void) | undefined;
150
+ }
151
+
152
+ /**
153
+ * The query string for a test page that installs the VS Code stub of
154
+ * `@miragon/modeler-shell/testing/vscode-stub` with `stubOptionsFromSearch(location.search)`:
155
+ * `theme=dark&kontrast=hoch`, plus the `colorTheme` setting and VS Code's reduce motion.
156
+ */
157
+ export function vsCodeStubSearch(
158
+ requested: Appearance,
159
+ extra: { colorTheme?: ColorThemeSetting | undefined; reduceMotion?: boolean | undefined } = {},
160
+ ): string {
161
+ const query = new URLSearchParams({ theme: requested.theme });
162
+ if (requested.kontrast) query.set("kontrast", requested.kontrast);
163
+ if (extra.colorTheme) query.set("colorTheme", extra.colorTheme);
164
+ if (extra.reduceMotion) query.set("reduceMotion", "");
165
+ return query.toString();
166
+ }
167
+
168
+ /** Registers every case as `shell conformance: <name>`. */
169
+ export function registerShellConformance<P extends PageLike>(
170
+ harness: ShellConformanceHarness<P>,
171
+ test: ShellConformanceTest<P>,
172
+ options: RegisterShellConformanceOptions = {},
173
+ ): void {
174
+ const onSkip =
175
+ options.onSkip ??
176
+ ((caseName: string, skips: readonly ShellSkip[]) => {
177
+ console.warn(
178
+ `@miragon/modeler-shell conformance: SKIPPED "${caseName}" (${harness.host} host): ${skips.map((skip) => skip.reason).join("; ")}`,
179
+ );
180
+ });
181
+ for (const conformanceCase of shellConformanceCases()) {
182
+ test(`shell conformance: ${conformanceCase.name}`, async ({ page }) => {
183
+ const outcome = await conformanceCase.run(page, harness);
184
+ if (outcome?.skipped && outcome.skipped.length > 0) onSkip(conformanceCase.name, outcome.skipped);
185
+ });
186
+ }
187
+ }
@@ -0,0 +1,19 @@
1
+ export type { PageLike } from "./page.js";
2
+ export type { ChromeMeasurement, ExpectedBox, IslandBox } from "./geometry.js";
3
+ export { GEOMETRY_TOLERANCE_PX, expectedChromeBoxes, measureChrome } from "./geometry.js";
4
+ export type {
5
+ AbsentFeature,
6
+ DropFixture,
7
+ DropFixtures,
8
+ OpenContext,
9
+ RegisterShellConformanceOptions,
10
+ ShellCaseOutcome,
11
+ ShellConformanceCase,
12
+ ShellConformanceHarness,
13
+ ShellConformanceTest,
14
+ ShellSkip,
15
+ ShowHook,
16
+ ShowHooks,
17
+ ToastFixture,
18
+ } from "./harness.js";
19
+ export { registerShellConformance, shellConformanceCases, vsCodeStubSearch } from "./harness.js";
@@ -0,0 +1,457 @@
1
+ import type { Size } from "../core/index.js";
2
+ import type { PageLike } from "./page.js";
3
+
4
+ /*
5
+ * Everything here that is handed to `page.evaluate` runs in the browser: Playwright serializes the
6
+ * function source, so these functions must not reference anything outside their own body. They
7
+ * only collect facts; every judgement happens in Node (`checks.ts`, `geometry.ts`), where it is
8
+ * unit-tested. `installPageTools` puts the shared helpers and the recorders under one page global.
9
+ */
10
+
11
+ /** A box relative to the shell root's padding box (where `position: absolute` islands anchor). */
12
+ export interface RelativeBox {
13
+ left: number;
14
+ top: number;
15
+ width: number;
16
+ height: number;
17
+ }
18
+
19
+ /** A `keydown` that reached the kit's listener on `window` (bubble phase), where VS Code's host page listens. */
20
+ export interface RecordedKey {
21
+ key: string;
22
+ code: string;
23
+ ctrlKey: boolean;
24
+ metaKey: boolean;
25
+ shiftKey: boolean;
26
+ altKey: boolean;
27
+ defaultPrevented: boolean;
28
+ }
29
+
30
+ /** The facts the kit reads about one element. */
31
+ export interface ElementFacts {
32
+ tag: string;
33
+ description: string;
34
+ attributes: Record<string, string>;
35
+ text: string;
36
+ visible: boolean;
37
+ /** The native `disabled` property (a disabled `<button>` loses focus, `aria-disabled` keeps it). */
38
+ disabled: boolean;
39
+ }
40
+
41
+ /** The helpers and recorders `installPageTools` keeps under the page global. */
42
+ export interface PageTools {
43
+ /** `keydown`s that reached `window`, where VS Code's host page listens. */
44
+ keys: RecordedKey[];
45
+ /**
46
+ * `keydown`s seen on `document` after every listener registered before the kit's (the shell's,
47
+ * the modeler's), so `defaultPrevented` is final even where the shell stops the key.
48
+ */
49
+ documentKeys: RecordedKey[];
50
+ errors: string[];
51
+ injected: Element[];
52
+ cleanups: Array<() => void>;
53
+ armedRoot?: HTMLElement;
54
+ /** Clicks a kit listener counted on a control (the web app's primary action). */
55
+ clicks: number;
56
+ /** Changes of the document's mode the kit records while it switches the web app's appearance. */
57
+ modeWatch?: ModeWatch | undefined;
58
+ /** `newState` of every `beforetoggle` of the web app's toast stack since the kit started listening. */
59
+ toastToggles?: string[] | undefined;
60
+ frame(): Promise<void>;
61
+ settle(): Promise<void>;
62
+ visible(element: Element): boolean;
63
+ container(root: HTMLElement): Size;
64
+ box(element: Element, root: HTMLElement): RelativeBox;
65
+ describe(element: Element): string;
66
+ facts(element: Element): ElementFacts;
67
+ }
68
+
69
+ export type ToolWindow = Window & { __modelerShellConformance?: PageTools };
70
+
71
+ /** One change of `<html>`'s mode or of its meta tags, with the animation frame it landed in. */
72
+ export interface ModeChange {
73
+ part: "theme" | "kontrast" | "color-scheme" | "theme-color" | "no-transition";
74
+ value: string | null;
75
+ frame: number;
76
+ }
77
+
78
+ export interface ModeWatch {
79
+ changes: ModeChange[];
80
+ stop(): void;
81
+ }
82
+
83
+ /** Installs the in-page helpers once per document (idempotent; a navigation drops them). */
84
+ export const installPageTools = (_: null): void => {
85
+ const scope = window as ToolWindow;
86
+ if (scope.__modelerShellConformance) return;
87
+ const modifierKeys = ["Control", "Meta", "Shift", "Alt", "AltGraph", "CapsLock", "Fn"];
88
+ const frame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
89
+ const borders = (element: Element) => {
90
+ const style = getComputedStyle(element);
91
+ return {
92
+ left: parseFloat(style.borderLeftWidth) || 0,
93
+ top: parseFloat(style.borderTopWidth) || 0,
94
+ right: parseFloat(style.borderRightWidth) || 0,
95
+ bottom: parseFloat(style.borderBottomWidth) || 0,
96
+ };
97
+ };
98
+ const visible = (element: Element): boolean => {
99
+ if (!element.isConnected) return false;
100
+ if (typeof element.checkVisibility === "function") {
101
+ if (!element.checkVisibility({ visibilityProperty: true })) return false;
102
+ } else {
103
+ if (element.closest("[hidden]")) return false;
104
+ for (let node: Element | null = element; node; node = node.parentElement) {
105
+ if (getComputedStyle(node).display === "none") return false;
106
+ }
107
+ if (getComputedStyle(element).visibility === "hidden") return false;
108
+ }
109
+ const rect = element.getBoundingClientRect();
110
+ return rect.width > 0 && rect.height > 0;
111
+ };
112
+ const describe = (element: Element): string => {
113
+ const id = element.id ? `#${element.id}` : "";
114
+ const classes = [...element.classList]
115
+ .slice(0, 3)
116
+ .map((name) => `.${name}`)
117
+ .join("");
118
+ const chrome = element.getAttribute("data-chrome");
119
+ return `${element.tagName.toLowerCase()}${id}${classes}${chrome ? `[data-chrome="${chrome}"]` : ""}`;
120
+ };
121
+ const tools: PageTools = {
122
+ keys: [],
123
+ documentKeys: [],
124
+ errors: [],
125
+ injected: [],
126
+ cleanups: [],
127
+ clicks: 0,
128
+ frame,
129
+ async settle() {
130
+ await frame();
131
+ await frame();
132
+ const running =
133
+ typeof document.getAnimations === "function"
134
+ ? document.getAnimations().filter((animation) => {
135
+ const end = animation.effect?.getComputedTiming().endTime;
136
+ return animation.playState === "running" && typeof end === "number" && Number.isFinite(end);
137
+ })
138
+ : [];
139
+ if (running.length > 0) {
140
+ await Promise.race([
141
+ Promise.allSettled(running.map((animation) => animation.finished)),
142
+ new Promise((resolve) => setTimeout(resolve, 2000)),
143
+ ]);
144
+ }
145
+ await frame();
146
+ },
147
+ visible,
148
+ container(root) {
149
+ const rect = root.getBoundingClientRect();
150
+ const border = borders(root);
151
+ return {
152
+ width: rect.width - border.left - border.right,
153
+ height: rect.height - border.top - border.bottom,
154
+ };
155
+ },
156
+ box(element, root) {
157
+ const rect = element.getBoundingClientRect();
158
+ const origin = root.getBoundingClientRect();
159
+ const border = borders(root);
160
+ return {
161
+ left: rect.left - origin.left - border.left,
162
+ top: rect.top - origin.top - border.top,
163
+ width: rect.width,
164
+ height: rect.height,
165
+ };
166
+ },
167
+ describe,
168
+ facts(element) {
169
+ const attributes: Record<string, string> = {};
170
+ for (const name of element.getAttributeNames()) attributes[name] = element.getAttribute(name) ?? "";
171
+ return {
172
+ tag: element.tagName.toLowerCase(),
173
+ description: describe(element),
174
+ attributes,
175
+ text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
176
+ visible: visible(element),
177
+ disabled: Boolean((element as HTMLButtonElement).disabled),
178
+ };
179
+ },
180
+ };
181
+ const recorder = (into: RecordedKey[]) => (event: KeyboardEvent) => {
182
+ if (modifierKeys.includes(event.key)) return;
183
+ into.push({
184
+ key: event.key,
185
+ code: event.code,
186
+ ctrlKey: event.ctrlKey,
187
+ metaKey: event.metaKey,
188
+ shiftKey: event.shiftKey,
189
+ altKey: event.altKey,
190
+ defaultPrevented: event.defaultPrevented,
191
+ });
192
+ };
193
+ document.addEventListener("keydown", recorder(tools.documentKeys));
194
+ window.addEventListener("keydown", recorder(tools.keys));
195
+ window.addEventListener("error", (event) => {
196
+ tools.errors.push(event.message || String(event.error));
197
+ });
198
+ window.addEventListener("unhandledrejection", (event) => {
199
+ tools.errors.push(`unhandled rejection: ${String(event.reason)}`);
200
+ });
201
+ scope.__modelerShellConformance = tools;
202
+ };
203
+
204
+ /** Runs `pageFunction` in the page after making sure the helpers are installed. */
205
+ export async function inPage<Arg, Result>(
206
+ page: PageLike,
207
+ pageFunction: (arg: Arg) => Result | Promise<Result>,
208
+ arg: Arg,
209
+ ): Promise<Result> {
210
+ await page.evaluate(installPageTools, null);
211
+ return page.evaluate(pageFunction, arg);
212
+ }
213
+
214
+ /** Two frames plus every finite running animation (pop-ins, slide-ins), capped at 2s. */
215
+ export const settleInPage = (_: null): Promise<void> =>
216
+ (window as ToolWindow).__modelerShellConformance!.settle();
217
+
218
+ export function settle(page: PageLike): Promise<void> {
219
+ return inPage(page, settleInPage, null);
220
+ }
221
+
222
+ /** The controls the kit hovers, clicks or focuses, found inside the shell root. */
223
+ export type ChromeTarget =
224
+ | "menu"
225
+ | "palette-entry"
226
+ | "palette-tab-stop"
227
+ | "zoom-out"
228
+ | "zoom-actual"
229
+ | "zoom-in"
230
+ | "zoom-fit"
231
+ | "canvas"
232
+ | "primary"
233
+ | "pill";
234
+
235
+ export const CHROME_TARGET_SELECTORS: Readonly<Record<ChromeTarget, string>> = {
236
+ menu: '[data-chrome="menu"]',
237
+ "palette-entry": '[data-chrome="palette"] [data-action]',
238
+ "palette-tab-stop": '[data-chrome="palette"] [data-action][tabindex="0"]',
239
+ "zoom-out": '[data-chrome="zoom"] [data-zoom="out"]',
240
+ "zoom-actual": '[data-chrome="zoom"] [data-zoom="actual"]',
241
+ "zoom-in": '[data-chrome="zoom"] [data-zoom="in"]',
242
+ "zoom-fit": '[data-chrome="zoom"] [data-zoom="fit"]',
243
+ canvas: ".djs-container > svg",
244
+ primary: '[data-chrome="primary"]',
245
+ pill: '[data-chrome="problems"]',
246
+ };
247
+
248
+ export interface TargetFacts {
249
+ /** Viewport coordinates of the centre, for `page.mouse`. */
250
+ client: { x: number; y: number };
251
+ box: RelativeBox;
252
+ /** `aria-label`, else the text. */
253
+ label: string;
254
+ }
255
+
256
+ /** The first visible match of `selector` inside the root; `null` when there is none. */
257
+ export const targetProbe = ({ root, selector }: { root: string; selector: string }): TargetFacts | null => {
258
+ const tools = (window as ToolWindow).__modelerShellConformance!;
259
+ const rootElement = document.querySelector<HTMLElement>(root);
260
+ if (!rootElement) return null;
261
+ const element = [...rootElement.querySelectorAll(selector)].find((candidate) => tools.visible(candidate));
262
+ if (!element) return null;
263
+ const rect = element.getBoundingClientRect();
264
+ return {
265
+ client: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
266
+ box: tools.box(element, rootElement),
267
+ label: (element.getAttribute("aria-label") ?? element.textContent ?? "").replace(/\s+/g, " ").trim(),
268
+ };
269
+ };
270
+
271
+ export async function findTarget(
272
+ page: PageLike,
273
+ root: string,
274
+ target: ChromeTarget,
275
+ ): Promise<TargetFacts | null> {
276
+ return inPage(page, targetProbe, { root, selector: CHROME_TARGET_SELECTORS[target] });
277
+ }
278
+
279
+ /** Focuses the first match of `selector` inside the root; the description of the focused element. */
280
+ export const focusProbe = ({ root, selector }: { root: string; selector: string }): string | null => {
281
+ const tools = (window as ToolWindow).__modelerShellConformance!;
282
+ const element = document.querySelector(root)?.querySelector<HTMLElement | SVGElement>(selector);
283
+ if (!element) return null;
284
+ element.focus();
285
+ return document.activeElement === element ? tools.describe(element) : null;
286
+ };
287
+
288
+ export async function focusTarget(
289
+ page: PageLike,
290
+ root: string,
291
+ target: ChromeTarget,
292
+ ): Promise<string | null> {
293
+ return inPage(page, focusProbe, { root, selector: CHROME_TARGET_SELECTORS[target] });
294
+ }
295
+
296
+ /** A visible popup with `role` inside the root (the dropdown, the chrome tooltip). */
297
+ export interface OverlayFacts {
298
+ box: RelativeBox;
299
+ text: string;
300
+ /** How many visible elements carry the role. */
301
+ count: number;
302
+ id: string;
303
+ }
304
+
305
+ export const overlayProbe = ({ root, role }: { root: string; role: string }): OverlayFacts | null => {
306
+ const tools = (window as ToolWindow).__modelerShellConformance!;
307
+ const rootElement = document.querySelector<HTMLElement>(root);
308
+ if (!rootElement) return null;
309
+ const shown = [...rootElement.querySelectorAll(`[role="${role}"]`)].filter((element) =>
310
+ tools.visible(element),
311
+ );
312
+ const first = shown[0];
313
+ if (!first) return null;
314
+ return {
315
+ box: tools.box(first, rootElement),
316
+ text: (first.textContent ?? "").replace(/\s+/g, " ").trim(),
317
+ count: shown.length,
318
+ id: first.id,
319
+ };
320
+ };
321
+
322
+ export async function findOverlay(
323
+ page: PageLike,
324
+ root: string,
325
+ role: "menu" | "tooltip",
326
+ ): Promise<OverlayFacts | null> {
327
+ return inPage(page, overlayProbe, { root, role });
328
+ }
329
+
330
+ export interface PressFacts {
331
+ /** The readout as a whole percentage; `null` without a readout. */
332
+ zoom: number | null;
333
+ keys: RecordedKey[];
334
+ documentKeys: RecordedKey[];
335
+ }
336
+
337
+ /** After a key press: settles, then reads the readout and takes the keys `window` and `document` saw. */
338
+ export const afterPressProbe = async ({ root }: { root: string }): Promise<PressFacts> => {
339
+ const tools = (window as ToolWindow).__modelerShellConformance!;
340
+ await tools.settle();
341
+ const readout = document
342
+ .querySelector(root)
343
+ ?.querySelector('[data-chrome="zoom"] [data-zoom="actual"]')?.textContent;
344
+ const digits = (readout ?? "").replace(/[^0-9]/g, "");
345
+ return {
346
+ zoom: digits ? Number(digits) : null,
347
+ keys: tools.keys.splice(0),
348
+ documentKeys: tools.documentKeys.splice(0),
349
+ };
350
+ };
351
+
352
+ /** Drops what `window` saw so far. */
353
+ export const clearKeysProbe = (_: null): void => {
354
+ const tools = (window as ToolWindow).__modelerShellConformance!;
355
+ tools.keys.splice(0);
356
+ tools.documentKeys.splice(0);
357
+ };
358
+
359
+ export type InjectedKind = "input" | "textarea" | "contenteditable" | "modal" | "popover" | "focus-target";
360
+
361
+ /**
362
+ * Adds a stand-in for what modelers render inside the shell (text fields: an input, a textarea, an
363
+ * editable label; a modal dialog, a popover, a plain focus target), inside the root or, once it is
364
+ * gone, the body; focuses it.
365
+ */
366
+ export const injectProbe = ({ root, kind }: { root: string; kind: InjectedKind }): boolean => {
367
+ const tools = (window as ToolWindow).__modelerShellConformance!;
368
+ const parent = document.querySelector(root) ?? tools.armedRoot ?? document.body;
369
+ const host = parent.isConnected ? parent : document.body;
370
+ const element =
371
+ kind === "input" || kind === "textarea"
372
+ ? document.createElement(kind)
373
+ : (document.createElement("div") as HTMLElement);
374
+ element.setAttribute("data-modeler-shell-conformance", kind);
375
+ element.style.cssText = "position:absolute;left:0;top:0;width:40px;height:20px;";
376
+ if (kind === "modal" || kind === "popover") {
377
+ element.setAttribute("role", "dialog");
378
+ element.setAttribute("aria-label", "Conformance stand-in");
379
+ if (kind === "modal") element.setAttribute("aria-modal", "true");
380
+ }
381
+ if (kind === "contenteditable") element.setAttribute("contenteditable", "true");
382
+ else if (kind !== "input" && kind !== "textarea") element.tabIndex = -1;
383
+ host.append(element);
384
+ tools.injected.push(element);
385
+ element.focus();
386
+ return document.activeElement === element;
387
+ };
388
+
389
+ export const removeInjectedProbe = (_: null): void => {
390
+ const tools = (window as ToolWindow).__modelerShellConformance!;
391
+ for (const element of tools.injected.splice(0)) element.remove();
392
+ for (const cleanup of tools.cleanups.splice(0)) cleanup();
393
+ };
394
+
395
+ /** Makes the canvas `<svg>` handle `key` (`preventDefault`), as a modeler's own canvas binding would. */
396
+ export const canvasHandlesProbe = ({ root, key }: { root: string; key: string }): boolean => {
397
+ const tools = (window as ToolWindow).__modelerShellConformance!;
398
+ const svg = document.querySelector(root)?.querySelector(".djs-container > svg");
399
+ if (!svg) return false;
400
+ const listener = (event: Event) => {
401
+ if ((event as KeyboardEvent).key === key) event.preventDefault();
402
+ };
403
+ svg.addEventListener("keydown", listener);
404
+ tools.cleanups.push(() => svg.removeEventListener("keydown", listener));
405
+ return true;
406
+ };
407
+
408
+ /** Remembers the root right before `destroy` (its class is gone afterwards) and clears the recorders. */
409
+ export const armCleanupProbe = ({ root }: { root: string }): boolean => {
410
+ const tools = (window as ToolWindow).__modelerShellConformance!;
411
+ const rootElement = document.querySelector<HTMLElement>(root);
412
+ if (rootElement) tools.armedRoot = rootElement;
413
+ tools.errors.splice(0);
414
+ tools.keys.splice(0);
415
+ tools.documentKeys.splice(0);
416
+ return Boolean(rootElement);
417
+ };
418
+
419
+ /** What the shell left behind after `destroy`. */
420
+ export interface LeftoverFacts {
421
+ /** Elements (other than `<html>`) with a `cd-shell*` class. */
422
+ classedElements: string[];
423
+ /** `cd-shell*` classes on `<html>`. */
424
+ documentClasses: string[];
425
+ /** Elements still carrying `data-chrome`. */
426
+ chromeElements: string[];
427
+ /** The remembered root's `class` and attributes. */
428
+ rootClasses: string[];
429
+ rootAttributes: string[];
430
+ errors: string[];
431
+ }
432
+
433
+ export const leftoverProbe = (_: null): LeftoverFacts => {
434
+ const tools = (window as ToolWindow).__modelerShellConformance!;
435
+ const ours = (element: Element) => element.hasAttribute("data-modeler-shell-conformance");
436
+ const shellClass = (name: string) => name.startsWith("cd-shell");
437
+ const root = tools.armedRoot;
438
+ return {
439
+ classedElements: [...document.querySelectorAll("[class]")]
440
+ .filter((element) => element !== document.documentElement && !ours(element))
441
+ .filter((element) => [...element.classList].some(shellClass))
442
+ .map((element) => tools.describe(element)),
443
+ documentClasses: [...document.documentElement.classList].filter(shellClass),
444
+ chromeElements: [...document.querySelectorAll("[data-chrome]")].map((element) => tools.describe(element)),
445
+ rootClasses: root ? [...root.classList].filter(shellClass) : [],
446
+ rootAttributes: root ? root.getAttributeNames() : [],
447
+ errors: [...tools.errors],
448
+ };
449
+ };
450
+
451
+ /** Puts a foreign page's `<html>` in a mode, as a themed host page around an embedded modeler. */
452
+ export const hostPageModeProbe = ({ theme, kontrast }: { theme: string; kontrast: string | null }): void => {
453
+ const html = document.documentElement;
454
+ html.setAttribute("data-cd-theme", theme);
455
+ if (kontrast) html.setAttribute("data-cd-kontrast", kontrast);
456
+ else html.removeAttribute("data-cd-kontrast");
457
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The part of Playwright's `Page` the kit drives, typed structurally: the kit has no runtime
3
+ * dependency on Playwright, the consumer's e2e suite passes its own `page`.
4
+ */
5
+ export interface PageLike {
6
+ evaluate<R, Arg>(pageFunction: (arg: Arg) => R | Promise<R>, arg: Arg): Promise<R>;
7
+ setViewportSize(size: { width: number; height: number }): Promise<void>;
8
+ keyboard: { press(key: string): Promise<void> };
9
+ mouse: {
10
+ click(x: number, y: number): Promise<void>;
11
+ dblclick(x: number, y: number): Promise<void>;
12
+ move(x: number, y: number): Promise<void>;
13
+ down(): Promise<void>;
14
+ up(): Promise<void>;
15
+ };
16
+ waitForTimeout(timeout: number): Promise<void>;
17
+ /**
18
+ * Playwright's `page.emulateMedia`; the web-host cases need it for the system's mode and contrast
19
+ * (`appearance`) and report a skip with `hook: "open"` without it.
20
+ */
21
+ emulateMedia?(options: {
22
+ colorScheme?: "light" | "dark" | "no-preference" | null;
23
+ contrast?: "more" | "no-preference" | null;
24
+ reducedMotion?: "reduce" | "no-preference" | null;
25
+ forcedColors?: "active" | "none" | null;
26
+ }): Promise<void>;
27
+ }