@miragon/modeler-shell 0.1.0 → 0.2.1

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 (98) hide show
  1. package/API.md +1051 -84
  2. package/README.md +162 -31
  3. package/dist/appearance-BBaM_4bD.d.ts +323 -0
  4. package/dist/appearance-XX3JC3pI.d.cts +323 -0
  5. package/dist/{chunk-6LQOFACK.js → chunk-A4YGKH4Y.js} +1252 -31
  6. package/dist/chunk-A4YGKH4Y.js.map +1 -0
  7. package/dist/{chunk-RJRFR6N3.js → chunk-KYDG5DMX.js} +2 -2
  8. package/dist/{chunk-5JSZ4VGA.js → chunk-OADW3GPA.js} +240 -6
  9. package/dist/chunk-OADW3GPA.js.map +1 -0
  10. package/dist/core.cjs +260 -6
  11. package/dist/core.cjs.map +1 -1
  12. package/dist/core.d.cts +81 -6
  13. package/dist/core.d.ts +81 -6
  14. package/dist/core.js +43 -3
  15. package/dist/diagram-js.cjs +34 -2
  16. package/dist/diagram-js.cjs.map +1 -1
  17. package/dist/diagram-js.d.cts +10 -3
  18. package/dist/diagram-js.d.ts +10 -3
  19. package/dist/diagram-js.js +14 -2
  20. package/dist/diagram-js.js.map +1 -1
  21. package/dist/index.cjs +1658 -196
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +18 -9
  24. package/dist/index.d.ts +18 -9
  25. package/dist/index.js +44 -4
  26. package/dist/shell.css +692 -3
  27. package/dist/testing-vscode-stub.js +2 -2
  28. package/dist/testing.cjs +2849 -550
  29. package/dist/testing.cjs.map +1 -1
  30. package/dist/testing.d.cts +75 -9
  31. package/dist/testing.d.ts +75 -9
  32. package/dist/testing.js +2744 -558
  33. package/dist/testing.js.map +1 -1
  34. package/dist/toasts-Bp0REqdq.d.cts +142 -0
  35. package/dist/toasts-Bp0REqdq.d.ts +142 -0
  36. package/dist/{types-OuFlqZp0.d.cts → types-CnRrINRs.d.cts} +170 -9
  37. package/dist/{types-DnZn9CPw.d.ts → types-PuDZczzy.d.ts} +170 -9
  38. package/dist/vscode-extension.js +2 -2
  39. package/dist/vscode-webview.cjs +1546 -178
  40. package/dist/vscode-webview.cjs.map +1 -1
  41. package/dist/vscode-webview.d.cts +6 -4
  42. package/dist/vscode-webview.d.ts +6 -4
  43. package/dist/vscode-webview.js +11 -2
  44. package/dist/vscode-webview.js.map +1 -1
  45. package/package.json +2 -2
  46. package/src/core/feedback/appearance.ts +141 -0
  47. package/src/core/feedback/problems.ts +23 -0
  48. package/src/core/feedback/toasts.ts +118 -0
  49. package/src/core/fit.ts +18 -3
  50. package/src/core/index.ts +43 -0
  51. package/src/core/keymap.ts +20 -0
  52. package/src/core/messages.ts +71 -0
  53. package/src/core/types.ts +24 -2
  54. package/src/core/web-chrome/bottom-row.ts +37 -0
  55. package/src/core/web-chrome/drop.ts +78 -0
  56. package/src/core/web-chrome/top-row.ts +66 -0
  57. package/src/diagram-js/service.ts +4 -1
  58. package/src/diagram-js/viewport.ts +19 -0
  59. package/src/dom/buttons.css +124 -0
  60. package/src/dom/feedback/appearance.ts +184 -0
  61. package/src/dom/feedback/feedback.css +435 -0
  62. package/src/dom/feedback/problems-popover.ts +287 -0
  63. package/src/dom/feedback/toasts.ts +431 -0
  64. package/src/dom/index.ts +13 -0
  65. package/src/dom/keys.ts +8 -0
  66. package/src/dom/menu.ts +28 -17
  67. package/src/dom/mount.ts +290 -20
  68. package/src/dom/problems.ts +5 -4
  69. package/src/dom/shell-dom.css +7 -4
  70. package/src/dom/tooltip.ts +10 -0
  71. package/src/dom/types.ts +188 -6
  72. package/src/dom/web/drop-zone.ts +181 -0
  73. package/src/dom/web/empty-state.ts +144 -0
  74. package/src/dom/web/legal.ts +75 -0
  75. package/src/dom/web/menu-groups.ts +69 -0
  76. package/src/dom/web/primary.ts +124 -0
  77. package/src/dom/web/web.css +386 -0
  78. package/src/shell.css +3 -0
  79. package/src/testing/case-tools.ts +164 -0
  80. package/src/testing/cases.ts +209 -144
  81. package/src/testing/checks.ts +51 -15
  82. package/src/testing/facts.ts +56 -2
  83. package/src/testing/geometry.ts +206 -26
  84. package/src/testing/harness.ts +56 -7
  85. package/src/testing/index.ts +4 -0
  86. package/src/testing/page-tools.ts +24 -1
  87. package/src/testing/page.ts +10 -0
  88. package/src/testing/web-cases.ts +913 -0
  89. package/src/testing/web-checks.ts +1049 -0
  90. package/src/testing/web-facts.ts +713 -0
  91. package/src/vscode/webview/mount.ts +13 -1
  92. package/dist/chunk-5JSZ4VGA.js.map +0 -1
  93. package/dist/chunk-6LQOFACK.js.map +0 -1
  94. package/dist/keymap-CObQE6q6.d.cts +0 -151
  95. package/dist/keymap-DORzHnK-.d.ts +0 -151
  96. package/dist/types-B6NfkiUG.d.cts +0 -66
  97. package/dist/types-B6NfkiUG.d.ts +0 -66
  98. /package/dist/{chunk-RJRFR6N3.js.map → chunk-KYDG5DMX.js.map} +0 -0
