@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
package/dist/testing.cjs CHANGED
@@ -38,7 +38,12 @@ var CHROME_ISLANDS = [
38
38
  "problems",
39
39
  "legend",
40
40
  "inspector",
41
- "status"
41
+ "status",
42
+ "legal",
43
+ "toasts",
44
+ "empty-state",
45
+ "drop-zone",
46
+ "problems-popover"
42
47
  ];
43
48
 
44
49
  // src/core/sizes.ts
@@ -67,10 +72,128 @@ var SHELL = {
67
72
  layerToast: 60
68
73
  };
69
74
 
75
+ // src/core/web-chrome/bottom-row.ts
76
+ var LEGAL_LINE_HEIGHT = 20;
77
+ function legalLineHeight(measured) {
78
+ if (measured === void 0 || !Number.isFinite(measured) || measured <= 0) return LEGAL_LINE_HEIGHT;
79
+ return Math.max(1, Math.round(measured / LEGAL_LINE_HEIGHT)) * LEGAL_LINE_HEIGHT;
80
+ }
81
+ function zoomRowRaised(input) {
82
+ const { containerWidth, legalRight, zoomWidth } = input;
83
+ if (legalRight === void 0 || zoomWidth === void 0) return false;
84
+ const zoomLeft = containerWidth - SHELL.edge - zoomWidth;
85
+ return legalRight + SHELL.gap > zoomLeft;
86
+ }
87
+
88
+ // src/core/messages.ts
89
+ var defaultMessages = {
90
+ menu: "Menu",
91
+ tools: "Tools",
92
+ canvas: "Diagram",
93
+ zoom: "Zoom",
94
+ zoomOut: "Zoom out",
95
+ zoomIn: "Zoom in",
96
+ zoomToActual: (percent) => `Zoom to ${percent}`,
97
+ zoomToActualNow: (percent, now) => `Zoom to ${percent} (now ${now})`,
98
+ fit: "Fit to view",
99
+ legend: "Legend",
100
+ closeLegend: "Close legend",
101
+ problems: (count, formattedCount) => `${formattedCount} ${count === 1 ? "problem" : "problems"}`,
102
+ problemsLabel: (count, errors, formattedCount, formattedErrors) => `${formattedCount} ${count === 1 ? "problem" : "problems"}, ${errors === 0 ? "no errors" : `${formattedErrors} ${errors === 1 ? "error" : "errors"}`}`,
103
+ showProblems: "Show in Problems view",
104
+ emptyHint: "Pick a tool from the palette to start.",
105
+ insertExample: "Insert example",
106
+ invalidFile: (diagram) => `This file isn't a valid ${diagram} right now. The diagram shows the last valid version and is read-only until you fix the text.`,
107
+ keyNames: { ctrl: "Ctrl", shift: "Shift", plus: "Plus", minus: "Minus" },
108
+ share: "Share",
109
+ legal: "Legal",
110
+ copyright: (year) => `\xA9 ${year} Miragon GmbH`,
111
+ opensInNewTab: "Opens in a new tab",
112
+ newDiagram: "New diagram",
113
+ openFile: "Open\u2026",
114
+ showExample: "Show example",
115
+ importFile: "Import\u2026",
116
+ appearance: "Appearance",
117
+ appearanceSystem: "System",
118
+ appearanceLight: "Light",
119
+ appearanceDark: "Dark",
120
+ problemsTitle: "Problems",
121
+ problemSeverity: (severity) => severity === "error" ? "Error" : "Warning",
122
+ close: "Close",
123
+ dropFile: "Drop file here",
124
+ dropForbidden: (reason) => reason === "multiple" ? "Drop one file at a time" : reason === "dialog" ? "Close the dialog to drop a file" : "This modeler can't open this file type",
125
+ dropHint: "Or drop a file here",
126
+ openFileLink: "Open a file\u2026",
127
+ cannotOpenFile: (name2, accepted) => `Couldn't open \u201C${name2}\u201D. Drop a ${accepted} file.`
128
+ };
129
+
70
130
  // src/core/layout.ts
71
131
  var PALETTE_CLEARANCE = 16;
72
132
  var TOOLTIP_DELAY_MS = 300;
73
133
  var INVALID_STATUS_DELAY_MS = 800;
134
+ function decidePaletteRow(input) {
135
+ const { containerWidth, paletteWidth, menuRight, primaryLeft } = input;
136
+ if (containerWidth <= SHELL.narrow) return "second-row";
137
+ const paletteLeft = (containerWidth - paletteWidth) / 2;
138
+ const paletteRight = (containerWidth + paletteWidth) / 2;
139
+ if (paletteLeft < menuRight + PALETTE_CLEARANCE) return "second-row";
140
+ if (primaryLeft !== void 0 && paletteRight > primaryLeft - PALETTE_CLEARANCE) return "second-row";
141
+ return "top";
142
+ }
143
+
144
+ // src/core/web-chrome/top-row.ts
145
+ function decideTopRow(input) {
146
+ const { containerWidth, paletteWidth, menuRight, primary } = input;
147
+ if (!primary) {
148
+ return {
149
+ paletteRow: decidePaletteRow({ containerWidth, paletteWidth, menuRight }),
150
+ primaryLabel: "shown"
151
+ };
152
+ }
153
+ const paletteRowBeside = (primaryWidth) => decidePaletteRow({
154
+ containerWidth,
155
+ paletteWidth,
156
+ menuRight,
157
+ primaryLeft: containerWidth - SHELL.edge - primaryWidth
158
+ });
159
+ if (paletteRowBeside(primary.withLabel) === "top") return { paletteRow: "top", primaryLabel: "shown" };
160
+ if (paletteRowBeside(primary.iconOnly) === "top") return { paletteRow: "top", primaryLabel: "collapsed" };
161
+ const labelledLeft = containerWidth - SHELL.edge - primary.withLabel;
162
+ return {
163
+ paletteRow: "second-row",
164
+ primaryLabel: menuRight + PALETTE_CLEARANCE <= labelledLeft ? "shown" : "collapsed"
165
+ };
166
+ }
167
+
168
+ // src/core/feedback/toasts.ts
169
+ var MAX_TOASTS = 3;
170
+ var TOAST_DURATION_MS = {
171
+ success: 2800,
172
+ info: 2800,
173
+ warning: 5e3,
174
+ danger: 5e3
175
+ };
176
+ var TOAST_MAX_WIDTH = 480;
177
+ function toastStackWidth(containerWidth) {
178
+ return Math.max(0, Math.min(TOAST_MAX_WIDTH, containerWidth - 2 * SHELL.edge));
179
+ }
180
+ function toastStackBottom(input) {
181
+ const stackWidth = toastStackWidth(input.containerWidth);
182
+ const { cardWidth } = input;
183
+ const width = cardWidth !== void 0 && Number.isFinite(cardWidth) ? Math.min(stackWidth, Math.max(0, cardWidth)) : stackWidth;
184
+ const left = (input.containerWidth - width) / 2;
185
+ const right = left + width;
186
+ let highest;
187
+ for (const obstacle of input.obstacles) {
188
+ const distance = Math.max(obstacle.left - right, left - obstacle.right);
189
+ if (distance >= SHELL.gap) continue;
190
+ highest = highest === void 0 ? obstacle.top : Math.max(highest, obstacle.top);
191
+ }
192
+ return highest === void 0 ? SHELL.edge : Math.max(SHELL.edge, highest + SHELL.gap);
193
+ }
194
+
195
+ // src/core/feedback/appearance.ts
196
+ var APPEARANCE_STORAGE_KEY = "miragon.darstellung";
74
197
 
75
198
  // src/testing/page-tools.ts
