@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,1077 @@
1
+ import {
2
+ APPEARANCE_STORAGE_KEY,
3
+ INVALID_STATUS_DELAY_MS,
4
+ SHELL,
5
+ TOOLTIP_DELAY_MS,
6
+ legalLineHeight,
7
+ type Appearance,
8
+ type AppearanceChoice,
9
+ } from "../core/index.js";
10
+ import {
11
+ absentIslandProblems,
12
+ afterDestroyKeyProblem,
13
+ appearanceProblems,
14
+ ariaProblems,
15
+ createProblems,
16
+ isToolEntry,
17
+ keyReachProblem,
18
+ leftoverProblems,
19
+ lockedShapeProblems,
20
+ menuClosedProblems,
21
+ menuKeysAtDocumentProblems,
22
+ menuOpenProblems,
23
+ optionalIslandProblems,
24
+ paletteColorProblems,
25
+ paletteShortcutProblems,
26
+ readOnlyMenuProblems,
27
+ readOnlyPaletteProblems,
28
+ readOnlyStateProblems,
29
+ surroundProblems,
30
+ tabSequenceProblems,
31
+ type KeyReach,
32
+ type PaletteShortcutFacts,
33
+ type TabStop,
34
+ } from "./checks.js";
35
+ import {
36
+ MODE_ROLES,
37
+ activeStopProbe,
38
+ appearanceProbe,
39
+ ariaProbe,
40
+ canvasStateProbe,
41
+ menuEntriesProbe,
42
+ menuProbe,
43
+ paletteColorProbe,
44
+ paletteEntriesProbe,
45
+ paletteFocusProbe,
46
+ paletteShortcutProbe,
47
+ readOnlyStateProbe,
48
+ surroundProbe,
49
+ zoomButtonsProbe,
50
+ type AppearanceFacts,
51
+ type AriaFacts,
52
+ type MenuFacts,
53
+ type PaletteEntryState,
54
+ type ShapeFacts,
55
+ } from "./facts.js";
56
+ import {
57
+ DEFAULT_ROOT,
58
+ dropdownProblems,
59
+ freeRectangle,
60
+ geometryProblems,
61
+ measureChrome,
62
+ tooltipProblems,
63
+ type ChromeMeasurement,
64
+ type TooltipRow,
65
+ } from "./geometry.js";
66
+ import type { ShellConformanceCase, ShellConformanceHarness, ShellSkip, ToastFixture } from "./harness.js";
67
+ import type { PageLike } from "./page.js";
68
+ import {
69
+ afterPressProbe,
70
+ armCleanupProbe,
71
+ canvasHandlesProbe,
72
+ clearKeysProbe,
73
+ findOverlay,
74
+ findTarget,
75
+ focusTarget,
76
+ hostPageModeProbe,
77
+ inPage,
78
+ injectProbe,
79
+ leftoverProbe,
80
+ removeInjectedProbe,
81
+ settle,
82
+ targetProbe,
83
+ type ChromeTarget,
84
+ type InjectedKind,
85
+ } from "./page-tools.js";
86
+ import {
87
+ appearanceChoiceProblems,
88
+ expectedWebMode,
89
+ lightOnlyMenuProblems,
90
+ menuGroupProblems,
91
+ modeSwitchProblems,
92
+ pillPopupAriaProblems,
93
+ webModeProblems,
94
+ } from "./web-checks.js";
95
+ import { createWebCases } from "./web-cases.js";
96
+ import {
97
+ DEFAULT_SIZES,
98
+ HOVER_MARGIN_MS,
99
+ LIGHT,
100
+ PARK,
101
+ SHOWN_ISLANDS,
102
+ assertHooksConsistent,
103
+ closeByEscape,
104
+ fail,
105
+ finish,
106
+ hover,
107
+ menuEntries,
108
+ openHarness,
109
+ openMenuByClick,
110
+ pollUntil,
111
+ shapes,
112
+ sizeLabel,
113
+ unfed,
114
+ webHookSkips,
115
+ } from "./case-tools.js";
116
+
117
+ export { DEFAULT_SIZES } from "./case-tools.js";
118
+ import { modeChangesProbe, watchModeProbe, webModeProbe, type WebModeFacts } from "./web-facts.js";
119
+
120
+ /** How far the read-only case drags a shape (guide §13.5: no move). */
121
+ const LOCKED_DRAG_PX = 40;
122
+
123
+ /** Room for a modeler that places, selects or re-renders a frame or two after Enter. */
124
+ const CREATE_WAIT_MS = 1000;
125
+
126
+ /** Room after `INVALID_STATUS_DELAY_MS` for the read-only marks and the status box to show. */
127
+ const READ_ONLY_WAIT_MS = 1000;
128
+
129
+ /** Room for the palette's 150ms colour transition (`--cd-motion-fast`) and a frame on either side. */
130
+ const COLOR_WAIT_MS = 600;
131
+
132
+ /** Requested from the modeler by the appearance case (VS Code Dark, High Contrast, High Contrast Light). */
133
+ const REQUESTED_APPEARANCES: readonly Appearance[] = [
134
+ { theme: "dark" },
135
+ { theme: "dark", kontrast: "hoch" },
136
+ { theme: "light", kontrast: "hoch" },
137
+ ];
138
+
139
+ /** Every kind of text field a modeler renders: inspector and dialog fields, the label editor. */
140
+ const TEXT_FIELDS: ReadonlyArray<readonly [InjectedKind, string]> = [
141
+ ["input", "a text field"],
142
+ ["textarea", "a textarea"],
143
+ ["contenteditable", "an editable label"],
144
+ ];
145
+
146
+ const UNDO_REDO_PRESSES = [
147
+ "Control+KeyZ",
148
+ "Meta+KeyZ",
149
+ "Control+Shift+KeyZ",
150
+ "Meta+Shift+KeyZ",
151
+ "Control+KeyY",
152
+ ] as const;
153
+
154
+ const ONE_STEP_UP = 135;
155
+ const TWO_STEPS_UP = 182;
156
+ const ZOOM_LIMIT_MAX = 400;
157
+ const ZOOM_LIMIT_MIN = 20;
158
+
159
+ function appearanceLabel(appearance: Appearance): string {
160
+ return appearance.kontrast ? `${appearance.theme} + ${appearance.kontrast}` : appearance.theme;
161
+ }
162
+
163
+ function missingHooks<P extends PageLike>(harness: ShellConformanceHarness<P>, what: string): ShellSkip[] {
164
+ return SHOWN_ISLANDS.filter((island) => unfed(harness, island)).map((island) => ({
165
+ hook: island,
166
+ reason: `show.${island} is undefined: ${what} of the ${island} island not verified`,
167
+ }));
168
+ }
169
+
170
+ /** The visible islands of an ARIA snapshot that a harness may declare absent. */
171
+ function visibleIslands(facts: AriaFacts): Set<string> {
172
+ const islands = new Set<string>();
173
+ for (const island of SHOWN_ISLANDS) if (facts[island]?.visible) islands.add(island);
174
+ return islands;
175
+ }
176
+
177
+ /** Dropdown and tooltips of one measured state (guide §5.4, §5.6). */
178
+ async function popupGeometry(
179
+ page: PageLike,
180
+ root: string,
181
+ measurement: ChromeMeasurement,
182
+ ): Promise<string[]> {
183
+ const problems: string[] = [];
184
+ if (!(await openMenuByClick(page, root))) return ["dropdown: no visible menu trigger to open it"];
185
+ const dropdown = await findOverlay(page, root, "menu");
186
+ if (!dropdown) problems.push('dropdown: no visible role="menu" after clicking the trigger');
187
+ else problems.push(...dropdownProblems(dropdown.box, measurement.container));
188
+ await closeByEscape(page);
189
+
190
+ const rows: Array<[ChromeTarget, TooltipRow]> = [
191
+ ["menu", "top"],
192
+ ["palette-entry", measurement.paletteRow === "second-row" ? "second-row" : "top"],
193
+ ["zoom-in", "bottom"],
194
+ ];
195
+ for (const [target, row] of rows) {
196
+ const anchor = await hover(page, root, target);
197
+ if (!anchor) {
198
+ problems.push(`tooltip: no visible ${target} to hover`);
199
+ continue;
200
+ }
201
+ const tooltip = await findOverlay(page, root, "tooltip");
202
+ if (!tooltip) {
203
+ problems.push(`tooltip: none visible ${TOOLTIP_DELAY_MS + HOVER_MARGIN_MS}ms after hovering ${target}`);
204
+ continue;
205
+ }
206
+ if (tooltip.count > 1) problems.push(`tooltip: ${tooltip.count} tooltips visible at once`);
207
+ if (anchor.label && !tooltip.text.startsWith(anchor.label)) {
208
+ problems.push(`tooltip: hovering ${target} ("${anchor.label}") shows "${tooltip.text}"`);
209
+ }
210
+ // The web app's raised zoom row stands above the legal line (`data-zoom-row="raised"`).
211
+ const legalHeight = legalLineHeight(measurement.islands.legal?.height);
212
+ const bottomRow = measurement.open.zoomRaised ? SHELL.edge + legalHeight + SHELL.gap : SHELL.edge;
213
+ problems.push(
214
+ ...tooltipProblems(tooltip.box, anchor.box, row, measurement.container, undefined, bottomRow).map(
215
+ (problem) => `${problem} (on ${target})`,
216
+ ),
217
+ );
218
+ }
219
+ await page.mouse.move(PARK.x, PARK.y);
220
+ return problems;
221
+ }
222
+
223
+ /** Shown by the geometry case after "as opened"; its action keeps it on screen through every state. */
224
+ const GEOMETRY_TOAST: ToastFixture = {
225
+ status: "info",
226
+ text: "Conformance: the toast stack's geometry",
227
+ actionLabel: "Undo",
228
+ };
229
+
230
+ /** The pill's list in the web app (and an embedded one that opens it), measured after a click (guide §5.10). */
231
+ async function withProblemsListOpen(
232
+ page: PageLike,
233
+ root: string,
234
+ check: () => Promise<unknown>,
235
+ ): Promise<void> {
236
+ const pill = await findTarget(page, root, "pill");
237
+ if (!pill) return;
238
+ await page.mouse.click(pill.client.x, pill.client.y);
239
+ await settle(page);
240
+ await check();
241
+ await closeByEscape(page);
242
+ }
243
+
244
+ const geometryCase: ShellConformanceCase = {
245
+ name: "geometry",
246
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
247
+ assertHooksConsistent("geometry", harness, [...SHOWN_ISLANDS, "toast", "emptyState"]);
248
+ const root = harness.root ?? DEFAULT_ROOT;
249
+ const problems: string[] = [];
250
+ for (const size of harness.sizes ?? DEFAULT_SIZES) {
251
+ const at = sizeLabel(size);
252
+ await openHarness(page, harness, { size, requested: LIGHT });
253
+ await settle(page);
254
+ const check = async (state: string): Promise<ChromeMeasurement> => {
255
+ const measurement = await measureChrome(page, root);
256
+ problems.push(
257
+ ...[
258
+ ...geometryProblems(measurement, harness.host),
259
+ ...absentIslandProblems(new Set(Object.keys(measurement.islands)), harness.absent),
260
+ ].map((problem) => `[${at}, ${state}] ${problem}`),
261
+ );
262
+ return measurement;
263
+ };
264
+ const opened = await check("as opened");
265
+ problems.push(...(await popupGeometry(page, root, opened)).map((problem) => `[${at}] ${problem}`));
266
+ const toast = harness.show?.toast;
267
+ if (toast) {
268
+ await toast(page, GEOMETRY_TOAST);
269
+ await settle(page);
270
+ await check("with a toast");
271
+ }
272
+ for (const island of SHOWN_ISLANDS) {
273
+ const show = harness.show?.[island];
274
+ if (!show) continue;
275
+ await show(page);
276
+ await settle(page);
277
+ await check(`after show.${island}`);
278
+ if (island === "problems" && harness.host !== "ide") {
279
+ await withProblemsListOpen(page, root, () => check("with the problems list open"));
280
+ }
281
+ }
282
+ const emptyState = harness.show?.emptyState;
283
+ if (emptyState) {
284
+ await emptyState(page);
285
+ await settle(page);
286
+ await check("after show.emptyState");
287
+ }
288
+ }
289
+ return finish("geometry", harness.host, problems, [
290
+ ...missingHooks(harness, "the geometry"),
291
+ ...webHookSkips(harness, {
292
+ toast: "the geometry of the toast stack",
293
+ emptyState: "the geometry of the start screen",
294
+ }),
295
+ ]);
296
+ },
297
+ };
298
+
299
+ async function menuState(page: PageLike, root: string): Promise<MenuFacts> {
300
+ const facts = await inPage(page, menuProbe, { root });
301
+ if (!facts) throw new Error(`@miragon/modeler-shell conformance: no element matches "${root}"`);
302
+ return facts;
303
+ }
304
+
305
+ /** Keyboard model of the menu (guide §5.4): Enter/↓ open on the first entry, ↑ on the last, wrap, Escape. */
306
+ async function menuKeyboardProblems(page: PageLike, root: string): Promise<string[]> {
307
+ const problems: string[] = [];
308
+ const openWith = async (key: string, focus: "first" | "last"): Promise<MenuFacts | undefined> => {
309
+ if (!(await focusTarget(page, root, "menu"))) {
310
+ problems.push("menu: the trigger cannot take focus");
311
+ return undefined;
312
+ }
313
+ await page.keyboard.press(key);
314
+ await settle(page);
315
+ const opened = await menuState(page, root);
316
+ problems.push(...menuOpenProblems(opened, focus).map((problem) => `${key} on the trigger: ${problem}`));
317
+ return opened;
318
+ };
319
+ const close = async (key: string) => {
320
+ await closeByEscape(page);
321
+ problems.push(
322
+ ...menuClosedProblems(await menuState(page, root), true).map(
323
+ (problem) => `${key}, then Escape: ${problem}`,
324
+ ),
325
+ );
326
+ };
327
+
328
+ const opened = await openWith("Enter", "first");
329
+ if (opened && opened.items.length > 1) {
330
+ const moves: Array<[string, (count: number) => number]> = [
331
+ ["ArrowUp", (count) => count - 1],
332
+ ["ArrowDown", () => 0],
333
+ ["End", (count) => count - 1],
334
+ ["Home", () => 0],
335
+ ];
336
+ await inPage(page, clearKeysProbe, null);
337
+ for (const [key, target] of moves) {
338
+ await page.keyboard.press(key);
339
+ const moved = await menuState(page, root);
340
+ const expected = target(moved.items.length);
341
+ if (moved.active !== expected) {
342
+ problems.push(
343
+ `menu: ${key} moves the focus to entry ${moved.active + 1}, expected entry ${expected + 1} of ${moved.items.length}`,
344
+ );
345
+ }
346
+ }
347
+ const { documentKeys } = await inPage(page, afterPressProbe, { root });
348
+ problems.push(
349
+ ...menuKeysAtDocumentProblems(
350
+ moves.map(([key]) => key),
351
+ documentKeys,
352
+ ),
353
+ );
354
+ }
355
+ if (opened) await close("Enter");
356
+ if (await openWith("ArrowDown", "first")) await close("ArrowDown");
357
+ if (await openWith("ArrowUp", "last")) await close("ArrowUp");
358
+ return problems;
359
+ }
360
+
361
+ /** Roving tabindex of the palette toolbar (guide §5.5): ← → Home End. */
362
+ async function paletteKeyboardProblems(page: PageLike, root: string): Promise<string[]> {
363
+ const before = await inPage(page, paletteFocusProbe, { root });
364
+ if (!before || before.entries.length < 2) return [];
365
+ if (!(await focusTarget(page, root, "palette-tab-stop"))) {
366
+ return ['palette: no entry with tabindex="0" takes the focus'];
367
+ }
368
+ const problems: string[] = [];
369
+ const start = before.entries.indexOf(before.tabStops[0] ?? "");
370
+ const step = start < before.entries.length - 1 ? 1 : -1;
371
+ const moves: Array<[string, number]> = [
372
+ [step === 1 ? "ArrowRight" : "ArrowLeft", start + step],
373
+ ["End", before.entries.length - 1],
374
+ ["Home", 0],
375
+ ];
376
+ for (const [key, index] of moves) {
377
+ await page.keyboard.press(key);
378
+ const after = await inPage(page, paletteFocusProbe, { root });
379
+ const expected = before.entries[index];
380
+ if (!after || after.active !== expected) {
381
+ problems.push(`palette: ${key} focuses "${after?.active ?? "nothing"}", expected "${expected}"`);
382
+ } else if (after.tabStops.length !== 1 || after.tabStops[0] !== expected) {
383
+ problems.push(
384
+ `palette: after ${key} tabindex="0" is on ${after.tabStops.join(", ") || "no entry"}, expected "${expected}"`,
385
+ );
386
+ }
387
+ }
388
+ return problems;
389
+ }
390
+
391
+ /** Every palette entry by keyboard: a shortcut its tooltip shows is named in `aria-keyshortcuts`. */
392
+ async function paletteShortcutWalk(page: PageLike, root: string): Promise<string[]> {
393
+ const before = await inPage(page, paletteFocusProbe, { root });
394
+ if (!before || before.entries.length === 0) return [];
395
+ if (!(await focusTarget(page, root, "palette-tab-stop"))) return [];
396
+ if (before.entries.length > 1) await page.keyboard.press("End");
397
+ await page.keyboard.press("Home");
398
+ const seen: PaletteShortcutFacts[] = [];
399
+ for (let index = 0; index < before.entries.length; index++) {
400
+ const facts = await inPage(page, paletteShortcutProbe, { root });
401
+ if (facts) seen.push(facts);
402
+ await page.keyboard.press("ArrowRight");
403
+ }
404
+ return paletteShortcutProblems(seen);
405
+ }
406
+
407
+ /** Tab from the menu trigger: the primary action (web app), the palette's one stop, then the canvas (guide §5.14). */
408
+ async function tabSequenceFromMenu(page: PageLike, root: string, primary: boolean): Promise<string[]> {
409
+ if (!(await focusTarget(page, root, "menu"))) return [];
410
+ const stops: TabStop[] = [];
411
+ for (let step = 0; step < 3; step++) {
412
+ await page.keyboard.press("Tab");
413
+ const stop = await inPage(page, activeStopProbe, { root });
414
+ stops.push({ description: stop.description, kind: stop.kind as TabStop["kind"] });
415
+ }
416
+ return tabSequenceProblems(stops, primary);
417
+ }
418
+
419
+ /** `aria-disabled` at 400 % and 20 % (guide §5.8), reached by clicking the buttons. */
420
+ async function zoomLimitProblems(page: PageLike, root: string): Promise<string[]> {
421
+ const problems: string[] = [];
422
+ const limits: Array<["in" | "out", ChromeTarget, number, number, "in" | "out"]> = [
423
+ ["in", "zoom-in", ZOOM_LIMIT_MAX, 12, "out"],
424
+ ["out", "zoom-out", ZOOM_LIMIT_MIN, 16, "in"],
425
+ ];
426
+ for (const [button, target, limit, maxClicks, other] of limits) {
427
+ const control = await findTarget(page, root, target);
428
+ if (!control) continue;
429
+ let buttons = await inPage(page, zoomButtonsProbe, { root });
430
+ for (
431
+ let click = 0;
432
+ click < maxClicks && buttons[button]?.attributes["aria-disabled"] !== "true";
433
+ click++
434
+ ) {
435
+ await page.mouse.click(control.client.x, control.client.y);
436
+ await settle(page);
437
+ buttons = await inPage(page, zoomButtonsProbe, { root });
438
+ }
439
+ const { zoom } = await inPage(page, afterPressProbe, { root });
440
+ const reached = buttons[button];
441
+ if (reached?.attributes["aria-disabled"] !== "true") {
442
+ problems.push(
443
+ `zoom: [data-zoom="${button}"] is not aria-disabled after ${maxClicks} clicks (readout ${zoom ?? "?"}%)`,
444
+ );
445
+ } else {
446
+ if (zoom !== limit) {
447
+ problems.push(`zoom: [data-zoom="${button}"] is disabled at ${zoom ?? "?"}%, expected at ${limit}%`);
448
+ }
449
+ if (reached.disabled) problems.push(`zoom: [data-zoom="${button}"] is natively disabled at the limit`);
450
+ }
451
+ if (buttons[other]?.attributes["aria-disabled"] === "true") {
452
+ problems.push(`zoom: [data-zoom="${other}"] is aria-disabled at ${zoom ?? "?"}%`);
453
+ }
454
+ if (buttons.actual?.attributes["aria-disabled"] === "true") {
455
+ problems.push("zoom: the readout is disabled at a limit");
456
+ }
457
+ }
458
+ await page.mouse.move(PARK.x, PARK.y);
459
+ return problems;
460
+ }
461
+
462
+ const ariaCase: ShellConformanceCase = {
463
+ name: "aria",
464
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
465
+ assertHooksConsistent("aria", harness, SHOWN_ISLANDS);
466
+ const root = harness.root ?? DEFAULT_ROOT;
467
+ const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
468
+ await openHarness(page, harness, { size, requested: LIGHT });
469
+ await settle(page);
470
+ const facts = await inPage(page, ariaProbe, { root });
471
+ if (!facts) fail("aria", harness.host, [`no element matches the shell root selector "${root}"`]);
472
+ const problems = ariaProblems(facts, harness.host);
473
+ problems.push(...menuGroupProblems(await menuEntries(page, root), harness.host, harness.themes));
474
+ problems.push(...(await menuKeyboardProblems(page, root)));
475
+ problems.push(...(await paletteKeyboardProblems(page, root)));
476
+ problems.push(...(await paletteShortcutWalk(page, root)));
477
+ problems.push(
478
+ ...(await tabSequenceFromMenu(page, root, harness.host === "web" && Boolean(facts.primary?.visible))),
479
+ );
480
+ problems.push(...(await zoomLimitProblems(page, root)));
481
+
482
+ const shown = new Set<string>();
483
+ for (const island of SHOWN_ISLANDS) {
484
+ const show = harness.show?.[island];
485
+ if (!show) continue;
486
+ await show(page);
487
+ shown.add(island);
488
+ }
489
+ let final: AriaFacts = facts;
490
+ if (shown.size > 0) {
491
+ await settle(page);
492
+ const withIslands = await inPage(page, ariaProbe, { root });
493
+ if (withIslands) {
494
+ final = withIslands;
495
+ problems.push(...optionalIslandProblems(withIslands, shown));
496
+ if (shown.has("problems")) problems.push(...pillPopupAriaProblems(withIslands, harness.host));
497
+ const known = new Set(problems);
498
+ problems.push(
499
+ ...ariaProblems(withIslands, harness.host)
500
+ .filter((problem) => !known.has(problem))
501
+ .map((problem) => `with every island shown: ${problem}`),
502
+ );
503
+ }
504
+ }
505
+ problems.push(...absentIslandProblems(visibleIslands(final), harness.absent));
506
+ return finish("aria", harness.host, problems, missingHooks(harness, "the roles and names"));
507
+ },
508
+ };
509
+
510
+ const keysCase: ShellConformanceCase = {
511
+ name: "keys",
512
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
513
+ const root = harness.root ?? DEFAULT_ROOT;
514
+ const host = harness.host;
515
+ const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
516
+ await openHarness(page, harness, { size, requested: LIGHT });
517
+ await settle(page);
518
+ const problems: string[] = [];
519
+
520
+ if (!(await focusTarget(page, root, "canvas"))) {
521
+ fail("keys", host, ["canvas: the diagram-js <svg> (.djs-container > svg) cannot take focus"]);
522
+ }
523
+ await inPage(page, clearKeysProbe, null);
524
+ let current = (await inPage(page, afterPressProbe, { root })).zoom;
525
+ if (current === null) fail("keys", host, ['zoom: no readout [data-zoom="actual"] showing a percentage']);
526
+
527
+ const press = async (
528
+ key: string,
529
+ reach: KeyReach | undefined,
530
+ zoom: number | "unchanged" | undefined,
531
+ context = "on the canvas",
532
+ ) => {
533
+ const before = current;
534
+ await page.keyboard.press(key);
535
+ let after = await inPage(page, afterPressProbe, { root });
536
+ const where = `${key} ${context}`;
537
+ if (reach) {
538
+ const problem = keyReachProblem(where, reach, host, after);
539
+ if (problem) problems.push(problem);
540
+ }
541
+ if (zoom === undefined) return;
542
+ const expected = zoom === "unchanged" ? before : zoom;
543
+ // a readout that has not moved yet may still be waiting for its frame; a wrong one stays wrong
544
+ for (let poll = 0; after.zoom === before && expected !== before && poll < 10; poll++) {
545
+ await page.waitForTimeout(50);
546
+ after = await inPage(page, afterPressProbe, { root });
547
+ }
548
+ if (after.zoom !== expected) {
549
+ problems.push(
550
+ zoom === "unchanged"
551
+ ? `${where} changed the zoom from ${before}% to ${after.zoom ?? "?"}%; it must not act`
552
+ : `${where} zoomed to ${after.zoom ?? "?"}% from ${before}%, expected ${expected}% (exactly one step)`,
553
+ );
554
+ }
555
+ current = after.zoom ?? current;
556
+ };
557
+ const inside = async (kind: InjectedKind, context: string, steps: () => Promise<void>) => {
558
+ if (await inPage(page, injectProbe, { root, kind })) await steps();
559
+ else problems.push(`cannot focus ${context} stand-in inside the root`);
560
+ await inPage(page, removeInjectedProbe, null);
561
+ };
562
+
563
+ await press("Control+Digit0", "handled", 100);
564
+ const pairs: Array<[string, string]> = [
565
+ ["Control+Equal", "Control+Minus"],
566
+ ["Control+Shift+Equal", "Control+Shift+Minus"],
567
+ ["Meta+Equal", "Meta+Minus"],
568
+ ["Meta+Shift+Equal", "Meta+Shift+Minus"],
569
+ ["Control+NumpadAdd", "Control+NumpadSubtract"],
570
+ ["Meta+NumpadAdd", "Meta+NumpadSubtract"],
571
+ ];
572
+ for (const [zoomIn, zoomOut] of pairs) {
573
+ await press(zoomIn, "handled", ONE_STEP_UP);
574
+ await press(zoomOut, "handled", 100);
575
+ }
576
+ for (const zoomToActual of ["Meta+Digit0", "Control+Numpad0"]) {
577
+ await press("Control+Equal", "handled", ONE_STEP_UP);
578
+ await press(zoomToActual, "handled", 100);
579
+ }
580
+ for (const key of ["Equal", "Minus", "Shift+Equal", "Shift+Minus", "NumpadAdd", "NumpadSubtract"]) {
581
+ await press(key, "free", "unchanged", "on the canvas without Cmd/Ctrl");
582
+ }
583
+ for (const key of ["Digit0", "Control+Alt+Equal", "Control+Alt+Minus", "Control+Shift+Digit1"]) {
584
+ await press(key, undefined, "unchanged");
585
+ }
586
+ await press("F1", "free", undefined);
587
+ await press("Escape", "free", undefined, "on the canvas without a menu or dialog");
588
+ if (await inPage(page, canvasHandlesProbe, { root, key: "F9" })) {
589
+ await press("F9", "canvas", undefined, "handled by the canvas");
590
+ }
591
+ await inPage(page, removeInjectedProbe, null);
592
+ if (host === "ide") {
593
+ // Guide §13.5: on the canvas, undo and redo use the document's history, never the canvas's.
594
+ for (const key of UNDO_REDO_PRESSES) {
595
+ await press(key, "free", undefined, "on the canvas (undo and redo belong to the VS Code document)");
596
+ }
597
+ }
598
+ // Guide §13.5: keys the canvas uses act on the canvas only, select all included; in VS Code an
599
+ // unhandled Cmd/Ctrl+A would select the webview's text instead.
600
+ for (const key of ["Control+KeyA", "Meta+KeyA"]) {
601
+ await press(key, "canvas", undefined, "on the canvas (select all acts on the canvas only)");
602
+ }
603
+
604
+ await press("Control+Equal", "handled", ONE_STEP_UP);
605
+ for (const [kind, field] of TEXT_FIELDS) {
606
+ await inside(kind, field, async () => {
607
+ await press("Control+Equal", "blocked", "unchanged", `in ${field}`);
608
+ await press("Control+Minus", "blocked", "unchanged", `in ${field}`);
609
+ await press("Shift+Digit1", "free", "unchanged", `in ${field}`);
610
+ await press("Control+KeyZ", "blocked", undefined, `in ${field}`);
611
+ await press("Meta+Shift+KeyZ", "blocked", undefined, `in ${field}`);
612
+ await press("Control+KeyY", "blocked", undefined, `in ${field}`);
613
+ });
614
+ }
615
+ await inside("modal", "a modal dialog", async () => {
616
+ await press("Control+Equal", "blocked", "unchanged", "with a modal dialog open");
617
+ await press("Shift+Digit1", "free", "unchanged", "with a modal dialog open");
618
+ await press("Escape", "blocked", undefined, "with a modal dialog open");
619
+ });
620
+ await inside("popover", "a popover", async () => {
621
+ await press("Control+Minus", "handled", 100, "in a popover");
622
+ await press("Control+Equal", "handled", ONE_STEP_UP, "in a popover");
623
+ await press("Escape", "blocked", undefined, "with a popover open");
624
+ });
625
+
626
+ if (await openMenuByClick(page, root)) {
627
+ await inPage(page, clearKeysProbe, null);
628
+ await press("Escape", "handled", undefined, "with the menu open");
629
+ if (await findOverlay(page, root, "menu")) problems.push("Escape with the menu open did not close it");
630
+ }
631
+
632
+ await focusTarget(page, root, "canvas");
633
+ await press("Control+Equal", "handled", TWO_STEPS_UP);
634
+ await page.keyboard.press("Shift+Digit1");
635
+ let fitted = await inPage(page, afterPressProbe, { root });
636
+ const reachProblem = keyReachProblem("Shift+Digit1 on the canvas", "handled", host, fitted);
637
+ if (reachProblem) problems.push(reachProblem);
638
+ for (let poll = 0; (fitted.zoom === null || fitted.zoom > 100) && poll < 20; poll++) {
639
+ await page.waitForTimeout(50);
640
+ fitted = await inPage(page, afterPressProbe, { root });
641
+ }
642
+ if (fitted.zoom === null || fitted.zoom > 100) {
643
+ problems.push(
644
+ `Shift+Digit1 on the canvas left the zoom at ${fitted.zoom ?? "?"}%, expected a fit (at most 100%)`,
645
+ );
646
+ }
647
+ return finish("keys", host, problems, []);
648
+ },
649
+ };
650
+
651
+ async function appearanceFacts(page: PageLike, root: string): Promise<AppearanceFacts | null> {
652
+ const probe = () => inPage(page, appearanceProbe, { root, roles: MODE_ROLES });
653
+ await hover(page, root, "zoom-in");
654
+ const withTooltip = await probe();
655
+ const opened = await openMenuByClick(page, root);
656
+ const withMenu = await probe();
657
+ if (opened) await closeByEscape(page);
658
+ await page.mouse.move(PARK.x, PARK.y);
659
+ if (!withTooltip || !withMenu) return withTooltip ?? withMenu;
660
+ const names = new Set(withTooltip.parts.map((part) => part.name));
661
+ return {
662
+ ...withMenu,
663
+ parts: [...withTooltip.parts, ...withMenu.parts.filter((part) => !names.has(part.name))],
664
+ };
665
+ }
666
+
667
+ /** The palette's tool colours in the current state, read until a running colour transition is done. */
668
+ async function paletteColors(page: PageLike, root: string): Promise<string[]> {
669
+ const read = async () => {
670
+ const facts = await inPage(page, paletteColorProbe, { root });
671
+ return facts ? paletteColorProblems(facts) : [];
672
+ };
673
+ return pollUntil(page, read, (problems) => problems.length === 0, COLOR_WAIT_MS);
674
+ }
675
+
676
+ /**
677
+ * Guide §5.5: every tool entry at rest, hovered in turn, and the first tool armed by a click
678
+ * (pressed and hovered, then pressed with the pointer away), Escape afterwards.
679
+ */
680
+ async function paletteColorWalk(page: PageLike, root: string): Promise<string[]> {
681
+ const problems: string[] = [];
682
+ await page.mouse.move(PARK.x, PARK.y);
683
+ problems.push(...(await paletteColors(page, root)));
684
+ const tools = (await inPage(page, paletteEntriesProbe, { root })).filter(
685
+ (entry) => isToolEntry(entry) && !entry.disabled,
686
+ );
687
+ for (const tool of tools) {
688
+ await page.mouse.move(tool.client.x, tool.client.y);
689
+ problems.push(...(await paletteColors(page, root)));
690
+ await page.mouse.move(PARK.x, PARK.y);
691
+ }
692
+ const armed =
693
+ tools.find((tool) => tool.action === "lasso-tool") ?? tools.find((tool) => tool.action.endsWith("-tool"));
694
+ if (armed) {
695
+ await page.mouse.click(armed.client.x, armed.client.y);
696
+ await settle(page);
697
+ const pressed = (await inPage(page, paletteEntriesProbe, { root })).find(
698
+ (entry) => entry.action === armed.action,
699
+ );
700
+ if (!pressed?.pressed) {
701
+ problems.push(`palette: clicking tool "${armed.action}" does not set aria-pressed="true" (guide §5.5)`);
702
+ }
703
+ problems.push(...(await paletteColors(page, root)));
704
+ await page.mouse.move(PARK.x, PARK.y);
705
+ problems.push(...(await paletteColors(page, root)));
706
+ await closeByEscape(page);
707
+ }
708
+ return [...new Set(problems)];
709
+ }
710
+
711
+ /** The web app's mode, its meta tags and the stored choice. */
712
+ function webMode(page: PageLike): Promise<WebModeFacts> {
713
+ return inPage(page, webModeProbe, { key: APPEARANCE_STORAGE_KEY });
714
+ }
715
+
716
+ const APPEARANCE_LABELS: Readonly<Record<AppearanceChoice, string>> = {
717
+ system: "System",
718
+ light: "Light",
719
+ dark: "Dark",
720
+ };
721
+
722
+ /**
723
+ * Guide §13.3 for a dark-capable web app: Dark, Light, then System through the Appearance group,
724
+ * each written to `<html>`, `color-scheme`, both `theme-color`s and `localStorage` in one frame and
725
+ * never half-dark; the theme colours are the ones the page showed under System.
726
+ */
727
+ async function appearanceSwitchProblems<P extends PageLike>(
728
+ page: P,
729
+ harness: ShellConformanceHarness<P>,
730
+ root: string,
731
+ requested: Appearance,
732
+ ): Promise<string[]> {
733
+ const problems: string[] = [];
734
+ const system = await webMode(page);
735
+ const media = (scheme: string) =>
736
+ system.themeColors.find((meta) => meta.media?.replace(/\s+/g, "") === `(prefers-color-scheme:${scheme})`)
737
+ ?.content;
738
+ const light = media("light");
739
+ const dark = media("dark");
740
+ const colors = light && dark ? { light, dark } : undefined;
741
+ for (const choice of ["dark", "light", "system"] as const) {
742
+ const label = APPEARANCE_LABELS[choice];
743
+ if (!(await openMenuByClick(page, root))) {
744
+ problems.push(`menu: no visible menu trigger to choose ${label}`);
745
+ break;
746
+ }
747
+ const option = await inPage(page, targetProbe, {
748
+ root,
749
+ selector: `[data-menu-id="appearance"] [data-menu-value="${choice}"]`,
750
+ });
751
+ if (!option) {
752
+ problems.push(`menu: no Appearance option "${choice}" to choose (guide §13.3)`);
753
+ await closeByEscape(page);
754
+ break;
755
+ }
756
+ await inPage(page, watchModeProbe, null);
757
+ await page.mouse.click(option.client.x, option.client.y);
758
+ await settle(page);
759
+ problems.push(...modeSwitchProblems(await inPage(page, modeChangesProbe, null), label));
760
+ problems.push(
761
+ ...appearanceChoiceProblems((await inPage(page, menuEntriesProbe, { root })) ?? [], choice),
762
+ );
763
+ await closeByEscape(page);
764
+ problems.push(...webModeProblems(await webMode(page), harness.themes, choice, colors));
765
+ const facts = await appearanceFacts(page, root);
766
+ if (facts) {
767
+ const expected = expectedWebMode(requested, harness.themes, choice);
768
+ problems.push(
769
+ ...appearanceProblems(facts, "web", requested, harness.themes, expected).problems.map(
770
+ (problem) => `[chose ${label}] ${problem}`,
771
+ ),
772
+ );
773
+ }
774
+ }
775
+ return problems;
776
+ }
777
+
778
+ const appearanceCase: ShellConformanceCase = {
779
+ name: "appearance",
780
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
781
+ const root = harness.root ?? DEFAULT_ROOT;
782
+ const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
783
+ const problems: string[] = [];
784
+ const skipped: ShellSkip[] = [];
785
+ const web = harness.host === "web";
786
+ // Without emulateMedia the web app's system preference stays the browser's light default.
787
+ const emulated = !web || page.emulateMedia !== undefined;
788
+ if (!emulated) {
789
+ skipped.push({
790
+ hook: "open",
791
+ reason:
792
+ "page.emulateMedia is undefined: the web app's mode under a dark system and under high contrast was not verified",
793
+ });
794
+ }
795
+ let switched = false;
796
+ for (const requested of emulated ? REQUESTED_APPEARANCES : [LIGHT]) {
797
+ const at = `[requested ${appearanceLabel(requested)}]`;
798
+ await openHarness(page, harness, { size, requested });
799
+ if (harness.host === "embedded") {
800
+ // The foreign page itself in that mode: a themed page around the modeler (guide §13.3).
801
+ await inPage(page, hostPageModeProbe, {
802
+ theme: requested.theme,
803
+ kontrast: requested.kontrast ?? null,
804
+ });
805
+ }
806
+ await settle(page);
807
+ const facts = await appearanceFacts(page, root);
808
+ if (!facts) {
809
+ problems.push(`${at} no element matches the shell root selector "${root}"`);
810
+ continue;
811
+ }
812
+ const expected = web ? expectedWebMode(requested, harness.themes, "system") : undefined;
813
+ const verdict = appearanceProblems(facts, harness.host, requested, harness.themes, expected);
814
+ problems.push(...verdict.problems.map((problem) => `${at} ${problem}`));
815
+ const surround = await inPage(page, surroundProbe, { root });
816
+ if (surround) problems.push(...surroundProblems(surround).map((problem) => `${at} ${problem}`));
817
+ problems.push(...(await paletteColorWalk(page, root)).map((problem) => `${at} ${problem}`));
818
+ if (web) {
819
+ problems.push(
820
+ ...webModeProblems(await webMode(page), harness.themes, "system", undefined).map(
821
+ (problem) => `${at} ${problem}`,
822
+ ),
823
+ ...lightOnlyMenuProblems(await menuEntries(page, root), harness.themes).map(
824
+ (problem) => `${at} ${problem}`,
825
+ ),
826
+ );
827
+ if (harness.themes.includes("dark") && !switched && requested.theme === "dark") {
828
+ switched = true;
829
+ problems.push(
830
+ ...(await appearanceSwitchProblems(page, harness, root, requested)).map(
831
+ (problem) => `${at} ${problem}`,
832
+ ),
833
+ );
834
+ }
835
+ }
836
+ }
837
+ return finish("appearance", harness.host, problems, skipped);
838
+ },
839
+ };
840
+
841
+ /** Moves the palette's roving focus by keyboard to `action`; `false` when it cannot get there. */
842
+ async function focusPaletteEntry(page: PageLike, root: string, action: string): Promise<boolean> {
843
+ const before = await inPage(page, paletteFocusProbe, { root });
844
+ if (!before || !before.entries.includes(action)) return false;
845
+ if (!(await focusTarget(page, root, "palette-tab-stop"))) return false;
846
+ await page.keyboard.press("Home");
847
+ for (let step = 0; step <= before.entries.length; step++) {
848
+ if ((await inPage(page, paletteFocusProbe, { root }))?.active === action) return true;
849
+ await page.keyboard.press("ArrowRight");
850
+ }
851
+ return false;
852
+ }
853
+
854
+ /** The first entry that creates something: outside the tools group and not a tool. */
855
+ function firstCreatingEntry(entries: readonly PaletteEntryState[]): PaletteEntryState | undefined {
856
+ return entries.find((entry) => !isToolEntry(entry));
857
+ }
858
+
859
+ /** Enter on `entry`; the shapes once a new one appeared or `CREATE_WAIT_MS` passed. */
860
+ async function enterOnEntry(
861
+ page: PageLike,
862
+ root: string,
863
+ entry: string,
864
+ before: readonly ShapeFacts[],
865
+ ): Promise<ShapeFacts[] | undefined> {
866
+ if (!(await focusPaletteEntry(page, root, entry))) return undefined;
867
+ await page.keyboard.press("Enter");
868
+ await settle(page);
869
+ const known = new Set(before.map((shape) => shape.id));
870
+ return pollUntil(
871
+ page,
872
+ () => shapes(page, root),
873
+ (after) => after.some((shape) => !known.has(shape.id)),
874
+ CREATE_WAIT_MS,
875
+ );
876
+ }
877
+
878
+ const createCase: ShellConformanceCase = {
879
+ name: "create",
880
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
881
+ const root = harness.root ?? DEFAULT_ROOT;
882
+ const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
883
+ await openHarness(page, harness, { size, requested: LIGHT });
884
+ await settle(page);
885
+ const entry = firstCreatingEntry(await inPage(page, paletteEntriesProbe, { root }));
886
+ // A notation whose palette only switches tools has nothing to place.
887
+ if (!entry) return undefined;
888
+ await page.mouse.move(PARK.x, PARK.y);
889
+ const free = freeRectangle(await measureChrome(page, root), harness.host);
890
+ const before = await shapes(page, root);
891
+ const after = await enterOnEntry(page, root, entry.action, before);
892
+ if (!after) {
893
+ fail("create", harness.host, [`create: the palette's roving focus does not reach "${entry.action}"`]);
894
+ }
895
+ const canvas = await inPage(page, canvasStateProbe, { root });
896
+ const problems = createProblems({ entry: entry.action, before, after, canvas, free });
897
+ await closeByEscape(page);
898
+ return finish("create", harness.host, problems, []);
899
+ },
900
+ };
901
+
902
+ /** A shape whose centre a pointer reaches (no chrome above it), found again by its id. */
903
+ async function reachableShape(page: PageLike, root: string, id?: string): Promise<ShapeFacts | undefined> {
904
+ const all = await shapes(page, root);
905
+ return id === undefined ? all.find((shape) => shape.hit) : all.find((shape) => shape.id === id);
906
+ }
907
+
908
+ /** Guide §13.5, "Was der Schreibschutz sperrt", on one shape: select, drag, double-click, delete. */
909
+ async function lockedShapeChecks(page: PageLike, root: string, shape: ShapeFacts): Promise<string[]> {
910
+ await page.mouse.click(shape.client.x, shape.client.y);
911
+ await settle(page);
912
+ const selected = await inPage(page, canvasStateProbe, { root });
913
+
914
+ const before = (await reachableShape(page, root, shape.id)) ?? shape;
915
+ await page.mouse.move(before.client.x, before.client.y);
916
+ await page.mouse.down();
917
+ for (let step = 1; step <= 4; step++) {
918
+ await page.mouse.move(before.client.x + (LOCKED_DRAG_PX * step) / 4, before.client.y);
919
+ }
920
+ await page.mouse.up();
921
+ await settle(page);
922
+ const dragged = await reachableShape(page, root, shape.id);
923
+
924
+ const target = dragged ?? before;
925
+ await page.mouse.dblclick(target.client.x, target.client.y);
926
+ await settle(page);
927
+ const { textFields } = await inPage(page, canvasStateProbe, { root });
928
+ await closeByEscape(page);
929
+
930
+ const kept = { Delete: true, Backspace: true };
931
+ for (const key of ["Delete", "Backspace"] as const) {
932
+ const current = await reachableShape(page, root, shape.id);
933
+ if (!current) break;
934
+ // a key that deletes the selection must not miss it; a click on a selected shape deselects it
935
+ if (!current.selected) await page.mouse.click(current.client.x, current.client.y);
936
+ await focusTarget(page, root, "canvas");
937
+ await page.keyboard.press(key);
938
+ await settle(page);
939
+ kept[key] = (await reachableShape(page, root, shape.id)) !== undefined;
940
+ }
941
+ await page.mouse.move(PARK.x, PARK.y);
942
+ return lockedShapeProblems({
943
+ contextPadEntries: selected.contextPadEntries,
944
+ dragged: { before, after: dragged },
945
+ textFields,
946
+ kept,
947
+ });
948
+ }
949
+
950
+ const readOnlyCase: ShellConformanceCase = {
951
+ name: "readOnly",
952
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
953
+ assertHooksConsistent("readOnly", harness, ["invalid"]);
954
+ const invalid = harness.show?.invalid;
955
+ if (!invalid) {
956
+ if (harness.absent?.includes("invalid")) return undefined;
957
+ return {
958
+ skipped: [
959
+ {
960
+ hook: "invalid",
961
+ reason: "show.invalid is undefined: the read-only canvas of an invalid file was not verified",
962
+ },
963
+ ],
964
+ };
965
+ }
966
+ const root = harness.root ?? DEFAULT_ROOT;
967
+ const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
968
+ const problems: string[] = [];
969
+ const skipped: ShellSkip[] = [];
970
+ await openHarness(page, harness, { size, requested: LIGHT });
971
+ await settle(page);
972
+ const menuBefore = await menuEntries(page, root);
973
+
974
+ await invalid(page);
975
+ await page.waitForTimeout(INVALID_STATUS_DELAY_MS);
976
+ const state = await pollUntil(
977
+ page,
978
+ () => inPage(page, readOnlyStateProbe, { root }),
979
+ (facts) => facts === null || readOnlyStateProblems(facts).length === 0,
980
+ READ_ONLY_WAIT_MS,
981
+ );
982
+ if (!state) fail("readOnly", harness.host, [`no element matches the shell root selector "${root}"`]);
983
+ await settle(page);
984
+ problems.push(...readOnlyStateProblems(state));
985
+
986
+ const entries = await inPage(page, paletteEntriesProbe, { root });
987
+ problems.push(...readOnlyPaletteProblems(entries));
988
+ problems.push(...(await paletteColors(page, root)));
989
+ const creating = firstCreatingEntry(entries);
990
+ if (creating) {
991
+ const before = await shapes(page, root);
992
+ const after = await enterOnEntry(page, root, creating.action, before);
993
+ const known = new Set(before.map((shape) => shape.id));
994
+ const added = (after ?? []).filter((shape) => !known.has(shape.id));
995
+ if (added.length > 0) {
996
+ problems.push(
997
+ `read-only: Enter on palette entry "${creating.action}" added ${added.map((shape) => `"${shape.id}"`).join(", ")} (guide §13.5)`,
998
+ );
999
+ }
1000
+ await closeByEscape(page);
1001
+ }
1002
+
1003
+ problems.push(...readOnlyMenuProblems(menuBefore, await menuEntries(page, root)));
1004
+
1005
+ const shape = await reachableShape(page, root);
1006
+ if (shape) {
1007
+ problems.push(...(await lockedShapeChecks(page, root, shape)));
1008
+ } else {
1009
+ skipped.push({
1010
+ hook: "open",
1011
+ reason:
1012
+ "the opened document shows no shape a pointer reaches: context pad, move, delete and label editor of the read-only canvas not verified",
1013
+ });
1014
+ }
1015
+ return finish("readOnly", harness.host, problems, skipped);
1016
+ },
1017
+ };
1018
+
1019
+ const cleanupCase: ShellConformanceCase = {
1020
+ name: "cleanup",
1021
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
1022
+ if (!harness.destroy) {
1023
+ return {
1024
+ skipped: [
1025
+ {
1026
+ hook: "destroy",
1027
+ reason: "harness.destroy is undefined: the cleanup after destroy was not verified",
1028
+ },
1029
+ ],
1030
+ };
1031
+ }
1032
+ const root = harness.root ?? DEFAULT_ROOT;
1033
+ const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
1034
+ await openHarness(page, harness, { size, requested: LIGHT });
1035
+ await settle(page);
1036
+ const armed = await inPage(page, armCleanupProbe, { root });
1037
+ if (!armed) fail("cleanup", harness.host, [`no element matches the shell root selector "${root}"`]);
1038
+ const problems: string[] = [];
1039
+
1040
+ // A hover leaves the tooltip timer pending, so a timer that outlives destroy shows up below.
1041
+ const zoomIn = await findTarget(page, root, "zoom-in");
1042
+ if (zoomIn) {
1043
+ await page.mouse.move(PARK.x, PARK.y);
1044
+ await page.mouse.move(zoomIn.client.x, zoomIn.client.y);
1045
+ }
1046
+ try {
1047
+ await harness.destroy(page);
1048
+ } catch (error) {
1049
+ problems.push(`destroy threw: ${error instanceof Error ? error.message : String(error)}`);
1050
+ }
1051
+ await page.waitForTimeout(Math.max(INVALID_STATUS_DELAY_MS, TOOLTIP_DELAY_MS) + HOVER_MARGIN_MS);
1052
+ await page.mouse.move(PARK.x, PARK.y);
1053
+ await page.setViewportSize({ width: size.width - 40, height: size.height - 40 });
1054
+ await settle(page);
1055
+
1056
+ await inPage(page, injectProbe, { root, kind: "focus-target" });
1057
+ await inPage(page, clearKeysProbe, null);
1058
+ for (const key of ["Control+Equal", "Control+Minus", "Control+Digit0", "Shift+Digit1"]) {
1059
+ await page.keyboard.press(key);
1060
+ const { keys } = await inPage(page, afterPressProbe, { root });
1061
+ const problem = afterDestroyKeyProblem(key, keys);
1062
+ if (problem) problems.push(problem);
1063
+ }
1064
+ await inPage(page, removeInjectedProbe, null);
1065
+ problems.push(...leftoverProblems(await inPage(page, leftoverProbe, null), harness.host));
1066
+ return finish("cleanup", harness.host, problems, []);
1067
+ },
1068
+ };
1069
+
1070
+ /** The 0.1 cases, extended for the web column. */
1071
+ export function createBaseCases(): ShellConformanceCase[] {
1072
+ return [geometryCase, ariaCase, keysCase, createCase, readOnlyCase, appearanceCase, cleanupCase];
1073
+ }
1074
+
1075
+ export function createCases(): ShellConformanceCase[] {
1076
+ return [...createBaseCases(), ...createWebCases()];
1077
+ }