@@ -0,0 +1,913 @@
1
+ import {
2
+ INVALID_STATUS_DELAY_MS,
3
+ TOAST_DURATION_MS,
4
+ TOOLTIP_DELAY_MS,
5
+ type ToastStatus,
6
+ } from "../core/index.js";
7
+ import {
8
+ DEFAULT_SIZES,
9
+ HOVER_MARGIN_MS,
10
+ LIGHT,
11
+ PARK,
12
+ assertHooksConsistent,
13
+ closeByEscape,
14
+ finish,
15
+ menuEntries,
16
+ openHarness,
17
+ openMenuByClick,
18
+ pollUntil,
19
+ unfed,
20
+ } from "./case-tools.js";
21
+ import { ariaProbe, menuProbe, type AriaFacts } from "./facts.js";
22
+ import {
23
+ DEFAULT_ROOT,
24
+ WORK_CHROME,
25
+ measureChrome,
26
+ webRowProblems,
27
+ type ChromeMeasurement,
28
+ } from "./geometry.js";
29
+ import type {
30
+ DropFixture,
31
+ ShellCaseOutcome,
32
+ ShellConformanceCase,
33
+ ShellConformanceHarness,
34
+ ShellSkip,
35
+ ToastFixture,
36
+ } from "./harness.js";
37
+ import type { PageLike } from "./page.js";
38
+ import {
39
+ afterPressProbe,
40
+ clearKeysProbe,
41
+ findOverlay,
42
+ findTarget,
43
+ focusTarget,
44
+ inPage,
45
+ injectProbe,
46
+ removeInjectedProbe,
47
+ settle,
48
+ } from "./page-tools.js";
49
+ import {
50
+ afterStartProblems,
51
+ closedPopoverProblems,
52
+ dropStateProblems,
53
+ emptyStateKeyProblems,
54
+ emptyStateProblems,
55
+ emptyStateTabProblems,
56
+ labelledWidth,
57
+ legalContentProblems,
58
+ legalReachProblems,
59
+ openPopoverProblems,
60
+ popoverKeyboardProblems,
61
+ primaryActivationProblems,
62
+ primaryTooltipProblems,
63
+ repeatedToastProblems,
64
+ toastAnatomyProblems,
65
+ toastLiveRegion,
66
+ toastStackProblems,
67
+ toastTimingProblems,
68
+ topRowProblems,
69
+ zoomRowBottomProblems,
70
+ type ExpectedDrop,
71
+ type PopoverKeyStep,
72
+ type ToastTiming,
73
+ } from "./web-checks.js";
74
+ import {
75
+ blurProbe,
76
+ clicksProbe,
77
+ countClicksProbe,
78
+ dragProbe,
79
+ emptyStateProbe,
80
+ focusOwnerProbe,
81
+ focusToastButtonProbe,
82
+ legalLineProbe,
83
+ popoverProbe,
84
+ showModalProbe,
85
+ toastStackTogglesProbe,
86
+ toastsProbe,
87
+ topRowProbe,
88
+ watchToastStackProbe,
89
+ type DragSpec,
90
+ type DropZoneFacts,
91
+ type EmptyStateFacts,
92
+ type PopoverFacts,
93
+ type ToastsFacts,
94
+ type TopRowFacts,
95
+ } from "./web-facts.js";
96
+
97
+ /** The width steps of the web app's top and bottom rows, from wide to narrow (guide §5.15). */
98
+ const WIDEST_STEP = 1280;
99
+ const NARROWEST_STEP = 400;
100
+ /** The narrowest container the legal line must still hold every link in (320 CSS px, WCAG 1.4.10). */
101
+ const PHONE_WIDTH = 320;
102
+ const STEP_PX = 24;
103
+
104
+ /** Room after a toast's lifetime for its 250ms exit and a frame on either side. */
105
+ const TOAST_MARGIN_MS = 900;
106
+
107
+ /** A toast's resumed time after hover or focus is at least this (API.md, Toasts). */
108
+ const TOAST_RESUME_MS = 1500;
109
+
110
+ /** How long a toast's text may take to reach its live region. */
111
+ const TOAST_ANNOUNCE_WAIT_MS = 1000;
112
+
113
+ /** Room for a modeler that reads a dropped file asynchronously before it opens it. */
114
+ const DROP_WAIT_MS = 2000;
115
+
116
+ /** Tab presses on the start screen: the card's two buttons, the legal links and the way out. */
117
+ const START_SCREEN_TABS = 10;
118
+
119
+ const TOAST_STATUSES: readonly ToastStatus[] = ["success", "info", "warning", "danger"];
120
+
121
+ /** The document's language, which decides whether names compare with the default messages. */
122
+ const documentLangProbe = (_: null): string => document.documentElement.lang;
123
+
124
+ function sizeOf<P extends PageLike>(harness: ShellConformanceHarness<P>) {
125
+ return (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
126
+ }
127
+
128
+ function skipFor(hook: ShellSkip["hook"], what: string): ShellCaseOutcome {
129
+ return {
130
+ skipped: [
131
+ {
132
+ hook,
133
+ reason: `${hook === "drop" ? "harness.drop" : `show.${hook}`} is undefined: ${what} not verified`,
134
+ },
135
+ ],
136
+ };
137
+ }
138
+
139
+ async function aria(page: PageLike, root: string): Promise<AriaFacts | null> {
140
+ return inPage(page, ariaProbe, { root });
141
+ }
142
+
143
+ // ---- webChrome ----
144
+
145
+ /** Steps the page from wide to narrow; at every width the top row's decision and the bottom row. */
146
+ async function rowSamples(
147
+ page: PageLike,
148
+ root: string,
149
+ height: number,
150
+ ): Promise<{ samples: TopRowFacts[]; measurements: ChromeMeasurement[] }> {
151
+ const samples: TopRowFacts[] = [];
152
+ const measurements: ChromeMeasurement[] = [];
153
+ for (let width = WIDEST_STEP; width >= NARROWEST_STEP; width -= STEP_PX) {
154
+ await page.setViewportSize({ width, height });
155
+ await settle(page);
156
+ const sample = await inPage(page, topRowProbe, { root });
157
+ if (sample) samples.push(sample);
158
+ measurements.push(await measureChrome(page, root));
159
+ }
160
+ return { samples, measurements };
161
+ }
162
+
163
+ /** Hovers the primary action at `width`; the visible chrome tooltip's text, `null` without one. */
164
+ async function primaryTooltipAt(
165
+ page: PageLike,
166
+ root: string,
167
+ width: number,
168
+ height: number,
169
+ ): Promise<string | null> {
170
+ await page.setViewportSize({ width, height });
171
+ await settle(page);
172
+ const primary = await findTarget(page, root, "primary");
173
+ if (!primary) return null;
174
+ await page.mouse.move(PARK.x, PARK.y);
175
+ await page.mouse.move(primary.client.x, primary.client.y);
176
+ await page.waitForTimeout(TOOLTIP_DELAY_MS + HOVER_MARGIN_MS);
177
+ await settle(page);
178
+ const tooltip = await findOverlay(page, root, "tooltip");
179
+ await page.mouse.move(PARK.x, PARK.y);
180
+ return tooltip?.text ?? null;
181
+ }
182
+
183
+ /** A pointer click and Enter on the primary action, counted by a kit listener (guide §5.7). */
184
+ async function primaryActivation(page: PageLike, root: string): Promise<string[]> {
185
+ const primary = await findTarget(page, root, "primary");
186
+ if (!primary) return [];
187
+ await inPage(page, countClicksProbe, { root, selector: '[data-chrome="primary"]' });
188
+ await page.mouse.click(primary.client.x, primary.client.y);
189
+ await settle(page);
190
+ const pointer = await inPage(page, clicksProbe, null);
191
+ if (!(await focusTarget(page, root, "primary"))) {
192
+ await inPage(page, removeInjectedProbe, null);
193
+ return ["primary: the primary action cannot take the focus"];
194
+ }
195
+ await page.keyboard.press("Enter");
196
+ await settle(page);
197
+ const enter = (await inPage(page, clicksProbe, null)) - pointer;
198
+ await inPage(page, removeInjectedProbe, null);
199
+ await page.mouse.move(PARK.x, PARK.y);
200
+ return primaryActivationProblems({ pointer, enter });
201
+ }
202
+
203
+ /** Each File entry: a click closes the menu before its handler runs (guide §5.4). */
204
+ async function fileEntryProblems(page: PageLike, root: string): Promise<string[]> {
205
+ const entries = (await menuEntries(page, root)).filter((entry) => entry.group === "file");
206
+ const problems: string[] = [];
207
+ for (const entry of entries) {
208
+ if (!entry.menuId || !(await openMenuByClick(page, root))) continue;
209
+ const item = await inPage(page, fileItemProbe, { root, menuId: entry.menuId });
210
+ if (!item) {
211
+ await closeByEscape(page);
212
+ continue;
213
+ }
214
+ await page.mouse.click(item.x, item.y);
215
+ await settle(page);
216
+ const menu = await inPage(page, menuProbe, { root });
217
+ if (menu?.menu || menu?.expanded === "true") {
218
+ problems.push(
219
+ `menu: File entry "${entry.name}" leaves the menu open; an action closes it first (guide §5.4)`,
220
+ );
221
+ await closeByEscape(page);
222
+ }
223
+ // A handler that opens an in-page dialog gets it closed again before the next entry.
224
+ if (entry.name.endsWith("…")) await closeByEscape(page);
225
+ }
226
+ return problems;
227
+ }
228
+
229
+ /** Viewport centre of the visible menu item `menuId`. */
230
+ const fileItemProbe = ({
231
+ root,
232
+ menuId,
233
+ }: {
234
+ root: string;
235
+ menuId: string;
236
+ }): { x: number; y: number } | null => {
237
+ const item = document.querySelector(root)?.querySelector(`[role="menu"] [data-menu-id="${menuId}"]`);
238
+ if (!item) return null;
239
+ const rect = item.getBoundingClientRect();
240
+ return rect.width > 0 ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : null;
241
+ };
242
+
243
+ /** Guide §13.5: File entries stay usable on a read-only canvas. */
244
+ async function lockedFileProblems<P extends PageLike>(
245
+ page: P,
246
+ harness: ShellConformanceHarness<P>,
247
+ root: string,
248
+ ): Promise<string[]> {
249
+ const invalid = harness.show?.invalid;
250
+ if (!invalid) return [];
251
+ await invalid(page);
252
+ await page.waitForTimeout(INVALID_STATUS_DELAY_MS + HOVER_MARGIN_MS);
253
+ return (await menuEntries(page, root))
254
+ .filter((entry) => entry.group === "file" && entry.disabled)
255
+ .map(
256
+ (entry) => `menu: File entry "${entry.name}" is aria-disabled on the read-only canvas (guide §13.5)`,
257
+ );
258
+ }
259
+
260
+ const webChromeCase: ShellConformanceCase = {
261
+ name: "webChrome",
262
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
263
+ const root = harness.root ?? DEFAULT_ROOT;
264
+ const size = sizeOf(harness);
265
+ await openHarness(page, harness, { size, requested: LIGHT });
266
+ await settle(page);
267
+ const problems: string[] = [];
268
+ if (harness.host !== "web") {
269
+ const measurement = await measureChrome(page, root);
270
+ for (const island of ["primary", "legal", "empty-state", "drop-zone"] as const) {
271
+ if (measurement.islands[island]) {
272
+ problems.push(
273
+ `${island}: visible in the ${harness.host} host; it belongs to the web app (guide §5.2)`,
274
+ );
275
+ }
276
+ }
277
+ return finish("webChrome", harness.host, problems, []);
278
+ }
279
+
280
+ const facts = await aria(page, root);
281
+ if (facts) problems.push(...legalContentProblems(facts.legalLinks));
282
+ const name = (facts?.primary?.attributes["aria-label"] ?? "").trim();
283
+ problems.push(...(await primaryActivation(page, root)));
284
+
285
+ const { samples, measurements } = await rowSamples(page, root, size.height);
286
+ for (const measurement of measurements) {
287
+ problems.push(
288
+ ...webRowProblems(measurement, "web").map(
289
+ (problem) => `[${Math.round(measurement.container.width)}px wide] ${problem}`,
290
+ ),
291
+ ...zoomRowBottomProblems(
292
+ measurement.islands.zoom,
293
+ measurement.open.zoomRaised,
294
+ measurement.container,
295
+ measurement.islands.legal?.height,
296
+ ),
297
+ );
298
+ }
299
+ if (samples.some((sample) => sample.primary)) {
300
+ const withLabel = labelledWidth(samples);
301
+ if (withLabel === undefined) {
302
+ problems.push(
303
+ `primary: never shows its label between ${WIDEST_STEP}px and ${NARROWEST_STEP}px; it collapses only when the top row gets tight (guide §5.15)`,
304
+ );
305
+ } else {
306
+ problems.push(...topRowProblems(samples, withLabel, name));
307
+ }
308
+ const collapsed = samples.find((sample) => sample.primary?.collapsed);
309
+ const labelled = samples.find((sample) => sample.primary && !sample.primary.collapsed);
310
+ for (const sample of [labelled, collapsed]) {
311
+ if (!sample?.primary) continue;
312
+ const width = sample.container.width;
313
+ const tooltip = await primaryTooltipAt(page, root, width, size.height);
314
+ problems.push(...primaryTooltipProblems(sample.primary.collapsed, tooltip, name, width));
315
+ }
316
+ }
317
+
318
+ // The narrowest phone: the legal line wraps rather than cutting off Datenschutz.
319
+ await page.setViewportSize({ width: PHONE_WIDTH, height: size.height });
320
+ await settle(page);
321
+ const legalLine = await inPage(page, legalLineProbe, { root });
322
+ if (legalLine) problems.push(...legalReachProblems(legalLine));
323
+
324
+ await page.setViewportSize({ width: size.width, height: size.height });
325
+ await settle(page);
326
+ problems.push(...(await fileEntryProblems(page, root)));
327
+ problems.push(...(await lockedFileProblems(page, harness, root)));
328
+ return finish("webChrome", harness.host, [...new Set(problems)], []);
329
+ },
330
+ };
331
+
332
+ // ---- emptyState ----
333
+
334
+ function startScreen(page: PageLike, root: string): Promise<EmptyStateFacts | null> {
335
+ return inPage(page, emptyStateProbe, { root, work: WORK_CHROME });
336
+ }
337
+
338
+ /**
339
+ * Closing a toast that holds the focus on the start screen hands the focus to the card; the canvas
340
+ * and the menu trigger behind it cannot take it, and <body> would lose the user's place.
341
+ */
342
+ async function toastFocusOnStartScreen<P extends PageLike>(
343
+ page: P,
344
+ root: string,
345
+ show: (page: P, toast: ToastFixture) => Promise<void>,
346
+ ): Promise<string[]> {
347
+ const text = "Conformance: a toast on the start screen";
348
+ await show(page, { status: "danger", text });
349
+ await settle(page);
350
+ const closeName = (await toasts(page, root))?.toasts
351
+ .find((toast) => toast.text.includes(text))
352
+ ?.buttons.at(-1)?.name;
353
+ if (!closeName || !(await inPage(page, focusToastButtonProbe, { root, text, name: closeName }))) return [];
354
+ await page.keyboard.press("Enter");
355
+ await settle(page);
356
+ const focus = await inPage(page, focusOwnerProbe, { root });
357
+ return focus.owner === "empty-state"
358
+ ? []
359
+ : [
360
+ `empty-state: closing a focused toast on the start screen leaves the focus on ${focus.description}; it goes to the card's "New diagram" (WCAG 2.4.3)`,
361
+ ];
362
+ }
363
+
364
+ const emptyStateCase: ShellConformanceCase = {
365
+ name: "emptyState",
366
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
367
+ assertHooksConsistent("emptyState", harness, ["emptyState"]);
368
+ const root = harness.root ?? DEFAULT_ROOT;
369
+ const size = sizeOf(harness);
370
+ const show = harness.show?.emptyState;
371
+ if (harness.host !== "web") {
372
+ await openHarness(page, harness, { size, requested: LIGHT });
373
+ if (show) await show(page);
374
+ await settle(page);
375
+ const facts = await startScreen(page, root);
376
+ const problems: string[] = [];
377
+ if (facts?.card?.visible || facts?.shown) {
378
+ problems.push(
379
+ `empty-state: a start screen in the ${harness.host} host; it shows the empty-file hint instead (guide §13.5)`,
380
+ );
381
+ }
382
+ return finish("emptyState", harness.host, problems, []);
383
+ }
384
+ if (!show) {
385
+ return unfed(harness, "emptyState") ? skipFor("emptyState", "the start screen") : undefined;
386
+ }
387
+
388
+ await openHarness(page, harness, { size, requested: LIGHT });
389
+ await settle(page);
390
+ const lang = await inPage(page, documentLangProbe, null);
391
+ await show(page);
392
+ await settle(page);
393
+ const container = (await measureChrome(page, root)).container;
394
+ const facts = await startScreen(page, root);
395
+ if (!facts) return finish("emptyState", harness.host, [`no element matches "${root}"`], []);
396
+ const problems = emptyStateProblems(facts, container, lang);
397
+
398
+ await inPage(page, blurProbe, null);
399
+ const stops: Array<{ owner: string; description: string }> = [];
400
+ for (let press = 0; press < START_SCREEN_TABS; press++) {
401
+ await page.keyboard.press("Tab");
402
+ stops.push(await inPage(page, focusOwnerProbe, { root }));
403
+ }
404
+ problems.push(...emptyStateTabProblems(stops));
405
+
406
+ await inPage(page, blurProbe, null);
407
+ for (const key of ["Control+Equal", "Control+Minus", "Shift+Digit1"]) {
408
+ await inPage(page, clearKeysProbe, null);
409
+ const before = (await inPage(page, afterPressProbe, { root })).zoom;
410
+ await page.keyboard.press(key);
411
+ const after = await inPage(page, afterPressProbe, { root });
412
+ problems.push(...emptyStateKeyProblems(key, before, after.zoom, after.keys));
413
+ }
414
+
415
+ const showToast = harness.show?.toast;
416
+ if (showToast) problems.push(...(await toastFocusOnStartScreen(page, root, showToast)));
417
+
418
+ const start = facts.buttons[0];
419
+ if (start) {
420
+ await page.mouse.click(start.client.x, start.client.y);
421
+ await settle(page);
422
+ const after = await pollUntil(
423
+ page,
424
+ () => startScreen(page, root),
425
+ (state) => state === null || !state.shown,
426
+ 1000,
427
+ );
428
+ if (after) problems.push(...afterStartProblems(after, await inPage(page, focusOwnerProbe, { root })));
429
+ }
430
+ return finish("emptyState", harness.host, problems, []);
431
+ },
432
+ };
433
+
434
+ // ---- toasts ----
435
+
436
+ function toasts(page: PageLike, root: string): Promise<ToastsFacts | null> {
437
+ return inPage(page, toastsProbe, { root });
438
+ }
439
+
440
+ /**
441
+ * Clicks the Close button of every visible toast, oldest first, and parks the pointer away from the
442
+ * stack: a toast appearing under it would be hovered, which pauses and protects it.
443
+ */
444
+ async function dismissAll(page: PageLike, root: string): Promise<void> {
445
+ for (let attempt = 0; attempt < 6; attempt++) {
446
+ const close = (await toasts(page, root))?.toasts[0]?.buttons.at(-1);
447
+ if (!close) break;
448
+ await page.mouse.click(close.client.x, close.client.y);
449
+ await settle(page);
450
+ }
451
+ await page.mouse.move(PARK.x, PARK.y);
452
+ }
453
+
454
+ /** Waits until `ms` after `since` (Node's clock), then reads which of `texts` are shown. */
455
+ async function presentAt(
456
+ page: PageLike,
457
+ root: string,
458
+ since: number,
459
+ ms: number,
460
+ texts: readonly string[],
461
+ ): Promise<Set<string>> {
462
+ const wait = since + ms - Date.now();
463
+ if (wait > 0) await page.waitForTimeout(wait);
464
+ await settle(page);
465
+ const shown = (await toasts(page, root))?.toasts ?? [];
466
+ return new Set(texts.filter((text) => shown.some((toast) => toast.text.includes(text))));
467
+ }
468
+
469
+ async function toastAnatomy<P extends PageLike>(
470
+ page: P,
471
+ root: string,
472
+ show: (page: P, toast: ToastFixture) => Promise<void>,
473
+ lang: string,
474
+ ): Promise<string[]> {
475
+ const problems: string[] = [];
476
+ // Read before any toast of the case: a region that only comes with its first text is too late.
477
+ const before = (await toasts(page, root))?.live ?? [];
478
+ for (const status of TOAST_STATUSES) {
479
+ const text = `Conformance: a ${status} toast`;
480
+ await show(page, { status, text });
481
+ await settle(page);
482
+ const toast = (await toasts(page, root))?.toasts.find((candidate) => candidate.text.includes(text));
483
+ if (!toast) {
484
+ problems.push(`toasts: show.toast with a ${status} toast shows nothing`);
485
+ continue;
486
+ }
487
+ const persistent = toastLiveRegion(before, status) !== undefined;
488
+ const reached = (facts: ToastsFacts | null): boolean =>
489
+ toastLiveRegion(facts?.live ?? [], status)?.text.includes(text) ?? false;
490
+ const announced =
491
+ persistent && reached(await pollUntil(page, () => toasts(page, root), reached, TOAST_ANNOUNCE_WAIT_MS));
492
+ problems.push(...toastAnatomyProblems(toast, status, lang, { persistent, announced }));
493
+ const close = toast.buttons.at(-1);
494
+ if (close) {
495
+ await page.mouse.click(close.client.x, close.client.y);
496
+ await page.mouse.move(PARK.x, PARK.y);
497
+ await settle(page);
498
+ const still = (await toasts(page, root))?.toasts.some((candidate) => candidate.text.includes(text));
499
+ if (still) problems.push(`toasts: Close does not close the ${status} toast`);
500
+ }
501
+ }
502
+ await dismissAll(page, root);
503
+ return problems;
504
+ }
505
+
506
+ async function toastStack<P extends PageLike>(
507
+ page: P,
508
+ root: string,
509
+ show: (page: P, toast: ToastFixture) => Promise<void>,
510
+ ): Promise<string[]> {
511
+ const texts = ["A", "B", "C", "D"].map((letter) => `Conformance: stacked ${letter}`);
512
+ const [first, second, third, fourth] = texts as [string, string, string, string];
513
+ await show(page, { status: "info", text: first, actionLabel: "Undo" });
514
+ for (const text of [second, third, fourth]) await show(page, { status: "success", text });
515
+ await settle(page);
516
+ const problems = toastStackProblems((await toasts(page, root))?.toasts ?? [], texts, [
517
+ first,
518
+ third,
519
+ fourth,
520
+ ]);
521
+ await show(page, { status: "success", text: third });
522
+ await settle(page);
523
+ problems.push(...repeatedToastProblems((await toasts(page, root))?.toasts ?? [], third));
524
+ await dismissAll(page, root);
525
+ return problems;
526
+ }
527
+
528
+ async function toastTiming<P extends PageLike>(
529
+ page: P,
530
+ root: string,
531
+ show: (page: P, toast: ToastFixture) => Promise<void>,
532
+ ): Promise<string[]> {
533
+ const lifetime = TOAST_DURATION_MS.success;
534
+ const plain = "Conformance: a plain toast";
535
+ const hovered = "Conformance: a hovered toast";
536
+ const focused = "Conformance: a focused toast";
537
+ const texts = [plain, hovered, focused];
538
+ for (const text of texts) await show(page, { status: "success", text });
539
+ const shownAt = Date.now();
540
+ await settle(page);
541
+ const target = (await toasts(page, root))?.toasts.find((toast) => toast.text.includes(hovered));
542
+ if (target) await page.mouse.move(target.client.x, target.client.y);
543
+ const focusTarget = (await toasts(page, root))?.toasts.find((toast) => toast.text.includes(focused));
544
+ const closeName = focusTarget?.buttons.at(-1)?.name ?? "";
545
+ await inPage(page, focusToastButtonProbe, { root, text: focused, name: closeName });
546
+
547
+ const timings: ToastTiming[] = [];
548
+ const record = (
549
+ shown: Set<string>,
550
+ after: number,
551
+ expect: Array<[string, ToastTiming["expectation"], ToastTiming["condition"]]>,
552
+ ) => {
553
+ for (const [text, expectation, condition] of expect) {
554
+ timings.push({ text, expectation, after, present: shown.has(text), condition });
555
+ }
556
+ };
557
+ const early = lifetime - 800;
558
+ record(await presentAt(page, root, shownAt, early, texts), early, [[plain, "present", "plain"]]);
559
+ const late = lifetime + TOAST_MARGIN_MS;
560
+ record(await presentAt(page, root, shownAt, late, texts), late, [
561
+ [plain, "gone", "plain"],
562
+ [hovered, "present", "hovered"],
563
+ [focused, "present", "focused"],
564
+ ]);
565
+
566
+ await page.mouse.move(PARK.x, PARK.y);
567
+ await inPage(page, blurProbe, null);
568
+ const leftAt = Date.now();
569
+ const resumed = TOAST_RESUME_MS - 500;
570
+ record(await presentAt(page, root, leftAt, resumed, texts), resumed, [
571
+ [hovered, "present", "resumed"],
572
+ [focused, "present", "resumed"],
573
+ ]);
574
+ const gone = lifetime + TOAST_MARGIN_MS;
575
+ record(await presentAt(page, root, leftAt, gone, texts), gone, [
576
+ [hovered, "gone", "resumed"],
577
+ [focused, "gone", "resumed"],
578
+ ]);
579
+
580
+ const action = "Conformance: a toast with an action";
581
+ await show(page, { status: "info", text: action, actionLabel: "Undo" });
582
+ const actionAt = Date.now();
583
+ const actionWait = TOAST_DURATION_MS.info + TOAST_MARGIN_MS;
584
+ record(await presentAt(page, root, actionAt, actionWait, [action]), actionWait, [
585
+ [action, "present", "action"],
586
+ ]);
587
+ await dismissAll(page, root);
588
+ return toastTimingProblems(timings);
589
+ }
590
+
591
+ /**
592
+ * Web host: a toast shown while a `showModal()` dialog is open stays above it (API.md, Toasts): the
593
+ * stack, already shown for an earlier toast, is shown again in the top layer, above the dialog.
594
+ * A modal dialog makes everything outside it inert, so the kit reads the popover's toggles instead
595
+ * of hit-testing the toast.
596
+ */
597
+ async function toastAboveModal<P extends PageLike>(
598
+ page: P,
599
+ root: string,
600
+ show: (page: P, toast: ToastFixture) => Promise<void>,
601
+ ): Promise<string[]> {
602
+ const before = "Conformance: a toast before the dialog";
603
+ const text = "Conformance: a toast above the dialog";
604
+ await show(page, { status: "info", text: before, actionLabel: "Undo" });
605
+ await settle(page);
606
+ const problems: string[] = [];
607
+ if ((await toasts(page, root))?.stack?.popover !== "manual") {
608
+ problems.push(
609
+ 'toasts: the web app\'s stack is no popover="manual"; it stays above a showModal() dialog only in the top layer (API.md, Toasts)',
610
+ );
611
+ } else if (await inPage(page, watchToastStackProbe, { root })) {
612
+ if (await inPage(page, showModalProbe, null)) {
613
+ await show(page, { status: "success", text });
614
+ await settle(page);
615
+ const shown = (await toasts(page, root))?.toasts.some((toast) => toast.text.includes(text)) ?? false;
616
+ const { toggles, open } = await inPage(page, toastStackTogglesProbe, { root });
617
+ if (!shown) problems.push("toasts: show.toast with a showModal() dialog open shows nothing");
618
+ else if (!open || toggles.at(-1) !== "open") {
619
+ problems.push(
620
+ "toasts: a toast shown with a showModal() dialog open does not raise the stack above it; the stack is shown again with every toast (API.md, Toasts)",
621
+ );
622
+ }
623
+ }
624
+ }
625
+ await inPage(page, removeInjectedProbe, null);
626
+ await settle(page);
627
+ await dismissAll(page, root);
628
+ return problems;
629
+ }
630
+
631
+ const toastsCase: ShellConformanceCase = {
632
+ name: "toasts",
633
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
634
+ assertHooksConsistent("toasts", harness, ["toast"]);
635
+ const root = harness.root ?? DEFAULT_ROOT;
636
+ const size = sizeOf(harness);
637
+ const show = harness.show?.toast;
638
+ if (harness.host === "ide") {
639
+ await openHarness(page, harness, { size, requested: LIGHT });
640
+ if (show) await show(page, { status: "success", text: "Conformance: a toast in the IDE host" });
641
+ await settle(page);
642
+ const facts = await toasts(page, root);
643
+ const problems: string[] = [];
644
+ if (facts?.stack) {
645
+ problems.push(
646
+ 'toasts: the IDE host renders a toast stack [data-chrome="toasts"]; it shows no toasts (guide §13.5)',
647
+ );
648
+ }
649
+ if ((facts?.toasts.length ?? 0) > 0 || (facts?.strays.length ?? 0) > 0) {
650
+ problems.push(
651
+ "toasts: show.toast shows a toast in the IDE host; the extension shows the host's notification (guide §13.5)",
652
+ );
653
+ }
654
+ return finish("toasts", harness.host, problems, []);
655
+ }
656
+ if (!show) return unfed(harness, "toast") ? skipFor("toast", "the toasts") : undefined;
657
+
658
+ await openHarness(page, harness, { size, requested: LIGHT });
659
+ await settle(page);
660
+ const lang = await inPage(page, documentLangProbe, null);
661
+ await dismissAll(page, root);
662
+ const problems = [
663
+ ...(await toastAnatomy(page, root, show, lang)),
664
+ ...(await toastStack(page, root, show)),
665
+ ...(await toastTiming(page, root, show)),
666
+ ];
667
+ if (harness.host === "web") problems.push(...(await toastAboveModal(page, root, show)));
668
+ return finish("toasts", harness.host, problems, []);
669
+ },
670
+ };
671
+
672
+ // ---- problemsList ----
673
+
674
+ function popover(page: PageLike, root: string): Promise<PopoverFacts | null> {
675
+ return inPage(page, popoverProbe, { root });
676
+ }
677
+
678
+ async function clickPill(page: PageLike, root: string): Promise<PopoverFacts | null> {
679
+ const pill = (await popover(page, root))?.pill;
680
+ if (!pill?.visible) return null;
681
+ await page.mouse.click(pill.client.x, pill.client.y);
682
+ await settle(page);
683
+ return popover(page, root);
684
+ }
685
+
686
+ /**
687
+ * Opens the list from the pill with Enter, then walks it with ArrowDown to the last row and jumps
688
+ * back with Home and to the end with End; every step must land on its row, inside the list.
689
+ */
690
+ async function keyboardRows(page: PageLike, root: string): Promise<string[]> {
691
+ if (!(await focusTarget(page, root, "pill"))) return ["problems: the pill cannot take the focus"];
692
+ await page.keyboard.press("Enter");
693
+ await settle(page);
694
+ const opened = await popover(page, root);
695
+ const count = opened?.rows.length ?? 0;
696
+ if (!opened?.popover?.visible || count === 0) {
697
+ await closeByEscape(page);
698
+ return opened?.popover?.visible ? [] : ["problems-popover: Enter on the pill does not open the list"];
699
+ }
700
+ const steps: PopoverKeyStep[] = [{ key: "Enter on the pill", expected: 0, focused: opened.focusedRow }];
701
+ for (let index = 1; index < count; index++) {
702
+ await page.keyboard.press("ArrowDown");
703
+ steps.push({
704
+ key: "ArrowDown",
705
+ expected: index,
706
+ focused: (await popover(page, root))?.focusedRow ?? null,
707
+ });
708
+ }
709
+ if (count > 1) {
710
+ await page.keyboard.press("Home");
711
+ steps.push({ key: "Home", expected: 0, focused: (await popover(page, root))?.focusedRow ?? null });
712
+ await page.keyboard.press("End");
713
+ steps.push({ key: "End", expected: count - 1, focused: (await popover(page, root))?.focusedRow ?? null });
714
+ }
715
+ await closeByEscape(page);
716
+ return popoverKeyboardProblems(steps, count);
717
+ }
718
+
719
+ const problemsListCase: ShellConformanceCase = {
720
+ name: "problemsList",
721
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
722
+ assertHooksConsistent("problemsList", harness, ["problems"]);
723
+ const root = harness.root ?? DEFAULT_ROOT;
724
+ const size = sizeOf(harness);
725
+ const show = harness.show?.problems;
726
+ if (!show) return unfed(harness, "problems") ? skipFor("problems", "the problems list") : undefined;
727
+ await openHarness(page, harness, { size, requested: LIGHT });
728
+ await settle(page);
729
+ await show(page);
730
+ await settle(page);
731
+ const before = await popover(page, root);
732
+ const problems: string[] = [];
733
+ if (!before?.pill?.visible) {
734
+ return finish(
735
+ "problemsList",
736
+ harness.host,
737
+ ["problems: the pill is not visible after show.problems"],
738
+ [],
739
+ );
740
+ }
741
+ const opensList = harness.host === "web" || before.pill.attributes["aria-haspopup"] === "dialog";
742
+ if (!opensList) {
743
+ const after = await clickPill(page, root);
744
+ if (after?.popover?.visible) {
745
+ problems.push(
746
+ `problems-popover: the ${harness.host} host shows a problems list; its pill opens the host's own view (guide §5.10)`,
747
+ );
748
+ }
749
+ return finish("problemsList", harness.host, problems, []);
750
+ }
751
+ const lang = await inPage(page, documentLangProbe, null);
752
+ const container = (await measureChrome(page, root)).container;
753
+ const opened = await clickPill(page, root);
754
+ if (!opened) return finish("problemsList", harness.host, ["problems: the pill cannot be clicked"], []);
755
+ problems.push(...openPopoverProblems(opened, container, lang));
756
+
757
+ await closeByEscape(page);
758
+ const escaped = await popover(page, root);
759
+ if (escaped) problems.push(...closedPopoverProblems(escaped, "Escape"));
760
+
761
+ await clickPill(page, root);
762
+ await page.mouse.click(PARK.x, PARK.y);
763
+ await settle(page);
764
+ const outside = await popover(page, root);
765
+ if (outside) problems.push(...closedPopoverProblems(outside, "an outside click"));
766
+
767
+ problems.push(...(await keyboardRows(page, root)));
768
+
769
+ const skipped: ShellSkip[] = [];
770
+ const reopened = await clickPill(page, root);
771
+ const row = reopened?.rows.find((candidate) => candidate.button);
772
+ if (row) {
773
+ await page.mouse.click(row.client.x, row.client.y);
774
+ await settle(page);
775
+ const selected = await popover(page, root);
776
+ if (selected) problems.push(...closedPopoverProblems(selected, "a row with an element"));
777
+ } else {
778
+ await closeByEscape(page);
779
+ if ((reopened?.rows.length ?? 0) > 0 && !harness.absent?.includes("problemSelection")) {
780
+ skipped.push({
781
+ hook: "problems",
782
+ reason:
783
+ 'show.problems gives no item with an elementId: a row that selects its element was not verified (absent: ["problemSelection"] for a modeler whose problems never point at an element)',
784
+ });
785
+ }
786
+ }
787
+ return finish("problemsList", harness.host, problems, skipped);
788
+ },
789
+ };
790
+
791
+ // ---- dropZone ----
792
+
793
+ function drag(page: PageLike, spec: DragSpec): Promise<DropZoneFacts | null> {
794
+ return inPage(page, dragProbe, spec);
795
+ }
796
+
797
+ /** A file the drop zone forbids as the second of two. */
798
+ function secondFile(file: DropFixture): DropFixture {
799
+ return { ...file, name: `second-${file.name}` };
800
+ }
801
+
802
+ const dropZoneCase: ShellConformanceCase = {
803
+ name: "dropZone",
804
+ async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
805
+ assertHooksConsistent("dropZone", harness, ["drop", "emptyState"]);
806
+ const root = harness.root ?? DEFAULT_ROOT;
807
+ const size = sizeOf(harness);
808
+ const fixtures = harness.drop;
809
+ if (harness.host === "web" && !fixtures) {
810
+ return harness.absent?.includes("drop") ? undefined : skipFor("drop", "the drop zone");
811
+ }
812
+ await openHarness(page, harness, { size, requested: LIGHT });
813
+ await settle(page);
814
+ const problems: string[] = [];
815
+ const probeFile: DropFixture = { name: "conformance.txt", type: "text/plain", text: "conformance" };
816
+
817
+ if (harness.host !== "web" || !fixtures) {
818
+ const facts = await drag(page, { root, files: [probeFile], steps: ["dragenter", "dragover"] });
819
+ if (facts && (facts.state !== null || facts.zone?.visible)) {
820
+ problems.push(
821
+ `drop-zone: a file drag shows a drop zone in the ${harness.host} host; its files belong to the host (guide §5.2)`,
822
+ );
823
+ }
824
+ await drag(page, { root, files: [probeFile], steps: ["dragleave"] });
825
+ return finish("dropZone", harness.host, problems, []);
826
+ }
827
+
828
+ const container = (await measureChrome(page, root)).container;
829
+ const { accepted, rejected } = fixtures;
830
+ const check = async (
831
+ files: readonly DropFixture[],
832
+ strings: readonly string[],
833
+ expected: ExpectedDrop,
834
+ context: string,
835
+ ) => {
836
+ const over = await drag(page, { root, files, strings, steps: ["dragenter", "dragover"] });
837
+ if (over) problems.push(...dropStateProblems(over, expected, context, container));
838
+ const left = await drag(page, { root, files, strings, steps: ["dragleave"] });
839
+ if (left && expected !== "none") {
840
+ problems.push(...dropStateProblems(left, "none", `${context}, once it left the page,`, container));
841
+ }
842
+ };
843
+ await check([accepted], [], "over", `a drag of "${accepted.name}"`);
844
+ await check(
845
+ [accepted, secondFile(accepted)],
846
+ [],
847
+ fixtures.multiple ? "over" : "forbidden",
848
+ fixtures.multiple ? "a drag of two files (multiple)" : "a drag of two files",
849
+ );
850
+ if (await inPage(page, injectProbe, { root, kind: "modal" })) {
851
+ await check([accepted], [], "forbidden", "a file drag with a modal dialog open");
852
+ }
853
+ await inPage(page, removeInjectedProbe, null);
854
+ await check([], ["Conformance text"], "none", "a text drag");
855
+
856
+ const emptyState = harness.show?.emptyState;
857
+ if (emptyState) {
858
+ await emptyState(page);
859
+ await settle(page);
860
+ }
861
+ const onStartScreen = async () =>
862
+ (await inPage(page, emptyStateProbe, { root, work: [] }))?.shown ?? false;
863
+ const started = await onStartScreen();
864
+ if (rejected) {
865
+ await drag(page, { root, files: [rejected], steps: ["dragenter", "dragover", "drop"] });
866
+ const shown = await pollUntil(
867
+ page,
868
+ () => toasts(page, root),
869
+ (facts) => facts?.toasts.some((toast) => toast.status === "danger") ?? false,
870
+ DROP_WAIT_MS,
871
+ );
872
+ if (!shown?.toasts.some((toast) => toast.status === "danger")) {
873
+ problems.push(
874
+ `drop-zone: dropping "${rejected.name}" shows no danger toast (guide §9, or the modeler's onReject)`,
875
+ );
876
+ }
877
+ if (started && !(await onStartScreen())) {
878
+ problems.push(
879
+ `drop-zone: dropping "${rejected.name}" opened it; a file outside accept opens nothing`,
880
+ );
881
+ }
882
+ await dismissAll(page, root);
883
+ }
884
+ const dropped = await drag(page, { root, files: [accepted], steps: ["dragenter", "dragover", "drop"] });
885
+ if (dropped?.events.find((event) => event.type === "drop")?.defaultPrevented === false) {
886
+ problems.push(
887
+ `drop-zone: the drop of "${accepted.name}" is not prevented, so the browser would open the file`,
888
+ );
889
+ }
890
+ if (started) {
891
+ const opened = await pollUntil(page, onStartScreen, (shown) => !shown, DROP_WAIT_MS);
892
+ if (opened)
893
+ problems.push(
894
+ `drop-zone: dropping "${accepted.name}" on the start screen does not open it (the card stays)`,
895
+ );
896
+ }
897
+ const after = await drag(page, { root, files: [], steps: [] });
898
+ if (after && (after.state !== null || after.zone?.visible)) {
899
+ problems.push("drop-zone: the drop zone still shows after the drop");
900
+ }
901
+ return finish("dropZone", harness.host, problems, []);
902
+ },
903
+ };
904
+
905
+ /**
906
+ * The web column's cases (0.2), in this order after the 0.1 cases of `createCases`: `webChrome`
907
+ * (primary action, legal line, raised zoom row, File entries), `emptyState`, `toasts`,
908
+ * `problemsList` and `dropZone`. Each runs in every host; outside the hosts it applies to it
909
+ * asserts that nothing of it shows (API.md, `./testing`).
910
+ */
911
+ export function createWebCases(): ShellConformanceCase[] {
912
+ return [webChromeCase, emptyStateCase, toastsCase, problemsListCase, dropZoneCase];
913
+ }