76
199
  var installPageTools = (_) => {
@@ -113,6 +236,7 @@ var installPageTools = (_) => {
113
236
  errors: [],
114
237
  injected: [],
115
238
  cleanups: [],
239
+ clicks: 0,
116
240
  frame,
117
241
  async settle() {
118
242
  await frame();
@@ -201,7 +325,9 @@ var CHROME_TARGET_SELECTORS = {
201
325
  "zoom-actual": '[data-chrome="zoom"] [data-zoom="actual"]',
202
326
  "zoom-in": '[data-chrome="zoom"] [data-zoom="in"]',
203
327
  "zoom-fit": '[data-chrome="zoom"] [data-zoom="fit"]',
204
- canvas: ".djs-container > svg"
328
+ canvas: ".djs-container > svg",
329
+ primary: '[data-chrome="primary"]',
330
+ pill: '[data-chrome="problems"]'
205
331
  };
206
332
  var targetProbe = ({ root, selector }) => {
207
333
  const tools = window.__modelerShellConformance;
@@ -333,7 +459,10 @@ var hostPageModeProbe = ({ theme, kontrast }) => {
333
459
  var GEOMETRY_TOLERANCE_PX = 1;
334
460
  var DEFAULT_ROOT = ".cd-shell";
335
461
  var ZOOM_PANEL_WIDTH = 173;
336
- var LEGAL_LINE = 20;
462
+ var LEGAL_LINE = LEGAL_LINE_HEIGHT;
463
+ var EMPTY_STATE_MAX_WIDTH = 400;
464
+ var POPOVER_MAX_WIDTH = 320;
465
+ var POPOVER_MAX_HEIGHT = 320;
337
466
  var LEGEND_MIN_WIDTH = 184;
338
467
  var STATUS_MAX_WIDTH = 560;
339
468
  var DROPDOWN_MAX_WIDTH = 320;
@@ -354,14 +483,19 @@ var measureProbe = ({ root, islands }) => {
354
483
  }
355
484
  boxes[name2] = tools.box(element, rootElement);
356
485
  }
486
+ const cardWidths = [...rootElement.querySelectorAll('[data-chrome="toasts"] > [data-status]')].filter((card) => tools.visible(card)).map((card) => card.getBoundingClientRect().width);
357
487
  return {
488
+ ...cardWidths.length > 0 ? { toastCardWidth: Math.max(...cardWidths) } : {},
358
489
  container: tools.container(rootElement),
359
490
  paletteRow: rootElement.getAttribute("data-palette") === "second-row" ? "second-row" : "top",
360
491
  islands: boxes,
361
492
  open: {
362
493
  legend: rootElement.getAttribute("data-legend") === "open",
363
494
  inspector: rootElement.getAttribute("data-inspector") === "open",
364
- problems: rootElement.getAttribute("data-problems") === "shown"
495
+ problems: rootElement.getAttribute("data-problems") === "shown",
496
+ emptyState: rootElement.getAttribute("data-empty-state") === "shown",
497
+ primaryCollapsed: rootElement.getAttribute("data-primary") === "collapsed",
498
+ zoomRaised: rootElement.getAttribute("data-zoom-row") === "raised"
365
499
  },
366
500
  duplicates
367
501
  };
@@ -377,7 +511,8 @@ async function measureChrome(page, root = DEFAULT_ROOT) {
377
511
  container: result.container,
378
512
  paletteRow: result.paletteRow,
379
513
  islands: result.islands,
380
- open: result.open
514
+ open: result.open,
515
+ ...result.toastCardWidth !== void 0 ? { toastCardWidth: result.toastCardWidth } : {}
381
516
  };
382
517
  }
383
518
  function isNarrow(container) {
@@ -389,34 +524,66 @@ function isVeryNarrow(container) {
389
524
  function sheetOpen(measurement) {
390
525
  return measurement.open.inspector && isNarrow(measurement.container);
391
526
  }
527
+ function bottomObstacle(box, container) {
528
+ return { left: box.left, right: box.left + box.width, top: container.height - box.top };
529
+ }
530
+ var WEB_ONLY = {
531
+ primary: "the top right stays empty in IDE and embedded hosts (guide \xA75.7)",
532
+ legal: "the legal line belongs to the web app; an IDE or an embedding host carries its own (guide \xA75.2)",
533
+ "empty-state": "IDE and embedded hosts have no start screen; they show the empty-file hint (guide \xA713.5)",
534
+ "drop-zone": "IDE and embedded hosts have no drop zone; their files belong to the host (guide \xA75.2)"
535
+ };
536
+ var WORK_CHROME = [
537
+ "menu",
538
+ "primary",
539
+ "palette",
540
+ "zoom",
541
+ "problems",
542
+ "legend",
543
+ "status",
544
+ "inspector"
545
+ ];
392
546
  function chromeExpectations(measurement, host) {
393
547
  const { container, islands, open, paletteRow } = measurement;
394
548
  const { edge, gap, row, free, inspectorWidth } = SHELL;
395
549
  const web = host === "web";
550
+ const startScreen2 = web && open.emptyState;
551
+ const raised = web && open.zoomRaised;
396
552
  const secondRow = paletteRow === "second-row";
397
553
  const sheet = sheetOpen(measurement);
398
554
  const sideInspector = open.inspector && !isNarrow(container);
555
+ const legalHeight = legalLineHeight(web ? islands.legal?.height : void 0);
399
556
  const boxes = {};
400
557
  const limits = {};
401
558
  const hidden = {};
402
- const required = ["menu", "palette"];
559
+ const required = startScreen2 ? ["empty-state"] : ["menu", "palette"];
560
+ if (web) required.push("legal");
403
561
  boxes.menu = { left: edge, top: edge, width: row, height: row };
404
562
  boxes.palette = { top: secondRow ? free : edge, height: row, centerX: container.width / 2 };
405
563
  const menu = islands.menu;
406
- limits.palette = secondRow ? { maxWidth: container.width - 2 * edge } : menu ? { minLeft: menu.left + menu.width + PALETTE_CLEARANCE } : {};
564
+ const primary = web ? islands.primary : void 0;
565
+ if (secondRow) {
566
+ limits.palette = { maxWidth: container.width - 2 * edge };
567
+ } else {
568
+ limits.palette = {};
569
+ if (menu) limits.palette.minLeft = menu.left + menu.width + PALETTE_CLEARANCE;
570
+ if (primary) limits.palette.minRight = container.width - primary.left + PALETTE_CLEARANCE;
571
+ }
407
572
  if (sheet) {
408
573
  const reason = "hidden while the inspector is a bottom sheet (guide \xA75.11)";
409
574
  hidden.zoom = reason;
410
575
  hidden.problems = reason;
411
576
  hidden.legend = reason;
412
577
  } else {
413
- required.push("zoom");
414
- boxes.zoom = { right: edge, bottom: edge, width: ZOOM_PANEL_WIDTH, height: row };
415
- const stackBase = web ? edge + LEGAL_LINE + gap : edge;
578
+ if (!startScreen2) required.push("zoom");
579
+ const legalStack = edge + legalHeight + gap;
580
+ boxes.zoom = { right: edge, bottom: raised ? legalStack : edge, width: ZOOM_PANEL_WIDTH, height: row };
581
+ const stackBase = web ? legalStack : edge;
416
582
  if (islands.problems) boxes.problems = { left: edge, bottom: stackBase, height: row };
417
583
  if (islands.legend) {
418
584
  const besidePill = open.problems ? stackBase + row + gap : stackBase;
419
- const bottom = isVeryNarrow(container) ? Math.max(besidePill, edge + row + gap) : besidePill;
585
+ const aboveBottomRow = (raised ? legalStack : edge) + row + gap;
586
+ const bottom = isVeryNarrow(container) ? Math.max(besidePill, aboveBottomRow) : besidePill;
420
587
  boxes.legend = { left: edge, bottom };
421
588
  limits.legend = { minWidth: LEGEND_MIN_WIDTH };
422
589
  }
@@ -439,9 +606,52 @@ function chromeExpectations(measurement, host) {
439
606
  };
440
607
  }
441
608
  if (web) {
442
- if (islands.primary) boxes.primary = { top: edge, right: edge, height: row };
609
+ if (primary) {
610
+ boxes.primary = { top: edge, right: edge, height: row };
611
+ if (open.primaryCollapsed) boxes.primary.width = row;
612
+ }
613
+ boxes.legal = { left: edge, bottom: edge, height: legalHeight };
614
+ limits.legal = { maxWidth: container.width - 2 * edge };
615
+ if (islands["empty-state"]) {
616
+ boxes["empty-state"] = {
617
+ centerX: container.width / 2,
618
+ centerY: container.height / 2,
619
+ width: Math.min(EMPTY_STATE_MAX_WIDTH, container.width - 2 * edge)
620
+ };
621
+ }
622
+ if (islands["drop-zone"]) boxes["drop-zone"] = { left: edge, top: edge, right: edge, bottom: edge };
443
623
  } else {
444
- hidden.primary = "the top right stays empty in IDE and embedded hosts (guide \xA75.7)";
624
+ Object.assign(hidden, WEB_ONLY);
625
+ }
626
+ if (host === "ide") {
627
+ hidden.toasts = "the IDE host shows no toasts; the extension shows the host's notification (guide \xA713.5)";
628
+ hidden["problems-popover"] = "the IDE host opens its Problems view, never a list of its own (guide \xA75.10)";
629
+ } else {
630
+ if (islands.toasts) {
631
+ const obstacles = ["zoom", "legal", "problems"].flatMap((name2) => {
632
+ const box = islands[name2];
633
+ return box ? [bottomObstacle(box, container)] : [];
634
+ });
635
+ boxes.toasts = {
636
+ centerX: container.width / 2,
637
+ width: toastStackWidth(container.width),
638
+ bottom: toastStackBottom({
639
+ containerWidth: container.width,
640
+ obstacles,
641
+ cardWidth: measurement.toastCardWidth
642
+ })
643
+ };
644
+ }
645
+ const pill = islands.problems;
646
+ if (pill) {
647
+ boxes["problems-popover"] = { left: edge, bottom: container.height - pill.top + gap };
648
+ limits["problems-popover"] = {
649
+ maxWidth: Math.min(POPOVER_MAX_WIDTH, container.width - 2 * edge),
650
+ maxHeight: POPOVER_MAX_HEIGHT
651
+ };
652
+ } else {
653
+ hidden["problems-popover"] = "the problems list opens only above a visible pill (guide \xA75.10)";
654
+ }
445
655
  }
446
656
  return { boxes, limits, required, hidden };
447
657
  }
@@ -457,7 +667,8 @@ function sides(box, container) {
457
667
  bottom: container.height - box.top - box.height,
458
668
  width: box.width,
459
669
  height: box.height,
460
- centerX: box.left + box.width / 2
670
+ centerX: box.left + box.width / 2,
671
+ centerY: box.top + box.height / 2
461
672
  };
462
673
  }
463
674
  function boxMismatches(name2, box, expected, container, tolerance = GEOMETRY_TOLERANCE_PX) {
@@ -518,12 +729,17 @@ function geometryProblems(measurement, host, tolerance = GEOMETRY_TOLERANCE_PX)
518
729
  }
519
730
  const palette = islands.palette;
520
731
  const menu = islands.menu;
732
+ const primary = host === "web" ? islands.primary : void 0;
521
733
  if (paletteRow === "second-row" && !isNarrow(container) && palette && menu) {
522
734
  const clipped = palette.width >= container.width - 2 * SHELL.edge - tolerance;
523
735
  const topRowLeft = (container.width - palette.width) / 2;
524
- if (!clipped && topRowLeft >= menu.left + menu.width + PALETTE_CLEARANCE + tolerance) {
736
+ const toMenu = topRowLeft - menu.left - menu.width;
737
+ const toPrimary = primary ? container.width - SHELL.edge - SHELL.row - (topRowLeft + palette.width) : Number.POSITIVE_INFINITY;
738
+ const fits = toMenu >= PALETTE_CLEARANCE + tolerance && toPrimary >= PALETTE_CLEARANCE + tolerance;
739
+ if (!clipped && fits) {
740
+ const kept = primary ? `${round(toMenu)}px to the menu trigger and ${round(toPrimary)}px to the primary action's icon` : `${round(toMenu)}px to the menu trigger`;
525
741
  problems.push(
526
- `palette: in the second row although the top row keeps ${round(topRowLeft - menu.left - menu.width)}px to the menu trigger (at least ${PALETTE_CLEARANCE}px suffice, guide \xA75.15)`
742
+ `palette: in the second row although the top row keeps ${kept} (at least ${PALETTE_CLEARANCE}px suffice, guide \xA75.15)`
527
743
  );
528
744
  }
529
745
  }
@@ -534,9 +750,43 @@ function geometryProblems(measurement, host, tolerance = GEOMETRY_TOLERANCE_PX)
534
750
  }
535
751
  if (!flag && shown) problems.push(`${island}: visible, but the root lacks ${attribute2}`);
536
752
  };
537
- state("problems", open.problems, 'data-problems="shown"', sheet);
538
- state("legend", open.legend, 'data-legend="open"', sheet);
539
- state("inspector", open.inspector, 'data-inspector="open"', false);
753
+ state("problems", open.problems, 'data-problems="shown"', sheet || open.emptyState);
754
+ state("legend", open.legend, 'data-legend="open"', sheet || open.emptyState);
755
+ state("inspector", open.inspector, 'data-inspector="open"', open.emptyState);
756
+ state("empty-state", open.emptyState, 'data-empty-state="shown"', false);
757
+ problems.push(...webRowProblems(measurement, host, tolerance));
758
+ return problems;
759
+ }
760
+ function webRowProblems(measurement, host, tolerance = GEOMETRY_TOLERANCE_PX) {
761
+ const { container, islands, open } = measurement;
762
+ const problems = [];
763
+ if (host !== "web") {
764
+ if (open.primaryCollapsed)
765
+ problems.push('primary: the root carries data-primary="collapsed" outside the web app');
766
+ if (open.zoomRaised) problems.push('zoom: the root carries data-zoom-row="raised" outside the web app');
767
+ return problems;
768
+ }
769
+ if (open.primaryCollapsed && !islands.primary && !open.emptyState) {
770
+ problems.push('primary: the root carries data-primary="collapsed", but no primary action is visible');
771
+ }
772
+ const legal = islands.legal;
773
+ const zoom = islands.zoom;
774
+ if (legal && zoom) {
775
+ const legalRight = legal.left + legal.width;
776
+ const gap = container.width - SHELL.edge - zoom.width - legalRight;
777
+ const raised = zoomRowRaised({ containerWidth: container.width, legalRight, zoomWidth: zoom.width });
778
+ const borderline = Math.abs(gap - SHELL.gap) <= tolerance;
779
+ if (raised && !open.zoomRaised && !borderline) {
780
+ problems.push(
781
+ `zoom: ${round(gap)}px between the legal line and the zoom panel, but the root lacks data-zoom-row="raised" (below ${SHELL.gap}px the zoom panel stands above the legal line)`
782
+ );
783
+ }
784
+ if (!raised && open.zoomRaised && !borderline) {
785
+ problems.push(
786
+ `zoom: the root carries data-zoom-row="raised" although the zoom panel keeps ${round(gap)}px to the legal line (at least ${SHELL.gap}px)`
787
+ );
788
+ }
789
+ }
540
790
  return problems;
541
791
  }
542
792
  function expectedFitInsets(measurement, host) {
@@ -544,7 +794,7 @@ function expectedFitInsets(measurement, host) {
544
794
  const insets = {
545
795
  top: paletteRow === "second-row" ? SHELL.fitInsetTopSecondRow : SHELL.fitInsetTop,
546
796
  right: SHELL.fitInsetSide,
547
- bottom: host === "web" && open.problems ? SHELL.fitInsetBottomWeb : SHELL.fitInsetBottom,
797
+ bottom: host === "web" && (open.problems || open.zoomRaised) ? SHELL.fitInsetBottomWeb + legalLineHeight(islands.legal?.height) - LEGAL_LINE : SHELL.fitInsetBottom,
548
798
  left: SHELL.fitInsetSide
549
799
  };
550
800
  if (sheetOpen(measurement)) {
@@ -588,10 +838,10 @@ function dropdownProblems(box, container, tolerance = GEOMETRY_TOLERANCE_PX) {
588
838
  )
589
839
  ];
590
840
  }
591
- function tooltipProblems(tooltip, anchor, row, container, tolerance = GEOMETRY_TOLERANCE_PX) {
841
+ function tooltipProblems(tooltip, anchor, row, container, tolerance = GEOMETRY_TOLERANCE_PX, bottomRow = SHELL.edge) {
592
842
  const { edge, gap, row: rowHeight, free } = SHELL;
593
843
  const clamp = (centred) => Math.max(edge, Math.min(centred, container.width - edge - tooltip.width));
594
- const expectedAt = (left) => row === "bottom" ? { left, bottom: edge + rowHeight + gap } : { left, top: (row === "top" ? edge : free) + rowHeight + gap };
844
+ const expectedAt = (left) => row === "bottom" ? { left, bottom: bottomRow + rowHeight + gap } : { left, top: (row === "top" ? edge : free) + rowHeight + gap };
595
845
  let mismatches = boxMismatches(
596
846
  "tooltip",
597
847
  tooltip,
@@ -629,10 +879,13 @@ var DOM_ORDER = [
629
879
  "canvas",
630
880
  "status",
631
881
  "hint",
882
+ "empty-state",
632
883
  "inspector",
633
884
  "problems",
634
885
  "legend",
635
- "zoom"
886
+ "legal",
887
+ "zoom",
888
+ "toasts"
636
889
  ];
637
890
  var ariaProbe = ({ root }) => {
638
891
  const tools = window.__modelerShellConformance;
@@ -653,6 +906,8 @@ var ariaProbe = ({ root }) => {
653
906
  }
654
907
  const legend = find('[data-chrome="legend"]');
655
908
  const labelledBy = legend?.getAttribute("aria-labelledby");
909
+ const legal = find('[data-chrome="legal"]');
910
+ const pillControls = find('[data-chrome="problems"]')?.getAttribute("aria-controls");
656
911
  const parts = [
657
912
  ["menu", trigger],
658
913
  ["primary", find('[data-chrome="primary"]')],
@@ -662,13 +917,17 @@ var ariaProbe = ({ root }) => {
662
917
  ["hint", find(".cd-shell-hint")],
663
918
  ["inspector", find('[data-chrome="inspector"]')],
664
919
  ["problems", find('[data-chrome="problems"]')],
920
+ ["empty-state", find('[data-chrome="empty-state"]')],
665
921
  ["legend", legend],
666
- ["zoom", zoom]
922
+ ["legal", find('[data-chrome="legal"]')],
923
+ ["zoom", zoom],
924
+ ["toasts", find('[data-chrome="toasts"]')]
667
925
  ];
668
926
  const present = parts.filter((part) => part[1] !== null);
669
927
  present.sort(([, a], [, b]) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1);
670
928
  const titledSelector = [
671
929
  '[data-chrome="menu"][title]',
930
+ '[data-chrome="primary"][title]',
672
931
  '[data-chrome="palette"] [data-action][title]',
673
932
  '[data-chrome="zoom"] [title]',
674
933
  '[data-chrome="problems"][title]'
@@ -696,7 +955,18 @@ var ariaProbe = ({ root }) => {
696
955
  legend: factsOf(legend),
697
956
  legendTitle: labelledBy ? labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim() : null,
698
957
  legendClose: factsOf(legend?.querySelector("button")),
699
- inspector: factsOf(find('[data-chrome="inspector"]'))
958
+ inspector: factsOf(find('[data-chrome="inspector"]')),
959
+ primary: factsOf(find('[data-chrome="primary"]')),
960
+ primaryCollapsed: rootElement.getAttribute("data-primary") === "collapsed",
961
+ legal: factsOf(legal),
962
+ legalLinks: [...legal?.querySelectorAll("a") ?? []].map((link) => ({
963
+ text: (link.textContent ?? "").replace(/\s+/g, " ").trim(),
964
+ href: link.getAttribute("href") ?? "",
965
+ lang: link.getAttribute("lang"),
966
+ target: link.getAttribute("target"),
967
+ rel: link.getAttribute("rel")
968
+ })),
969
+ problemsControlled: factsOf(pillControls ? document.getElementById(pillControls) : null)
700
970
  };
701
971
  };
702
972
  var menuProbe = ({ root }) => {
@@ -965,617 +1235,2583 @@ var menuEntriesProbe = ({ root }) => {
965
1235
  role,
966
1236
  menuId: element.closest("[data-menu-id]")?.getAttribute("data-menu-id") ?? null,
967
1237
  disabled: element.getAttribute("aria-disabled") === "true",
968
- group: element.closest("[data-menu-group]")?.getAttribute("data-menu-group") ?? null
1238
+ group: element.closest("[data-menu-group]")?.getAttribute("data-menu-group") ?? null,
1239
+ value: element.getAttribute("data-menu-value"),
1240
+ checked: element.getAttribute("aria-checked"),
1241
+ icon: element.querySelector(".cd-shell-menu-item-icon svg") !== null
969
1242
  });
970
1243
  }
971
1244
  return entries;
972
1245
  };
973
1246
 
974
- // src/testing/checks.ts
1247
+ // src/testing/web-checks.ts
1248
+ var round2 = (value) => Math.round(value * 10) / 10;
975
1249
  var normalize = (text) => text.replace(/\s+/g, " ").trim();
976
- var name = (element) => normalize(element.attributes["aria-label"] ?? element.text);
977
- var attribute = (element, key) => element.attributes[key];
978
- function expectedKeyShortcuts(platform) {
979
- const modifier = platform === "mac" ? "Meta" : "Control";
980
- return {
981
- in: `${modifier}+= ${modifier}+Plus`,
982
- out: `${modifier}+-`,
983
- actual: `${modifier}+0`,
984
- fit: "Shift+1"
985
- };
1250
+ var MIRAGON_HOME = "https://www.miragon.io/";
1251
+ var MIRAGON_IMPRESSUM = "https://www.miragon.io/impressum/";
1252
+ var MIRAGON_DATENSCHUTZ = "https://www.miragon.io/datenschutz/";
1253
+ var GERMAN_LEGAL_LINKS = ["Impressum", "Datenschutz"];
1254
+ function speaksEnglish(lang) {
1255
+ return /^en(-|$)/i.test(lang.trim());
986
1256
  }
987
- function ariaProblems(facts, host) {
1257
+ function primaryAriaProblems(facts, host) {
1258
+ const primary = facts.primary;
1259
+ if (!primary || host !== "web") return [];
988
1260
  const problems = [];
989
- if (host === "embedded") {
990
- if (!facts.rootLang)
991
- problems.push("root: an embedded shell root carries the tool's lang itself (guide \xA72, \xA712)");
992
- } else if (!facts.documentLang) {
993
- problems.push("<html> has no lang (guide \xA72)");
1261
+ if (primary.tag !== "button") problems.push(`primary: a <${primary.tag}>, expected a <button>`);
1262
+ const label = normalize(primary.attributes["aria-label"] ?? "");
1263
+ if (!label) {
1264
+ problems.push(
1265
+ "primary: no aria-label; it names the action also while the label is collapsed (guide \xA75.15)"
1266
+ );
1267
+ } else if (!facts.primaryCollapsed && primary.text && !label.includes(primary.text)) {
1268
+ problems.push(
1269
+ `primary: the aria-label "${label}" does not contain the visible label "${primary.text}" (WCAG 2.5.3)`
1270
+ );
994
1271
  }
995
- const trigger = facts.trigger;
996
- if (!trigger) {
997
- problems.push('menu: no [data-chrome="menu"] trigger');
998
- } else {
999
- if (trigger.tag !== "button")
1000
- problems.push(`menu: the trigger is a <${trigger.tag}>, expected a <button>`);
1001
- if (!attribute(trigger, "aria-label")) problems.push("menu: the icon-only trigger has no aria-label");
1002
- if (attribute(trigger, "aria-haspopup") !== "menu") {
1003
- problems.push(`menu: aria-haspopup is "${attribute(trigger, "aria-haspopup") ?? ""}", expected "menu"`);
1272
+ if (primary.disabled) {
1273
+ problems.push(
1274
+ "primary: natively disabled; a disabled primary action keeps the focus with aria-disabled (guide \xA78)"
1275
+ );
1276
+ }
1277
+ return problems;
1278
+ }
1279
+ function legalAriaProblems(facts, host) {
1280
+ if (host !== "web") return [];
1281
+ const legal = facts.legal;
1282
+ if (!legal) return ['legal: no [data-chrome="legal"] in the web app (guide \xA75.10)'];
1283
+ const problems = [];
1284
+ if (legal.tag !== "nav") problems.push(`legal: the legal line is a <${legal.tag}>, expected a <nav>`);
1285
+ if (!normalize(legal.attributes["aria-label"] ?? "")) problems.push("legal: the <nav> has no aria-label");
1286
+ for (const text of GERMAN_LEGAL_LINKS) {
1287
+ const link = facts.legalLinks.find((candidate) => candidate.text === text);
1288
+ if (!link) {
1289
+ problems.push(`legal: no "${text}" link (guide \xA75.10)`);
1290
+ } else if (link.lang !== "de") {
1291
+ problems.push(
1292
+ `legal: "${text}" has ${link.lang === null ? "no lang" : `lang="${link.lang}"`}, expected lang="de" (guide \xA72: Impressum and Datenschutz stay German)`
1293
+ );
1004
1294
  }
1005
- if (attribute(trigger, "aria-expanded") !== "false") {
1295
+ }
1296
+ for (const link of facts.legalLinks) {
1297
+ if (link.target !== "_blank") continue;
1298
+ const rel = (link.rel ?? "").split(/\s+/);
1299
+ if (!rel.includes("noopener") || !rel.includes("noreferrer")) {
1300
+ problems.push(`legal: the link "${link.text}" opens a new tab without rel="noopener noreferrer"`);
1301
+ }
1302
+ }
1303
+ return problems;
1304
+ }
1305
+ function pillPopupAriaProblems(facts, host) {
1306
+ const pill = facts.problems;
1307
+ if (!pill || host === "ide") return [];
1308
+ const haspopup = pill.attributes["aria-haspopup"];
1309
+ if (host === "embedded" && haspopup === void 0) return [];
1310
+ const problems = [];
1311
+ if (haspopup !== "dialog") {
1312
+ problems.push(
1313
+ `problems: the pill has aria-haspopup "${haspopup ?? ""}", expected "dialog" (it opens the problems list)`
1314
+ );
1315
+ }
1316
+ if (pill.attributes["aria-expanded"] !== "false") {
1317
+ problems.push(
1318
+ `problems: the pill has aria-expanded "${pill.attributes["aria-expanded"] ?? ""}" while the list is closed, expected "false"`
1319
+ );
1320
+ }
1321
+ const controls = pill.attributes["aria-controls"];
1322
+ if (!controls) {
1323
+ problems.push("problems: the pill has no aria-controls");
1324
+ } else if (!facts.problemsControlled) {
1325
+ problems.push(`problems: aria-controls="${controls}" names no element`);
1326
+ } else {
1327
+ const role = facts.problemsControlled.attributes.role;
1328
+ if (role !== "dialog") {
1006
1329
  problems.push(
1007
- `menu: aria-expanded is "${attribute(trigger, "aria-expanded") ?? ""}" while closed, expected "false"`
1330
+ `problems: aria-controls names a ${facts.problemsControlled.description} with role "${role ?? ""}", expected "dialog"`
1008
1331
  );
1009
1332
  }
1010
- if (!attribute(trigger, "aria-controls")) {
1011
- problems.push("menu: the trigger has no aria-controls");
1012
- } else if (!facts.controlled) {
1013
- problems.push(`menu: aria-controls="${attribute(trigger, "aria-controls")}" names no element`);
1014
- } else if (attribute(facts.controlled, "role") !== "menu") {
1333
+ if (facts.problemsControlled.attributes["aria-modal"] === "true") {
1015
1334
  problems.push(
1016
- `menu: aria-controls names a ${facts.controlled.description} with role "${attribute(facts.controlled, "role") ?? ""}", expected "menu"`
1335
+ 'problems: the problems list is aria-modal="true"; it is a non-modal popover (guide \xA75.10)'
1017
1336
  );
1018
1337
  }
1019
1338
  }
1020
- if (!facts.palette) {
1021
- problems.push('palette: no [data-chrome="palette"]');
1022
- } else {
1023
- const toolbar = facts.toolbar;
1024
- if (!toolbar || attribute(toolbar, "role") !== "toolbar") {
1025
- problems.push('palette: .djs-palette-entries has no role="toolbar"');
1026
- } else if (!attribute(toolbar, "aria-label")) {
1027
- problems.push("palette: the toolbar has no aria-label");
1028
- }
1029
- for (const entry of facts.entries) {
1030
- const action = attribute(entry, "data-action") ?? entry.description;
1031
- if (attribute(entry, "role") !== "button")
1032
- problems.push(`palette: entry "${action}" has no role="button"`);
1033
- if (!attribute(entry, "aria-label"))
1034
- problems.push(`palette: entry "${action}" has no aria-label (title)`);
1035
- const tabindex = attribute(entry, "tabindex");
1036
- if (tabindex !== "0" && tabindex !== "-1") {
1037
- problems.push(`palette: entry "${action}" has tabindex "${tabindex ?? ""}", expected roving 0/-1`);
1038
- }
1339
+ return problems;
1340
+ }
1341
+ var MENU_GROUP_ORDER = ["file", "model", "export", "appearance", "help"];
1342
+ var FILE_ENTRIES = [
1343
+ { id: "new-diagram", icon: true },
1344
+ { id: "open", icon: true },
1345
+ { id: "show-example", icon: false },
1346
+ { id: "import", icon: true }
1347
+ ];
1348
+ var APPEARANCE_VALUES = ["system", "light", "dark"];
1349
+ function menuGroupProblems(entries, host, themes, choice = "system") {
1350
+ const problems = [];
1351
+ const file = entries.filter((entry) => entry.group === "file");
1352
+ const appearance = entries.filter((entry) => entry.group === "appearance");
1353
+ if (host !== "web") {
1354
+ const guide = host === "ide" ? "its file actions are native (guide \xA713.5)" : "the host owns file and mode (guide \xA75.2)";
1355
+ if (file.length > 0) problems.push(`menu: the ${host} host shows a File group; ${guide}`);
1356
+ if (appearance.length > 0) problems.push(`menu: the ${host} host shows an Appearance group; ${guide}`);
1357
+ return problems;
1358
+ }
1359
+ const sequence = [];
1360
+ for (const entry of entries) {
1361
+ if (entry.group && sequence.at(-1) !== entry.group) sequence.push(entry.group);
1362
+ }
1363
+ const rank = (group) => MENU_GROUP_ORDER.indexOf(group);
1364
+ const ordered = sequence.every((group, index) => index === 0 || rank(sequence[index - 1]) < rank(group));
1365
+ if (!ordered) {
1366
+ problems.push(
1367
+ `menu: the groups stand in the order ${sequence.join(", ")}; expected File, Model, Export, Appearance, Help (guide \xA75.4)`
1368
+ );
1369
+ }
1370
+ const known = FILE_ENTRIES.map((entry) => entry.id);
1371
+ for (const entry of file) {
1372
+ const spec = FILE_ENTRIES.find((candidate) => candidate.id === entry.menuId);
1373
+ if (!spec) {
1374
+ problems.push(
1375
+ `menu: File entry "${entry.name}" has data-menu-id "${entry.menuId ?? ""}"; the shell builds the File group from ${known.join(", ")}`
1376
+ );
1377
+ continue;
1039
1378
  }
1040
- const stops = facts.entries.filter((entry) => attribute(entry, "tabindex") === "0");
1041
- if (facts.entries.length > 0 && stops.length !== 1) {
1379
+ if (entry.role !== "menuitem")
1380
+ problems.push(`menu: File entry "${entry.name}" is a ${entry.role}, expected a menuitem`);
1381
+ if (spec.icon && !entry.icon) problems.push(`menu: File entry "${entry.name}" has no icon (guide \xA75.4)`);
1382
+ if (!spec.icon && entry.icon) {
1042
1383
  problems.push(
1043
- `palette: ${stops.length} entries with tabindex="0", expected exactly one (roving tabindex)`
1384
+ `menu: File entry "${entry.name}" carries an icon; the guide gives ${entry.menuId} none (guide \xA75.14)`
1044
1385
  );
1045
1386
  }
1046
- const tools = facts.entries.filter(
1047
- (entry) => entry.group === "tools" && (attribute(entry, "data-action") ?? "").endsWith("-tool")
1387
+ }
1388
+ const fileIds = file.map((entry) => entry.menuId ?? "");
1389
+ const expectedIds = known.filter((id) => fileIds.includes(id));
1390
+ if (fileIds.join() !== expectedIds.join() && fileIds.every((id) => known.includes(id))) {
1391
+ problems.push(
1392
+ `menu: the File group reads ${fileIds.join(", ")}; expected the order ${expectedIds.join(", ")} (guide \xA75.4)`
1048
1393
  );
1049
- for (const tool of tools) {
1050
- const pressed2 = attribute(tool, "aria-pressed");
1051
- if (pressed2 !== "true" && pressed2 !== "false") {
1052
- problems.push(`palette: tool "${attribute(tool, "data-action")}" has no aria-pressed`);
1394
+ }
1395
+ if (themes.includes("dark")) {
1396
+ if (appearance.length === 0) {
1397
+ problems.push("menu: a dark-capable modeler shows no Appearance group (guide \xA75.4, \xA713.3)");
1398
+ } else {
1399
+ const values = appearance.map((entry) => entry.value ?? "");
1400
+ if (values.join() !== APPEARANCE_VALUES.join()) {
1401
+ problems.push(
1402
+ `menu: the Appearance options are ${values.join(", ")}; expected system, light, dark (guide \xA713.3)`
1403
+ );
1404
+ }
1405
+ for (const entry of appearance) {
1406
+ if (entry.role !== "menuitemradio" || entry.menuId !== "appearance") {
1407
+ problems.push(
1408
+ `menu: Appearance option "${entry.name}" is a ${entry.role} in "${entry.menuId ?? ""}"; expected a menuitemradio of the radio group "appearance"`
1409
+ );
1410
+ }
1053
1411
  }
1412
+ problems.push(...appearanceChoiceProblems(entries, choice));
1054
1413
  }
1055
- const pressed = tools.filter((tool) => attribute(tool, "aria-pressed") === "true");
1056
- if (pressed.length > 1) problems.push(`palette: ${pressed.length} tools are aria-pressed at once`);
1057
1414
  }
1058
- const zoom = facts.zoom;
1059
- if (!zoom) {
1060
- problems.push('zoom: no [data-chrome="zoom"]');
1061
- } else {
1062
- if (attribute(zoom, "role") !== "group") problems.push('zoom: the panel has no role="group"');
1063
- if (!attribute(zoom, "aria-label")) problems.push("zoom: the panel has no aria-label");
1064
- const shortcuts = expectedKeyShortcuts(facts.platform);
1065
- for (const button of ["out", "actual", "in", "fit"]) {
1066
- const control = facts.zoomButtons[button];
1067
- if (!control) {
1068
- problems.push(`zoom: no [data-zoom="${button}"] button`);
1069
- continue;
1070
- }
1071
- if (control.tag !== "button") problems.push(`zoom: [data-zoom="${button}"] is a <${control.tag}>`);
1072
- if (!name(control)) problems.push(`zoom: [data-zoom="${button}"] has no accessible name`);
1073
- if (attribute(control, "aria-keyshortcuts") !== shortcuts[button]) {
1415
+ return problems;
1416
+ }
1417
+ function appearanceChoiceProblems(entries, choice) {
1418
+ const checked = entries.filter((entry) => entry.group === "appearance" && entry.checked === "true").map((entry) => entry.value ?? "");
1419
+ return checked.length === 1 && checked[0] === choice ? [] : [
1420
+ `menu: the Appearance group checks ${checked.length > 0 ? checked.join(", ") : "nothing"}; expected "${choice}" (aria-checked, guide \xA712)`
1421
+ ];
1422
+ }
1423
+ function lightOnlyMenuProblems(entries, themes) {
1424
+ if (themes.includes("dark")) return [];
1425
+ return entries.some((entry) => entry.group === "appearance" || entry.menuId === "appearance") ? ["menu: a light-only modeler shows the Appearance group; it comes with dark (guide \xA713.3)"] : [];
1426
+ }
1427
+ function labelledWidth(samples) {
1428
+ const widths = samples.flatMap(
1429
+ (sample) => sample.primary && !sample.primary.collapsed && sample.primary.label ? [sample.primary.box.width] : []
1430
+ );
1431
+ return widths.length > 0 ? Math.max(...widths) : void 0;
1432
+ }
1433
+ function topRowProblems(samples, withLabel, name2) {
1434
+ const problems = [];
1435
+ for (const sample of samples) {
1436
+ const { container, paletteWidth, menuRight, primary } = sample;
1437
+ if (!primary || paletteWidth === null || menuRight === null) continue;
1438
+ const decide = (palette, labelled) => decideTopRow({
1439
+ containerWidth: container.width,
1440
+ paletteWidth: palette,
1441
+ menuRight,
1442
+ primary: { withLabel: labelled, iconOnly: SHELL.row }
1443
+ });
1444
+ const expected = decide(paletteWidth, withLabel);
1445
+ const low = decide(paletteWidth - 1, withLabel - 1);
1446
+ const high = decide(paletteWidth + 1, withLabel + 1);
1447
+ const at = `[${round2(container.width)}px wide]`;
1448
+ if (low.paletteRow === high.paletteRow && low.primaryLabel === high.primaryLabel && expected.paletteRow === low.paletteRow && expected.primaryLabel === low.primaryLabel) {
1449
+ const collapsed = expected.primaryLabel === "collapsed";
1450
+ if (collapsed && !primary.collapsed) {
1074
1451
  problems.push(
1075
- `zoom: [data-zoom="${button}"] aria-keyshortcuts is "${attribute(control, "aria-keyshortcuts") ?? ""}", expected "${shortcuts[button]}" (${facts.platform})`
1452
+ expected.paletteRow === "top" ? `${at} primary: shows its label, but the ${round2(paletteWidth)}px palette fits the top row only beside the icon; the label collapses first (guide \xA75.15)` : `${at} primary: shows its label, but it no longer fits next to the menu trigger (data-primary="collapsed", guide \xA75.15)`
1076
1453
  );
1077
1454
  }
1078
- if (control.disabled) {
1455
+ if (!collapsed && primary.collapsed) {
1456
+ problems.push(`${at} primary: collapsed although its ${round2(withLabel)}px label fits (guide \xA75.15)`);
1457
+ }
1458
+ if (expected.paletteRow !== sample.paletteRow) {
1079
1459
  problems.push(
1080
- `zoom: [data-zoom="${button}"] is natively disabled; aria-disabled keeps the focus (guide \xA75.8)`
1460
+ expected.paletteRow === "top" ? `${at} palette: in the second row although it fits the top row beside the collapsed primary action; the label collapses first (guide \xA75.15)` : `${at} palette: in the top row although it keeps less than 16px to the primary action even icon only (guide \xA75.15)`
1081
1461
  );
1082
1462
  }
1083
1463
  }
1084
- const readout = facts.zoomButtons.actual;
1085
- if (readout) {
1086
- if (!/\d/.test(readout.text))
1087
- problems.push(`zoom: the readout shows "${readout.text}", expected a percentage`);
1088
- const label = normalize(attribute(readout, "aria-label") ?? "");
1089
- if (!label.includes(readout.text)) {
1464
+ if (primary.collapsed) {
1465
+ if (Math.abs(primary.box.width - SHELL.row) > GEOMETRY_TOLERANCE_PX + 1e-6) {
1090
1466
  problems.push(
1091
- `zoom: the readout's aria-label "${label}" does not name the current zoom "${readout.text}"`
1467
+ `${at} primary: ${round2(primary.box.width)}px wide while collapsed, expected ${SHELL.row}px`
1092
1468
  );
1093
1469
  }
1094
- if (attribute(readout, "aria-disabled") === "true")
1095
- problems.push("zoom: the readout is never disabled");
1470
+ if (primary.label)
1471
+ problems.push(`${at} primary: collapsed, but its label "${primary.label}" still shows`);
1472
+ if (primary.ariaLabel !== name2) {
1473
+ problems.push(
1474
+ `${at} primary: the aria-label is "${primary.ariaLabel ?? ""}" while collapsed; it keeps "${name2}" (guide \xA75.15)`
1475
+ );
1476
+ }
1477
+ } else if (!primary.label) {
1478
+ problems.push(`${at} primary: shows no label although the root lacks data-primary="collapsed"`);
1479
+ }
1480
+ if (primary.describedBy) {
1481
+ problems.push(
1482
+ `${at} primary: aria-describedby="${primary.describedBy}"; its tooltip repeats the name (API.md, Primary action)`
1483
+ );
1096
1484
  }
1097
1485
  }
1098
- const present = new Set(facts.order);
1099
- const expectedOrder = DOM_ORDER.filter((part) => present.has(part));
1100
- if (facts.order.join() !== expectedOrder.join()) {
1486
+ return problems;
1487
+ }
1488
+ function primaryTooltipProblems(collapsed, tooltip, name2, width) {
1489
+ const at = `[${round2(width)}px wide]`;
1490
+ if (collapsed && tooltip === null) {
1491
+ return [`${at} primary: no chrome tooltip while collapsed; it shows the label "${name2}" (guide \xA75.15)`];
1492
+ }
1493
+ if (collapsed && tooltip !== null && !normalize(tooltip).startsWith(name2)) {
1494
+ return [`${at} primary: the tooltip while collapsed reads "${normalize(tooltip)}", expected "${name2}"`];
1495
+ }
1496
+ if (!collapsed && tooltip !== null) {
1497
+ return [
1498
+ `${at} primary: a tooltip "${normalize(tooltip)}" while the label shows; only the collapsed action has one`
1499
+ ];
1500
+ }
1501
+ return [];
1502
+ }
1503
+ function primaryActivationProblems(clicks) {
1504
+ const problems = [];
1505
+ if (clicks.pointer !== 1)
1506
+ problems.push(`primary: a pointer click reached it ${clicks.pointer} times, expected once`);
1507
+ if (clicks.enter !== 1) {
1101
1508
  problems.push(
1102
- `DOM order is ${facts.order.join(", ")}; expected ${expectedOrder.join(", ")} (guide \xA75.14)`
1509
+ `primary: Enter on the focused primary action clicked it ${clicks.enter} times, expected once (keyboard activation)`
1103
1510
  );
1104
1511
  }
1105
- if (!present.has("canvas")) problems.push("canvas: no diagram-js <svg> (.djs-container > svg) in the root");
1106
- const canvas = facts.canvas;
1107
- if (canvas && !normalize(attribute(canvas, "aria-label") ?? "") && !attribute(canvas, "aria-labelledby")) {
1512
+ return problems;
1513
+ }
1514
+ function legalContentProblems(links) {
1515
+ const problems = [];
1516
+ const [copyright, impressum, datenschutz] = links;
1517
+ if (!copyright || !/^©/.test(copyright.text) || !copyright.text.includes("Miragon GmbH")) {
1108
1518
  problems.push(
1109
- 'canvas: the focusable <svg> has no accessible name (WCAG 4.1.2); shellModule names it role="application" with messages.canvas'
1519
+ `legal: the line starts with "${copyright?.text ?? ""}", expected the copyright "\xA9 <year> Miragon GmbH" (guide \xA75.10)`
1110
1520
  );
1521
+ } else if (copyright.href !== MIRAGON_HOME) {
1522
+ problems.push(`legal: the copyright links "${copyright.href}", expected ${MIRAGON_HOME}`);
1111
1523
  }
1112
- for (const element of facts.positiveTabindex)
1113
- problems.push(`positive tabindex on ${element} (guide \xA75.14)`);
1114
- for (const element of facts.titled) {
1115
- problems.push(`${element} has a native title; the chrome tooltip replaces it (guide \xA75.6)`);
1524
+ const expected = [
1525
+ [impressum, "Impressum", MIRAGON_IMPRESSUM],
1526
+ [datenschutz, "Datenschutz", MIRAGON_DATENSCHUTZ]
1527
+ ];
1528
+ for (const [link, text, href] of expected) {
1529
+ if (link?.text !== text) {
1530
+ problems.push(
1531
+ `legal: the links read ${links.map((entry) => `"${entry.text}"`).join(", ")}; expected the copyright, "Impressum", "Datenschutz", then the modeler's links (guide \xA75.10)`
1532
+ );
1533
+ break;
1534
+ }
1535
+ if (link.href !== href) problems.push(`legal: "${text}" links "${link.href}", expected ${href}`);
1116
1536
  }
1117
- return problems;
1537
+ return [...new Set(problems)];
1118
1538
  }
1119
- function optionalIslandProblems(facts, shown) {
1539
+ function legalReachProblems(facts) {
1540
+ const legal = facts.legal;
1541
+ if (!legal) return [];
1542
+ const width = round2(facts.container.width);
1120
1543
  const problems = [];
1121
- if (shown.has("problems")) {
1122
- const pill = facts.problems;
1123
- if (!pill?.visible) {
1124
- problems.push("problems: the pill is not visible after show.problems");
1125
- } else {
1126
- if (pill.tag !== "button") problems.push(`problems: the pill is a <${pill.tag}>, expected a <button>`);
1127
- const label = normalize(attribute(pill, "aria-label") ?? "");
1128
- if (!label) problems.push("problems: the pill has no aria-label (count and errors)");
1129
- else if (!label.includes(pill.text)) {
1130
- problems.push(`problems: the aria-label "${label}" does not contain the visible text "${pill.text}"`);
1131
- }
1544
+ for (const link of facts.links) {
1545
+ const box = link.box;
1546
+ const inside = box.left >= Math.max(0, legal.left) - GEOMETRY_TOLERANCE_PX && box.left + box.width <= Math.min(legal.left + legal.width, facts.container.width) + GEOMETRY_TOLERANCE_PX && box.top >= legal.top - GEOMETRY_TOLERANCE_PX && box.top + box.height <= legal.top + legal.height + GEOMETRY_TOLERANCE_PX;
1547
+ if (!inside) {
1548
+ problems.push(
1549
+ `legal: [${width}px wide] "${link.text}" is cut off or scrolled out of the legal line; it wraps instead (guide \xA75.10, WCAG 1.4.10)`
1550
+ );
1132
1551
  }
1133
1552
  }
1134
- if (shown.has("status")) {
1135
- const status = facts.status;
1136
- if (!status?.visible) {
1137
- problems.push("status: the status box is not visible after show.status");
1138
- } else {
1139
- const role = attribute(status, "role");
1140
- const severity = attribute(status, "data-severity");
1141
- const expected = severity === "danger" ? "alert" : "status";
1142
- if (role !== expected) {
1553
+ return problems;
1554
+ }
1555
+ function zoomRowBottomProblems(zoom, raised, container, legalHeight) {
1556
+ if (!zoom) return [];
1557
+ const bottom = raised ? SHELL.edge + legalLineHeight(legalHeight) + SHELL.gap : SHELL.edge;
1558
+ return boxMismatches("zoom", zoom, { bottom }, container).map(
1559
+ (problem) => `[${round2(container.width)}px wide${raised ? ", raised" : ""}] ${problem}`
1560
+ );
1561
+ }
1562
+ function emptyStateProblems(facts, container, lang) {
1563
+ const problems = [];
1564
+ if (!facts.shown)
1565
+ problems.push('empty-state: the root lacks data-empty-state="shown" after show.emptyState');
1566
+ const card = facts.card;
1567
+ if (!card?.visible) return [...problems, "empty-state: the card is not visible after show.emptyState"];
1568
+ problems.push(
1569
+ ...boxMismatches(
1570
+ "empty-state",
1571
+ card.box,
1572
+ {
1573
+ centerX: container.width / 2,
1574
+ centerY: container.height / 2,
1575
+ width: Math.min(400, container.width - 2 * SHELL.edge)
1576
+ },
1577
+ container
1578
+ )
1579
+ );
1580
+ if (!card.labelledBy) problems.push("empty-state: the card is not labelled by its title (aria-labelledby)");
1581
+ if (!facts.komet) {
1582
+ problems.push("empty-state: no Komet <img> on the card (guide \xA72, \xA78)");
1583
+ } else {
1584
+ if (facts.komet.alt !== "") {
1585
+ problems.push(
1586
+ `empty-state: the Komet has ${facts.komet.alt === null ? "no alt" : `alt="${facts.komet.alt}"`}; it is decorative, alt="" (guide \xA78)`
1587
+ );
1588
+ }
1589
+ if (Math.abs(facts.komet.width - 64) > 1 || Math.abs(facts.komet.height - 64) > 1) {
1590
+ problems.push(
1591
+ `empty-state: the Komet is ${round2(facts.komet.width)} \xD7 ${round2(facts.komet.height)}px, expected 64 \xD7 64 (guide \xA78)`
1592
+ );
1593
+ }
1594
+ }
1595
+ if (facts.heading?.tag !== "h1") {
1596
+ problems.push(
1597
+ `empty-state: the title is ${facts.heading ? `a <${facts.heading.tag}>` : "missing"}, expected an <h1>`
1598
+ );
1599
+ }
1600
+ const [primary, ghost, ...rest] = facts.buttons;
1601
+ if (!primary) {
1602
+ problems.push('empty-state: no "New diagram" button on the card (guide \xA78)');
1603
+ } else {
1604
+ if (primary.variant !== "primary") {
1605
+ problems.push(
1606
+ `empty-state: the first button "${primary.name}" is ${primary.variant ?? "no"} variant, expected the blue primary "New diagram" first (guide \xA78)`
1607
+ );
1608
+ }
1609
+ if (ghost && ghost.variant !== "ghost") {
1610
+ problems.push(
1611
+ `empty-state: the second button "${ghost.name}" is ${ghost.variant ?? "no"} variant, expected the ghost "Show example" (guide \xA78)`
1612
+ );
1613
+ }
1614
+ if (rest.length > 0)
1615
+ problems.push(
1616
+ `empty-state: ${facts.buttons.length} buttons on the card; exactly two actions (guide \xA78)`
1617
+ );
1618
+ for (const button of [primary, ghost]) {
1619
+ if (button && Math.abs(button.height - SHELL.entry) > 1) {
1143
1620
  problems.push(
1144
- `status: role is "${role ?? ""}" for data-severity="${severity ?? ""}", expected "${expected}"`
1621
+ `empty-state: "${button.name}" is ${round2(button.height)}px high, expected ${SHELL.entry}px (guide \xA78)`
1145
1622
  );
1146
1623
  }
1147
1624
  }
1148
- }
1149
- if (shown.has("legend")) {
1150
- const legend = facts.legend;
1151
- if (!legend?.visible) {
1152
- problems.push("legend: the legend is not visible after show.legend");
1153
- } else {
1154
- if (!facts.legendTitle)
1155
- problems.push("legend: the card is not labelled by its title (aria-labelledby)");
1156
- if (!facts.legendClose || !attribute(facts.legendClose, "aria-label")) {
1157
- problems.push("legend: the close button has no aria-label");
1625
+ if (speaksEnglish(lang)) {
1626
+ if (primary.name !== defaultMessages.newDiagram) {
1627
+ problems.push(
1628
+ `empty-state: the primary button reads "${primary.name}", expected "${defaultMessages.newDiagram}" (guide \xA72)`
1629
+ );
1630
+ }
1631
+ if (ghost && ghost.name !== defaultMessages.showExample) {
1632
+ problems.push(
1633
+ `empty-state: the ghost button reads "${ghost.name}", expected "${defaultMessages.showExample}" (guide \xA72)`
1634
+ );
1158
1635
  }
1159
1636
  }
1160
1637
  }
1161
- if (shown.has("inspector")) {
1162
- const inspector = facts.inspector;
1163
- if (!inspector?.visible) {
1164
- problems.push("inspector: the inspector is not visible after show.inspector");
1165
- } else {
1166
- if (inspector.tag !== "aside") problems.push(`inspector: a <${inspector.tag}>, expected an <aside>`);
1167
- if (!attribute(inspector, "aria-label")) problems.push("inspector: the <aside> has no aria-label");
1638
+ for (const part of facts.work) {
1639
+ if (part.visible) {
1640
+ problems.push(
1641
+ `empty-state: ${part.name} is visible behind the start screen; it hides the work chrome (guide \xA78)`
1642
+ );
1168
1643
  }
1169
1644
  }
1645
+ if (!facts.canvasInert) {
1646
+ problems.push(
1647
+ "empty-state: the canvas slot is not inert behind the start screen; nothing else takes a click or the Tab (guide \xA78)"
1648
+ );
1649
+ }
1650
+ if (!facts.legalVisible)
1651
+ problems.push("empty-state: the legal line is not visible; a public page always shows it (guide \xA78)");
1170
1652
  return problems;
1171
1653
  }
1172
- function tabSequenceProblems(stops) {
1173
- const [first, second, third] = stops;
1174
- const sequence = first?.kind === "primary" ? [second, third] : [first, second];
1175
- const [palette, canvas] = sequence;
1654
+ function emptyStateTabProblems(owners) {
1176
1655
  const problems = [];
1177
- if (palette?.kind !== "palette") {
1656
+ for (const stop of owners) {
1657
+ if (["empty-state", "legal", "toasts", "body", "outside"].includes(stop.owner)) continue;
1178
1658
  problems.push(
1179
- `tab order: Tab from the menu trigger lands on ${palette?.description ?? "nothing"}, expected the palette's roving entry (guide \xA75.14)`
1659
+ `empty-state: Tab reaches ${stop.description} (${stop.owner}) behind the start screen; only the card and the legal links take the focus (guide \xA78)`
1180
1660
  );
1181
- } else if (canvas?.kind !== "canvas") {
1661
+ }
1662
+ if (!owners.some((stop) => stop.owner === "empty-state")) {
1663
+ problems.push("empty-state: Tab never reaches the card's buttons");
1664
+ }
1665
+ if (!owners.some((stop) => stop.owner === "legal"))
1666
+ problems.push("empty-state: Tab never reaches the legal links");
1667
+ return [...new Set(problems)];
1668
+ }
1669
+ function emptyStateKeyProblems(key, before, after, keys) {
1670
+ const problems = [];
1671
+ if (after !== before) {
1182
1672
  problems.push(
1183
- `tab order: Tab from the palette lands on ${canvas?.description ?? "nothing"}, expected the canvas <svg> (an unexpected tab stop, guide \xA75.14)`
1673
+ `empty-state: ${key} behind the start screen changed the zoom from ${before ?? "?"}% to ${after ?? "?"}%; the chrome keys zoom no diagram there`
1674
+ );
1675
+ }
1676
+ if (keys.some((recorded) => recorded.defaultPrevented)) {
1677
+ problems.push(
1678
+ `empty-state: ${key} behind the start screen arrived prevented; with no diagram to zoom the browser keeps its page zoom (WCAG 1.4.4)`
1184
1679
  );
1185
1680
  }
1186
1681
  return problems;
1187
1682
  }
1188
- function paletteShortcutProblems(entries) {
1189
- return entries.filter((entry) => entry.chip && !entry.keyShortcuts).map(
1190
- (entry) => `palette: entry "${entry.action}" shows the shortcut "${entry.chip}" in its tooltip but has no aria-keyshortcuts (guide \xA75.6)`
1191
- );
1683
+ function afterStartProblems(facts, focus) {
1684
+ const problems = [];
1685
+ const after = 'after "New diagram"';
1686
+ if (facts.shown) problems.push(`empty-state: ${after} the root still carries data-empty-state="shown"`);
1687
+ if (facts.card?.visible) problems.push(`empty-state: ${after} the card still shows`);
1688
+ for (const name2 of ["menu", "palette", "zoom"]) {
1689
+ const part = facts.work.find((candidate) => candidate.name === name2);
1690
+ if (part && !part.visible) problems.push(`empty-state: ${after} the ${name2} is still hidden`);
1691
+ }
1692
+ if (facts.canvasInert) problems.push(`empty-state: ${after} the canvas slot is still inert`);
1693
+ if (focus.owner !== "canvas") {
1694
+ problems.push(
1695
+ `empty-state: ${after} the focus is on ${focus.description}, expected the canvas <svg> (API.md, Empty state)`
1696
+ );
1697
+ }
1698
+ return problems;
1192
1699
  }
1193
- function menuKeysAtDocumentProblems(pressed, documentKeys) {
1194
- const reached = new Set(documentKeys.map((key) => key.key));
1195
- return pressed.filter((key) => reached.has(key)).map(
1196
- (key) => `menu: ${key} in the open menu reached a keydown listener on document; a modeler's document keyboard acts on it (stop it at the menu, guide \xA75.4)`
1197
- );
1700
+ function toastLiveRegion(regions, status) {
1701
+ const role = status === "danger" ? "alert" : "status";
1702
+ return regions.find((region) => region.role === role);
1198
1703
  }
1199
- function menuOpenProblems(facts, focus) {
1704
+ function toastAnatomyProblems(toast, status, lang, announcement) {
1200
1705
  const problems = [];
1201
- if (facts.expanded !== "true") {
1202
- problems.push(`menu: aria-expanded is "${facts.expanded ?? ""}" while open, expected "true"`);
1706
+ const role = status === "danger" ? "alert" : "status";
1707
+ if (!announcement.persistent) {
1708
+ problems.push(
1709
+ `toasts: no role="${role}" live region in the root before the first toast; a live region created together with its text is often not announced (guide \xA78, \xA712)`
1710
+ );
1711
+ } else if (!announcement.announced) {
1712
+ problems.push(
1713
+ `toasts: the ${status} toast's text never reaches the role="${role}" live region (guide \xA78, \xA712)`
1714
+ );
1203
1715
  }
1204
- const menu = facts.menu;
1205
- if (!menu) return [...problems, 'menu: no visible role="menu" after opening'];
1206
- if (!attribute(menu, "aria-label")) problems.push("menu: the dropdown has no aria-label");
1207
- if (facts.controls && attribute(menu, "id") !== facts.controls) {
1716
+ if (toast.role === "status" || toast.role === "alert") {
1208
1717
  problems.push(
1209
- `menu: the visible dropdown is #${attribute(menu, "id") ?? ""}, the trigger controls #${facts.controls}`
1718
+ `toasts: the ${status} toast's card carries role="${toast.role}"; the live region announces it, so a live card is read twice`
1210
1719
  );
1211
1720
  }
1212
- if (facts.items.length === 0) problems.push("menu: the dropdown has no menuitem");
1213
- facts.items.forEach((item, index) => {
1214
- const label = item.name || `item ${index + 1}`;
1215
- if (!item.name) problems.push(`menu: ${label} has no accessible name`);
1216
- if (item.role !== "menuitem" && item.checked !== "true" && item.checked !== "false") {
1217
- problems.push(`menu: "${label}" (${item.role}) has aria-checked "${item.checked ?? ""}"`);
1218
- }
1219
- if (item.role === "menuitemradio" && !item.group) {
1220
- problems.push(`menu: radio "${label}" is not in a labelled role="group"`);
1721
+ const close = toast.buttons.at(-1);
1722
+ if (!close || close.text !== "") {
1723
+ problems.push(`toasts: the ${status} toast has no Close icon button at its end (guide \xA78)`);
1724
+ } else if (!close.name) {
1725
+ problems.push(`toasts: the ${status} toast's Close button has no accessible name`);
1726
+ } else if (speaksEnglish(lang) && close.name !== defaultMessages.close) {
1727
+ problems.push(
1728
+ `toasts: the Close button is named "${close.name}", expected "${defaultMessages.close}" (guide \xA78)`
1729
+ );
1730
+ }
1731
+ if (toast.box.height < 40 - GEOMETRY_TOLERANCE_PX) {
1732
+ problems.push(
1733
+ `toasts: the ${status} toast is ${round2(toast.box.height)}px high, at least 40px (guide \xA78)`
1734
+ );
1735
+ }
1736
+ return problems;
1737
+ }
1738
+ function shownToasts(toasts2, texts) {
1739
+ return toasts2.flatMap((toast) => texts.filter((text) => toast.text.includes(text)));
1740
+ }
1741
+ function toastStackProblems(toasts2, shown, expected) {
1742
+ const problems = [];
1743
+ const present = shownToasts(toasts2, shown);
1744
+ if (toasts2.length > MAX_TOASTS)
1745
+ problems.push(`toasts: ${toasts2.length} toasts at once, at most ${MAX_TOASTS} (guide \xA78)`);
1746
+ if (present.join("\0") !== expected.join("\0")) {
1747
+ problems.push(
1748
+ `toasts: after ${shown.length} toasts the stack shows ${present.map((text) => `"${text}"`).join(", ") || "none"}; expected ${expected.map((text) => `"${text}"`).join(", ")} (at most ${MAX_TOASTS}, the oldest without an action leaves, guide \xA78)`
1749
+ );
1750
+ }
1751
+ const tops = toasts2.filter((toast) => shown.some((text) => toast.text.includes(text))).map((toast) => toast.box.top);
1752
+ if (tops.some((top, index) => index > 0 && top < tops[index - 1])) {
1753
+ problems.push("toasts: the newest toast is not at the bottom of the stack (guide \xA78)");
1754
+ }
1755
+ return problems;
1756
+ }
1757
+ function repeatedToastProblems(toasts2, text) {
1758
+ const count = toasts2.filter((toast) => toast.text.includes(text)).length;
1759
+ return count > 1 ? [`toasts: a repeated "${text}" stacks ${count} toasts; it restarts the shown one (API.md, Toasts)`] : [];
1760
+ }
1761
+ var TOAST_RULES = {
1762
+ plain: "it stays for its lifetime",
1763
+ hovered: "hover pauses its time",
1764
+ focused: "focus pauses its time",
1765
+ resumed: "it resumes with at least 1.5s left",
1766
+ action: "a toast with an action never closes by itself"
1767
+ };
1768
+ function toastTimingProblems(timings) {
1769
+ return timings.flatMap((timing) => {
1770
+ const since = timing.condition === "resumed" ? "after the pointer and the focus left it" : "after it appeared";
1771
+ if (timing.expectation === "present" && !timing.present) {
1772
+ return [
1773
+ `toasts: "${timing.text}" closed within ${timing.after}ms ${since}; ${TOAST_RULES[timing.condition]} (guide \xA78)`
1774
+ ];
1221
1775
  }
1222
- if (item.haspopup === "dialog" && !item.name.endsWith("\u2026")) {
1223
- problems.push(`menu: "${label}" opens a dialog, so its label ends with "\u2026" (U+2026)`);
1776
+ if (timing.expectation === "gone" && timing.present) {
1777
+ return [
1778
+ `toasts: "${timing.text}" is still there ${timing.after}ms ${since}; it closes after its lifetime (guide \xA78)`
1779
+ ];
1224
1780
  }
1781
+ return [];
1225
1782
  });
1226
- if (focus && facts.items.length > 0) {
1227
- const expected = focus === "first" ? 0 : facts.items.length - 1;
1228
- if (facts.active !== expected) {
1783
+ }
1784
+ function popoverKeyboardProblems(steps, rowCount) {
1785
+ const problems = [];
1786
+ for (const step of steps) {
1787
+ if (step.focused?.index !== step.expected) {
1229
1788
  problems.push(
1230
- `menu: focus is on ${facts.active < 0 ? "no entry" : `entry ${facts.active + 1}`}, expected the ${focus} entry`
1789
+ `problems-popover: ${step.key} reaches ${step.focused ? `row ${step.focused.index + 1}` : "no row"}, expected row ${step.expected + 1} of ${rowCount}; the keyboard reads every row, text rows too (guide \xA75.10, WCAG 2.1.1)`
1790
+ );
1791
+ } else if (!step.focused.inView) {
1792
+ problems.push(
1793
+ `problems-popover: row ${step.expected + 1} takes the focus on ${step.key} but stays scrolled out of the list`
1231
1794
  );
1232
1795
  }
1233
1796
  }
1234
1797
  return problems;
1235
1798
  }
1236
- function menuClosedProblems(facts, focusOnTrigger) {
1799
+ function openPopoverProblems(facts, container, lang) {
1237
1800
  const problems = [];
1238
- if (facts.expanded !== "false")
1239
- problems.push(`menu: aria-expanded is "${facts.expanded ?? ""}" after closing`);
1240
- if (facts.menu) problems.push("menu: the dropdown is still visible after Escape");
1241
- if (focusOnTrigger && !facts.activeIsTrigger)
1242
- problems.push("menu: Escape did not return the focus to the trigger");
1243
- return problems;
1244
- }
1245
- function keyReachProblem(press, reach, host, seen) {
1246
- const shouldPrevent = reach === "handled" || reach === "canvas";
1247
- if (host === "ide" && reach !== "free") {
1248
- if (seen.keys.length > 0) {
1249
- return `${press} reached a keydown listener on window; in the IDE host the shell stops it on document, or VS Code runs it again (guide \xA713.5)`;
1250
- }
1251
- const atDocument = seen.documentKeys ?? [];
1252
- const prevented2 = atDocument.length === 1 ? atDocument[0]?.defaultPrevented : void 0;
1253
- if (prevented2 === true && !shouldPrevent) {
1254
- return `${press} was prevented; the shell only stops it, so the field, dialog or browser still acts (guide \xA713.5)`;
1255
- }
1256
- if (prevented2 === false && shouldPrevent) {
1257
- return `${press} was stopped but not prevented; a handled key also calls preventDefault`;
1258
- }
1259
- return void 0;
1801
+ const popover2 = facts.popover;
1802
+ if (!popover2?.visible)
1803
+ return ["problems-popover: a click on the pill shows no problems list (guide \xA75.10)"];
1804
+ if (popover2.attributes.role !== "dialog") {
1805
+ problems.push(`problems-popover: role "${popover2.attributes.role ?? ""}", expected "dialog"`);
1260
1806
  }
1261
- if (seen.keys.length === 0) {
1262
- return host === "ide" ? `${press} never reached window; keys the webview does not handle must go on to VS Code (guide \xA713.5)` : `${press} never reached window; outside the IDE host the shell never stops propagation`;
1807
+ if (popover2.attributes["aria-modal"] === "true")
1808
+ problems.push('problems-popover: aria-modal="true"; it is not modal');
1809
+ if (!popover2.title) {
1810
+ problems.push("problems-popover: not labelled by its title (aria-labelledby)");
1811
+ } else if (speaksEnglish(lang) && popover2.title !== defaultMessages.problemsTitle) {
1812
+ problems.push(
1813
+ `problems-popover: titled "${popover2.title}", expected "${defaultMessages.problemsTitle}" (guide \xA75.10)`
1814
+ );
1263
1815
  }
1264
- if (seen.keys.length > 1) return `${press} reached window ${seen.keys.length} times`;
1265
- const prevented = seen.keys[0]?.defaultPrevented ?? false;
1266
- if (prevented && !shouldPrevent) {
1267
- return `${press} arrived prevented; ${reach === "free" ? "nothing may handle it" : "the browser, the field or the dialog must act"}`;
1816
+ if (facts.pill?.attributes["aria-expanded"] !== "true") {
1817
+ problems.push(
1818
+ `problems: aria-expanded is "${facts.pill?.attributes["aria-expanded"] ?? ""}" while the list is open, expected "true"`
1819
+ );
1268
1820
  }
1269
- if (!prevented && shouldPrevent) {
1270
- return `${press} arrived unprevented; a handled key calls preventDefault (the browser would zoom the page)`;
1821
+ if (facts.rows.length === 0) {
1822
+ problems.push("problems-popover: the list has no rows; show.problems passes items in the web host");
1271
1823
  }
1272
- return void 0;
1273
- }
1274
- function expectedAppearance(requested, themes) {
1275
- const fallback = themes[0];
1276
- if (fallback === void 0) throw new Error("@miragon/modeler-shell conformance: harness.themes is empty");
1277
- const theme = themes.includes(requested.theme) ? requested.theme : fallback;
1278
- return requested.kontrast ? { theme, kontrast: requested.kontrast } : { theme };
1279
- }
1280
- var describeAppearance = (appearance) => appearance.kontrast ? `${appearance.theme} + ${appearance.kontrast}` : appearance.theme;
1281
- function expectedEmbeddedAppearance(facts, themes) {
1282
- const fallback = themes[0];
1283
- if (fallback === void 0) throw new Error("@miragon/modeler-shell conformance: harness.themes is empty");
1284
- const host = facts.themed.filter((element) => element.relation === "ancestor").at(-1);
1285
- return host?.kontrast === "hoch" ? { theme: fallback, kontrast: "hoch" } : { theme: fallback };
1286
- }
1287
- function appearanceProblems(facts, host, requested, themes) {
1288
- const ownDocument = host !== "embedded";
1289
- const expected = ownDocument ? expectedAppearance(requested, themes) : expectedEmbeddedAppearance(facts, themes);
1290
- const problems = [];
1291
- const label = ownDocument ? `requested ${describeAppearance(requested)}, expected ${describeAppearance(expected)}` : `host page ${describeAppearance(requested)}, expected ${describeAppearance(expected)}`;
1292
- const carrier = ownDocument ? facts.themed.find((element) => element.isDocument && element.theme !== null) : facts.themed.find((element) => element.relation === "self" && element.theme !== null);
1293
- if (!carrier) {
1824
+ for (const row of facts.rows) {
1825
+ if (row.height < SHELL.entry - GEOMETRY_TOLERANCE_PX) {
1826
+ problems.push(
1827
+ `problems-popover: the row "${row.text}" is ${round2(row.height)}px high, at least ${SHELL.entry}px (guide \xA75.10)`
1828
+ );
1829
+ }
1830
+ if (!row.icon)
1831
+ problems.push(`problems-popover: the row "${row.text}" has no severity icon (guide \xA75.10)`);
1832
+ }
1833
+ if (facts.focus !== "popover") {
1294
1834
  problems.push(
1295
- ownDocument ? `<html> has no data-cd-theme (${label}); the host always writes it, also "light" (guide \xA713.3)` : `the embedded root has no data-cd-theme (${label}): it takes the host page's mode instead of its own (guide \xA713.3)`
1835
+ `problems-popover: opening it leaves the focus on ${facts.focused}; it moves to the first row (API.md, Problems popover)`
1296
1836
  );
1297
- } else {
1298
- if (carrier.theme !== expected.theme) {
1299
- problems.push(`${carrier.description} has data-cd-theme="${carrier.theme}" (${label})`);
1300
- }
1301
- if ((carrier.kontrast ?? void 0) !== expected.kontrast) {
1837
+ }
1838
+ const pill = facts.pill;
1839
+ if (pill) {
1840
+ problems.push(
1841
+ ...boxMismatches(
1842
+ "problems-popover",
1843
+ popover2.box,
1844
+ { left: SHELL.edge, bottom: container.height - pill.box.top + SHELL.gap },
1845
+ container
1846
+ )
1847
+ );
1848
+ if (popover2.box.width > Math.min(320, container.width - 2 * SHELL.edge) + GEOMETRY_TOLERANCE_PX + 1e-6) {
1302
1849
  problems.push(
1303
- `${carrier.description} has ${carrier.kontrast ? `data-cd-kontrast="${carrier.kontrast}"` : "no data-cd-kontrast"} (${label})`
1850
+ `problems-popover: width is ${round2(popover2.box.width)}px, at most ${round2(Math.min(320, container.width - 2 * SHELL.edge))}px expected`
1304
1851
  );
1305
1852
  }
1853
+ if (popover2.box.height > 320 + GEOMETRY_TOLERANCE_PX + 1e-6) {
1854
+ problems.push(`problems-popover: height is ${round2(popover2.box.height)}px, at most 320px expected`);
1855
+ }
1306
1856
  }
1307
- for (const element of facts.themed) {
1308
- if (element === carrier) continue;
1309
- const stray = ownDocument ? !element.isDocument || element.theme === null : element.relation === "descendant" || element.relation === "self";
1310
- if (!stray) continue;
1311
- const values = [
1312
- element.theme !== null ? `data-cd-theme="${element.theme}"` : "",
1313
- element.kontrast !== null ? `data-cd-kontrast="${element.kontrast}"` : ""
1314
- ].filter(Boolean).join(" ");
1315
- const sameMode = carrier !== void 0 && element.theme === carrier.theme && element.kontrast === carrier.kontrast;
1857
+ return problems;
1858
+ }
1859
+ function closedPopoverProblems(facts, how) {
1860
+ const problems = [];
1861
+ if (facts.popover?.visible) problems.push(`problems-popover: ${how} does not close the list`);
1862
+ if (facts.pill && facts.pill.attributes["aria-expanded"] !== "false") {
1316
1863
  problems.push(
1317
- element.theme === null ? `${element.description} carries ${values} without data-cd-theme on the same element (guide \xA713.3)` : sameMode ? `${element.description} repeats ${values}: in an own document the attributes stand only on <html> (guide \xA713.5)` : `${element.description} carries ${values}: a second mode inside the modeler is half-dark (guide \xA713.5)`
1864
+ `problems: aria-expanded is "${facts.pill.attributes["aria-expanded"] ?? ""}" after ${how}, expected "false"`
1318
1865
  );
1319
1866
  }
1320
- if (ownDocument && !facts.documentClasses.includes("cd-shell-document")) {
1867
+ if (how === "Escape" && facts.focus !== "pill") {
1868
+ problems.push(`problems-popover: Escape leaves the focus on ${facts.focused}, expected the pill`);
1869
+ }
1870
+ if (how === "an outside click" && facts.focus === "pill") {
1321
1871
  problems.push(
1322
- "<html> lacks cd-shell-document in an own document (the reset of the webview's host styles)"
1872
+ "problems-popover: an outside click moved the focus to the pill; it closes without moving the focus"
1323
1873
  );
1324
1874
  }
1325
- if (!ownDocument && facts.documentClasses.includes("cd-shell-document")) {
1875
+ if (how === "a row with an element" && facts.focus !== "canvas") {
1326
1876
  problems.push(
1327
- "<html> of a foreign page carries cd-shell-document: the own-document reset restyles the host page"
1877
+ `problems-popover: a row with an element leaves the focus on ${facts.focused}, expected the canvas`
1328
1878
  );
1329
1879
  }
1330
- const reference = facts.parts[0];
1331
- if (reference && !reference.roles["--cd-ui-text"]) {
1880
+ return problems;
1881
+ }
1882
+ function dropStateProblems(facts, expected, context, container) {
1883
+ const problems = [];
1884
+ const zone = facts.zone;
1885
+ const over = facts.events.filter((event) => event.type === "dragover");
1886
+ if (expected === "none") {
1887
+ if (facts.state !== null)
1888
+ problems.push(`drop-zone: ${context} sets data-drop="${facts.state}"; it is no file drag`);
1889
+ if (zone?.visible) problems.push(`drop-zone: ${context} shows the drop zone`);
1890
+ if (over.some((event) => event.defaultPrevented))
1891
+ problems.push(`drop-zone: ${context} is prevented; the drop zone leaves non-file drags alone`);
1892
+ return problems;
1893
+ }
1894
+ if (facts.state !== expected) {
1332
1895
  problems.push(
1333
- "--cd-ui-text is empty at the shell root: the vendored cd-tokens.generated.css is not loaded"
1896
+ `drop-zone: ${context} sets data-drop="${facts.state ?? ""}", expected "${expected}" (guide \xA79)`
1334
1897
  );
1335
1898
  }
1336
- for (const part of facts.parts) {
1337
- const schemes = part.colorScheme.split(/\s+/);
1338
- const dark = schemes.includes("dark");
1339
- const light = schemes.includes("light");
1340
- if (expected.theme === "light" && (dark || carrier && !light)) {
1341
- problems.push(`${part.name}: color-scheme is "${part.colorScheme}" (${label})`);
1342
- }
1343
- if (expected.theme === "dark" && (!dark || light)) {
1344
- problems.push(`${part.name}: color-scheme is "${part.colorScheme}" (${label})`);
1345
- }
1346
- if (reference && part !== reference) {
1347
- for (const [role, value] of Object.entries(part.roles)) {
1348
- if (value !== reference.roles[role]) {
1349
- problems.push(
1350
- `${part.name}: ${role} resolves to "${value}", the root to "${reference.roles[role] ?? ""}" (half-dark)`
1351
- );
1352
- }
1353
- }
1354
- }
1899
+ if (facts.events.some((event) => !event.defaultPrevented && event.type !== "dragleave")) {
1900
+ problems.push(`drop-zone: ${context} is not prevented, so the browser would open the file`);
1355
1901
  }
1356
- for (const required of ["dropdown", "tooltip"]) {
1357
- if (!facts.parts.some((part) => part.name === required)) {
1358
- problems.push(`${required}: not visible when the kit opened it, so its mode was not checked`);
1359
- }
1902
+ const effect = expected === "over" ? "copy" : "none";
1903
+ for (const event of over) {
1904
+ if (event.dropEffect !== effect)
1905
+ problems.push(`drop-zone: ${context} has dropEffect "${event.dropEffect}", expected "${effect}"`);
1360
1906
  }
1361
- return { problems };
1907
+ if (!zone?.visible) return [...problems, `drop-zone: ${context} shows no drop zone (guide \xA79)`];
1908
+ problems.push(
1909
+ ...boxMismatches("drop-zone", zone.box, { left: 12, top: 12, right: 12, bottom: 12 }, container).map(
1910
+ (problem) => `${problem} (${context})`
1911
+ )
1912
+ );
1913
+ if (zone.ariaHidden !== "true") problems.push('drop-zone: the overlay lacks aria-hidden="true"');
1914
+ const fill = expected === "over" ? ["--cd-ui-hover", facts.roles.hover] : ["--cd-ui-danger-flaeche", facts.roles.dangerFlaeche];
1915
+ const border = expected === "over" ? ["--cd-ui-interaktion", facts.roles.interaktion] : ["--cd-ui-danger", facts.roles.danger];
1916
+ if (zone.background !== fill[1]) {
1917
+ problems.push(
1918
+ `drop-zone: ${context} fills ${zone.background}, expected ${fill[0]} (${fill[1]}) (guide \xA79)`
1919
+ );
1920
+ }
1921
+ if (zone.borderStyle !== "dashed" || zone.borderWidth !== "2px" || zone.borderColor !== border[1]) {
1922
+ problems.push(
1923
+ `drop-zone: ${context} has a ${zone.borderWidth} ${zone.borderStyle} ${zone.borderColor} border, expected 2px dashed ${border[0]} (${border[1]}) (guide \xA79)`
1924
+ );
1925
+ }
1926
+ if (!facts.chip?.text) {
1927
+ problems.push(`drop-zone: ${context} shows no chip text (guide \xA79)`);
1928
+ } else if (facts.chip.background !== facts.roles.flaecheHoch) {
1929
+ problems.push(
1930
+ `drop-zone: the chip fills ${facts.chip.background}, expected --cd-ui-flaeche-hoch (${facts.roles.flaecheHoch})`
1931
+ );
1932
+ }
1933
+ return problems;
1362
1934
  }
1363
- var SHELL_ROOT_ATTRIBUTES = [
1364
- "data-cd-shell-host",
1365
- "data-palette",
1366
- "data-legend",
1367
- "data-inspector",
1368
- "data-problems",
1369
- "data-readonly"
1370
- ];
1371
- var EMBEDDED_ROOT_ATTRIBUTES = ["data-cd-theme", "data-cd-kontrast"];
1372
- var BENIGN_ERRORS = /ResizeObserver loop/;
1373
- function leftoverProblems(facts, host) {
1935
+ function expectedWebMode(requested, themes, choice) {
1936
+ const kontrast = requested.kontrast ? { kontrast: requested.kontrast } : {};
1937
+ if (!themes.includes("dark")) return { carrier: "light", effective: { theme: "light", ...kontrast } };
1938
+ if (choice === "system") return { carrier: "system", effective: { theme: requested.theme, ...kontrast } };
1939
+ return { carrier: choice, effective: { theme: choice, ...kontrast } };
1940
+ }
1941
+ function webModeProblems(facts, themes, choice, colors) {
1374
1942
  const problems = [];
1375
- for (const element of facts.classedElements) problems.push(`${element} is left after destroy`);
1376
- const ownDocument = host !== "embedded";
1377
- for (const name2 of facts.documentClasses) {
1378
- if (ownDocument && name2 === "cd-shell-document") continue;
1379
- problems.push(`<html> keeps the class ${name2} after destroy`);
1943
+ const dark = themes.includes("dark");
1944
+ if (!dark) {
1945
+ const metas = facts.themeColors;
1946
+ if (metas.length !== 1 || metas[0]?.media !== null) {
1947
+ problems.push(
1948
+ `mode: a light-only web app carries one theme-color without media (guide \xA713.3); found ${metas.map((meta) => meta.media ? `${meta.content} (${meta.media})` : meta.content).join(", ") || "none"}`
1949
+ );
1950
+ }
1951
+ if (facts.colorScheme !== null && facts.colorScheme.split(/\s+/).includes("dark")) {
1952
+ problems.push(`mode: a light-only web app declares color-scheme "${facts.colorScheme}"`);
1953
+ }
1954
+ return problems;
1380
1955
  }
1381
- for (const element of facts.chromeElements) problems.push(`${element} is left after destroy`);
1382
- for (const name2 of facts.rootClasses) problems.push(`the root keeps the class ${name2} after destroy`);
1383
- const written = ownDocument ? SHELL_ROOT_ATTRIBUTES : [...SHELL_ROOT_ATTRIBUTES, ...EMBEDDED_ROOT_ATTRIBUTES];
1384
- for (const name2 of facts.rootAttributes) {
1385
- if (written.includes(name2)) problems.push(`the root keeps ${name2} after destroy`);
1956
+ const label = choice === "system" ? "System" : choice === "dark" ? "Dark" : "Light";
1957
+ const expectedScheme = choice === "system" ? "light dark" : choice;
1958
+ if (facts.colorScheme !== expectedScheme) {
1959
+ problems.push(
1960
+ `mode: with ${label} <meta name="color-scheme"> reads "${facts.colorScheme ?? "(none)"}", expected "${expectedScheme}" (guide \xA713.3)`
1961
+ );
1386
1962
  }
1387
- for (const error of facts.errors) {
1388
- if (!BENIGN_ERRORS.test(error)) problems.push(`the page reported an error after destroy: ${error}`);
1963
+ const stored = choice === "system" ? null : choice;
1964
+ if (!facts.storageBlocked && facts.stored !== stored) {
1965
+ problems.push(
1966
+ stored === null ? `mode: with System localStorage keeps miragon.darstellung="${facts.stored ?? ""}"; System removes the entry (guide \xA713.3)` : `mode: with ${label} localStorage holds miragon.darstellung=${facts.stored === null ? "(none)" : `"${facts.stored}"`}, expected "${stored}" (guide \xA713.3)`
1967
+ );
1968
+ }
1969
+ const byMedia = (scheme) => facts.themeColors.find((meta) => meta.media?.replace(/\s+/g, "") === `(prefers-color-scheme:${scheme})`);
1970
+ const light = byMedia("light");
1971
+ const darkMeta = byMedia("dark");
1972
+ if (!light || !darkMeta) {
1973
+ problems.push(
1974
+ "mode: a dark-capable web app carries two theme-color metas, one per prefers-color-scheme (guide \xA713.3)"
1975
+ );
1976
+ } else if (colors) {
1977
+ const want = choice === "system" ? colors : { light: colors[choice], dark: colors[choice] };
1978
+ if (light.content !== want.light || darkMeta.content !== want.dark) {
1979
+ problems.push(
1980
+ `mode: with ${label} the theme-color metas read ${light.content} (light) and ${darkMeta.content} (dark), expected ${want.light} and ${want.dark} (guide \xA713.3)`
1981
+ );
1982
+ }
1389
1983
  }
1390
1984
  return problems;
1391
1985
  }
1392
- function afterDestroyKeyProblem(press, recorded) {
1393
- if (recorded.length === 0)
1394
- return `${press} after destroy never reached window: a shell listener still stops it`;
1395
- if (recorded.some((key) => key.defaultPrevented)) {
1396
- return `${press} after destroy arrived prevented: a shell listener is still installed`;
1986
+ function modeSwitchProblems(changes, label) {
1987
+ const relevant = changes.filter((change) => ["theme", "color-scheme", "theme-color"].includes(change.part));
1988
+ if (relevant.length === 0) return [`mode: choosing ${label} changed nothing on <html> or its meta tags`];
1989
+ const problems = [];
1990
+ const frames = [...new Set(relevant.map((change) => change.frame))];
1991
+ if (frames.length > 1) {
1992
+ const parts = relevant.map((change) => `${change.part} in frame ${change.frame}`).join(", ");
1993
+ problems.push(`mode: choosing ${label} changed ${parts}; one frame for all of them (guide \xA713.3)`);
1397
1994
  }
1398
- return void 0;
1399
- }
1400
- var ABSENT_ISLANDS = ["legend", "inspector", "problems", "status"];
1401
- function absentIslands(absent) {
1402
- return ABSENT_ISLANDS.filter((island) => absent?.includes(island));
1403
- }
1404
- function absentIslandProblems(visible, absent) {
1405
- return absentIslands(absent).filter((island) => visible.has(island)).map((island) => `${island}: visible, but harness.absent says the modeler has no ${island}`);
1406
- }
1407
- function harnessHookProblems(harness, hooks) {
1408
- return hooks.filter((hook) => harness.absent?.includes(hook) && harness.show?.[hook]).map((hook) => `harness: absent lists "${hook}", but show.${hook} is given`);
1409
- }
1410
- function surroundProblems(facts) {
1411
- if (!facts.container) return ["canvas surround: no .djs-container in the root to check"];
1412
- if (!facts.painted) {
1413
- return [
1414
- "canvas surround: nothing from .djs-container out to the root paints a background colour; the surround is var(--cd-ui-grund) (guide \xA710.1)"
1415
- ];
1995
+ const first = Math.min(...frames);
1996
+ if (!changes.some(
1997
+ (change) => change.part === "no-transition" && change.value === "on" && change.frame === first
1998
+ )) {
1999
+ problems.push(
2000
+ `mode: choosing ${label} switched without cd-shell-no-transition on <html> in that frame (guide \xA77: no colour fades)`
2001
+ );
1416
2002
  }
1417
- if (facts.painted.color === facts.grund) return [];
1418
- return [
1419
- `canvas surround: ${facts.painted.description} paints ${facts.painted.color}, but --cd-ui-grund resolves to ${facts.grund}; the surround follows the mode (guide \xA710.1)`
1420
- ];
1421
- }
1422
- function isToolEntry(entry) {
1423
- return entry.group === "tools" || entry.action.endsWith("-tool");
2003
+ return problems;
1424
2004
  }
1425
- function paletteEntryState(entry) {
1426
- if (entry.disabled) return "aria-disabled";
1427
- if (entry.hovered) return entry.pressed ? "pressed and hovered" : "hovered";
1428
- return entry.pressed ? "pressed" : "at rest";
2005
+
2006
+ // src/testing/checks.ts
2007
+ var normalize2 = (text) => text.replace(/\s+/g, " ").trim();
2008
+ var name = (element) => normalize2(element.attributes["aria-label"] ?? element.text);
2009
+ var attribute = (element, key) => element.attributes[key];
2010
+ function expectedKeyShortcuts(platform) {
2011
+ const modifier = platform === "mac" ? "Meta" : "Control";
2012
+ return {
2013
+ in: `${modifier}+= ${modifier}+Plus`,
2014
+ out: `${modifier}+-`,
2015
+ actual: `${modifier}+0`,
2016
+ fit: "Shift+1"
2017
+ };
1429
2018
  }
1430
- function paletteColorProblems(facts) {
2019
+ function ariaProblems(facts, host) {
1431
2020
  const problems = [];
1432
- for (const entry of facts.entries) {
1433
- if (!isToolEntry(entry)) continue;
1434
- const [role, value] = entry.disabled ? ["--cd-ui-text-leise", facts.roles.textLeise] : entry.hovered ? ["--cd-ui-interaktion", facts.roles.interaktion] : ["--cd-ui-text", facts.roles.text];
1435
- const state = paletteEntryState(entry);
1436
- if (entry.color !== value) {
2021
+ if (host === "embedded") {
2022
+ if (!facts.rootLang)
2023
+ problems.push("root: an embedded shell root carries the tool's lang itself (guide \xA72, \xA712)");
2024
+ } else if (!facts.documentLang) {
2025
+ problems.push("<html> has no lang (guide \xA72)");
2026
+ }
2027
+ const trigger = facts.trigger;
2028
+ if (!trigger) {
2029
+ problems.push('menu: no [data-chrome="menu"] trigger');
2030
+ } else {
2031
+ if (trigger.tag !== "button")
2032
+ problems.push(`menu: the trigger is a <${trigger.tag}>, expected a <button>`);
2033
+ if (!attribute(trigger, "aria-label")) problems.push("menu: the icon-only trigger has no aria-label");
2034
+ if (attribute(trigger, "aria-haspopup") !== "menu") {
2035
+ problems.push(`menu: aria-haspopup is "${attribute(trigger, "aria-haspopup") ?? ""}", expected "menu"`);
2036
+ }
2037
+ if (attribute(trigger, "aria-expanded") !== "false") {
1437
2038
  problems.push(
1438
- `palette: tool "${entry.action}" ${state} has color ${entry.color}, expected ${role} (${value}) (guide \xA75.5)`
2039
+ `menu: aria-expanded is "${attribute(trigger, "aria-expanded") ?? ""}" while closed, expected "false"`
1439
2040
  );
1440
2041
  }
1441
- if (entry.icon && entry.icon.color !== value) {
1442
- const icon = entry.icon.kind === "svg" ? "<svg> icon" : "::before icon";
2042
+ if (!attribute(trigger, "aria-controls")) {
2043
+ problems.push("menu: the trigger has no aria-controls");
2044
+ } else if (!facts.controlled) {
2045
+ problems.push(`menu: aria-controls="${attribute(trigger, "aria-controls")}" names no element`);
2046
+ } else if (attribute(facts.controlled, "role") !== "menu") {
1443
2047
  problems.push(
1444
- `palette: the ${icon} of tool "${entry.action}" ${state} has color ${entry.icon.color}, expected ${role} (${value}); a renderer rule overrides the skin (guide \xA75.5)`
2048
+ `menu: aria-controls names a ${facts.controlled.description} with role "${attribute(facts.controlled, "role") ?? ""}", expected "menu"`
1445
2049
  );
1446
2050
  }
1447
2051
  }
1448
- return problems;
1449
- }
1450
- var round2 = (value) => Math.round(value * 10) / 10;
1451
- function createProblems(facts) {
1452
- const known = new Set(facts.before.map((shape2) => shape2.id));
1453
- const added = facts.after.filter((shape2) => !known.has(shape2.id));
1454
- const pressed = `Enter on palette entry "${facts.entry}"`;
1455
- if (added.length === 0) {
1456
- return [
1457
- `create: ${pressed} added no element; Enter on a creating entry places it in the centre of the free rectangle (guide \xA75.5)`
1458
- ];
2052
+ if (!facts.palette) {
2053
+ problems.push('palette: no [data-chrome="palette"]');
2054
+ } else {
2055
+ const toolbar = facts.toolbar;
2056
+ if (!toolbar || attribute(toolbar, "role") !== "toolbar") {
2057
+ problems.push('palette: .djs-palette-entries has no role="toolbar"');
2058
+ } else if (!attribute(toolbar, "aria-label")) {
2059
+ problems.push("palette: the toolbar has no aria-label");
2060
+ }
2061
+ for (const entry of facts.entries) {
2062
+ const action = attribute(entry, "data-action") ?? entry.description;
2063
+ if (attribute(entry, "role") !== "button")
2064
+ problems.push(`palette: entry "${action}" has no role="button"`);
2065
+ if (!attribute(entry, "aria-label"))
2066
+ problems.push(`palette: entry "${action}" has no aria-label (title)`);
2067
+ const tabindex = attribute(entry, "tabindex");
2068
+ if (tabindex !== "0" && tabindex !== "-1") {
2069
+ problems.push(`palette: entry "${action}" has tabindex "${tabindex ?? ""}", expected roving 0/-1`);
2070
+ }
2071
+ }
2072
+ const stops = facts.entries.filter((entry) => attribute(entry, "tabindex") === "0");
2073
+ if (facts.entries.length > 0 && stops.length !== 1) {
2074
+ problems.push(
2075
+ `palette: ${stops.length} entries with tabindex="0", expected exactly one (roving tabindex)`
2076
+ );
2077
+ }
2078
+ const tools = facts.entries.filter(
2079
+ (entry) => entry.group === "tools" && (attribute(entry, "data-action") ?? "").endsWith("-tool")
2080
+ );
2081
+ for (const tool of tools) {
2082
+ const pressed2 = attribute(tool, "aria-pressed");
2083
+ if (pressed2 !== "true" && pressed2 !== "false") {
2084
+ problems.push(`palette: tool "${attribute(tool, "data-action")}" has no aria-pressed`);
2085
+ }
2086
+ }
2087
+ const pressed = tools.filter((tool) => attribute(tool, "aria-pressed") === "true");
2088
+ if (pressed.length > 1) problems.push(`palette: ${pressed.length} tools are aria-pressed at once`);
1459
2089
  }
1460
- if (added.length > 1) {
1461
- return [
1462
- `create: ${pressed} added ${added.length} elements (${added.map((shape2) => shape2.id).join(", ")}), expected exactly one`
1463
- ];
2090
+ const zoom = facts.zoom;
2091
+ if (!zoom) {
2092
+ problems.push('zoom: no [data-chrome="zoom"]');
2093
+ } else {
2094
+ if (attribute(zoom, "role") !== "group") problems.push('zoom: the panel has no role="group"');
2095
+ if (!attribute(zoom, "aria-label")) problems.push("zoom: the panel has no aria-label");
2096
+ const shortcuts = expectedKeyShortcuts(facts.platform);
2097
+ for (const button of ["out", "actual", "in", "fit"]) {
2098
+ const control = facts.zoomButtons[button];
2099
+ if (!control) {
2100
+ problems.push(`zoom: no [data-zoom="${button}"] button`);
2101
+ continue;
2102
+ }
2103
+ if (control.tag !== "button") problems.push(`zoom: [data-zoom="${button}"] is a <${control.tag}>`);
2104
+ if (!name(control)) problems.push(`zoom: [data-zoom="${button}"] has no accessible name`);
2105
+ if (attribute(control, "aria-keyshortcuts") !== shortcuts[button]) {
2106
+ problems.push(
2107
+ `zoom: [data-zoom="${button}"] aria-keyshortcuts is "${attribute(control, "aria-keyshortcuts") ?? ""}", expected "${shortcuts[button]}" (${facts.platform})`
2108
+ );
2109
+ }
2110
+ if (control.disabled) {
2111
+ problems.push(
2112
+ `zoom: [data-zoom="${button}"] is natively disabled; aria-disabled keeps the focus (guide \xA75.8)`
2113
+ );
2114
+ }
2115
+ }
2116
+ const readout = facts.zoomButtons.actual;
2117
+ if (readout) {
2118
+ if (!/\d/.test(readout.text))
2119
+ problems.push(`zoom: the readout shows "${readout.text}", expected a percentage`);
2120
+ const label = normalize2(attribute(readout, "aria-label") ?? "");
2121
+ if (!label.includes(readout.text)) {
2122
+ problems.push(
2123
+ `zoom: the readout's aria-label "${label}" does not name the current zoom "${readout.text}"`
2124
+ );
2125
+ }
2126
+ if (attribute(readout, "aria-disabled") === "true")
2127
+ problems.push("zoom: the readout is never disabled");
2128
+ }
1464
2129
  }
1465
- const [shape] = added;
1466
- const problems = [];
1467
- if (!shape.selected) problems.push(`create: the new element "${shape.id}" is not selected (guide \xA75.5)`);
1468
- if (!facts.canvas.focusInCanvas) {
2130
+ const present = new Set(facts.order);
2131
+ const expectedOrder = DOM_ORDER.filter((part) => present.has(part));
2132
+ if (facts.order.join() !== expectedOrder.join()) {
1469
2133
  problems.push(
1470
- `create: after ${pressed} the focus is on ${facts.canvas.focused}, expected the canvas (its <svg> or the label editor, guide \xA75.5)`
2134
+ `DOM order is ${facts.order.join(", ")}; expected ${expectedOrder.join(", ")} (guide \xA75.14)`
1471
2135
  );
1472
2136
  }
1473
- const centre = { x: shape.box.left + shape.box.width / 2, y: shape.box.top + shape.box.height / 2 };
1474
- const { free } = facts;
1475
- const tolerance = GEOMETRY_TOLERANCE_PX + 1e-6;
1476
- const inside = centre.x >= free.left - tolerance && centre.x <= free.right + tolerance && centre.y >= free.top - tolerance && centre.y <= free.bottom + tolerance;
1477
- if (!inside) {
2137
+ if (!present.has("canvas")) problems.push("canvas: no diagram-js <svg> (.djs-container > svg) in the root");
2138
+ const canvas = facts.canvas;
2139
+ if (canvas && !normalize2(attribute(canvas, "aria-label") ?? "") && !attribute(canvas, "aria-labelledby")) {
1478
2140
  problems.push(
1479
- `create: the new element "${shape.id}" is centred at (${round2(centre.x)}, ${round2(centre.y)})px, outside the free rectangle ${round2(free.left)}\u2013${round2(free.right)} \xD7 ${round2(free.top)}\u2013${round2(free.bottom)}px (guide \xA75.5, \xA75.9)`
2141
+ 'canvas: the focusable <svg> has no accessible name (WCAG 4.1.2); shellModule names it role="application" with messages.canvas'
1480
2142
  );
1481
2143
  }
2144
+ for (const element of facts.positiveTabindex)
2145
+ problems.push(`positive tabindex on ${element} (guide \xA75.14)`);
2146
+ for (const element of facts.titled) {
2147
+ problems.push(`${element} has a native title; the chrome tooltip replaces it (guide \xA75.6)`);
2148
+ }
2149
+ problems.push(...primaryAriaProblems(facts, host), ...legalAriaProblems(facts, host));
2150
+ return problems;
2151
+ }
2152
+ function optionalIslandProblems(facts, shown) {
2153
+ const problems = [];
2154
+ if (shown.has("problems")) {
2155
+ const pill = facts.problems;
2156
+ if (!pill?.visible) {
2157
+ problems.push("problems: the pill is not visible after show.problems");
2158
+ } else {
2159
+ if (pill.tag !== "button") problems.push(`problems: the pill is a <${pill.tag}>, expected a <button>`);
2160
+ const label = normalize2(attribute(pill, "aria-label") ?? "");
2161
+ if (!label) problems.push("problems: the pill has no aria-label (count and errors)");
2162
+ else if (!label.includes(pill.text)) {
2163
+ problems.push(`problems: the aria-label "${label}" does not contain the visible text "${pill.text}"`);
2164
+ }
2165
+ }
2166
+ }
2167
+ if (shown.has("status")) {
2168
+ const status = facts.status;
2169
+ if (!status?.visible) {
2170
+ problems.push("status: the status box is not visible after show.status");
2171
+ } else {
2172
+ const role = attribute(status, "role");
2173
+ const severity = attribute(status, "data-severity");
2174
+ const expected = severity === "danger" ? "alert" : "status";
2175
+ if (role !== expected) {
2176
+ problems.push(
2177
+ `status: role is "${role ?? ""}" for data-severity="${severity ?? ""}", expected "${expected}"`
2178
+ );
2179
+ }
2180
+ }
2181
+ }
2182
+ if (shown.has("legend")) {
2183
+ const legend = facts.legend;
2184
+ if (!legend?.visible) {
2185
+ problems.push("legend: the legend is not visible after show.legend");
2186
+ } else {
2187
+ if (!facts.legendTitle)
2188
+ problems.push("legend: the card is not labelled by its title (aria-labelledby)");
2189
+ if (!facts.legendClose || !attribute(facts.legendClose, "aria-label")) {
2190
+ problems.push("legend: the close button has no aria-label");
2191
+ }
2192
+ }
2193
+ }
2194
+ if (shown.has("inspector")) {
2195
+ const inspector = facts.inspector;
2196
+ if (!inspector?.visible) {
2197
+ problems.push("inspector: the inspector is not visible after show.inspector");
2198
+ } else {
2199
+ if (inspector.tag !== "aside") problems.push(`inspector: a <${inspector.tag}>, expected an <aside>`);
2200
+ if (!attribute(inspector, "aria-label")) problems.push("inspector: the <aside> has no aria-label");
2201
+ }
2202
+ }
2203
+ return problems;
2204
+ }
2205
+ function tabSequenceProblems(stops, primary = false) {
2206
+ const [first, second, third] = stops;
2207
+ const sequence = first?.kind === "primary" ? [second, third] : [first, second];
2208
+ const [palette, canvas] = sequence;
2209
+ const problems = [];
2210
+ if (primary && first?.kind !== "primary") {
2211
+ problems.push(
2212
+ `tab order: Tab from the menu trigger lands on ${first?.description ?? "nothing"}, expected the primary action (top left, top right, top centre, guide \xA75.14)`
2213
+ );
2214
+ }
2215
+ if (palette?.kind !== "palette") {
2216
+ problems.push(
2217
+ `tab order: Tab from the menu trigger lands on ${palette?.description ?? "nothing"}, expected the palette's roving entry (guide \xA75.14)`
2218
+ );
2219
+ } else if (canvas?.kind !== "canvas") {
2220
+ problems.push(
2221
+ `tab order: Tab from the palette lands on ${canvas?.description ?? "nothing"}, expected the canvas <svg> (an unexpected tab stop, guide \xA75.14)`
2222
+ );
2223
+ }
2224
+ return problems;
2225
+ }
2226
+ function paletteShortcutProblems(entries) {
2227
+ return entries.filter((entry) => entry.chip && !entry.keyShortcuts).map(
2228
+ (entry) => `palette: entry "${entry.action}" shows the shortcut "${entry.chip}" in its tooltip but has no aria-keyshortcuts (guide \xA75.6)`
2229
+ );
2230
+ }
2231
+ function menuKeysAtDocumentProblems(pressed, documentKeys) {
2232
+ const reached = new Set(documentKeys.map((key) => key.key));
2233
+ return pressed.filter((key) => reached.has(key)).map(
2234
+ (key) => `menu: ${key} in the open menu reached a keydown listener on document; a modeler's document keyboard acts on it (stop it at the menu, guide \xA75.4)`
2235
+ );
2236
+ }
2237
+ function menuOpenProblems(facts, focus) {
2238
+ const problems = [];
2239
+ if (facts.expanded !== "true") {
2240
+ problems.push(`menu: aria-expanded is "${facts.expanded ?? ""}" while open, expected "true"`);
2241
+ }
2242
+ const menu = facts.menu;
2243
+ if (!menu) return [...problems, 'menu: no visible role="menu" after opening'];
2244
+ if (!attribute(menu, "aria-label")) problems.push("menu: the dropdown has no aria-label");
2245
+ if (facts.controls && attribute(menu, "id") !== facts.controls) {
2246
+ problems.push(
2247
+ `menu: the visible dropdown is #${attribute(menu, "id") ?? ""}, the trigger controls #${facts.controls}`
2248
+ );
2249
+ }
2250
+ if (facts.items.length === 0) problems.push("menu: the dropdown has no menuitem");
2251
+ facts.items.forEach((item, index) => {
2252
+ const label = item.name || `item ${index + 1}`;
2253
+ if (!item.name) problems.push(`menu: ${label} has no accessible name`);
2254
+ if (item.role !== "menuitem" && item.checked !== "true" && item.checked !== "false") {
2255
+ problems.push(`menu: "${label}" (${item.role}) has aria-checked "${item.checked ?? ""}"`);
2256
+ }
2257
+ if (item.role === "menuitemradio" && !item.group) {
2258
+ problems.push(`menu: radio "${label}" is not in a labelled role="group"`);
2259
+ }
2260
+ if (item.haspopup === "dialog" && !item.name.endsWith("\u2026")) {
2261
+ problems.push(`menu: "${label}" opens a dialog, so its label ends with "\u2026" (U+2026)`);
2262
+ }
2263
+ });
2264
+ if (focus && facts.items.length > 0) {
2265
+ const expected = focus === "first" ? 0 : facts.items.length - 1;
2266
+ if (facts.active !== expected) {
2267
+ problems.push(
2268
+ `menu: focus is on ${facts.active < 0 ? "no entry" : `entry ${facts.active + 1}`}, expected the ${focus} entry`
2269
+ );
2270
+ }
2271
+ }
2272
+ return problems;
2273
+ }
2274
+ function menuClosedProblems(facts, focusOnTrigger) {
2275
+ const problems = [];
2276
+ if (facts.expanded !== "false")
2277
+ problems.push(`menu: aria-expanded is "${facts.expanded ?? ""}" after closing`);
2278
+ if (facts.menu) problems.push("menu: the dropdown is still visible after Escape");
2279
+ if (focusOnTrigger && !facts.activeIsTrigger)
2280
+ problems.push("menu: Escape did not return the focus to the trigger");
2281
+ return problems;
2282
+ }
2283
+ function keyReachProblem(press, reach, host, seen) {
2284
+ const shouldPrevent = reach === "handled" || reach === "canvas";
2285
+ if (host === "ide" && reach !== "free") {
2286
+ if (seen.keys.length > 0) {
2287
+ return `${press} reached a keydown listener on window; in the IDE host the shell stops it on document, or VS Code runs it again (guide \xA713.5)`;
2288
+ }
2289
+ const atDocument = seen.documentKeys ?? [];
2290
+ const prevented2 = atDocument.length === 1 ? atDocument[0]?.defaultPrevented : void 0;
2291
+ if (prevented2 === true && !shouldPrevent) {
2292
+ return `${press} was prevented; the shell only stops it, so the field, dialog or browser still acts (guide \xA713.5)`;
2293
+ }
2294
+ if (prevented2 === false && shouldPrevent) {
2295
+ return `${press} was stopped but not prevented; a handled key also calls preventDefault`;
2296
+ }
2297
+ return void 0;
2298
+ }
2299
+ if (seen.keys.length === 0) {
2300
+ return host === "ide" ? `${press} never reached window; keys the webview does not handle must go on to VS Code (guide \xA713.5)` : `${press} never reached window; outside the IDE host the shell never stops propagation`;
2301
+ }
2302
+ if (seen.keys.length > 1) return `${press} reached window ${seen.keys.length} times`;
2303
+ const prevented = seen.keys[0]?.defaultPrevented ?? false;
2304
+ if (prevented && !shouldPrevent) {
2305
+ return `${press} arrived prevented; ${reach === "free" ? "nothing may handle it" : "the browser, the field or the dialog must act"}`;
2306
+ }
2307
+ if (!prevented && shouldPrevent) {
2308
+ return `${press} arrived unprevented; a handled key calls preventDefault (the browser would zoom the page)`;
2309
+ }
2310
+ return void 0;
2311
+ }
2312
+ function expectedAppearance(requested, themes) {
2313
+ const fallback = themes[0];
2314
+ if (fallback === void 0) throw new Error("@miragon/modeler-shell conformance: harness.themes is empty");
2315
+ const theme = themes.includes(requested.theme) ? requested.theme : fallback;
2316
+ return requested.kontrast ? { theme, kontrast: requested.kontrast } : { theme };
2317
+ }
2318
+ var describeAppearance = (appearance) => appearance.kontrast ? `${appearance.theme} + ${appearance.kontrast}` : appearance.theme;
2319
+ function expectedEmbeddedAppearance(facts, themes) {
2320
+ const fallback = themes[0];
2321
+ if (fallback === void 0) throw new Error("@miragon/modeler-shell conformance: harness.themes is empty");
2322
+ const host = facts.themed.filter((element) => element.relation === "ancestor").at(-1);
2323
+ return host?.kontrast === "hoch" ? { theme: fallback, kontrast: "hoch" } : { theme: fallback };
2324
+ }
2325
+ function appearanceProblems(facts, host, requested, themes, web) {
2326
+ const ownDocument = host !== "embedded";
2327
+ const expected = web?.effective ?? (ownDocument ? expectedAppearance(requested, themes) : expectedEmbeddedAppearance(facts, themes));
2328
+ const carrierTheme = web?.carrier ?? expected.theme;
2329
+ const problems = [];
2330
+ const described = carrierTheme === expected.theme ? describeAppearance(expected) : `${carrierTheme} showing ${describeAppearance(expected)}`;
2331
+ const label = ownDocument ? `requested ${describeAppearance(requested)}, expected ${described}` : `host page ${describeAppearance(requested)}, expected ${described}`;
2332
+ const carrier = ownDocument ? facts.themed.find((element) => element.isDocument && element.theme !== null) : facts.themed.find((element) => element.relation === "self" && element.theme !== null);
2333
+ if (!carrier) {
2334
+ problems.push(
2335
+ ownDocument ? `<html> has no data-cd-theme (${label}); the host always writes it, also "light" (guide \xA713.3)` : `the embedded root has no data-cd-theme (${label}): it takes the host page's mode instead of its own (guide \xA713.3)`
2336
+ );
2337
+ } else {
2338
+ if (carrier.theme !== carrierTheme) {
2339
+ problems.push(`${carrier.description} has data-cd-theme="${carrier.theme}" (${label})`);
2340
+ }
2341
+ if ((carrier.kontrast ?? void 0) !== expected.kontrast) {
2342
+ problems.push(
2343
+ `${carrier.description} has ${carrier.kontrast ? `data-cd-kontrast="${carrier.kontrast}"` : "no data-cd-kontrast"} (${label})`
2344
+ );
2345
+ }
2346
+ }
2347
+ for (const element of facts.themed) {
2348
+ if (element === carrier) continue;
2349
+ const stray = ownDocument ? !element.isDocument || element.theme === null : element.relation === "descendant" || element.relation === "self";
2350
+ if (!stray) continue;
2351
+ const values = [
2352
+ element.theme !== null ? `data-cd-theme="${element.theme}"` : "",
2353
+ element.kontrast !== null ? `data-cd-kontrast="${element.kontrast}"` : ""
2354
+ ].filter(Boolean).join(" ");
2355
+ const sameMode = carrier !== void 0 && element.theme === carrier.theme && element.kontrast === carrier.kontrast;
2356
+ problems.push(
2357
+ element.theme === null ? `${element.description} carries ${values} without data-cd-theme on the same element (guide \xA713.3)` : sameMode ? `${element.description} repeats ${values}: in an own document the attributes stand only on <html> (guide \xA713.5)` : `${element.description} carries ${values}: a second mode inside the modeler is half-dark (guide \xA713.5)`
2358
+ );
2359
+ }
2360
+ if (ownDocument && !facts.documentClasses.includes("cd-shell-document")) {
2361
+ problems.push(
2362
+ "<html> lacks cd-shell-document in an own document (the reset of the webview's host styles)"
2363
+ );
2364
+ }
2365
+ if (!ownDocument && facts.documentClasses.includes("cd-shell-document")) {
2366
+ problems.push(
2367
+ "<html> of a foreign page carries cd-shell-document: the own-document reset restyles the host page"
2368
+ );
2369
+ }
2370
+ const reference = facts.parts[0];
2371
+ if (reference && !reference.roles["--cd-ui-text"]) {
2372
+ problems.push(
2373
+ "--cd-ui-text is empty at the shell root: the vendored cd-tokens.generated.css is not loaded"
2374
+ );
2375
+ }
2376
+ for (const part of facts.parts) {
2377
+ const schemes = part.colorScheme.split(/\s+/);
2378
+ const dark = schemes.includes("dark");
2379
+ const light = schemes.includes("light");
2380
+ if (expected.theme === "light" && (dark || carrier && !light)) {
2381
+ problems.push(`${part.name}: color-scheme is "${part.colorScheme}" (${label})`);
2382
+ }
2383
+ if (expected.theme === "dark" && (!dark || light)) {
2384
+ problems.push(`${part.name}: color-scheme is "${part.colorScheme}" (${label})`);
2385
+ }
2386
+ if (reference && part !== reference) {
2387
+ for (const [role, value] of Object.entries(part.roles)) {
2388
+ if (value !== reference.roles[role]) {
2389
+ problems.push(
2390
+ `${part.name}: ${role} resolves to "${value}", the root to "${reference.roles[role] ?? ""}" (half-dark)`
2391
+ );
2392
+ }
2393
+ }
2394
+ }
2395
+ }
2396
+ for (const required of ["dropdown", "tooltip"]) {
2397
+ if (!facts.parts.some((part) => part.name === required)) {
2398
+ problems.push(`${required}: not visible when the kit opened it, so its mode was not checked`);
2399
+ }
2400
+ }
2401
+ return { problems };
2402
+ }
2403
+ var SHELL_ROOT_ATTRIBUTES = [
2404
+ "data-cd-shell-host",
2405
+ "data-palette",
2406
+ "data-legend",
2407
+ "data-inspector",
2408
+ "data-problems",
2409
+ "data-readonly",
2410
+ "data-primary",
2411
+ "data-zoom-row",
2412
+ "data-empty-state",
2413
+ "data-drop"
2414
+ ];
2415
+ var EMBEDDED_ROOT_ATTRIBUTES = ["data-cd-theme", "data-cd-kontrast"];
2416
+ var BENIGN_ERRORS = /ResizeObserver loop/;
2417
+ function leftoverProblems(facts, host) {
2418
+ const problems = [];
2419
+ for (const element of facts.classedElements) problems.push(`${element} is left after destroy`);
2420
+ const ownDocument = host !== "embedded";
2421
+ for (const name2 of facts.documentClasses) {
2422
+ if (ownDocument && name2 === "cd-shell-document") continue;
2423
+ problems.push(`<html> keeps the class ${name2} after destroy`);
2424
+ }
2425
+ for (const element of facts.chromeElements) problems.push(`${element} is left after destroy`);
2426
+ for (const name2 of facts.rootClasses) problems.push(`the root keeps the class ${name2} after destroy`);
2427
+ const written = ownDocument ? SHELL_ROOT_ATTRIBUTES : [...SHELL_ROOT_ATTRIBUTES, ...EMBEDDED_ROOT_ATTRIBUTES];
2428
+ for (const name2 of facts.rootAttributes) {
2429
+ if (written.includes(name2)) problems.push(`the root keeps ${name2} after destroy`);
2430
+ }
2431
+ for (const error of facts.errors) {
2432
+ if (!BENIGN_ERRORS.test(error)) problems.push(`the page reported an error after destroy: ${error}`);
2433
+ }
2434
+ return problems;
2435
+ }
2436
+ function afterDestroyKeyProblem(press, recorded) {
2437
+ if (recorded.length === 0)
2438
+ return `${press} after destroy never reached window: a shell listener still stops it`;
2439
+ if (recorded.some((key) => key.defaultPrevented)) {
2440
+ return `${press} after destroy arrived prevented: a shell listener is still installed`;
2441
+ }
2442
+ return void 0;
2443
+ }
2444
+ var ABSENT_ISLANDS = ["legend", "inspector", "problems", "status"];
2445
+ function absentIslands(absent) {
2446
+ return ABSENT_ISLANDS.filter((island) => absent?.includes(island));
2447
+ }
2448
+ function absentIslandProblems(visible, absent) {
2449
+ return absentIslands(absent).filter((island) => visible.has(island)).map((island) => `${island}: visible, but harness.absent says the modeler has no ${island}`);
2450
+ }
2451
+ function harnessHookProblems(harness, hooks) {
2452
+ return hooks.filter(
2453
+ (hook) => hook === "drop" ? harness.absent?.includes("drop") && harness.drop : hook !== "problemSelection" && harness.absent?.includes(hook) && harness.show?.[hook]
2454
+ ).map(
2455
+ (hook) => hook === "drop" ? 'harness: absent lists "drop", but harness.drop is given' : `harness: absent lists "${hook}", but show.${hook} is given`
2456
+ );
2457
+ }
2458
+ function surroundProblems(facts) {
2459
+ if (!facts.container) return ["canvas surround: no .djs-container in the root to check"];
2460
+ if (!facts.painted) {
2461
+ return [
2462
+ "canvas surround: nothing from .djs-container out to the root paints a background colour; the surround is var(--cd-ui-grund) (guide \xA710.1)"
2463
+ ];
2464
+ }
2465
+ if (facts.painted.color === facts.grund) return [];
2466
+ return [
2467
+ `canvas surround: ${facts.painted.description} paints ${facts.painted.color}, but --cd-ui-grund resolves to ${facts.grund}; the surround follows the mode (guide \xA710.1)`
2468
+ ];
2469
+ }
2470
+ function isToolEntry(entry) {
2471
+ return entry.group === "tools" || entry.action.endsWith("-tool");
2472
+ }
2473
+ function paletteEntryState(entry) {
2474
+ if (entry.disabled) return "aria-disabled";
2475
+ if (entry.hovered) return entry.pressed ? "pressed and hovered" : "hovered";
2476
+ return entry.pressed ? "pressed" : "at rest";
2477
+ }
2478
+ function paletteColorProblems(facts) {
2479
+ const problems = [];
2480
+ for (const entry of facts.entries) {
2481
+ if (!isToolEntry(entry)) continue;
2482
+ const [role, value] = entry.disabled ? ["--cd-ui-text-leise", facts.roles.textLeise] : entry.hovered ? ["--cd-ui-interaktion", facts.roles.interaktion] : ["--cd-ui-text", facts.roles.text];
2483
+ const state = paletteEntryState(entry);
2484
+ if (entry.color !== value) {
2485
+ problems.push(
2486
+ `palette: tool "${entry.action}" ${state} has color ${entry.color}, expected ${role} (${value}) (guide \xA75.5)`
2487
+ );
2488
+ }
2489
+ if (entry.icon && entry.icon.color !== value) {
2490
+ const icon = entry.icon.kind === "svg" ? "<svg> icon" : "::before icon";
2491
+ problems.push(
2492
+ `palette: the ${icon} of tool "${entry.action}" ${state} has color ${entry.icon.color}, expected ${role} (${value}); a renderer rule overrides the skin (guide \xA75.5)`
2493
+ );
2494
+ }
2495
+ }
2496
+ return problems;
2497
+ }
2498
+ var round3 = (value) => Math.round(value * 10) / 10;
2499
+ function createProblems(facts) {
2500
+ const known = new Set(facts.before.map((shape2) => shape2.id));
2501
+ const added = facts.after.filter((shape2) => !known.has(shape2.id));
2502
+ const pressed = `Enter on palette entry "${facts.entry}"`;
2503
+ if (added.length === 0) {
2504
+ return [
2505
+ `create: ${pressed} added no element; Enter on a creating entry places it in the centre of the free rectangle (guide \xA75.5)`
2506
+ ];
2507
+ }
2508
+ if (added.length > 1) {
2509
+ return [
2510
+ `create: ${pressed} added ${added.length} elements (${added.map((shape2) => shape2.id).join(", ")}), expected exactly one`
2511
+ ];
2512
+ }
2513
+ const [shape] = added;
2514
+ const problems = [];
2515
+ if (!shape.selected) problems.push(`create: the new element "${shape.id}" is not selected (guide \xA75.5)`);
2516
+ if (!facts.canvas.focusInCanvas) {
2517
+ problems.push(
2518
+ `create: after ${pressed} the focus is on ${facts.canvas.focused}, expected the canvas (its <svg> or the label editor, guide \xA75.5)`
2519
+ );
2520
+ }
2521
+ const centre = { x: shape.box.left + shape.box.width / 2, y: shape.box.top + shape.box.height / 2 };
2522
+ const { free } = facts;
2523
+ const tolerance = GEOMETRY_TOLERANCE_PX + 1e-6;
2524
+ const inside = centre.x >= free.left - tolerance && centre.x <= free.right + tolerance && centre.y >= free.top - tolerance && centre.y <= free.bottom + tolerance;
2525
+ if (!inside) {
2526
+ problems.push(
2527
+ `create: the new element "${shape.id}" is centred at (${round3(centre.x)}, ${round3(centre.y)})px, outside the free rectangle ${round3(free.left)}\u2013${round3(free.right)} \xD7 ${round3(free.top)}\u2013${round3(free.bottom)}px (guide \xA75.5, \xA75.9)`
2528
+ );
2529
+ }
2530
+ return problems;
2531
+ }
2532
+ function readOnlyStateProblems(facts) {
2533
+ const problems = [];
2534
+ const after = `${INVALID_STATUS_DELAY_MS}ms after show.invalid`;
2535
+ if (!facts.readOnly) problems.push(`read-only: the root has no data-readonly ${after}`);
2536
+ const status = facts.status;
2537
+ if (!status?.visible) {
2538
+ problems.push(`read-only: the status box is not visible ${after} (guide \xA75.12)`);
2539
+ } else if (status.attributes.role !== "alert") {
2540
+ problems.push(
2541
+ `read-only: the status box of an invalid file has role "${status.attributes.role ?? ""}", expected "alert" (guide \xA713.5)`
2542
+ );
2543
+ }
2544
+ return problems;
2545
+ }
2546
+ var READ_ONLY_PALETTE_ACTIONS = ["lasso-tool", "hand-tool"];
2547
+ function readOnlyPaletteProblems(entries) {
2548
+ return entries.flatMap((entry) => {
2549
+ const staysActive = READ_ONLY_PALETTE_ACTIONS.includes(entry.action);
2550
+ if (staysActive && entry.disabled) {
2551
+ return [
2552
+ `palette: "${entry.action}" is aria-disabled on the read-only canvas; Lasso and Hand stay active (guide \xA713.5)`
2553
+ ];
2554
+ }
2555
+ if (!staysActive && !entry.disabled) {
2556
+ return [`palette: "${entry.action}" is not aria-disabled on the read-only canvas (guide \xA713.5)`];
2557
+ }
2558
+ return [];
2559
+ });
2560
+ }
2561
+ var LEGEND_MENU_ID = "legend";
2562
+ function readOnlyMenuProblems(before, after) {
2563
+ const problems = [];
2564
+ const key = (entry) => `${entry.menuId ?? ""}\0${entry.name}`;
2565
+ const enabledBefore = new Set(before.filter((entry) => !entry.disabled).map(key));
2566
+ for (const entry of after) {
2567
+ if (!entry.disabled) continue;
2568
+ if (entry.menuId === LEGEND_MENU_ID && entry.role === "menuitemcheckbox") {
2569
+ problems.push(
2570
+ "menu: the Legend toggle is aria-disabled on the read-only canvas; a view-only toggle stays usable (guide \xA713.5)"
2571
+ );
2572
+ } else if (entry.group !== "model" && enabledBefore.has(key(entry))) {
2573
+ problems.push(
2574
+ `menu: "${entry.name}" is aria-disabled on the read-only canvas; export and help stay usable (guide \xA713.5)`
2575
+ );
2576
+ }
2577
+ }
2578
+ return problems;
2579
+ }
2580
+ function lockedShapeProblems(facts) {
2581
+ const problems = [];
2582
+ const { before, after } = facts.dragged;
2583
+ if (facts.contextPadEntries.length > 0) {
2584
+ problems.push(
2585
+ `read-only: selecting "${before.id}" shows the context pad (${facts.contextPadEntries.join(", ")}) (guide \xA713.5)`
2586
+ );
2587
+ }
2588
+ if (!after) {
2589
+ problems.push(`read-only: a 40px drag removed "${before.id}" from the canvas (guide \xA713.5)`);
2590
+ } else if (after.transform !== before.transform || Math.abs(after.size.width - before.size.width) > 0.5 || Math.abs(after.size.height - before.size.height) > 0.5) {
2591
+ problems.push(
2592
+ `read-only: a 40px drag changed "${before.id}" (transform "${before.transform}" \u2192 "${after.transform}", ${round3(before.size.width)} \xD7 ${round3(before.size.height)} \u2192 ${round3(after.size.width)} \xD7 ${round3(after.size.height)}) (guide \xA713.5)`
2593
+ );
2594
+ }
2595
+ if (facts.textFields.length > 0) {
2596
+ problems.push(
2597
+ `read-only: a double-click on "${before.id}" opens a text field (${facts.textFields.join(", ")}) (guide \xA713.5)`
2598
+ );
2599
+ }
2600
+ for (const key of ["Delete", "Backspace"]) {
2601
+ if (!facts.kept[key])
2602
+ problems.push(`read-only: ${key} on the canvas deleted "${before.id}" (guide \xA713.5)`);
2603
+ }
2604
+ return problems;
2605
+ }
2606
+
2607
+ // src/testing/case-tools.ts
2608
+ var DEFAULT_SIZES = [
2609
+ { width: 1280, height: 900 },
2610
+ { width: 720, height: 700 },
2611
+ { width: 420, height: 700 }
2612
+ ];
2613
+ var LIGHT = { theme: "light" };
2614
+ var SHOWN_ISLANDS = ["legend", "problems", "status", "inspector"];
2615
+ var POLL_MS = 50;
2616
+ var HOVER_MARGIN_MS = 200;
2617
+ var PARK = { x: 1, y: 1 };
2618
+ function sizeLabel(size) {
2619
+ return `${size.width} \xD7 ${size.height}`;
2620
+ }
2621
+ function fail(caseName, host, problems) {
2622
+ const unique = [...new Set(problems)];
2623
+ throw new Error(
2624
+ `@miragon/modeler-shell conformance: ${caseName} (${host} host)
2625
+ ${unique.map((problem) => `- ${problem}`).join("\n")}`
2626
+ );
2627
+ }
2628
+ function finish(caseName, host, problems, skipped) {
2629
+ if (problems.length > 0) fail(caseName, host, problems);
2630
+ const seen = /* @__PURE__ */ new Set();
2631
+ const unique = [...skipped].filter((skip) => !seen.has(skip.reason) && seen.add(skip.reason));
2632
+ return unique.length > 0 ? { skipped: unique } : void 0;
2633
+ }
2634
+ function assertHooksConsistent(caseName, harness, hooks) {
2635
+ const problems = harnessHookProblems(harness, hooks);
2636
+ if (problems.length > 0) fail(caseName, harness.host, problems);
2637
+ }
2638
+ function unfed(harness, hook) {
2639
+ return !harness.show?.[hook] && !harness.absent?.includes(hook);
2640
+ }
2641
+ async function openHarness(page, harness, context) {
2642
+ if (harness.host === "web" && page.emulateMedia) {
2643
+ await page.emulateMedia({
2644
+ colorScheme: context.requested.theme,
2645
+ contrast: context.requested.kontrast === "hoch" ? "more" : "no-preference"
2646
+ });
2647
+ }
2648
+ await harness.open(page, context);
2649
+ }
2650
+ function webHookSkips(harness, hooks) {
2651
+ return Object.entries(hooks).filter(([hook]) => hook === "toast" ? harness.host !== "ide" : harness.host === "web").filter(([hook]) => unfed(harness, hook)).map(([hook, what]) => ({ hook, reason: `show.${hook} is undefined: ${what} not verified` }));
2652
+ }
2653
+ async function pollUntil(page, read, done, timeoutMs) {
2654
+ let value = await read();
2655
+ for (let waited = 0; !done(value) && waited < timeoutMs; waited += POLL_MS) {
2656
+ await page.waitForTimeout(POLL_MS);
2657
+ value = await read();
2658
+ }
2659
+ return value;
2660
+ }
2661
+ async function hover(page, root, target) {
2662
+ const anchor = await findTarget(page, root, target);
2663
+ if (!anchor) return null;
2664
+ await page.mouse.move(PARK.x, PARK.y);
2665
+ await page.mouse.move(anchor.client.x, anchor.client.y);
2666
+ await page.waitForTimeout(TOOLTIP_DELAY_MS + HOVER_MARGIN_MS);
2667
+ await settle(page);
2668
+ return anchor;
2669
+ }
2670
+ async function openMenuByClick(page, root) {
2671
+ const trigger = await findTarget(page, root, "menu");
2672
+ if (!trigger) return false;
2673
+ await page.mouse.click(trigger.client.x, trigger.client.y);
2674
+ await settle(page);
2675
+ return true;
2676
+ }
2677
+ async function closeByEscape(page) {
2678
+ await page.keyboard.press("Escape");
2679
+ await settle(page);
2680
+ }
2681
+ async function menuEntries(page, root) {
2682
+ if (!await openMenuByClick(page, root)) return [];
2683
+ const entries = await inPage(page, menuEntriesProbe, { root }) ?? [];
2684
+ await closeByEscape(page);
2685
+ return entries;
2686
+ }
2687
+ function shapes(page, root) {
2688
+ return inPage(page, shapesProbe, { root });
2689
+ }
2690
+
2691
+ // src/testing/web-facts.ts
2692
+ var webModeProbe = ({ key }) => {
2693
+ const html = document.documentElement;
2694
+ let stored = null;
2695
+ let storageBlocked = false;
2696
+ try {
2697
+ stored = localStorage.getItem(key);
2698
+ } catch {
2699
+ storageBlocked = true;
2700
+ }
2701
+ return {
2702
+ theme: html.getAttribute("data-cd-theme"),
2703
+ kontrast: html.getAttribute("data-cd-kontrast"),
2704
+ colorScheme: document.querySelector('meta[name="color-scheme"]')?.getAttribute("content") ?? null,
2705
+ themeColors: [...document.querySelectorAll('meta[name="theme-color"]')].map((meta) => ({
2706
+ content: meta.getAttribute("content") ?? "",
2707
+ media: meta.getAttribute("media")
2708
+ })),
2709
+ stored,
2710
+ storageBlocked,
2711
+ noTransition: html.classList.contains("cd-shell-no-transition")
2712
+ };
2713
+ };
2714
+ var watchModeProbe = (_) => {
2715
+ const tools = window.__modelerShellConformance;
2716
+ tools.modeWatch?.stop();
2717
+ const html = document.documentElement;
2718
+ const changes = [];
2719
+ let frame = 0;
2720
+ let running = true;
2721
+ const tick = () => {
2722
+ if (!running) return;
2723
+ frame += 1;
2724
+ requestAnimationFrame(tick);
2725
+ };
2726
+ requestAnimationFrame(tick);
2727
+ const observer = new MutationObserver((records) => {
2728
+ for (const record of records) {
2729
+ const target = record.target;
2730
+ if (target === html) {
2731
+ if (record.attributeName === "class") {
2732
+ const had = (record.oldValue ?? "").split(/\s+/).includes("cd-shell-no-transition");
2733
+ const has = html.classList.contains("cd-shell-no-transition");
2734
+ if (had !== has) changes.push({ part: "no-transition", value: has ? "on" : "off", frame });
2735
+ } else if (record.attributeName === "data-cd-theme") {
2736
+ changes.push({ part: "theme", value: html.getAttribute("data-cd-theme"), frame });
2737
+ } else if (record.attributeName === "data-cd-kontrast") {
2738
+ changes.push({ part: "kontrast", value: html.getAttribute("data-cd-kontrast"), frame });
2739
+ }
2740
+ } else if (target.localName === "meta") {
2741
+ const name2 = target.getAttribute("name");
2742
+ const media = target.getAttribute("media");
2743
+ changes.push({
2744
+ part: name2 === "color-scheme" ? "color-scheme" : "theme-color",
2745
+ value: `${target.getAttribute("content") ?? ""}${media ? ` ${media}` : ""}`,
2746
+ frame
2747
+ });
2748
+ }
2749
+ }
2750
+ });
2751
+ observer.observe(html, {
2752
+ attributes: true,
2753
+ attributeOldValue: true,
2754
+ attributeFilter: ["data-cd-theme", "data-cd-kontrast", "class"]
2755
+ });
2756
+ for (const meta of document.querySelectorAll('meta[name="color-scheme"], meta[name="theme-color"]')) {
2757
+ observer.observe(meta, { attributes: true, attributeFilter: ["content"] });
2758
+ }
2759
+ tools.modeWatch = {
2760
+ changes,
2761
+ stop() {
2762
+ running = false;
2763
+ observer.disconnect();
2764
+ }
2765
+ };
2766
+ };
2767
+ var modeChangesProbe = (_) => {
2768
+ const tools = window.__modelerShellConformance;
2769
+ const watch = tools.modeWatch;
2770
+ if (!watch) return [];
2771
+ watch.stop();
2772
+ tools.modeWatch = void 0;
2773
+ return [...watch.changes];
2774
+ };
2775
+ var legalLineProbe = ({ root }) => {
2776
+ const tools = window.__modelerShellConformance;
2777
+ const rootElement = document.querySelector(root);
2778
+ if (!rootElement) return null;
2779
+ const legal = rootElement.querySelector('[data-chrome="legal"]');
2780
+ return {
2781
+ container: tools.container(rootElement),
2782
+ legal: legal && tools.visible(legal) ? tools.box(legal, rootElement) : null,
2783
+ links: [...legal?.querySelectorAll("a") ?? []].map((link) => ({
2784
+ text: (link.textContent ?? "").replace(/\s+/g, " ").trim(),
2785
+ box: tools.box(link, rootElement)
2786
+ }))
2787
+ };
2788
+ };
2789
+ var topRowProbe = ({ root }) => {
2790
+ const tools = window.__modelerShellConformance;
2791
+ const rootElement = document.querySelector(root);
2792
+ if (!rootElement) return null;
2793
+ const palette = rootElement.querySelector('[data-chrome="palette"]');
2794
+ const menu = rootElement.querySelector('[data-chrome="menu"]');
2795
+ const primary = rootElement.querySelector('[data-chrome="primary"]');
2796
+ const menuBox = menu && tools.visible(menu) ? tools.box(menu, rootElement) : null;
2797
+ let primaryFacts = null;
2798
+ if (primary && tools.visible(primary)) {
2799
+ const rect = primary.getBoundingClientRect();
2800
+ const labels = [...primary.querySelectorAll("span")].filter(
2801
+ (span) => tools.visible(span) && (span.textContent ?? "").trim() !== ""
2802
+ );
2803
+ primaryFacts = {
2804
+ box: tools.box(primary, rootElement),
2805
+ client: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
2806
+ collapsed: rootElement.getAttribute("data-primary") === "collapsed",
2807
+ ariaLabel: primary.getAttribute("aria-label"),
2808
+ describedBy: primary.getAttribute("aria-describedby"),
2809
+ label: labels.length > 0 ? labels.map((span) => (span.textContent ?? "").trim()).join(" ") : null
2810
+ };
2811
+ }
2812
+ return {
2813
+ container: tools.container(rootElement),
2814
+ paletteRow: rootElement.getAttribute("data-palette") === "second-row" ? "second-row" : "top",
2815
+ paletteWidth: palette ? palette.offsetWidth - palette.clientWidth + palette.scrollWidth : null,
2816
+ menuRight: menuBox ? menuBox.left + menuBox.width : null,
2817
+ primary: primaryFacts
2818
+ };
2819
+ };
2820
+ var countClicksProbe = ({ root, selector }) => {
2821
+ const tools = window.__modelerShellConformance;
2822
+ const element = document.querySelector(root)?.querySelector(selector);
2823
+ if (!element) return false;
2824
+ tools.clicks = 0;
2825
+ const listener = () => {
2826
+ tools.clicks += 1;
2827
+ };
2828
+ element.addEventListener("click", listener, true);
2829
+ tools.cleanups.push(() => element.removeEventListener("click", listener, true));
2830
+ return true;
2831
+ };
2832
+ var clicksProbe = (_) => window.__modelerShellConformance.clicks;
2833
+ var emptyStateProbe = ({
2834
+ root,
2835
+ work
2836
+ }) => {
2837
+ const tools = window.__modelerShellConformance;
2838
+ const rootElement = document.querySelector(root);
2839
+ if (!rootElement) return null;
2840
+ const card = rootElement.querySelector('[data-chrome="empty-state"]');
2841
+ const image = card?.querySelector("img") ?? null;
2842
+ const heading = card?.querySelector("h1, h2, h3, h4, h5, h6") ?? null;
2843
+ const labelledBy = card?.getAttribute("aria-labelledby");
2844
+ const parts = work.map((name2) => [
2845
+ name2,
2846
+ rootElement.querySelector(`[data-chrome="${name2}"]`)
2847
+ ]);
2848
+ parts.push(["hint", rootElement.querySelector(".cd-shell-hint")]);
2849
+ const legal = rootElement.querySelector('[data-chrome="legal"]');
2850
+ return {
2851
+ shown: rootElement.getAttribute("data-empty-state") === "shown",
2852
+ card: card ? {
2853
+ visible: tools.visible(card),
2854
+ box: tools.box(card, rootElement),
2855
+ tag: card.tagName.toLowerCase(),
2856
+ labelledBy: labelledBy ? labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim() : null
2857
+ } : null,
2858
+ komet: image ? {
2859
+ tag: image.tagName.toLowerCase(),
2860
+ alt: image.getAttribute("alt"),
2861
+ width: image.getBoundingClientRect().width,
2862
+ height: image.getBoundingClientRect().height
2863
+ } : null,
2864
+ heading: heading ? { tag: heading.tagName.toLowerCase(), text: (heading.textContent ?? "").replace(/\s+/g, " ").trim() } : null,
2865
+ // The card's actions; a text link inside the hint paragraph is no action (guide §8).
2866
+ buttons: [...card?.querySelectorAll("button") ?? []].filter((button) => tools.visible(button) && !button.closest("p")).map((button) => {
2867
+ const rect = button.getBoundingClientRect();
2868
+ return {
2869
+ variant: button.getAttribute("data-variant"),
2870
+ name: (button.getAttribute("aria-label") ?? button.textContent ?? "").replace(/\s+/g, " ").trim(),
2871
+ height: rect.height,
2872
+ client: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
2873
+ };
2874
+ }),
2875
+ hint: [...card?.querySelectorAll("p") ?? []].map((paragraph) => (paragraph.textContent ?? "").trim()).filter(Boolean).at(-1) ?? null,
2876
+ work: parts.filter((part) => part[1] !== null).map(([name2, element]) => ({
2877
+ name: name2,
2878
+ visibility: getComputedStyle(element).visibility,
2879
+ visible: tools.visible(element)
2880
+ })),
2881
+ canvasInert: rootElement.querySelector(".cd-shell-canvas")?.hasAttribute("inert") ?? false,
2882
+ legalVisible: legal ? tools.visible(legal) : false
2883
+ };
2884
+ };
2885
+ var focusOwnerProbe = ({ root }) => {
2886
+ const tools = window.__modelerShellConformance;
2887
+ const active = document.activeElement;
2888
+ const rootElement = document.querySelector(root);
2889
+ if (!active || active === document.body || active === document.documentElement) {
2890
+ return { owner: "body", description: "<body>" };
2891
+ }
2892
+ const description = tools.describe(active);
2893
+ if (!rootElement?.contains(active)) return { owner: "outside", description };
2894
+ if (active.closest(".djs-container")) return { owner: "canvas", description };
2895
+ const island = active.closest("[data-chrome]")?.getAttribute("data-chrome");
2896
+ return { owner: island ?? "other", description };
2897
+ };
2898
+ var blurProbe = (_) => {
2899
+ document.activeElement?.blur?.();
2900
+ };
2901
+ var toastsProbe = ({ root }) => {
2902
+ const tools = window.__modelerShellConformance;
2903
+ const rootElement = document.querySelector(root);
2904
+ if (!rootElement) return null;
2905
+ const stack = rootElement.querySelector('[data-chrome="toasts"]');
2906
+ const open = (element) => {
2907
+ try {
2908
+ return element.matches(":popover-open");
2909
+ } catch {
2910
+ return false;
2911
+ }
2912
+ };
2913
+ const toasts2 = stack ? [...stack.querySelectorAll(":scope > [data-status]")].filter(
2914
+ (toast) => tools.visible(toast)
2915
+ ) : [];
2916
+ const name2 = (element) => (element.getAttribute("aria-label") ?? element.textContent ?? "").replace(/\s+/g, " ").trim();
2917
+ const centre = (element) => {
2918
+ const rect = element.getBoundingClientRect();
2919
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
2920
+ };
2921
+ return {
2922
+ stack: stack ? {
2923
+ visible: tools.visible(stack),
2924
+ box: tools.box(stack, rootElement),
2925
+ popover: stack.getAttribute("popover"),
2926
+ popoverOpen: open(stack)
2927
+ } : null,
2928
+ toasts: toasts2.map((toast) => {
2929
+ const point = centre(toast);
2930
+ const hit = document.elementFromPoint(point.x, point.y);
2931
+ return {
2932
+ text: (toast.textContent ?? "").replace(/\s+/g, " ").trim(),
2933
+ role: toast.getAttribute("role"),
2934
+ status: toast.getAttribute("data-status"),
2935
+ box: tools.box(toast, rootElement),
2936
+ client: point,
2937
+ buttons: [...toast.querySelectorAll("button")].filter((button) => tools.visible(button)).map((button) => ({
2938
+ name: name2(button),
2939
+ text: (button.textContent ?? "").replace(/\s+/g, " ").trim(),
2940
+ client: centre(button)
2941
+ })),
2942
+ topmost: hit !== null && toast.contains(hit),
2943
+ focused: toast.contains(document.activeElement)
2944
+ };
2945
+ }),
2946
+ strays: [...document.querySelectorAll(".cd-shell-toast")].filter((element) => !stack?.contains(element) && tools.visible(element)).map((element) => tools.describe(element)),
2947
+ live: [...rootElement.querySelectorAll(".cd-shell-live [role], .cd-shell-live [aria-live]")].map(
2948
+ (region) => ({
2949
+ role: region.getAttribute("role"),
2950
+ live: region.getAttribute("aria-live"),
2951
+ text: (region.textContent ?? "").replace(/\s+/g, " ").trim()
2952
+ })
2953
+ )
2954
+ };
2955
+ };
2956
+ var focusToastButtonProbe = ({
2957
+ root,
2958
+ text,
2959
+ name: name2
2960
+ }) => {
2961
+ const stack = document.querySelector(root)?.querySelector('[data-chrome="toasts"]');
2962
+ const toast = [...stack?.querySelectorAll(":scope > [data-status]") ?? []].find(
2963
+ (candidate) => (candidate.textContent ?? "").includes(text)
2964
+ );
2965
+ const button = [...toast?.querySelectorAll("button") ?? []].find(
2966
+ (candidate) => (candidate.getAttribute("aria-label") ?? candidate.textContent ?? "").trim() === name2
2967
+ );
2968
+ if (!button) return false;
2969
+ button.focus();
2970
+ return document.activeElement === button;
2971
+ };
2972
+ var watchToastStackProbe = ({ root }) => {
2973
+ const tools = window.__modelerShellConformance;
2974
+ const stack = document.querySelector(root)?.querySelector('[data-chrome="toasts"]');
2975
+ if (!stack) return false;
2976
+ const seen = [];
2977
+ const listener = (event) => seen.push(event.newState);
2978
+ stack.addEventListener("beforetoggle", listener);
2979
+ tools.cleanups.push(() => stack.removeEventListener("beforetoggle", listener));
2980
+ tools.toastToggles = seen;
2981
+ return true;
2982
+ };
2983
+ var toastStackTogglesProbe = ({ root }) => {
2984
+ const tools = window.__modelerShellConformance;
2985
+ const stack = document.querySelector(root)?.querySelector('[data-chrome="toasts"]');
2986
+ let open = false;
2987
+ try {
2988
+ open = stack?.matches(":popover-open") ?? false;
2989
+ } catch {
2990
+ open = false;
2991
+ }
2992
+ return { toggles: [...tools.toastToggles ?? []], open };
2993
+ };
2994
+ var showModalProbe = (_) => {
2995
+ const tools = window.__modelerShellConformance;
2996
+ const dialog = document.createElement("dialog");
2997
+ dialog.setAttribute("data-modeler-shell-conformance", "show-modal");
2998
+ dialog.setAttribute("aria-label", "Conformance stand-in");
2999
+ dialog.style.cssText = "width:60vw;height:60vh;max-width:none;max-height:none;";
3000
+ document.body.append(dialog);
3001
+ tools.injected.push(dialog);
3002
+ dialog.showModal();
3003
+ try {
3004
+ return dialog.matches(":modal");
3005
+ } catch {
3006
+ return dialog.open;
3007
+ }
3008
+ };
3009
+ var popoverProbe = ({ root }) => {
3010
+ const tools = window.__modelerShellConformance;
3011
+ const rootElement = document.querySelector(root);
3012
+ if (!rootElement) return null;
3013
+ const pill = rootElement.querySelector('[data-chrome="problems"]');
3014
+ const controls = pill?.getAttribute("aria-controls");
3015
+ const popover2 = (controls ? document.getElementById(controls) : null) ?? rootElement.querySelector('[data-chrome="problems-popover"]');
3016
+ const attributes = (element) => {
3017
+ const all = {};
3018
+ for (const name2 of element.getAttributeNames()) all[name2] = element.getAttribute(name2) ?? "";
3019
+ return all;
3020
+ };
3021
+ const labelledBy = popover2?.getAttribute("aria-labelledby");
3022
+ const active = document.activeElement;
3023
+ const rows = popover2 ? [...popover2.querySelectorAll(".cd-shell-problem")].filter((row) => tools.visible(row)) : [];
3024
+ const pillRect = pill?.getBoundingClientRect();
3025
+ return {
3026
+ pill: pill && pillRect ? {
3027
+ attributes: attributes(pill),
3028
+ box: tools.box(pill, rootElement),
3029
+ client: { x: pillRect.left + pillRect.width / 2, y: pillRect.top + pillRect.height / 2 },
3030
+ visible: tools.visible(pill)
3031
+ } : null,
3032
+ popover: popover2 ? {
3033
+ attributes: attributes(popover2),
3034
+ box: tools.box(popover2, rootElement),
3035
+ visible: tools.visible(popover2),
3036
+ title: labelledBy ? labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim() : null
3037
+ } : null,
3038
+ rows: rows.map((row) => {
3039
+ const target = row.matches("button") ? row : row.querySelector("button");
3040
+ const rect = (target ?? row).getBoundingClientRect();
3041
+ return {
3042
+ button: target !== null,
3043
+ height: row.getBoundingClientRect().height,
3044
+ icon: row.querySelector("svg") !== null,
3045
+ text: (row.textContent ?? "").replace(/\s+/g, " ").trim(),
3046
+ client: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
3047
+ };
3048
+ }),
3049
+ focus: !active || active === document.body ? "body" : pill && active === pill ? "pill" : popover2?.contains(active) ? "popover" : active.closest(".djs-container") ? "canvas" : "other",
3050
+ focused: !active || active === document.body ? "<body>" : tools.describe(active),
3051
+ focusedRow: (() => {
3052
+ const index = active ? rows.findIndex((row2) => row2 === active || row2.contains(active)) : -1;
3053
+ const row = rows[index];
3054
+ if (!row) return null;
3055
+ let scroller = row.parentElement;
3056
+ while (scroller && scroller !== popover2 && scroller.scrollHeight <= scroller.clientHeight) {
3057
+ scroller = scroller.parentElement;
3058
+ }
3059
+ const view = (scroller ?? popover2 ?? row).getBoundingClientRect();
3060
+ const box = row.getBoundingClientRect();
3061
+ return { index, inView: box.top >= view.top - 1 && box.bottom <= view.bottom + 1 };
3062
+ })()
3063
+ };
3064
+ };
3065
+ var dragProbe = async (spec) => {
3066
+ const tools = window.__modelerShellConformance;
3067
+ const rootElement = document.querySelector(spec.root);
3068
+ if (!rootElement) return null;
3069
+ const transfer = new DataTransfer();
3070
+ for (const file of spec.files) {
3071
+ transfer.items.add(new File([file.text], file.name, { type: file.type }));
3072
+ }
3073
+ for (const text of spec.strings ?? []) transfer.items.add(text, "text/plain");
3074
+ const rect = rootElement.getBoundingClientRect();
3075
+ const clientX = rect.left + rect.width / 2;
3076
+ const clientY = rect.top + rect.height / 2;
3077
+ const target = document.elementFromPoint(clientX, clientY) ?? rootElement;
3078
+ const events = [];
3079
+ for (const type of spec.steps) {
3080
+ const event = new DragEvent(type, {
3081
+ bubbles: true,
3082
+ cancelable: true,
3083
+ composed: true,
3084
+ dataTransfer: transfer,
3085
+ clientX,
3086
+ clientY
3087
+ });
3088
+ const effects = { dropEffect: "none", effectAllowed: "all" };
3089
+ const recorded = new Proxy(transfer, {
3090
+ get(target2, key) {
3091
+ if (key === "dropEffect" || key === "effectAllowed") return effects[key];
3092
+ const value = Reflect.get(target2, key, target2);
3093
+ return typeof value === "function" ? value.bind(target2) : value;
3094
+ },
3095
+ set(target2, key, value) {
3096
+ if (key === "dropEffect" || key === "effectAllowed") {
3097
+ effects[key] = String(value);
3098
+ return true;
3099
+ }
3100
+ return Reflect.set(target2, key, value, target2);
3101
+ }
3102
+ });
3103
+ Object.defineProperty(event, "dataTransfer", { configurable: true, value: recorded });
3104
+ target.dispatchEvent(event);
3105
+ events.push({ type, defaultPrevented: event.defaultPrevented, dropEffect: effects.dropEffect ?? "none" });
3106
+ }
3107
+ await tools.settle();
3108
+ const resolve = (role) => {
3109
+ const probe = document.createElement("span");
3110
+ probe.setAttribute("data-modeler-shell-conformance", "role-probe");
3111
+ probe.style.cssText = `position:absolute;visibility:hidden;background-color:var(${role});`;
3112
+ rootElement.append(probe);
3113
+ const color = getComputedStyle(probe).backgroundColor;
3114
+ probe.remove();
3115
+ return color;
3116
+ };
3117
+ const resolveColor = (role) => {
3118
+ const probe = document.createElement("span");
3119
+ probe.setAttribute("data-modeler-shell-conformance", "role-probe");
3120
+ probe.style.cssText = `position:absolute;visibility:hidden;color:var(${role});`;
3121
+ rootElement.append(probe);
3122
+ const color = getComputedStyle(probe).color;
3123
+ probe.remove();
3124
+ return color;
3125
+ };
3126
+ const zone = rootElement.querySelector('[data-chrome="drop-zone"]');
3127
+ const chip = zone?.querySelector(".cd-shell-drop-zone-chip") ?? null;
3128
+ const zoneStyle = zone ? getComputedStyle(zone) : null;
3129
+ return {
3130
+ state: rootElement.getAttribute("data-drop"),
3131
+ events,
3132
+ zone: zone && zoneStyle ? {
3133
+ visible: tools.visible(zone),
3134
+ box: tools.box(zone, rootElement),
3135
+ ariaHidden: zone.getAttribute("aria-hidden"),
3136
+ background: zoneStyle.backgroundColor,
3137
+ borderStyle: zoneStyle.borderTopStyle,
3138
+ borderWidth: zoneStyle.borderTopWidth,
3139
+ borderColor: zoneStyle.borderTopColor,
3140
+ radius: zoneStyle.borderTopLeftRadius,
3141
+ cursor: zoneStyle.cursor
3142
+ } : null,
3143
+ chip: chip ? {
3144
+ text: (chip.textContent ?? "").replace(/\s+/g, " ").trim(),
3145
+ background: getComputedStyle(chip).backgroundColor
3146
+ } : null,
3147
+ roles: {
3148
+ hover: resolve("--cd-ui-hover"),
3149
+ interaktion: resolveColor("--cd-ui-interaktion"),
3150
+ dangerFlaeche: resolve("--cd-ui-danger-flaeche"),
3151
+ danger: resolveColor("--cd-ui-danger"),
3152
+ flaecheHoch: resolve("--cd-ui-flaeche-hoch")
3153
+ }
3154
+ };
3155
+ };
3156
+
3157
+ // src/testing/web-cases.ts
3158
+ var WIDEST_STEP = 1280;
3159
+ var NARROWEST_STEP = 400;
3160
+ var PHONE_WIDTH = 320;
3161
+ var STEP_PX = 24;
3162
+ var TOAST_MARGIN_MS = 900;
3163
+ var TOAST_RESUME_MS = 1500;
3164
+ var TOAST_ANNOUNCE_WAIT_MS = 1e3;
3165
+ var DROP_WAIT_MS = 2e3;
3166
+ var START_SCREEN_TABS = 10;
3167
+ var TOAST_STATUSES = ["success", "info", "warning", "danger"];
3168
+ var documentLangProbe = (_) => document.documentElement.lang;
3169
+ function sizeOf(harness) {
3170
+ return (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0];
3171
+ }
3172
+ function skipFor(hook, what) {
3173
+ return {
3174
+ skipped: [
3175
+ {
3176
+ hook,
3177
+ reason: `${hook === "drop" ? "harness.drop" : `show.${hook}`} is undefined: ${what} not verified`
3178
+ }
3179
+ ]
3180
+ };
3181
+ }
3182
+ async function aria(page, root) {
3183
+ return inPage(page, ariaProbe, { root });
3184
+ }
3185
+ async function rowSamples(page, root, height) {
3186
+ const samples = [];
3187
+ const measurements = [];
3188
+ for (let width = WIDEST_STEP; width >= NARROWEST_STEP; width -= STEP_PX) {
3189
+ await page.setViewportSize({ width, height });
3190
+ await settle(page);
3191
+ const sample = await inPage(page, topRowProbe, { root });
3192
+ if (sample) samples.push(sample);
3193
+ measurements.push(await measureChrome(page, root));
3194
+ }
3195
+ return { samples, measurements };
3196
+ }
3197
+ async function primaryTooltipAt(page, root, width, height) {
3198
+ await page.setViewportSize({ width, height });
3199
+ await settle(page);
3200
+ const primary = await findTarget(page, root, "primary");
3201
+ if (!primary) return null;
3202
+ await page.mouse.move(PARK.x, PARK.y);
3203
+ await page.mouse.move(primary.client.x, primary.client.y);
3204
+ await page.waitForTimeout(TOOLTIP_DELAY_MS + HOVER_MARGIN_MS);
3205
+ await settle(page);
3206
+ const tooltip = await findOverlay(page, root, "tooltip");
3207
+ await page.mouse.move(PARK.x, PARK.y);
3208
+ return tooltip?.text ?? null;
3209
+ }
3210
+ async function primaryActivation(page, root) {
3211
+ const primary = await findTarget(page, root, "primary");
3212
+ if (!primary) return [];
3213
+ await inPage(page, countClicksProbe, { root, selector: '[data-chrome="primary"]' });
3214
+ await page.mouse.click(primary.client.x, primary.client.y);
3215
+ await settle(page);
3216
+ const pointer = await inPage(page, clicksProbe, null);
3217
+ if (!await focusTarget(page, root, "primary")) {
3218
+ await inPage(page, removeInjectedProbe, null);
3219
+ return ["primary: the primary action cannot take the focus"];
3220
+ }
3221
+ await page.keyboard.press("Enter");
3222
+ await settle(page);
3223
+ const enter = await inPage(page, clicksProbe, null) - pointer;
3224
+ await inPage(page, removeInjectedProbe, null);
3225
+ await page.mouse.move(PARK.x, PARK.y);
3226
+ return primaryActivationProblems({ pointer, enter });
3227
+ }
3228
+ async function fileEntryProblems(page, root) {
3229
+ const entries = (await menuEntries(page, root)).filter((entry) => entry.group === "file");
3230
+ const problems = [];
3231
+ for (const entry of entries) {
3232
+ if (!entry.menuId || !await openMenuByClick(page, root)) continue;
3233
+ const item = await inPage(page, fileItemProbe, { root, menuId: entry.menuId });
3234
+ if (!item) {
3235
+ await closeByEscape(page);
3236
+ continue;
3237
+ }
3238
+ await page.mouse.click(item.x, item.y);
3239
+ await settle(page);
3240
+ const menu = await inPage(page, menuProbe, { root });
3241
+ if (menu?.menu || menu?.expanded === "true") {
3242
+ problems.push(
3243
+ `menu: File entry "${entry.name}" leaves the menu open; an action closes it first (guide \xA75.4)`
3244
+ );
3245
+ await closeByEscape(page);
3246
+ }
3247
+ if (entry.name.endsWith("\u2026")) await closeByEscape(page);
3248
+ }
3249
+ return problems;
3250
+ }
3251
+ var fileItemProbe = ({
3252
+ root,
3253
+ menuId
3254
+ }) => {
3255
+ const item = document.querySelector(root)?.querySelector(`[role="menu"] [data-menu-id="${menuId}"]`);
3256
+ if (!item) return null;
3257
+ const rect = item.getBoundingClientRect();
3258
+ return rect.width > 0 ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : null;
3259
+ };
3260
+ async function lockedFileProblems(page, harness, root) {
3261
+ const invalid = harness.show?.invalid;
3262
+ if (!invalid) return [];
3263
+ await invalid(page);
3264
+ await page.waitForTimeout(INVALID_STATUS_DELAY_MS + HOVER_MARGIN_MS);
3265
+ return (await menuEntries(page, root)).filter((entry) => entry.group === "file" && entry.disabled).map(
3266
+ (entry) => `menu: File entry "${entry.name}" is aria-disabled on the read-only canvas (guide \xA713.5)`
3267
+ );
3268
+ }
3269
+ var webChromeCase = {
3270
+ name: "webChrome",
3271
+ async run(page, harness) {
3272
+ const root = harness.root ?? DEFAULT_ROOT;
3273
+ const size = sizeOf(harness);
3274
+ await openHarness(page, harness, { size, requested: LIGHT });
3275
+ await settle(page);
3276
+ const problems = [];
3277
+ if (harness.host !== "web") {
3278
+ const measurement = await measureChrome(page, root);
3279
+ for (const island of ["primary", "legal", "empty-state", "drop-zone"]) {
3280
+ if (measurement.islands[island]) {
3281
+ problems.push(
3282
+ `${island}: visible in the ${harness.host} host; it belongs to the web app (guide \xA75.2)`
3283
+ );
3284
+ }
3285
+ }
3286
+ return finish("webChrome", harness.host, problems, []);
3287
+ }
3288
+ const facts = await aria(page, root);
3289
+ if (facts) problems.push(...legalContentProblems(facts.legalLinks));
3290
+ const name2 = (facts?.primary?.attributes["aria-label"] ?? "").trim();
3291
+ problems.push(...await primaryActivation(page, root));
3292
+ const { samples, measurements } = await rowSamples(page, root, size.height);
3293
+ for (const measurement of measurements) {
3294
+ problems.push(
3295
+ ...webRowProblems(measurement, "web").map(
3296
+ (problem) => `[${Math.round(measurement.container.width)}px wide] ${problem}`
3297
+ ),
3298
+ ...zoomRowBottomProblems(
3299
+ measurement.islands.zoom,
3300
+ measurement.open.zoomRaised,
3301
+ measurement.container,
3302
+ measurement.islands.legal?.height
3303
+ )
3304
+ );
3305
+ }
3306
+ if (samples.some((sample) => sample.primary)) {
3307
+ const withLabel = labelledWidth(samples);
3308
+ if (withLabel === void 0) {
3309
+ problems.push(
3310
+ `primary: never shows its label between ${WIDEST_STEP}px and ${NARROWEST_STEP}px; it collapses only when the top row gets tight (guide \xA75.15)`
3311
+ );
3312
+ } else {
3313
+ problems.push(...topRowProblems(samples, withLabel, name2));
3314
+ }
3315
+ const collapsed = samples.find((sample) => sample.primary?.collapsed);
3316
+ const labelled = samples.find((sample) => sample.primary && !sample.primary.collapsed);
3317
+ for (const sample of [labelled, collapsed]) {
3318
+ if (!sample?.primary) continue;
3319
+ const width = sample.container.width;
3320
+ const tooltip = await primaryTooltipAt(page, root, width, size.height);
3321
+ problems.push(...primaryTooltipProblems(sample.primary.collapsed, tooltip, name2, width));
3322
+ }
3323
+ }
3324
+ await page.setViewportSize({ width: PHONE_WIDTH, height: size.height });
3325
+ await settle(page);
3326
+ const legalLine = await inPage(page, legalLineProbe, { root });
3327
+ if (legalLine) problems.push(...legalReachProblems(legalLine));
3328
+ await page.setViewportSize({ width: size.width, height: size.height });
3329
+ await settle(page);
3330
+ problems.push(...await fileEntryProblems(page, root));
3331
+ problems.push(...await lockedFileProblems(page, harness, root));
3332
+ return finish("webChrome", harness.host, [...new Set(problems)], []);
3333
+ }
3334
+ };
3335
+ function startScreen(page, root) {
3336
+ return inPage(page, emptyStateProbe, { root, work: WORK_CHROME });
3337
+ }
3338
+ async function toastFocusOnStartScreen(page, root, show) {
3339
+ const text = "Conformance: a toast on the start screen";
3340
+ await show(page, { status: "danger", text });
3341
+ await settle(page);
3342
+ const closeName = (await toasts(page, root))?.toasts.find((toast) => toast.text.includes(text))?.buttons.at(-1)?.name;
3343
+ if (!closeName || !await inPage(page, focusToastButtonProbe, { root, text, name: closeName })) return [];
3344
+ await page.keyboard.press("Enter");
3345
+ await settle(page);
3346
+ const focus = await inPage(page, focusOwnerProbe, { root });
3347
+ return focus.owner === "empty-state" ? [] : [
3348
+ `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)`
3349
+ ];
3350
+ }
3351
+ var emptyStateCase = {
3352
+ name: "emptyState",
3353
+ async run(page, harness) {
3354
+ assertHooksConsistent("emptyState", harness, ["emptyState"]);
3355
+ const root = harness.root ?? DEFAULT_ROOT;
3356
+ const size = sizeOf(harness);
3357
+ const show = harness.show?.emptyState;
3358
+ if (harness.host !== "web") {
3359
+ await openHarness(page, harness, { size, requested: LIGHT });
3360
+ if (show) await show(page);
3361
+ await settle(page);
3362
+ const facts2 = await startScreen(page, root);
3363
+ const problems2 = [];
3364
+ if (facts2?.card?.visible || facts2?.shown) {
3365
+ problems2.push(
3366
+ `empty-state: a start screen in the ${harness.host} host; it shows the empty-file hint instead (guide \xA713.5)`
3367
+ );
3368
+ }
3369
+ return finish("emptyState", harness.host, problems2, []);
3370
+ }
3371
+ if (!show) {
3372
+ return unfed(harness, "emptyState") ? skipFor("emptyState", "the start screen") : void 0;
3373
+ }
3374
+ await openHarness(page, harness, { size, requested: LIGHT });
3375
+ await settle(page);
3376
+ const lang = await inPage(page, documentLangProbe, null);
3377
+ await show(page);
3378
+ await settle(page);
3379
+ const container = (await measureChrome(page, root)).container;
3380
+ const facts = await startScreen(page, root);
3381
+ if (!facts) return finish("emptyState", harness.host, [`no element matches "${root}"`], []);
3382
+ const problems = emptyStateProblems(facts, container, lang);
3383
+ await inPage(page, blurProbe, null);
3384
+ const stops = [];
3385
+ for (let press = 0; press < START_SCREEN_TABS; press++) {
3386
+ await page.keyboard.press("Tab");
3387
+ stops.push(await inPage(page, focusOwnerProbe, { root }));
3388
+ }
3389
+ problems.push(...emptyStateTabProblems(stops));
3390
+ await inPage(page, blurProbe, null);
3391
+ for (const key of ["Control+Equal", "Control+Minus", "Shift+Digit1"]) {
3392
+ await inPage(page, clearKeysProbe, null);
3393
+ const before = (await inPage(page, afterPressProbe, { root })).zoom;
3394
+ await page.keyboard.press(key);
3395
+ const after = await inPage(page, afterPressProbe, { root });
3396
+ problems.push(...emptyStateKeyProblems(key, before, after.zoom, after.keys));
3397
+ }
3398
+ const showToast = harness.show?.toast;
3399
+ if (showToast) problems.push(...await toastFocusOnStartScreen(page, root, showToast));
3400
+ const start = facts.buttons[0];
3401
+ if (start) {
3402
+ await page.mouse.click(start.client.x, start.client.y);
3403
+ await settle(page);
3404
+ const after = await pollUntil(
3405
+ page,
3406
+ () => startScreen(page, root),
3407
+ (state) => state === null || !state.shown,
3408
+ 1e3
3409
+ );
3410
+ if (after) problems.push(...afterStartProblems(after, await inPage(page, focusOwnerProbe, { root })));
3411
+ }
3412
+ return finish("emptyState", harness.host, problems, []);
3413
+ }
3414
+ };
3415
+ function toasts(page, root) {
3416
+ return inPage(page, toastsProbe, { root });
3417
+ }
3418
+ async function dismissAll(page, root) {
3419
+ for (let attempt = 0; attempt < 6; attempt++) {
3420
+ const close = (await toasts(page, root))?.toasts[0]?.buttons.at(-1);
3421
+ if (!close) break;
3422
+ await page.mouse.click(close.client.x, close.client.y);
3423
+ await settle(page);
3424
+ }
3425
+ await page.mouse.move(PARK.x, PARK.y);
3426
+ }
3427
+ async function presentAt(page, root, since, ms, texts) {
3428
+ const wait = since + ms - Date.now();
3429
+ if (wait > 0) await page.waitForTimeout(wait);
3430
+ await settle(page);
3431
+ const shown = (await toasts(page, root))?.toasts ?? [];
3432
+ return new Set(texts.filter((text) => shown.some((toast) => toast.text.includes(text))));
3433
+ }
3434
+ async function toastAnatomy(page, root, show, lang) {
3435
+ const problems = [];
3436
+ const before = (await toasts(page, root))?.live ?? [];
3437
+ for (const status of TOAST_STATUSES) {
3438
+ const text = `Conformance: a ${status} toast`;
3439
+ await show(page, { status, text });
3440
+ await settle(page);
3441
+ const toast = (await toasts(page, root))?.toasts.find((candidate) => candidate.text.includes(text));
3442
+ if (!toast) {
3443
+ problems.push(`toasts: show.toast with a ${status} toast shows nothing`);
3444
+ continue;
3445
+ }
3446
+ const persistent = toastLiveRegion(before, status) !== void 0;
3447
+ const reached = (facts) => toastLiveRegion(facts?.live ?? [], status)?.text.includes(text) ?? false;
3448
+ const announced = persistent && reached(await pollUntil(page, () => toasts(page, root), reached, TOAST_ANNOUNCE_WAIT_MS));
3449
+ problems.push(...toastAnatomyProblems(toast, status, lang, { persistent, announced }));
3450
+ const close = toast.buttons.at(-1);
3451
+ if (close) {
3452
+ await page.mouse.click(close.client.x, close.client.y);
3453
+ await page.mouse.move(PARK.x, PARK.y);
3454
+ await settle(page);
3455
+ const still = (await toasts(page, root))?.toasts.some((candidate) => candidate.text.includes(text));
3456
+ if (still) problems.push(`toasts: Close does not close the ${status} toast`);
3457
+ }
3458
+ }
3459
+ await dismissAll(page, root);
3460
+ return problems;
3461
+ }
3462
+ async function toastStack(page, root, show) {
3463
+ const texts = ["A", "B", "C", "D"].map((letter) => `Conformance: stacked ${letter}`);
3464
+ const [first, second, third, fourth] = texts;
3465
+ await show(page, { status: "info", text: first, actionLabel: "Undo" });
3466
+ for (const text of [second, third, fourth]) await show(page, { status: "success", text });
3467
+ await settle(page);
3468
+ const problems = toastStackProblems((await toasts(page, root))?.toasts ?? [], texts, [
3469
+ first,
3470
+ third,
3471
+ fourth
3472
+ ]);
3473
+ await show(page, { status: "success", text: third });
3474
+ await settle(page);
3475
+ problems.push(...repeatedToastProblems((await toasts(page, root))?.toasts ?? [], third));
3476
+ await dismissAll(page, root);
1482
3477
  return problems;
1483
3478
  }
1484
- function readOnlyStateProblems(facts) {
3479
+ async function toastTiming(page, root, show) {
3480
+ const lifetime = TOAST_DURATION_MS.success;
3481
+ const plain = "Conformance: a plain toast";
3482
+ const hovered = "Conformance: a hovered toast";
3483
+ const focused = "Conformance: a focused toast";
3484
+ const texts = [plain, hovered, focused];
3485
+ for (const text of texts) await show(page, { status: "success", text });
3486
+ const shownAt = Date.now();
3487
+ await settle(page);
3488
+ const target = (await toasts(page, root))?.toasts.find((toast) => toast.text.includes(hovered));
3489
+ if (target) await page.mouse.move(target.client.x, target.client.y);
3490
+ const focusTarget2 = (await toasts(page, root))?.toasts.find((toast) => toast.text.includes(focused));
3491
+ const closeName = focusTarget2?.buttons.at(-1)?.name ?? "";
3492
+ await inPage(page, focusToastButtonProbe, { root, text: focused, name: closeName });
3493
+ const timings = [];
3494
+ const record = (shown, after, expect) => {
3495
+ for (const [text, expectation, condition] of expect) {
3496
+ timings.push({ text, expectation, after, present: shown.has(text), condition });
3497
+ }
3498
+ };
3499
+ const early = lifetime - 800;
3500
+ record(await presentAt(page, root, shownAt, early, texts), early, [[plain, "present", "plain"]]);
3501
+ const late = lifetime + TOAST_MARGIN_MS;
3502
+ record(await presentAt(page, root, shownAt, late, texts), late, [
3503
+ [plain, "gone", "plain"],
3504
+ [hovered, "present", "hovered"],
3505
+ [focused, "present", "focused"]
3506
+ ]);
3507
+ await page.mouse.move(PARK.x, PARK.y);
3508
+ await inPage(page, blurProbe, null);
3509
+ const leftAt = Date.now();
3510
+ const resumed = TOAST_RESUME_MS - 500;
3511
+ record(await presentAt(page, root, leftAt, resumed, texts), resumed, [
3512
+ [hovered, "present", "resumed"],
3513
+ [focused, "present", "resumed"]
3514
+ ]);
3515
+ const gone = lifetime + TOAST_MARGIN_MS;
3516
+ record(await presentAt(page, root, leftAt, gone, texts), gone, [
3517
+ [hovered, "gone", "resumed"],
3518
+ [focused, "gone", "resumed"]
3519
+ ]);
3520
+ const action = "Conformance: a toast with an action";
3521
+ await show(page, { status: "info", text: action, actionLabel: "Undo" });
3522
+ const actionAt = Date.now();
3523
+ const actionWait = TOAST_DURATION_MS.info + TOAST_MARGIN_MS;
3524
+ record(await presentAt(page, root, actionAt, actionWait, [action]), actionWait, [
3525
+ [action, "present", "action"]
3526
+ ]);
3527
+ await dismissAll(page, root);
3528
+ return toastTimingProblems(timings);
3529
+ }
3530
+ async function toastAboveModal(page, root, show) {
3531
+ const before = "Conformance: a toast before the dialog";
3532
+ const text = "Conformance: a toast above the dialog";
3533
+ await show(page, { status: "info", text: before, actionLabel: "Undo" });
3534
+ await settle(page);
1485
3535
  const problems = [];
1486
- const after = `${INVALID_STATUS_DELAY_MS}ms after show.invalid`;
1487
- if (!facts.readOnly) problems.push(`read-only: the root has no data-readonly ${after}`);
1488
- const status = facts.status;
1489
- if (!status?.visible) {
1490
- problems.push(`read-only: the status box is not visible ${after} (guide \xA75.12)`);
1491
- } else if (status.attributes.role !== "alert") {
3536
+ if ((await toasts(page, root))?.stack?.popover !== "manual") {
1492
3537
  problems.push(
1493
- `read-only: the status box of an invalid file has role "${status.attributes.role ?? ""}", expected "alert" (guide \xA713.5)`
3538
+ `toasts: the web app's stack is no popover="manual"; it stays above a showModal() dialog only in the top layer (API.md, Toasts)`
1494
3539
  );
3540
+ } else if (await inPage(page, watchToastStackProbe, { root })) {
3541
+ if (await inPage(page, showModalProbe, null)) {
3542
+ await show(page, { status: "success", text });
3543
+ await settle(page);
3544
+ const shown = (await toasts(page, root))?.toasts.some((toast) => toast.text.includes(text)) ?? false;
3545
+ const { toggles, open } = await inPage(page, toastStackTogglesProbe, { root });
3546
+ if (!shown) problems.push("toasts: show.toast with a showModal() dialog open shows nothing");
3547
+ else if (!open || toggles.at(-1) !== "open") {
3548
+ problems.push(
3549
+ "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)"
3550
+ );
3551
+ }
3552
+ }
1495
3553
  }
3554
+ await inPage(page, removeInjectedProbe, null);
3555
+ await settle(page);
3556
+ await dismissAll(page, root);
1496
3557
  return problems;
1497
3558
  }
1498
- var READ_ONLY_PALETTE_ACTIONS = ["lasso-tool", "hand-tool"];
1499
- function readOnlyPaletteProblems(entries) {
1500
- return entries.flatMap((entry) => {
1501
- const staysActive = READ_ONLY_PALETTE_ACTIONS.includes(entry.action);
1502
- if (staysActive && entry.disabled) {
1503
- return [
1504
- `palette: "${entry.action}" is aria-disabled on the read-only canvas; Lasso and Hand stay active (guide \xA713.5)`
1505
- ];
1506
- }
1507
- if (!staysActive && !entry.disabled) {
1508
- return [`palette: "${entry.action}" is not aria-disabled on the read-only canvas (guide \xA713.5)`];
3559
+ var toastsCase = {
3560
+ name: "toasts",
3561
+ async run(page, harness) {
3562
+ assertHooksConsistent("toasts", harness, ["toast"]);
3563
+ const root = harness.root ?? DEFAULT_ROOT;
3564
+ const size = sizeOf(harness);
3565
+ const show = harness.show?.toast;
3566
+ if (harness.host === "ide") {
3567
+ await openHarness(page, harness, { size, requested: LIGHT });
3568
+ if (show) await show(page, { status: "success", text: "Conformance: a toast in the IDE host" });
3569
+ await settle(page);
3570
+ const facts = await toasts(page, root);
3571
+ const problems2 = [];
3572
+ if (facts?.stack) {
3573
+ problems2.push(
3574
+ 'toasts: the IDE host renders a toast stack [data-chrome="toasts"]; it shows no toasts (guide \xA713.5)'
3575
+ );
3576
+ }
3577
+ if ((facts?.toasts.length ?? 0) > 0 || (facts?.strays.length ?? 0) > 0) {
3578
+ problems2.push(
3579
+ "toasts: show.toast shows a toast in the IDE host; the extension shows the host's notification (guide \xA713.5)"
3580
+ );
3581
+ }
3582
+ return finish("toasts", harness.host, problems2, []);
1509
3583
  }
1510
- return [];
1511
- });
3584
+ if (!show) return unfed(harness, "toast") ? skipFor("toast", "the toasts") : void 0;
3585
+ await openHarness(page, harness, { size, requested: LIGHT });
3586
+ await settle(page);
3587
+ const lang = await inPage(page, documentLangProbe, null);
3588
+ await dismissAll(page, root);
3589
+ const problems = [
3590
+ ...await toastAnatomy(page, root, show, lang),
3591
+ ...await toastStack(page, root, show),
3592
+ ...await toastTiming(page, root, show)
3593
+ ];
3594
+ if (harness.host === "web") problems.push(...await toastAboveModal(page, root, show));
3595
+ return finish("toasts", harness.host, problems, []);
3596
+ }
3597
+ };
3598
+ function popover(page, root) {
3599
+ return inPage(page, popoverProbe, { root });
1512
3600
  }
1513
- var LEGEND_MENU_ID = "legend";
1514
- function readOnlyMenuProblems(before, after) {
1515
- const problems = [];
1516
- const key = (entry) => `${entry.menuId ?? ""}\0${entry.name}`;
1517
- const enabledBefore = new Set(before.filter((entry) => !entry.disabled).map(key));
1518
- for (const entry of after) {
1519
- if (!entry.disabled) continue;
1520
- if (entry.menuId === LEGEND_MENU_ID && entry.role === "menuitemcheckbox") {
1521
- problems.push(
1522
- "menu: the Legend toggle is aria-disabled on the read-only canvas; a view-only toggle stays usable (guide \xA713.5)"
1523
- );
1524
- } else if (entry.group !== "model" && enabledBefore.has(key(entry))) {
1525
- problems.push(
1526
- `menu: "${entry.name}" is aria-disabled on the read-only canvas; export and help stay usable (guide \xA713.5)`
3601
+ async function clickPill(page, root) {
3602
+ const pill = (await popover(page, root))?.pill;
3603
+ if (!pill?.visible) return null;
3604
+ await page.mouse.click(pill.client.x, pill.client.y);
3605
+ await settle(page);
3606
+ return popover(page, root);
3607
+ }
3608
+ async function keyboardRows(page, root) {
3609
+ if (!await focusTarget(page, root, "pill")) return ["problems: the pill cannot take the focus"];
3610
+ await page.keyboard.press("Enter");
3611
+ await settle(page);
3612
+ const opened = await popover(page, root);
3613
+ const count = opened?.rows.length ?? 0;
3614
+ if (!opened?.popover?.visible || count === 0) {
3615
+ await closeByEscape(page);
3616
+ return opened?.popover?.visible ? [] : ["problems-popover: Enter on the pill does not open the list"];
3617
+ }
3618
+ const steps = [{ key: "Enter on the pill", expected: 0, focused: opened.focusedRow }];
3619
+ for (let index = 1; index < count; index++) {
3620
+ await page.keyboard.press("ArrowDown");
3621
+ steps.push({
3622
+ key: "ArrowDown",
3623
+ expected: index,
3624
+ focused: (await popover(page, root))?.focusedRow ?? null
3625
+ });
3626
+ }
3627
+ if (count > 1) {
3628
+ await page.keyboard.press("Home");
3629
+ steps.push({ key: "Home", expected: 0, focused: (await popover(page, root))?.focusedRow ?? null });
3630
+ await page.keyboard.press("End");
3631
+ steps.push({ key: "End", expected: count - 1, focused: (await popover(page, root))?.focusedRow ?? null });
3632
+ }
3633
+ await closeByEscape(page);
3634
+ return popoverKeyboardProblems(steps, count);
3635
+ }
3636
+ var problemsListCase = {
3637
+ name: "problemsList",
3638
+ async run(page, harness) {
3639
+ assertHooksConsistent("problemsList", harness, ["problems"]);
3640
+ const root = harness.root ?? DEFAULT_ROOT;
3641
+ const size = sizeOf(harness);
3642
+ const show = harness.show?.problems;
3643
+ if (!show) return unfed(harness, "problems") ? skipFor("problems", "the problems list") : void 0;
3644
+ await openHarness(page, harness, { size, requested: LIGHT });
3645
+ await settle(page);
3646
+ await show(page);
3647
+ await settle(page);
3648
+ const before = await popover(page, root);
3649
+ const problems = [];
3650
+ if (!before?.pill?.visible) {
3651
+ return finish(
3652
+ "problemsList",
3653
+ harness.host,
3654
+ ["problems: the pill is not visible after show.problems"],
3655
+ []
1527
3656
  );
1528
3657
  }
3658
+ const opensList = harness.host === "web" || before.pill.attributes["aria-haspopup"] === "dialog";
3659
+ if (!opensList) {
3660
+ const after = await clickPill(page, root);
3661
+ if (after?.popover?.visible) {
3662
+ problems.push(
3663
+ `problems-popover: the ${harness.host} host shows a problems list; its pill opens the host's own view (guide \xA75.10)`
3664
+ );
3665
+ }
3666
+ return finish("problemsList", harness.host, problems, []);
3667
+ }
3668
+ const lang = await inPage(page, documentLangProbe, null);
3669
+ const container = (await measureChrome(page, root)).container;
3670
+ const opened = await clickPill(page, root);
3671
+ if (!opened) return finish("problemsList", harness.host, ["problems: the pill cannot be clicked"], []);
3672
+ problems.push(...openPopoverProblems(opened, container, lang));
3673
+ await closeByEscape(page);
3674
+ const escaped = await popover(page, root);
3675
+ if (escaped) problems.push(...closedPopoverProblems(escaped, "Escape"));
3676
+ await clickPill(page, root);
3677
+ await page.mouse.click(PARK.x, PARK.y);
3678
+ await settle(page);
3679
+ const outside = await popover(page, root);
3680
+ if (outside) problems.push(...closedPopoverProblems(outside, "an outside click"));
3681
+ problems.push(...await keyboardRows(page, root));
3682
+ const skipped = [];
3683
+ const reopened = await clickPill(page, root);
3684
+ const row = reopened?.rows.find((candidate) => candidate.button);
3685
+ if (row) {
3686
+ await page.mouse.click(row.client.x, row.client.y);
3687
+ await settle(page);
3688
+ const selected = await popover(page, root);
3689
+ if (selected) problems.push(...closedPopoverProblems(selected, "a row with an element"));
3690
+ } else {
3691
+ await closeByEscape(page);
3692
+ if ((reopened?.rows.length ?? 0) > 0 && !harness.absent?.includes("problemSelection")) {
3693
+ skipped.push({
3694
+ hook: "problems",
3695
+ reason: '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)'
3696
+ });
3697
+ }
3698
+ }
3699
+ return finish("problemsList", harness.host, problems, skipped);
1529
3700
  }
1530
- return problems;
3701
+ };
3702
+ function drag(page, spec) {
3703
+ return inPage(page, dragProbe, spec);
1531
3704
  }
1532
- function lockedShapeProblems(facts) {
1533
- const problems = [];
1534
- const { before, after } = facts.dragged;
1535
- if (facts.contextPadEntries.length > 0) {
1536
- problems.push(
1537
- `read-only: selecting "${before.id}" shows the context pad (${facts.contextPadEntries.join(", ")}) (guide \xA713.5)`
1538
- );
1539
- }
1540
- if (!after) {
1541
- problems.push(`read-only: a 40px drag removed "${before.id}" from the canvas (guide \xA713.5)`);
1542
- } else if (after.transform !== before.transform || Math.abs(after.size.width - before.size.width) > 0.5 || Math.abs(after.size.height - before.size.height) > 0.5) {
1543
- problems.push(
1544
- `read-only: a 40px drag changed "${before.id}" (transform "${before.transform}" \u2192 "${after.transform}", ${round2(before.size.width)} \xD7 ${round2(before.size.height)} \u2192 ${round2(after.size.width)} \xD7 ${round2(after.size.height)}) (guide \xA713.5)`
1545
- );
1546
- }
1547
- if (facts.textFields.length > 0) {
1548
- problems.push(
1549
- `read-only: a double-click on "${before.id}" opens a text field (${facts.textFields.join(", ")}) (guide \xA713.5)`
3705
+ function secondFile(file) {
3706
+ return { ...file, name: `second-${file.name}` };
3707
+ }
3708
+ var dropZoneCase = {
3709
+ name: "dropZone",
3710
+ async run(page, harness) {
3711
+ assertHooksConsistent("dropZone", harness, ["drop", "emptyState"]);
3712
+ const root = harness.root ?? DEFAULT_ROOT;
3713
+ const size = sizeOf(harness);
3714
+ const fixtures = harness.drop;
3715
+ if (harness.host === "web" && !fixtures) {
3716
+ return harness.absent?.includes("drop") ? void 0 : skipFor("drop", "the drop zone");
3717
+ }
3718
+ await openHarness(page, harness, { size, requested: LIGHT });
3719
+ await settle(page);
3720
+ const problems = [];
3721
+ const probeFile = { name: "conformance.txt", type: "text/plain", text: "conformance" };
3722
+ if (harness.host !== "web" || !fixtures) {
3723
+ const facts = await drag(page, { root, files: [probeFile], steps: ["dragenter", "dragover"] });
3724
+ if (facts && (facts.state !== null || facts.zone?.visible)) {
3725
+ problems.push(
3726
+ `drop-zone: a file drag shows a drop zone in the ${harness.host} host; its files belong to the host (guide \xA75.2)`
3727
+ );
3728
+ }
3729
+ await drag(page, { root, files: [probeFile], steps: ["dragleave"] });
3730
+ return finish("dropZone", harness.host, problems, []);
3731
+ }
3732
+ const container = (await measureChrome(page, root)).container;
3733
+ const { accepted, rejected } = fixtures;
3734
+ const check = async (files, strings, expected, context) => {
3735
+ const over = await drag(page, { root, files, strings, steps: ["dragenter", "dragover"] });
3736
+ if (over) problems.push(...dropStateProblems(over, expected, context, container));
3737
+ const left = await drag(page, { root, files, strings, steps: ["dragleave"] });
3738
+ if (left && expected !== "none") {
3739
+ problems.push(...dropStateProblems(left, "none", `${context}, once it left the page,`, container));
3740
+ }
3741
+ };
3742
+ await check([accepted], [], "over", `a drag of "${accepted.name}"`);
3743
+ await check(
3744
+ [accepted, secondFile(accepted)],
3745
+ [],
3746
+ fixtures.multiple ? "over" : "forbidden",
3747
+ fixtures.multiple ? "a drag of two files (multiple)" : "a drag of two files"
1550
3748
  );
3749
+ if (await inPage(page, injectProbe, { root, kind: "modal" })) {
3750
+ await check([accepted], [], "forbidden", "a file drag with a modal dialog open");
3751
+ }
3752
+ await inPage(page, removeInjectedProbe, null);
3753
+ await check([], ["Conformance text"], "none", "a text drag");
3754
+ const emptyState = harness.show?.emptyState;
3755
+ if (emptyState) {
3756
+ await emptyState(page);
3757
+ await settle(page);
3758
+ }
3759
+ const onStartScreen = async () => (await inPage(page, emptyStateProbe, { root, work: [] }))?.shown ?? false;
3760
+ const started = await onStartScreen();
3761
+ if (rejected) {
3762
+ await drag(page, { root, files: [rejected], steps: ["dragenter", "dragover", "drop"] });
3763
+ const shown = await pollUntil(
3764
+ page,
3765
+ () => toasts(page, root),
3766
+ (facts) => facts?.toasts.some((toast) => toast.status === "danger") ?? false,
3767
+ DROP_WAIT_MS
3768
+ );
3769
+ if (!shown?.toasts.some((toast) => toast.status === "danger")) {
3770
+ problems.push(
3771
+ `drop-zone: dropping "${rejected.name}" shows no danger toast (guide \xA79, or the modeler's onReject)`
3772
+ );
3773
+ }
3774
+ if (started && !await onStartScreen()) {
3775
+ problems.push(
3776
+ `drop-zone: dropping "${rejected.name}" opened it; a file outside accept opens nothing`
3777
+ );
3778
+ }
3779
+ await dismissAll(page, root);
3780
+ }
3781
+ const dropped = await drag(page, { root, files: [accepted], steps: ["dragenter", "dragover", "drop"] });
3782
+ if (dropped?.events.find((event) => event.type === "drop")?.defaultPrevented === false) {
3783
+ problems.push(
3784
+ `drop-zone: the drop of "${accepted.name}" is not prevented, so the browser would open the file`
3785
+ );
3786
+ }
3787
+ if (started) {
3788
+ const opened = await pollUntil(page, onStartScreen, (shown) => !shown, DROP_WAIT_MS);
3789
+ if (opened)
3790
+ problems.push(
3791
+ `drop-zone: dropping "${accepted.name}" on the start screen does not open it (the card stays)`
3792
+ );
3793
+ }
3794
+ const after = await drag(page, { root, files: [], steps: [] });
3795
+ if (after && (after.state !== null || after.zone?.visible)) {
3796
+ problems.push("drop-zone: the drop zone still shows after the drop");
3797
+ }
3798
+ return finish("dropZone", harness.host, problems, []);
1551
3799
  }
1552
- for (const key of ["Delete", "Backspace"]) {
1553
- if (!facts.kept[key])
1554
- problems.push(`read-only: ${key} on the canvas deleted "${before.id}" (guide \xA713.5)`);
1555
- }
1556
- return problems;
3800
+ };
3801
+ function createWebCases() {
3802
+ return [webChromeCase, emptyStateCase, toastsCase, problemsListCase, dropZoneCase];
1557
3803
  }
1558
3804
 
1559
3805
  // src/testing/cases.ts
1560
- var DEFAULT_SIZES = [
1561
- { width: 1280, height: 900 },
1562
- { width: 720, height: 700 },
1563
- { width: 420, height: 700 }
1564
- ];
1565
- var LIGHT = { theme: "light" };
1566
- var SHOWN_ISLANDS = ["legend", "problems", "status", "inspector"];
1567
3806
  var LOCKED_DRAG_PX = 40;
1568
3807
  var CREATE_WAIT_MS = 1e3;
1569
3808
  var READ_ONLY_WAIT_MS = 1e3;
1570
3809
  var COLOR_WAIT_MS = 600;
1571
- var POLL_MS = 50;
1572
3810
  var REQUESTED_APPEARANCES = [
1573
3811
  { theme: "dark" },
1574
3812
  { theme: "dark", kontrast: "hoch" },
1575
3813
  { theme: "light", kontrast: "hoch" }
1576
3814
  ];
1577
- var HOVER_MARGIN_MS = 200;
1578
- var PARK = { x: 1, y: 1 };
1579
3815
  var TEXT_FIELDS = [
1580
3816
  ["input", "a text field"],
1581
3817
  ["textarea", "a textarea"],
@@ -1592,71 +3828,20 @@ var ONE_STEP_UP = 135;
1592
3828
  var TWO_STEPS_UP = 182;
1593
3829
  var ZOOM_LIMIT_MAX = 400;
1594
3830
  var ZOOM_LIMIT_MIN = 20;
1595
- function sizeLabel(size) {
1596
- return `${size.width} \xD7 ${size.height}`;
1597
- }
1598
3831
  function appearanceLabel(appearance) {
1599
3832
  return appearance.kontrast ? `${appearance.theme} + ${appearance.kontrast}` : appearance.theme;
1600
3833
  }
1601
- function fail(caseName, host, problems) {
1602
- const unique = [...new Set(problems)];
1603
- throw new Error(
1604
- `@miragon/modeler-shell conformance: ${caseName} (${host} host)
1605
- ${unique.map((problem) => `- ${problem}`).join("\n")}`
1606
- );
1607
- }
1608
- function finish(caseName, host, problems, skipped) {
1609
- if (problems.length > 0) fail(caseName, host, problems);
1610
- const seen = /* @__PURE__ */ new Set();
1611
- const unique = [...skipped].filter((skip) => !seen.has(skip.reason) && seen.add(skip.reason));
1612
- return unique.length > 0 ? { skipped: unique } : void 0;
1613
- }
1614
- function assertHooksConsistent(caseName, harness, hooks) {
1615
- const problems = harnessHookProblems(harness, hooks);
1616
- if (problems.length > 0) fail(caseName, harness.host, problems);
1617
- }
1618
- function unfed(harness, hook) {
1619
- return !harness.show?.[hook] && !harness.absent?.includes(hook);
1620
- }
1621
3834
  function missingHooks(harness, what) {
1622
3835
  return SHOWN_ISLANDS.filter((island) => unfed(harness, island)).map((island) => ({
1623
3836
  hook: island,
1624
3837
  reason: `show.${island} is undefined: ${what} of the ${island} island not verified`
1625
3838
  }));
1626
3839
  }
1627
- async function pollUntil(page, read, done, timeoutMs) {
1628
- let value = await read();
1629
- for (let waited = 0; !done(value) && waited < timeoutMs; waited += POLL_MS) {
1630
- await page.waitForTimeout(POLL_MS);
1631
- value = await read();
1632
- }
1633
- return value;
1634
- }
1635
3840
  function visibleIslands(facts) {
1636
3841
  const islands = /* @__PURE__ */ new Set();
1637
3842
  for (const island of SHOWN_ISLANDS) if (facts[island]?.visible) islands.add(island);
1638
3843
  return islands;
1639
3844
  }
1640
- async function hover(page, root, target) {
1641
- const anchor = await findTarget(page, root, target);
1642
- if (!anchor) return null;
1643
- await page.mouse.move(PARK.x, PARK.y);
1644
- await page.mouse.move(anchor.client.x, anchor.client.y);
1645
- await page.waitForTimeout(TOOLTIP_DELAY_MS + HOVER_MARGIN_MS);
1646
- await settle(page);
1647
- return anchor;
1648
- }
1649
- async function openMenuByClick(page, root) {
1650
- const trigger = await findTarget(page, root, "menu");
1651
- if (!trigger) return false;
1652
- await page.mouse.click(trigger.client.x, trigger.client.y);
1653
- await settle(page);
1654
- return true;
1655
- }
1656
- async function closeByEscape(page) {
1657
- await page.keyboard.press("Escape");
1658
- await settle(page);
1659
- }
1660
3845
  async function popupGeometry(page, root, measurement) {
1661
3846
  const problems = [];
1662
3847
  if (!await openMenuByClick(page, root)) return ["dropdown: no visible menu trigger to open it"];
@@ -1684,8 +3869,10 @@ async function popupGeometry(page, root, measurement) {
1684
3869
  if (anchor.label && !tooltip.text.startsWith(anchor.label)) {
1685
3870
  problems.push(`tooltip: hovering ${target} ("${anchor.label}") shows "${tooltip.text}"`);
1686
3871
  }
3872
+ const legalHeight = legalLineHeight(measurement.islands.legal?.height);
3873
+ const bottomRow = measurement.open.zoomRaised ? SHELL.edge + legalHeight + SHELL.gap : SHELL.edge;
1687
3874
  problems.push(
1688
- ...tooltipProblems(tooltip.box, anchor.box, row, measurement.container).map(
3875
+ ...tooltipProblems(tooltip.box, anchor.box, row, measurement.container, void 0, bottomRow).map(
1689
3876
  (problem) => `${problem} (on ${target})`
1690
3877
  )
1691
3878
  );
@@ -1693,15 +3880,28 @@ async function popupGeometry(page, root, measurement) {
1693
3880
  await page.mouse.move(PARK.x, PARK.y);
1694
3881
  return problems;
1695
3882
  }
3883
+ var GEOMETRY_TOAST = {
3884
+ status: "info",
3885
+ text: "Conformance: the toast stack's geometry",
3886
+ actionLabel: "Undo"
3887
+ };
3888
+ async function withProblemsListOpen(page, root, check) {
3889
+ const pill = await findTarget(page, root, "pill");
3890
+ if (!pill) return;
3891
+ await page.mouse.click(pill.client.x, pill.client.y);
3892
+ await settle(page);
3893
+ await check();
3894
+ await closeByEscape(page);
3895
+ }
1696
3896
  var geometryCase = {
1697
3897
  name: "geometry",
1698
3898
  async run(page, harness) {
1699
- assertHooksConsistent("geometry", harness, SHOWN_ISLANDS);
3899
+ assertHooksConsistent("geometry", harness, [...SHOWN_ISLANDS, "toast", "emptyState"]);
1700
3900
  const root = harness.root ?? DEFAULT_ROOT;
1701
3901
  const problems = [];
1702
3902
  for (const size of harness.sizes ?? DEFAULT_SIZES) {
1703
3903
  const at = sizeLabel(size);
1704
- await harness.open(page, { size, requested: LIGHT });
3904
+ await openHarness(page, harness, { size, requested: LIGHT });
1705
3905
  await settle(page);
1706
3906
  const check = async (state) => {
1707
3907
  const measurement = await measureChrome(page, root);
@@ -1715,15 +3915,36 @@ var geometryCase = {
1715
3915
  };
1716
3916
  const opened = await check("as opened");
1717
3917
  problems.push(...(await popupGeometry(page, root, opened)).map((problem) => `[${at}] ${problem}`));
3918
+ const toast = harness.show?.toast;
3919
+ if (toast) {
3920
+ await toast(page, GEOMETRY_TOAST);
3921
+ await settle(page);
3922
+ await check("with a toast");
3923
+ }
1718
3924
  for (const island of SHOWN_ISLANDS) {
1719
3925
  const show = harness.show?.[island];
1720
3926
  if (!show) continue;
1721
3927
  await show(page);
1722
3928
  await settle(page);
1723
3929
  await check(`after show.${island}`);
3930
+ if (island === "problems" && harness.host !== "ide") {
3931
+ await withProblemsListOpen(page, root, () => check("with the problems list open"));
3932
+ }
3933
+ }
3934
+ const emptyState = harness.show?.emptyState;
3935
+ if (emptyState) {
3936
+ await emptyState(page);
3937
+ await settle(page);
3938
+ await check("after show.emptyState");
1724
3939
  }
1725
3940
  }
1726
- return finish("geometry", harness.host, problems, missingHooks(harness, "the geometry"));
3941
+ return finish("geometry", harness.host, problems, [
3942
+ ...missingHooks(harness, "the geometry"),
3943
+ ...webHookSkips(harness, {
3944
+ toast: "the geometry of the toast stack",
3945
+ emptyState: "the geometry of the start screen"
3946
+ })
3947
+ ]);
1727
3948
  }
1728
3949
  };
1729
3950
  async function menuState(page, root) {
@@ -1826,7 +4047,7 @@ async function paletteShortcutWalk(page, root) {
1826
4047
  }
1827
4048
  return paletteShortcutProblems(seen);
1828
4049
  }
1829
- async function tabSequenceFromMenu(page, root) {
4050
+ async function tabSequenceFromMenu(page, root, primary) {
1830
4051
  if (!await focusTarget(page, root, "menu")) return [];
1831
4052
  const stops = [];
1832
4053
  for (let step = 0; step < 3; step++) {
@@ -1834,7 +4055,7 @@ async function tabSequenceFromMenu(page, root) {
1834
4055
  const stop = await inPage(page, activeStopProbe, { root });
1835
4056
  stops.push({ description: stop.description, kind: stop.kind });
1836
4057
  }
1837
- return tabSequenceProblems(stops);
4058
+ return tabSequenceProblems(stops, primary);
1838
4059
  }
1839
4060
  async function zoomLimitProblems(page, root) {
1840
4061
  const problems = [];
@@ -1879,15 +4100,18 @@ var ariaCase = {
1879
4100
  assertHooksConsistent("aria", harness, SHOWN_ISLANDS);
1880
4101
  const root = harness.root ?? DEFAULT_ROOT;
1881
4102
  const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0];
1882
- await harness.open(page, { size, requested: LIGHT });
4103
+ await openHarness(page, harness, { size, requested: LIGHT });
1883
4104
  await settle(page);
1884
4105
  const facts = await inPage(page, ariaProbe, { root });
1885
4106
  if (!facts) fail("aria", harness.host, [`no element matches the shell root selector "${root}"`]);
1886
4107
  const problems = ariaProblems(facts, harness.host);
4108
+ problems.push(...menuGroupProblems(await menuEntries(page, root), harness.host, harness.themes));
1887
4109
  problems.push(...await menuKeyboardProblems(page, root));
1888
4110
  problems.push(...await paletteKeyboardProblems(page, root));
1889
4111
  problems.push(...await paletteShortcutWalk(page, root));
1890
- problems.push(...await tabSequenceFromMenu(page, root));
4112
+ problems.push(
4113
+ ...await tabSequenceFromMenu(page, root, harness.host === "web" && Boolean(facts.primary?.visible))
4114
+ );
1891
4115
  problems.push(...await zoomLimitProblems(page, root));
1892
4116
  const shown = /* @__PURE__ */ new Set();
1893
4117
  for (const island of SHOWN_ISLANDS) {
@@ -1903,6 +4127,7 @@ var ariaCase = {
1903
4127
  if (withIslands) {
1904
4128
  final = withIslands;
1905
4129
  problems.push(...optionalIslandProblems(withIslands, shown));
4130
+ if (shown.has("problems")) problems.push(...pillPopupAriaProblems(withIslands, harness.host));
1906
4131
  const known = new Set(problems);
1907
4132
  problems.push(
1908
4133
  ...ariaProblems(withIslands, harness.host).filter((problem) => !known.has(problem)).map((problem) => `with every island shown: ${problem}`)
@@ -1919,7 +4144,7 @@ var keysCase = {
1919
4144
  const root = harness.root ?? DEFAULT_ROOT;
1920
4145
  const host = harness.host;
1921
4146
  const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0];
1922
- await harness.open(page, { size, requested: LIGHT });
4147
+ await openHarness(page, harness, { size, requested: LIGHT });
1923
4148
  await settle(page);
1924
4149
  const problems = [];
1925
4150
  if (!await focusTarget(page, root, "canvas")) {
@@ -2087,15 +4312,76 @@ async function paletteColorWalk(page, root) {
2087
4312
  }
2088
4313
  return [...new Set(problems)];
2089
4314
  }
4315
+ function webMode(page) {
4316
+ return inPage(page, webModeProbe, { key: APPEARANCE_STORAGE_KEY });
4317
+ }
4318
+ var APPEARANCE_LABELS = {
4319
+ system: "System",
4320
+ light: "Light",
4321
+ dark: "Dark"
4322
+ };
4323
+ async function appearanceSwitchProblems(page, harness, root, requested) {
4324
+ const problems = [];
4325
+ const system = await webMode(page);
4326
+ const media = (scheme) => system.themeColors.find((meta) => meta.media?.replace(/\s+/g, "") === `(prefers-color-scheme:${scheme})`)?.content;
4327
+ const light = media("light");
4328
+ const dark = media("dark");
4329
+ const colors = light && dark ? { light, dark } : void 0;
4330
+ for (const choice of ["dark", "light", "system"]) {
4331
+ const label = APPEARANCE_LABELS[choice];
4332
+ if (!await openMenuByClick(page, root)) {
4333
+ problems.push(`menu: no visible menu trigger to choose ${label}`);
4334
+ break;
4335
+ }
4336
+ const option = await inPage(page, targetProbe, {
4337
+ root,
4338
+ selector: `[data-menu-id="appearance"] [data-menu-value="${choice}"]`
4339
+ });
4340
+ if (!option) {
4341
+ problems.push(`menu: no Appearance option "${choice}" to choose (guide \xA713.3)`);
4342
+ await closeByEscape(page);
4343
+ break;
4344
+ }
4345
+ await inPage(page, watchModeProbe, null);
4346
+ await page.mouse.click(option.client.x, option.client.y);
4347
+ await settle(page);
4348
+ problems.push(...modeSwitchProblems(await inPage(page, modeChangesProbe, null), label));
4349
+ problems.push(
4350
+ ...appearanceChoiceProblems(await inPage(page, menuEntriesProbe, { root }) ?? [], choice)
4351
+ );
4352
+ await closeByEscape(page);
4353
+ problems.push(...webModeProblems(await webMode(page), harness.themes, choice, colors));
4354
+ const facts = await appearanceFacts(page, root);
4355
+ if (facts) {
4356
+ const expected = expectedWebMode(requested, harness.themes, choice);
4357
+ problems.push(
4358
+ ...appearanceProblems(facts, "web", requested, harness.themes, expected).problems.map(
4359
+ (problem) => `[chose ${label}] ${problem}`
4360
+ )
4361
+ );
4362
+ }
4363
+ }
4364
+ return problems;
4365
+ }
2090
4366
  var appearanceCase = {
2091
4367
  name: "appearance",
2092
4368
  async run(page, harness) {
2093
4369
  const root = harness.root ?? DEFAULT_ROOT;
2094
4370
  const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0];
2095
4371
  const problems = [];
2096
- for (const requested of REQUESTED_APPEARANCES) {
4372
+ const skipped = [];
4373
+ const web = harness.host === "web";
4374
+ const emulated = !web || page.emulateMedia !== void 0;
4375
+ if (!emulated) {
4376
+ skipped.push({
4377
+ hook: "open",
4378
+ reason: "page.emulateMedia is undefined: the web app's mode under a dark system and under high contrast was not verified"
4379
+ });
4380
+ }
4381
+ let switched = false;
4382
+ for (const requested of emulated ? REQUESTED_APPEARANCES : [LIGHT]) {
2097
4383
  const at = `[requested ${appearanceLabel(requested)}]`;
2098
- await harness.open(page, { size, requested });
4384
+ await openHarness(page, harness, { size, requested });
2099
4385
  if (harness.host === "embedded") {
2100
4386
  await inPage(page, hostPageModeProbe, {
2101
4387
  theme: requested.theme,
@@ -2108,18 +4394,34 @@ var appearanceCase = {
2108
4394
  problems.push(`${at} no element matches the shell root selector "${root}"`);
2109
4395
  continue;
2110
4396
  }
2111
- const verdict = appearanceProblems(facts, harness.host, requested, harness.themes);
4397
+ const expected = web ? expectedWebMode(requested, harness.themes, "system") : void 0;
4398
+ const verdict = appearanceProblems(facts, harness.host, requested, harness.themes, expected);
2112
4399
  problems.push(...verdict.problems.map((problem) => `${at} ${problem}`));
2113
4400
  const surround = await inPage(page, surroundProbe, { root });
2114
4401
  if (surround) problems.push(...surroundProblems(surround).map((problem) => `${at} ${problem}`));
2115
4402
  problems.push(...(await paletteColorWalk(page, root)).map((problem) => `${at} ${problem}`));
4403
+ if (web) {
4404
+ problems.push(
4405
+ ...webModeProblems(await webMode(page), harness.themes, "system", void 0).map(
4406
+ (problem) => `${at} ${problem}`
4407
+ ),
4408
+ ...lightOnlyMenuProblems(await menuEntries(page, root), harness.themes).map(
4409
+ (problem) => `${at} ${problem}`
4410
+ )
4411
+ );
4412
+ if (harness.themes.includes("dark") && !switched && requested.theme === "dark") {
4413
+ switched = true;
4414
+ problems.push(
4415
+ ...(await appearanceSwitchProblems(page, harness, root, requested)).map(
4416
+ (problem) => `${at} ${problem}`
4417
+ )
4418
+ );
4419
+ }
4420
+ }
2116
4421
  }
2117
- return finish("appearance", harness.host, problems, []);
4422
+ return finish("appearance", harness.host, problems, skipped);
2118
4423
  }
2119
4424
  };
2120
- function shapes(page, root) {
2121
- return inPage(page, shapesProbe, { root });
2122
- }
2123
4425
  async function focusPaletteEntry(page, root, action) {
2124
4426
  const before = await inPage(page, paletteFocusProbe, { root });
2125
4427
  if (!before || !before.entries.includes(action)) return false;
@@ -2151,7 +4453,7 @@ var createCase = {
2151
4453
  async run(page, harness) {
2152
4454
  const root = harness.root ?? DEFAULT_ROOT;
2153
4455
  const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0];
2154
- await harness.open(page, { size, requested: LIGHT });
4456
+ await openHarness(page, harness, { size, requested: LIGHT });
2155
4457
  await settle(page);
2156
4458
  const entry = firstCreatingEntry(await inPage(page, paletteEntriesProbe, { root }));
2157
4459
  if (!entry) return void 0;
@@ -2168,12 +4470,6 @@ var createCase = {
2168
4470
  return finish("create", harness.host, problems, []);
2169
4471
  }
2170
4472
  };
2171
- async function menuEntries(page, root) {
2172
- if (!await openMenuByClick(page, root)) return [];
2173
- const entries = await inPage(page, menuEntriesProbe, { root }) ?? [];
2174
- await closeByEscape(page);
2175
- return entries;
2176
- }
2177
4473
  async function reachableShape(page, root, id) {
2178
4474
  const all = await shapes(page, root);
2179
4475
  return id === void 0 ? all.find((shape) => shape.hit) : all.find((shape) => shape.id === id);
@@ -2234,7 +4530,7 @@ var readOnlyCase = {
2234
4530
  const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0];
2235
4531
  const problems = [];
2236
4532
  const skipped = [];
2237
- await harness.open(page, { size, requested: LIGHT });
4533
+ await openHarness(page, harness, { size, requested: LIGHT });
2238
4534
  await settle(page);
2239
4535
  const menuBefore = await menuEntries(page, root);
2240
4536
  await invalid(page);
@@ -2292,7 +4588,7 @@ var cleanupCase = {
2292
4588
  }
2293
4589
  const root = harness.root ?? DEFAULT_ROOT;
2294
4590
  const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0];
2295
- await harness.open(page, { size, requested: LIGHT });
4591
+ await openHarness(page, harness, { size, requested: LIGHT });
2296
4592
  await settle(page);
2297
4593
  const armed = await inPage(page, armCleanupProbe, { root });
2298
4594
  if (!armed) fail("cleanup", harness.host, [`no element matches the shell root selector "${root}"`]);
@@ -2324,9 +4620,12 @@ var cleanupCase = {
2324
4620
  return finish("cleanup", harness.host, problems, []);
2325
4621
  }
2326
4622
  };
2327
- function createCases() {
4623
+ function createBaseCases() {
2328
4624
  return [geometryCase, ariaCase, keysCase, createCase, readOnlyCase, appearanceCase, cleanupCase];
2329
4625
  }
4626
+ function createCases() {
4627
+ return [...createBaseCases(), ...createWebCases()];
4628
+ }
2330
4629
 
2331
4630
  // src/testing/harness.ts
2332
4631
  function shellConformanceCases() {