@miragon/modeler-shell 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/API.md +1902 -0
  2. package/LICENSE +21 -0
  3. package/README.md +435 -2
  4. package/THIRD_PARTY_NOTICES.md +52 -0
  5. package/dist/appearance-D3FUcbu6.d.cts +53 -0
  6. package/dist/appearance-D3FUcbu6.d.ts +53 -0
  7. package/dist/chunk-5JSZ4VGA.js +551 -0
  8. package/dist/chunk-5JSZ4VGA.js.map +1 -0
  9. package/dist/chunk-6LQOFACK.js +1576 -0
  10. package/dist/chunk-6LQOFACK.js.map +1 -0
  11. package/dist/chunk-N4QXFF3A.js +23 -0
  12. package/dist/chunk-N4QXFF3A.js.map +1 -0
  13. package/dist/chunk-RJRFR6N3.js +136 -0
  14. package/dist/chunk-RJRFR6N3.js.map +1 -0
  15. package/dist/core.cjs +619 -0
  16. package/dist/core.cjs.map +1 -0
  17. package/dist/core.d.cts +171 -0
  18. package/dist/core.d.ts +171 -0
  19. package/dist/core.js +91 -0
  20. package/dist/core.js.map +1 -0
  21. package/dist/diagram-js.cjs +1115 -0
  22. package/dist/diagram-js.cjs.map +1 -0
  23. package/dist/diagram-js.d.cts +163 -0
  24. package/dist/diagram-js.d.ts +163 -0
  25. package/dist/diagram-js.js +867 -0
  26. package/dist/diagram-js.js.map +1 -0
  27. package/dist/index.cjs +2175 -0
  28. package/dist/index.cjs.map +1 -0
  29. package/dist/index.d.cts +20 -0
  30. package/dist/index.d.ts +20 -0
  31. package/dist/index.js +103 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/keymap-CObQE6q6.d.cts +151 -0
  34. package/dist/keymap-DORzHnK-.d.ts +151 -0
  35. package/dist/protocol-DhYBakWL.d.cts +15 -0
  36. package/dist/protocol-IeW8bf4f.d.ts +15 -0
  37. package/dist/shell.css +914 -0
  38. package/dist/testing-vscode-stub.cjs +332 -0
  39. package/dist/testing-vscode-stub.cjs.map +1 -0
  40. package/dist/testing-vscode-stub.d.cts +65 -0
  41. package/dist/testing-vscode-stub.d.ts +65 -0
  42. package/dist/testing-vscode-stub.js +154 -0
  43. package/dist/testing-vscode-stub.js.map +1 -0
  44. package/dist/testing.cjs +2364 -0
  45. package/dist/testing.cjs.map +1 -0
  46. package/dist/testing.d.cts +165 -0
  47. package/dist/testing.d.ts +165 -0
  48. package/dist/testing.js +2297 -0
  49. package/dist/testing.js.map +1 -0
  50. package/dist/types-B6NfkiUG.d.cts +66 -0
  51. package/dist/types-B6NfkiUG.d.ts +66 -0
  52. package/dist/types-DnZn9CPw.d.ts +284 -0
  53. package/dist/types-OuFlqZp0.d.cts +284 -0
  54. package/dist/vscode-extension.cjs +215 -0
  55. package/dist/vscode-extension.cjs.map +1 -0
  56. package/dist/vscode-extension.d.cts +103 -0
  57. package/dist/vscode-extension.d.ts +103 -0
  58. package/dist/vscode-extension.js +24 -0
  59. package/dist/vscode-extension.js.map +1 -0
  60. package/dist/vscode-webview.cjs +2198 -0
  61. package/dist/vscode-webview.cjs.map +1 -0
  62. package/dist/vscode-webview.d.cts +82 -0
  63. package/dist/vscode-webview.d.ts +82 -0
  64. package/dist/vscode-webview.js +250 -0
  65. package/dist/vscode-webview.js.map +1 -0
  66. package/package.json +145 -4
  67. package/src/core/appearance.ts +104 -0
  68. package/src/core/fit.ts +205 -0
  69. package/src/core/index.ts +84 -0
  70. package/src/core/keymap.ts +275 -0
  71. package/src/core/layout.ts +69 -0
  72. package/src/core/messages.ts +88 -0
  73. package/src/core/sizes.ts +85 -0
  74. package/src/core/types.ts +84 -0
  75. package/src/core/zoom.ts +74 -0
  76. package/src/core.ts +2 -0
  77. package/src/diagram-js/connect.ts +75 -0
  78. package/src/diagram-js/index.ts +16 -0
  79. package/src/diagram-js/module.ts +20 -0
  80. package/src/diagram-js/palette-skin.ts +267 -0
  81. package/src/diagram-js/palette.css +269 -0
  82. package/src/diagram-js/service.ts +240 -0
  83. package/src/diagram-js/types.ts +67 -0
  84. package/src/diagram-js/viewport.ts +564 -0
  85. package/src/diagram-js.ts +2 -0
  86. package/src/dom/element.ts +51 -0
  87. package/src/dom/hint.ts +51 -0
  88. package/src/dom/icons.ts +309 -0
  89. package/src/dom/index.ts +29 -0
  90. package/src/dom/inspector.ts +35 -0
  91. package/src/dom/keys.ts +59 -0
  92. package/src/dom/ledger.ts +87 -0
  93. package/src/dom/legend.ts +85 -0
  94. package/src/dom/menu.ts +393 -0
  95. package/src/dom/mount.ts +461 -0
  96. package/src/dom/problems.ts +72 -0
  97. package/src/dom/shell-dom.css +966 -0
  98. package/src/dom/status.ts +72 -0
  99. package/src/dom/tooltip.ts +237 -0
  100. package/src/dom/types.ts +277 -0
  101. package/src/dom/zoom-panel.ts +154 -0
  102. package/src/index.ts +3 -0
  103. package/src/shell.css +6 -0
  104. package/src/testing/cases.ts +1012 -0
  105. package/src/testing/checks.ts +832 -0
  106. package/src/testing/facts.ts +587 -0
  107. package/src/testing/geometry.ts +472 -0
  108. package/src/testing/harness.ts +138 -0
  109. package/src/testing/index.ts +15 -0
  110. package/src/testing/page-tools.ts +434 -0
  111. package/src/testing/page.ts +17 -0
  112. package/src/testing/vscode-stub.ts +237 -0
  113. package/src/testing-vscode-stub.ts +2 -0
  114. package/src/testing.ts +2 -0
  115. package/src/vscode/extension/html.ts +107 -0
  116. package/src/vscode/extension/index.ts +8 -0
  117. package/src/vscode/extension/reveal.ts +9 -0
  118. package/src/vscode/extension/theme-bridge.ts +85 -0
  119. package/src/vscode/extension/types.ts +36 -0
  120. package/src/vscode/protocol.ts +31 -0
  121. package/src/vscode/webview/appearance.ts +141 -0
  122. package/src/vscode/webview/index.ts +8 -0
  123. package/src/vscode/webview/mount.ts +76 -0
  124. package/src/vscode/webview/state.ts +116 -0
  125. package/src/vscode-extension.ts +2 -0
  126. package/src/vscode-webview.ts +2 -0
@@ -0,0 +1,472 @@
1
+ import {
2
+ CHROME_ISLANDS,
3
+ PALETTE_CLEARANCE,
4
+ SHELL,
5
+ type ChromeIsland,
6
+ type Insets,
7
+ type PaletteRow,
8
+ type ShellHost,
9
+ type Size,
10
+ } from "../core/index.js";
11
+ import type { PageLike } from "./page.js";
12
+ import { inPage, type RelativeBox, type ToolWindow } from "./page-tools.js";
13
+
14
+ /** Every island box must match within this many pixels (guide §13.4). */
15
+ export const GEOMETRY_TOLERANCE_PX = 1;
16
+
17
+ /**
18
+ * `getBoundingClientRect()` relative to the shell root's padding box, where its absolutely
19
+ * positioned islands anchor (a border on the root, as in a framed foreign page, does not count).
20
+ */
21
+ export interface IslandBox {
22
+ left: number;
23
+ top: number;
24
+ width: number;
25
+ height: number;
26
+ }
27
+
28
+ export interface ChromeMeasurement {
29
+ container: Size;
30
+ /** From the root's `data-palette` attribute. */
31
+ paletteRow: PaletteRow;
32
+ /** The visible islands, found by `[data-chrome]`. */
33
+ islands: Partial<Record<ChromeIsland, IslandBox>>;
34
+ /** `[data-legend="open"]`, `[data-inspector="open"]`, `[data-problems="shown"]` on the root. */
35
+ open: { legend: boolean; inspector: boolean; problems: boolean };
36
+ }
37
+
38
+ /** Constraints of one island; every given field must hold within `GEOMETRY_TOLERANCE_PX`. */
39
+ export interface ExpectedBox {
40
+ left?: number;
41
+ top?: number;
42
+ right?: number;
43
+ bottom?: number;
44
+ width?: number;
45
+ height?: number;
46
+ /** Horizontal centre relative to the root. */
47
+ centerX?: number;
48
+ }
49
+
50
+ export const DEFAULT_ROOT = ".cd-shell";
51
+
52
+ /** `[−] [NN%] [+] | [Fit]` with a 56px readout (guide §5.1, §5.8): 1+3+32+2+56+2+32+2+5+2+32+3+1. */
53
+ export const ZOOM_PANEL_WIDTH = 173;
54
+
55
+ /** Height of the web app's legal line under the bottom-left stack (guide §5.10). */
56
+ const LEGAL_LINE = 20;
57
+
58
+ /** Minimum width of the legend card (guide §5.10). */
59
+ const LEGEND_MIN_WIDTH = 184;
60
+
61
+ const STATUS_MAX_WIDTH = 560;
62
+ const DROPDOWN_MAX_WIDTH = 320;
63
+ const TOOLTIP_MAX_WIDTH = 280;
64
+ const SHEET_MAX_HEIGHT_SHARE = 0.45;
65
+
66
+ type MeasureResult = { error: string } | (ChromeMeasurement & { duplicates: string[] });
67
+
68
+ const measureProbe = ({ root, islands }: { root: string; islands: readonly string[] }): MeasureResult => {
69
+ const tools = (window as ToolWindow).__modelerShellConformance!;
70
+ const rootElement = document.querySelector<HTMLElement>(root);
71
+ if (!rootElement) return { error: `no element matches the shell root selector "${root}"` };
72
+ const boxes: Partial<Record<string, RelativeBox>> = {};
73
+ const duplicates: string[] = [];
74
+ for (const element of rootElement.querySelectorAll("[data-chrome]")) {
75
+ const name = element.getAttribute("data-chrome") ?? "";
76
+ if (!islands.includes(name) || !tools.visible(element)) continue;
77
+ if (boxes[name]) {
78
+ duplicates.push(name);
79
+ continue;
80
+ }
81
+ boxes[name] = tools.box(element, rootElement);
82
+ }
83
+ return {
84
+ container: tools.container(rootElement),
85
+ paletteRow: rootElement.getAttribute("data-palette") === "second-row" ? "second-row" : "top",
86
+ islands: boxes,
87
+ open: {
88
+ legend: rootElement.getAttribute("data-legend") === "open",
89
+ inspector: rootElement.getAttribute("data-inspector") === "open",
90
+ problems: rootElement.getAttribute("data-problems") === "shown",
91
+ },
92
+ duplicates,
93
+ };
94
+ };
95
+
96
+ /** Measures the islands of the shell under `root` (default `.cd-shell`) in the page. */
97
+ export async function measureChrome(page: PageLike, root: string = DEFAULT_ROOT): Promise<ChromeMeasurement> {
98
+ const result = await inPage(page, measureProbe, { root, islands: CHROME_ISLANDS });
99
+ if ("error" in result) throw new Error(`@miragon/modeler-shell conformance: ${result.error}`);
100
+ if (result.duplicates.length > 0) {
101
+ const names = [...new Set(result.duplicates)].map((name) => `[data-chrome="${name}"]`).join(", ");
102
+ throw new Error(`@miragon/modeler-shell conformance: more than one visible ${names}`);
103
+ }
104
+ return {
105
+ container: result.container,
106
+ paletteRow: result.paletteRow,
107
+ islands: result.islands,
108
+ open: result.open,
109
+ };
110
+ }
111
+
112
+ /** Upper and lower limits the guide gives as "at least" and "at most" (guide §5.4 to §5.15). */
113
+ export interface BoxLimits {
114
+ minWidth?: number;
115
+ maxWidth?: number;
116
+ maxHeight?: number;
117
+ minLeft?: number;
118
+ minRight?: number;
119
+ minBottom?: number;
120
+ }
121
+
122
+ /** What the guide expects of the chrome in a measured state. */
123
+ export interface ChromeExpectations {
124
+ boxes: Partial<Record<ChromeIsland, ExpectedBox>>;
125
+ limits: Partial<Record<ChromeIsland, BoxLimits>>;
126
+ /** Islands that must be visible. */
127
+ required: ChromeIsland[];
128
+ /** Islands that must not be visible, with the rule that hides them. */
129
+ hidden: Partial<Record<ChromeIsland, string>>;
130
+ }
131
+
132
+ function isNarrow(container: Size): boolean {
133
+ return container.width <= SHELL.narrow;
134
+ }
135
+
136
+ function isVeryNarrow(container: Size): boolean {
137
+ return container.width <= SHELL.veryNarrow;
138
+ }
139
+
140
+ /** The open inspector is a bottom sheet up to `SHELL.narrow` (guide §5.11). */
141
+ function sheetOpen(measurement: ChromeMeasurement): boolean {
142
+ return measurement.open.inspector && isNarrow(measurement.container);
143
+ }
144
+
145
+ /** The anchors and limits of guide §5.2 to §5.15 for `measurement` in `host`. */
146
+ export function chromeExpectations(measurement: ChromeMeasurement, host: ShellHost): ChromeExpectations {
147
+ const { container, islands, open, paletteRow } = measurement;
148
+ const { edge, gap, row, free, inspectorWidth } = SHELL;
149
+ const web = host === "web";
150
+ const secondRow = paletteRow === "second-row";
151
+ const sheet = sheetOpen(measurement);
152
+ const sideInspector = open.inspector && !isNarrow(container);
153
+ const boxes: Partial<Record<ChromeIsland, ExpectedBox>> = {};
154
+ const limits: Partial<Record<ChromeIsland, BoxLimits>> = {};
155
+ const hidden: Partial<Record<ChromeIsland, string>> = {};
156
+ const required: ChromeIsland[] = ["menu", "palette"];
157
+
158
+ boxes.menu = { left: edge, top: edge, width: row, height: row };
159
+
160
+ boxes.palette = { top: secondRow ? free : edge, height: row, centerX: container.width / 2 };
161
+ const menu = islands.menu;
162
+ limits.palette = secondRow
163
+ ? { maxWidth: container.width - 2 * edge }
164
+ : menu
165
+ ? { minLeft: menu.left + menu.width + PALETTE_CLEARANCE }
166
+ : {};
167
+
168
+ if (sheet) {
169
+ const reason = "hidden while the inspector is a bottom sheet (guide §5.11)";
170
+ hidden.zoom = reason;
171
+ hidden.problems = reason;
172
+ hidden.legend = reason;
173
+ } else {
174
+ required.push("zoom");
175
+ boxes.zoom = { right: edge, bottom: edge, width: ZOOM_PANEL_WIDTH, height: row };
176
+ const stackBase = web ? edge + LEGAL_LINE + gap : edge;
177
+ if (islands.problems) boxes.problems = { left: edge, bottom: stackBase, height: row };
178
+ if (islands.legend) {
179
+ const besidePill = open.problems ? stackBase + row + gap : stackBase;
180
+ const bottom = isVeryNarrow(container) ? Math.max(besidePill, edge + row + gap) : besidePill;
181
+ boxes.legend = { left: edge, bottom };
182
+ limits.legend = { minWidth: LEGEND_MIN_WIDTH };
183
+ }
184
+ }
185
+
186
+ if (islands.inspector) {
187
+ if (sheet) {
188
+ boxes.inspector = { left: edge, right: edge, bottom: edge };
189
+ limits.inspector = { maxHeight: container.height * SHEET_MAX_HEIGHT_SHARE };
190
+ } else {
191
+ boxes.inspector = { top: free, right: edge, width: inspectorWidth };
192
+ limits.inspector = { minBottom: free };
193
+ }
194
+ }
195
+
196
+ if (islands.status) {
197
+ const strip = sideInspector ? container.width - edge - inspectorWidth : container.width;
198
+ boxes.status = { top: secondRow ? free + row + edge : free, centerX: strip / 2 };
199
+ limits.status = {
200
+ minLeft: edge,
201
+ maxWidth: Math.min(STATUS_MAX_WIDTH, container.width - 2 * edge),
202
+ };
203
+ }
204
+
205
+ if (web) {
206
+ if (islands.primary) boxes.primary = { top: edge, right: edge, height: row };
207
+ } else {
208
+ hidden.primary = "the top right stays empty in IDE and embedded hosts (guide §5.7)";
209
+ }
210
+
211
+ return { boxes, limits, required, hidden };
212
+ }
213
+
214
+ /** The boxes guide §5.2–§5.12 expects for what was measured (anchors per host and breakpoint). */
215
+ export function expectedChromeBoxes(
216
+ measurement: ChromeMeasurement,
217
+ host: ShellHost,
218
+ ): Partial<Record<ChromeIsland, ExpectedBox>> {
219
+ return chromeExpectations(measurement, host).boxes;
220
+ }
221
+
222
+ const round = (value: number): number => Math.round(value * 10) / 10;
223
+
224
+ function sides(box: IslandBox, container: Size): Required<ExpectedBox> {
225
+ return {
226
+ left: box.left,
227
+ top: box.top,
228
+ right: container.width - box.left - box.width,
229
+ bottom: container.height - box.top - box.height,
230
+ width: box.width,
231
+ height: box.height,
232
+ centerX: box.left + box.width / 2,
233
+ };
234
+ }
235
+
236
+ /** Every field of `expected` that `box` misses by more than `tolerance`, as readable problems. */
237
+ export function boxMismatches(
238
+ name: string,
239
+ box: IslandBox,
240
+ expected: ExpectedBox,
241
+ container: Size,
242
+ tolerance: number = GEOMETRY_TOLERANCE_PX,
243
+ ): string[] {
244
+ const actual = sides(box, container);
245
+ return (Object.keys(expected) as Array<keyof ExpectedBox>).flatMap((field) => {
246
+ const want = expected[field];
247
+ if (want === undefined) return [];
248
+ const have = actual[field];
249
+ return Math.abs(have - want) > tolerance + 1e-6
250
+ ? [`${name}: ${field} is ${round(have)}px, expected ${round(want)}px (±${tolerance}px)`]
251
+ : [];
252
+ });
253
+ }
254
+
255
+ /** Every limit `box` breaks by more than `tolerance`. */
256
+ export function limitViolations(
257
+ name: string,
258
+ box: IslandBox,
259
+ limits: BoxLimits,
260
+ container: Size,
261
+ tolerance: number = GEOMETRY_TOLERANCE_PX,
262
+ ): string[] {
263
+ const actual = sides(box, container);
264
+ const problems: string[] = [];
265
+ const atLeast = (field: string, have: number, limit: number | undefined) => {
266
+ if (limit !== undefined && have < limit - tolerance - 1e-6) {
267
+ problems.push(`${name}: ${field} is ${round(have)}px, at least ${round(limit)}px expected`);
268
+ }
269
+ };
270
+ const atMost = (field: string, have: number, limit: number | undefined) => {
271
+ if (limit !== undefined && have > limit + tolerance + 1e-6) {
272
+ problems.push(`${name}: ${field} is ${round(have)}px, at most ${round(limit)}px expected`);
273
+ }
274
+ };
275
+ atLeast("width", actual.width, limits.minWidth);
276
+ atMost("width", actual.width, limits.maxWidth);
277
+ atMost("height", actual.height, limits.maxHeight);
278
+ atLeast("left", actual.left, limits.minLeft);
279
+ atLeast("right", actual.right, limits.minRight);
280
+ atLeast("bottom", actual.bottom, limits.minBottom);
281
+ return problems;
282
+ }
283
+
284
+ /**
285
+ * Every deviation of a measured state from guide §5.2 to §5.15: missing and unexpected islands,
286
+ * boxes and limits, the palette row decision and the root's state attributes.
287
+ */
288
+ export function geometryProblems(
289
+ measurement: ChromeMeasurement,
290
+ host: ShellHost,
291
+ tolerance: number = GEOMETRY_TOLERANCE_PX,
292
+ ): string[] {
293
+ const { container, islands, open, paletteRow } = measurement;
294
+ const expectations = chromeExpectations(measurement, host);
295
+ const problems: string[] = [];
296
+ const sheet = sheetOpen(measurement);
297
+
298
+ for (const name of expectations.required) {
299
+ if (!islands[name]) problems.push(`${name}: [data-chrome="${name}"] is missing or not visible`);
300
+ }
301
+ for (const name of CHROME_ISLANDS) {
302
+ const box = islands[name];
303
+ if (!box) continue;
304
+ const expected = expectations.boxes[name];
305
+ if (!expected) {
306
+ const reason = expectations.hidden[name] ?? "no anchor of the guide applies";
307
+ problems.push(`${name}: visible, but ${reason}`);
308
+ continue;
309
+ }
310
+ problems.push(...boxMismatches(name, box, expected, container, tolerance));
311
+ const limits = expectations.limits[name];
312
+ if (limits) problems.push(...limitViolations(name, box, limits, container, tolerance));
313
+ }
314
+
315
+ if (isNarrow(container) && paletteRow !== "second-row") {
316
+ problems.push(
317
+ `palette: data-palette="${paletteRow}" in a ${round(container.width)}px wide container; up to ${SHELL.narrow}px it is "second-row" (guide §5.15)`,
318
+ );
319
+ }
320
+ const palette = islands.palette;
321
+ const menu = islands.menu;
322
+ if (paletteRow === "second-row" && !isNarrow(container) && palette && menu) {
323
+ const clipped = palette.width >= container.width - 2 * SHELL.edge - tolerance;
324
+ const topRowLeft = (container.width - palette.width) / 2;
325
+ if (!clipped && topRowLeft >= menu.left + menu.width + PALETTE_CLEARANCE + tolerance) {
326
+ problems.push(
327
+ `palette: in the second row although the top row keeps ${round(topRowLeft - menu.left - menu.width)}px to the menu trigger (at least ${PALETTE_CLEARANCE}px suffice, guide §5.15)`,
328
+ );
329
+ }
330
+ }
331
+
332
+ const state = (island: ChromeIsland, flag: boolean, attribute: string, hiddenBySheet: boolean) => {
333
+ const shown = Boolean(islands[island]);
334
+ if (flag && !shown && !hiddenBySheet) {
335
+ problems.push(`${island}: the root carries ${attribute}, but the island is not visible`);
336
+ }
337
+ if (!flag && shown) problems.push(`${island}: visible, but the root lacks ${attribute}`);
338
+ };
339
+ state("problems", open.problems, 'data-problems="shown"', sheet);
340
+ state("legend", open.legend, 'data-legend="open"', sheet);
341
+ state("inspector", open.inspector, 'data-inspector="open"', false);
342
+
343
+ return problems;
344
+ }
345
+
346
+ /**
347
+ * The fit insets of guide §5.9 for what was measured, recomputed from the guide's table: top 88
348
+ * (second row 140), sides 24, bottom 64 (web app with the pill 92); right 12 + 288 + 24 with a side
349
+ * inspector; left 12 + legend width + 24 with an open legend in a container wider than 440px; with
350
+ * a bottom sheet the bottom is the distance to its top edge plus 12, and the legend does not count.
351
+ */
352
+ export function expectedFitInsets(measurement: ChromeMeasurement, host: ShellHost): Insets {
353
+ const { container, islands, open, paletteRow } = measurement;
354
+ const insets: Insets = {
355
+ top: paletteRow === "second-row" ? SHELL.fitInsetTopSecondRow : SHELL.fitInsetTop,
356
+ right: SHELL.fitInsetSide,
357
+ bottom: host === "web" && open.problems ? SHELL.fitInsetBottomWeb : SHELL.fitInsetBottom,
358
+ left: SHELL.fitInsetSide,
359
+ };
360
+ if (sheetOpen(measurement)) {
361
+ const sheet = islands.inspector;
362
+ insets.bottom = sheet ? container.height - sheet.top + SHELL.edge : SHELL.fitInsetBottom;
363
+ return insets;
364
+ }
365
+ if (open.inspector) insets.right = SHELL.edge + SHELL.inspectorWidth + SHELL.fitInsetSide;
366
+ const legend = islands.legend;
367
+ if (open.legend && legend && legend.width > 0 && container.width > SHELL.veryNarrow) {
368
+ insets.left = SHELL.edge + legend.width + SHELL.fitInsetSide;
369
+ }
370
+ return insets;
371
+ }
372
+
373
+ /** A rectangle relative to the shell root's padding box, by its edges. */
374
+ export interface Rectangle {
375
+ left: number;
376
+ top: number;
377
+ right: number;
378
+ bottom: number;
379
+ }
380
+
381
+ /**
382
+ * The free rectangle of guide §5.9: the container minus `expectedFitInsets`. Where the insets
383
+ * leave no room on an axis, that axis spans the whole container (as the fit centres there).
384
+ */
385
+ export function freeRectangle(measurement: ChromeMeasurement, host: ShellHost): Rectangle {
386
+ const { container } = measurement;
387
+ const insets = expectedFitInsets(measurement, host);
388
+ const horizontal = container.width - insets.left - insets.right > 0;
389
+ const vertical = container.height - insets.top - insets.bottom > 0;
390
+ return {
391
+ left: horizontal ? insets.left : 0,
392
+ top: vertical ? insets.top : 0,
393
+ right: horizontal ? container.width - insets.right : container.width,
394
+ bottom: vertical ? container.height - insets.bottom : container.height,
395
+ };
396
+ }
397
+
398
+ /** Where the dropdown opens (guide §5.4): under the trigger, at least 248px, inside the container. */
399
+ export function dropdownProblems(
400
+ box: IslandBox,
401
+ container: Size,
402
+ tolerance = GEOMETRY_TOLERANCE_PX,
403
+ ): string[] {
404
+ const { edge, gap, row, menuMinWidth } = SHELL;
405
+ return [
406
+ ...boxMismatches("dropdown", box, { top: edge + row + gap, left: edge }, container, tolerance),
407
+ ...limitViolations(
408
+ "dropdown",
409
+ box,
410
+ {
411
+ minWidth: menuMinWidth,
412
+ maxWidth: Math.min(DROPDOWN_MAX_WIDTH, container.width - 2 * edge),
413
+ minBottom: edge,
414
+ },
415
+ container,
416
+ tolerance,
417
+ ),
418
+ ];
419
+ }
420
+
421
+ /** Which island a tooltip belongs to decides its row (guide §5.6). */
422
+ export type TooltipRow = "top" | "second-row" | "bottom";
423
+
424
+ /**
425
+ * Where the chrome tooltip of a control stands (guide §5.6): 8px below the top row (`top: 60px`)
426
+ * or the second row (`top: 112px`), 8px above the bottom row (`bottom: 60px`); centred on its
427
+ * control and shifted to keep 12px to the sides.
428
+ */
429
+ export function tooltipProblems(
430
+ tooltip: IslandBox,
431
+ anchor: IslandBox,
432
+ row: TooltipRow,
433
+ container: Size,
434
+ tolerance = GEOMETRY_TOLERANCE_PX,
435
+ ): string[] {
436
+ const { edge, gap, row: rowHeight, free } = SHELL;
437
+ const clamp = (centred: number) =>
438
+ Math.max(edge, Math.min(centred, container.width - edge - tooltip.width));
439
+ const expectedAt = (left: number): ExpectedBox =>
440
+ row === "bottom"
441
+ ? { left, bottom: edge + rowHeight + gap }
442
+ : { left, top: (row === "top" ? edge : free) + rowHeight + gap };
443
+ let mismatches = boxMismatches(
444
+ "tooltip",
445
+ tooltip,
446
+ expectedAt(clamp(anchor.left + anchor.width / 2 - tooltip.width / 2)),
447
+ container,
448
+ tolerance,
449
+ );
450
+ // Guide §5.15 reads "mittig im Container" for the second row; until the guide settles whether
451
+ // that means the container or the entry, either centre conforms.
452
+ if (row === "second-row" && mismatches.length > 0) {
453
+ const inContainer = boxMismatches(
454
+ "tooltip",
455
+ tooltip,
456
+ expectedAt(clamp(container.width / 2 - tooltip.width / 2)),
457
+ container,
458
+ tolerance,
459
+ );
460
+ if (inContainer.length === 0) mismatches = inContainer;
461
+ }
462
+ return [
463
+ ...mismatches,
464
+ ...limitViolations(
465
+ "tooltip",
466
+ tooltip,
467
+ { minLeft: edge, minRight: edge, maxWidth: Math.min(TOOLTIP_MAX_WIDTH, container.width - 2 * edge) },
468
+ container,
469
+ tolerance,
470
+ ),
471
+ ];
472
+ }
@@ -0,0 +1,138 @@
1
+ import type { Appearance, ColorThemeSetting, ShellHost, Size, Theme } 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.
12
+ */
13
+ requested: Appearance;
14
+ }
15
+
16
+ /**
17
+ * How the kit makes the optional islands visible (`geometry`, `aria`) and puts the modeler into its
18
+ * invalid-file state (`readOnly`).
19
+ */
20
+ export interface ShowHooks<P extends PageLike = PageLike> {
21
+ problems?: ((page: P) => Promise<void>) | undefined;
22
+ status?: ((page: P) => Promise<void>) | undefined;
23
+ legend?: ((page: P) => Promise<void>) | undefined;
24
+ inspector?: ((page: P) => Promise<void>) | undefined;
25
+ /**
26
+ * Puts the modeler into its invalid-file state the way its host does (an invalid import): a
27
+ * read-only canvas and the status box. The kit waits `INVALID_STATUS_DELAY_MS` afterwards.
28
+ */
29
+ invalid?: ((page: P) => Promise<void>) | undefined;
30
+ }
31
+
32
+ /** A show hook by name: the islands `legend`, `inspector`, `problems`, `status` and the state `invalid`. */
33
+ export type ShowHook = keyof ShowHooks;
34
+
35
+ export interface ShellConformanceHarness<P extends PageLike = PageLike> {
36
+ host: ShellHost;
37
+ /** The modes the modeler declares (static); the appearance case requests dark from `["light"]`. */
38
+ themes: readonly Theme[];
39
+ /** Load the modeler with the shell at `size`, as the host would with `requested`; resolve once the first import is done. */
40
+ open(page: P, context: OpenContext): Promise<void>;
41
+ /** Tear modeler and shell down through the page's own path; `undefined` skips the cleanup case. */
42
+ destroy?: ((page: P) => Promise<void>) | undefined;
43
+ /** CSS selector of the shell root; default `.cd-shell`. */
44
+ root?: string | undefined;
45
+ /** Hooks for the islands and states the modeler has; one that is neither given nor `absent` is reported as skipped. */
46
+ show?: ShowHooks<P> | undefined;
47
+ /**
48
+ * The islands and states the modeler does not have. An absent hook is not reported as skipped,
49
+ * and `geometry` and `aria` assert that its island is never visible. Listing a hook that `show`
50
+ * also gives fails every case that uses it.
51
+ */
52
+ absent?: readonly ShowHook[] | undefined;
53
+ /** Container sizes of the geometry case; default wide 1280 × 900, narrow 720 × 700, very narrow 420 × 700. */
54
+ sizes?: readonly Size[] | undefined;
55
+ }
56
+
57
+ /** One part of a case the harness could not feed. */
58
+ export interface ShellSkip {
59
+ /**
60
+ * The harness field that would feed it: a show hook, `destroy`, or `open` when the document it
61
+ * opens lacks what the case needs (a shape on the canvas).
62
+ */
63
+ hook: ShowHook | "destroy" | "open";
64
+ reason: string;
65
+ }
66
+
67
+ export interface ShellCaseOutcome {
68
+ /** What the case could not (fully) run, one entry per missing harness field. */
69
+ skipped?: readonly ShellSkip[];
70
+ }
71
+
72
+ export interface ShellConformanceCase {
73
+ name: string;
74
+ run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>): Promise<ShellCaseOutcome | void>;
75
+ }
76
+
77
+ /**
78
+ * The cases: `geometry` (every `data-chrome` island ±1px, per host and breakpoint), `aria`
79
+ * (roles, names, `aria-expanded`/`-controls`/`-keyshortcuts`/`-disabled`, DOM order, no positive
80
+ * tabindex), `keys` (each chrome key acts exactly once; in the IDE host a `keydown` listener on
81
+ * `window` never fires for a handled key; unmodified `+`/`-` never zoom; select all acts on the
82
+ * canvas only), `create` (Enter on the first creating palette entry places one selected element in
83
+ * the free rectangle and focuses the canvas), `readOnly` (the invalid-file lock of guide §13.5),
84
+ * `appearance` (dark requested from a light-only modeler: nothing turns dark, `color-scheme` light
85
+ * everywhere, the canvas surround and the palette's tool colours from the roles) and `cleanup` (after
86
+ * `destroy` no shell element, class, attribute or key handling remains).
87
+ */
88
+ export function shellConformanceCases(): ShellConformanceCase[] {
89
+ return createCases();
90
+ }
91
+
92
+ /** Playwright's `test` (or any runner whose body receives `{ page }`). */
93
+ export type ShellConformanceTest<P extends PageLike = PageLike> = (
94
+ name: string,
95
+ body: (fixtures: { page: P }) => Promise<void>,
96
+ ) => unknown;
97
+
98
+ export interface RegisterShellConformanceOptions {
99
+ /** Default: `console.warn`, one line per case with its skips joined by "; ". */
100
+ onSkip?: ((caseName: string, skips: readonly ShellSkip[]) => void) | undefined;
101
+ }
102
+
103
+ /**
104
+ * The query string for a test page that installs the VS Code stub of
105
+ * `@miragon/modeler-shell/testing/vscode-stub` with `stubOptionsFromSearch(location.search)`:
106
+ * `theme=dark&kontrast=hoch`, plus the `colorTheme` setting and VS Code's reduce motion.
107
+ */
108
+ export function vsCodeStubSearch(
109
+ requested: Appearance,
110
+ extra: { colorTheme?: ColorThemeSetting | undefined; reduceMotion?: boolean | undefined } = {},
111
+ ): string {
112
+ const query = new URLSearchParams({ theme: requested.theme });
113
+ if (requested.kontrast) query.set("kontrast", requested.kontrast);
114
+ if (extra.colorTheme) query.set("colorTheme", extra.colorTheme);
115
+ if (extra.reduceMotion) query.set("reduceMotion", "");
116
+ return query.toString();
117
+ }
118
+
119
+ /** Registers every case as `shell conformance: <name>`. */
120
+ export function registerShellConformance<P extends PageLike>(
121
+ harness: ShellConformanceHarness<P>,
122
+ test: ShellConformanceTest<P>,
123
+ options: RegisterShellConformanceOptions = {},
124
+ ): void {
125
+ const onSkip =
126
+ options.onSkip ??
127
+ ((caseName: string, skips: readonly ShellSkip[]) => {
128
+ console.warn(
129
+ `@miragon/modeler-shell conformance: SKIPPED "${caseName}" (${harness.host} host): ${skips.map((skip) => skip.reason).join("; ")}`,
130
+ );
131
+ });
132
+ for (const conformanceCase of shellConformanceCases()) {
133
+ test(`shell conformance: ${conformanceCase.name}`, async ({ page }) => {
134
+ const outcome = await conformanceCase.run(page, harness);
135
+ if (outcome?.skipped && outcome.skipped.length > 0) onSkip(conformanceCase.name, outcome.skipped);
136
+ });
137
+ }
138
+ }
@@ -0,0 +1,15 @@
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
+ OpenContext,
6
+ RegisterShellConformanceOptions,
7
+ ShellCaseOutcome,
8
+ ShellConformanceCase,
9
+ ShellConformanceHarness,
10
+ ShellConformanceTest,
11
+ ShellSkip,
12
+ ShowHook,
13
+ ShowHooks,
14
+ } from "./harness.js";
15
+ export { registerShellConformance, shellConformanceCases, vsCodeStubSearch } from "./harness.js";