@miragon/modeler-shell 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) 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-66HCSJNY.js} +1247 -31
  6. package/dist/chunk-66HCSJNY.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 +1654 -197
  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 +1541 -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/types.ts +188 -6
  71. package/src/dom/web/drop-zone.ts +181 -0
  72. package/src/dom/web/empty-state.ts +144 -0
  73. package/src/dom/web/legal.ts +75 -0
  74. package/src/dom/web/menu-groups.ts +69 -0
  75. package/src/dom/web/primary.ts +124 -0
  76. package/src/dom/web/web.css +386 -0
  77. package/src/shell.css +3 -0
  78. package/src/testing/case-tools.ts +164 -0
  79. package/src/testing/cases.ts +209 -144
  80. package/src/testing/checks.ts +51 -15
  81. package/src/testing/facts.ts +56 -2
  82. package/src/testing/geometry.ts +206 -26
  83. package/src/testing/harness.ts +56 -7
  84. package/src/testing/index.ts +4 -0
  85. package/src/testing/page-tools.ts +24 -1
  86. package/src/testing/page.ts +10 -0
  87. package/src/testing/web-cases.ts +913 -0
  88. package/src/testing/web-checks.ts +1049 -0
  89. package/src/testing/web-facts.ts +713 -0
  90. package/src/vscode/webview/mount.ts +13 -1
  91. package/dist/chunk-5JSZ4VGA.js.map +0 -1
  92. package/dist/chunk-6LQOFACK.js.map +0 -1
  93. package/dist/keymap-CObQE6q6.d.cts +0 -151
  94. package/dist/keymap-DORzHnK-.d.ts +0 -151
  95. package/dist/types-B6NfkiUG.d.cts +0 -66
  96. package/dist/types-B6NfkiUG.d.ts +0 -66
  97. /package/dist/{chunk-RJRFR6N3.js.map → chunk-KYDG5DMX.js.map} +0 -0
@@ -1,9 +1,11 @@
1
1
  import {
2
+ APPEARANCE_STORAGE_KEY,
2
3
  INVALID_STATUS_DELAY_MS,
4
+ SHELL,
3
5
  TOOLTIP_DELAY_MS,
6
+ legalLineHeight,
4
7
  type Appearance,
5
- type ShellHost,
6
- type Size,
8
+ type AppearanceChoice,
7
9
  } from "../core/index.js";
8
10
  import {
9
11
  absentIslandProblems,
@@ -11,7 +13,6 @@ import {
11
13
  appearanceProblems,
12
14
  ariaProblems,
13
15
  createProblems,
14
- harnessHookProblems,
15
16
  isToolEntry,
16
17
  keyReachProblem,
17
18
  leftoverProblems,
@@ -44,12 +45,10 @@ import {
44
45
  paletteFocusProbe,
45
46
  paletteShortcutProbe,
46
47
  readOnlyStateProbe,
47
- shapesProbe,
48
48
  surroundProbe,
49
49
  zoomButtonsProbe,
50
50
  type AppearanceFacts,
51
51
  type AriaFacts,
52
- type MenuEntryFacts,
53
52
  type MenuFacts,
54
53
  type PaletteEntryState,
55
54
  type ShapeFacts,
@@ -64,14 +63,7 @@ import {
64
63
  type ChromeMeasurement,
65
64
  type TooltipRow,
66
65
  } from "./geometry.js";
67
- import type {
68
- ShellCaseOutcome,
69
- ShellConformanceCase,
70
- ShellConformanceHarness,
71
- ShellSkip,
72
- ShowHook,
73
- ShowHooks,
74
- } from "./harness.js";
66
+ import type { ShellConformanceCase, ShellConformanceHarness, ShellSkip, ToastFixture } from "./harness.js";
75
67
  import type { PageLike } from "./page.js";
76
68
  import {
77
69
  afterPressProbe,
@@ -87,24 +79,43 @@ import {
87
79
  leftoverProbe,
88
80
  removeInjectedProbe,
89
81
  settle,
82
+ targetProbe,
90
83
  type ChromeTarget,
91
84
  type InjectedKind,
92
- type TargetFacts,
93
85
  } from "./page-tools.js";
94
-
95
- /** Wide, narrow and very narrow (guide §5.15); 900 is above, 700 below the low breakpoint. */
96
- export const DEFAULT_SIZES: readonly Size[] = [
97
- { width: 1280, height: 900 },
98
- { width: 720, height: 700 },
99
- { width: 420, height: 700 },
100
- ];
101
-
102
- const LIGHT: Appearance = { theme: "light" };
103
-
104
- /** Shown in this order, so the legend is measured without and then with the pill. */
105
- const SHOWN_ISLANDS = ["legend", "problems", "status", "inspector"] as const satisfies ReadonlyArray<
106
- keyof ShowHooks
107
- >;
86
+ import {
87
+ appearanceChoiceProblems,
88
+ expectedWebMode,
89
+ lightOnlyMenuProblems,
90
+ menuGroupProblems,
91
+ modeSwitchProblems,
92
+ pillPopupAriaProblems,
93
+ webModeProblems,
94
+ } from "./web-checks.js";
95
+ import { createWebCases } from "./web-cases.js";
96
+ import {
97
+ DEFAULT_SIZES,
98
+ HOVER_MARGIN_MS,
99
+ LIGHT,
100
+ PARK,
101
+ SHOWN_ISLANDS,
102
+ assertHooksConsistent,
103
+ closeByEscape,
104
+ fail,
105
+ finish,
106
+ hover,
107
+ menuEntries,
108
+ openHarness,
109
+ openMenuByClick,
110
+ pollUntil,
111
+ shapes,
112
+ sizeLabel,
113
+ unfed,
114
+ webHookSkips,
115
+ } from "./case-tools.js";
116
+
117
+ export { DEFAULT_SIZES } from "./case-tools.js";
118
+ import { modeChangesProbe, watchModeProbe, webModeProbe, type WebModeFacts } from "./web-facts.js";
108
119
 
109
120
  /** How far the read-only case drags a shape (guide §13.5: no move). */
110
121
  const LOCKED_DRAG_PX = 40;
@@ -118,8 +129,6 @@ const READ_ONLY_WAIT_MS = 1000;
118
129
  /** Room for the palette's 150ms colour transition (`--cd-motion-fast`) and a frame on either side. */
119
130
  const COLOR_WAIT_MS = 600;
120
131
 
121
- const POLL_MS = 50;
122
-
123
132
  /** Requested from the modeler by the appearance case (VS Code Dark, High Contrast, High Contrast Light). */
124
133
  const REQUESTED_APPEARANCES: readonly Appearance[] = [
125
134
  { theme: "dark" },
@@ -127,12 +136,6 @@ const REQUESTED_APPEARANCES: readonly Appearance[] = [
127
136
  { theme: "light", kontrast: "hoch" },
128
137
  ];
129
138
 
130
- /** Room for the chrome tooltip's own fade after its 300ms hover delay. */
131
- const HOVER_MARGIN_MS = 200;
132
-
133
- /** A spot without chrome: the root's top-left corner lies outside every island (12px edge). */
134
- const PARK = { x: 1, y: 1 } as const;
135
-
136
139
  /** Every kind of text field a modeler renders: inspector and dialog fields, the label editor. */
137
140
  const TEXT_FIELDS: ReadonlyArray<readonly [InjectedKind, string]> = [
138
141
  ["input", "a text field"],
@@ -153,48 +156,10 @@ const TWO_STEPS_UP = 182;
153
156
  const ZOOM_LIMIT_MAX = 400;
154
157
  const ZOOM_LIMIT_MIN = 20;
155
158
 
156
- function sizeLabel(size: Size): string {
157
- return `${size.width} × ${size.height}`;
158
- }
159
-
160
159
  function appearanceLabel(appearance: Appearance): string {
161
160
  return appearance.kontrast ? `${appearance.theme} + ${appearance.kontrast}` : appearance.theme;
162
161
  }
163
162
 
164
- function fail(caseName: string, host: ShellHost, problems: readonly string[]): never {
165
- const unique = [...new Set(problems)];
166
- throw new Error(
167
- `@miragon/modeler-shell conformance: ${caseName} (${host} host)\n${unique.map((problem) => `- ${problem}`).join("\n")}`,
168
- );
169
- }
170
-
171
- function finish(
172
- caseName: string,
173
- host: ShellHost,
174
- problems: readonly string[],
175
- skipped: Iterable<ShellSkip>,
176
- ): ShellCaseOutcome | void {
177
- if (problems.length > 0) fail(caseName, host, problems);
178
- const seen = new Set<string>();
179
- const unique = [...skipped].filter((skip) => !seen.has(skip.reason) && seen.add(skip.reason));
180
- return unique.length > 0 ? { skipped: unique } : undefined;
181
- }
182
-
183
- /** A harness that gives a hook it also declares absent contradicts itself: fail before opening anything. */
184
- function assertHooksConsistent<P extends PageLike>(
185
- caseName: string,
186
- harness: ShellConformanceHarness<P>,
187
- hooks: readonly ShowHook[],
188
- ): void {
189
- const problems = harnessHookProblems(harness, hooks);
190
- if (problems.length > 0) fail(caseName, harness.host, problems);
191
- }
192
-
193
- /** A hook that is neither given nor declared absent. */
194
- function unfed<P extends PageLike>(harness: ShellConformanceHarness<P>, hook: ShowHook): boolean {
195
- return !harness.show?.[hook] && !harness.absent?.includes(hook);
196
- }
197
-
198
163
  function missingHooks<P extends PageLike>(harness: ShellConformanceHarness<P>, what: string): ShellSkip[] {
199
164
  return SHOWN_ISLANDS.filter((island) => unfed(harness, island)).map((island) => ({
200
165
  hook: island,
@@ -202,21 +167,6 @@ function missingHooks<P extends PageLike>(harness: ShellConformanceHarness<P>, w
202
167
  }));
203
168
  }
204
169
 
205
- /** Reads until `done` holds or `timeoutMs` passed; the last value either way. */
206
- async function pollUntil<T>(
207
- page: PageLike,
208
- read: () => Promise<T>,
209
- done: (value: T) => boolean,
210
- timeoutMs: number,
211
- ): Promise<T> {
212
- let value = await read();
213
- for (let waited = 0; !done(value) && waited < timeoutMs; waited += POLL_MS) {
214
- await page.waitForTimeout(POLL_MS);
215
- value = await read();
216
- }
217
- return value;
218
- }
219
-
220
170
  /** The visible islands of an ARIA snapshot that a harness may declare absent. */
221
171
  function visibleIslands(facts: AriaFacts): Set<string> {
222
172
  const islands = new Set<string>();
@@ -224,30 +174,6 @@ function visibleIslands(facts: AriaFacts): Set<string> {
224
174
  return islands;
225
175
  }
226
176
 
227
- /** Hovers `target` long enough for its chrome tooltip; the hovered control, `null` when there is none. */
228
- async function hover(page: PageLike, root: string, target: ChromeTarget): Promise<TargetFacts | null> {
229
- const anchor = await findTarget(page, root, target);
230
- if (!anchor) return null;
231
- await page.mouse.move(PARK.x, PARK.y);
232
- await page.mouse.move(anchor.client.x, anchor.client.y);
233
- await page.waitForTimeout(TOOLTIP_DELAY_MS + HOVER_MARGIN_MS);
234
- await settle(page);
235
- return anchor;
236
- }
237
-
238
- async function openMenuByClick(page: PageLike, root: string): Promise<boolean> {
239
- const trigger = await findTarget(page, root, "menu");
240
- if (!trigger) return false;
241
- await page.mouse.click(trigger.client.x, trigger.client.y);
242
- await settle(page);
243
- return true;
244
- }
245
-
246
- async function closeByEscape(page: PageLike): Promise<void> {
247
- await page.keyboard.press("Escape");
248
- await settle(page);
249
- }
250
-
251
177
  /** Dropdown and tooltips of one measured state (guide §5.4, §5.6). */
252
178
  async function popupGeometry(
253
179
  page: PageLike,
@@ -281,8 +207,11 @@ async function popupGeometry(
281
207
  if (anchor.label && !tooltip.text.startsWith(anchor.label)) {
282
208
  problems.push(`tooltip: hovering ${target} ("${anchor.label}") shows "${tooltip.text}"`);
283
209
  }
210
+ // The web app's raised zoom row stands above the legal line (`data-zoom-row="raised"`).
211
+ const legalHeight = legalLineHeight(measurement.islands.legal?.height);
212
+ const bottomRow = measurement.open.zoomRaised ? SHELL.edge + legalHeight + SHELL.gap : SHELL.edge;
284
213
  problems.push(
285
- ...tooltipProblems(tooltip.box, anchor.box, row, measurement.container).map(
214
+ ...tooltipProblems(tooltip.box, anchor.box, row, measurement.container, undefined, bottomRow).map(
286
215
  (problem) => `${problem} (on ${target})`,
287
216
  ),
288
217
  );
@@ -291,15 +220,36 @@ async function popupGeometry(
291
220
  return problems;
292
221
  }
293
222
 
223
+ /** Shown by the geometry case after "as opened"; its action keeps it on screen through every state. */
224
+ const GEOMETRY_TOAST: ToastFixture = {
225
+ status: "info",
226
+ text: "Conformance: the toast stack's geometry",
227
+ actionLabel: "Undo",
228
+ };
229
+
230
+ /** The pill's list in the web app (and an embedded one that opens it), measured after a click (guide §5.10). */
231
+ async function withProblemsListOpen(
232
+ page: PageLike,
233
+ root: string,
234
+ check: () => Promise<unknown>,
235
+ ): Promise<void> {
236
+ const pill = await findTarget(page, root, "pill");
237
+ if (!pill) return;
238
+ await page.mouse.click(pill.client.x, pill.client.y);
239
+ await settle(page);
240
+ await check();
241
+ await closeByEscape(page);
242
+ }
243
+
294
244
  const geometryCase: ShellConformanceCase = {
295
245
  name: "geometry",
296
246
  async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
297
- assertHooksConsistent("geometry", harness, SHOWN_ISLANDS);
247
+ assertHooksConsistent("geometry", harness, [...SHOWN_ISLANDS, "toast", "emptyState"]);
298
248
  const root = harness.root ?? DEFAULT_ROOT;
299
249
  const problems: string[] = [];
300
250
  for (const size of harness.sizes ?? DEFAULT_SIZES) {
301
251
  const at = sizeLabel(size);
302
- await harness.open(page, { size, requested: LIGHT });
252
+ await openHarness(page, harness, { size, requested: LIGHT });
303
253
  await settle(page);
304
254
  const check = async (state: string): Promise<ChromeMeasurement> => {
305
255
  const measurement = await measureChrome(page, root);
@@ -313,15 +263,36 @@ const geometryCase: ShellConformanceCase = {
313
263
  };
314
264
  const opened = await check("as opened");
315
265
  problems.push(...(await popupGeometry(page, root, opened)).map((problem) => `[${at}] ${problem}`));
266
+ const toast = harness.show?.toast;
267
+ if (toast) {
268
+ await toast(page, GEOMETRY_TOAST);
269
+ await settle(page);
270
+ await check("with a toast");
271
+ }
316
272
  for (const island of SHOWN_ISLANDS) {
317
273
  const show = harness.show?.[island];
318
274
  if (!show) continue;
319
275
  await show(page);
320
276
  await settle(page);
321
277
  await check(`after show.${island}`);
278
+ if (island === "problems" && harness.host !== "ide") {
279
+ await withProblemsListOpen(page, root, () => check("with the problems list open"));
280
+ }
281
+ }
282
+ const emptyState = harness.show?.emptyState;
283
+ if (emptyState) {
284
+ await emptyState(page);
285
+ await settle(page);
286
+ await check("after show.emptyState");
322
287
  }
323
288
  }
324
- return finish("geometry", harness.host, problems, missingHooks(harness, "the geometry"));
289
+ return finish("geometry", harness.host, problems, [
290
+ ...missingHooks(harness, "the geometry"),
291
+ ...webHookSkips(harness, {
292
+ toast: "the geometry of the toast stack",
293
+ emptyState: "the geometry of the start screen",
294
+ }),
295
+ ]);
325
296
  },
326
297
  };
327
298
 
@@ -433,8 +404,8 @@ async function paletteShortcutWalk(page: PageLike, root: string): Promise<string
433
404
  return paletteShortcutProblems(seen);
434
405
  }
435
406
 
436
- /** Tab from the menu trigger: the palette's one stop, then the canvas (guide §5.14). */
437
- async function tabSequenceFromMenu(page: PageLike, root: string): Promise<string[]> {
407
+ /** Tab from the menu trigger: the primary action (web app), the palette's one stop, then the canvas (guide §5.14). */
408
+ async function tabSequenceFromMenu(page: PageLike, root: string, primary: boolean): Promise<string[]> {
438
409
  if (!(await focusTarget(page, root, "menu"))) return [];
439
410
  const stops: TabStop[] = [];
440
411
  for (let step = 0; step < 3; step++) {
@@ -442,7 +413,7 @@ async function tabSequenceFromMenu(page: PageLike, root: string): Promise<string
442
413
  const stop = await inPage(page, activeStopProbe, { root });
443
414
  stops.push({ description: stop.description, kind: stop.kind as TabStop["kind"] });
444
415
  }
445
- return tabSequenceProblems(stops);
416
+ return tabSequenceProblems(stops, primary);
446
417
  }
447
418
 
448
419
  /** `aria-disabled` at 400 % and 20 % (guide §5.8), reached by clicking the buttons. */
@@ -494,15 +465,18 @@ const ariaCase: ShellConformanceCase = {
494
465
  assertHooksConsistent("aria", harness, SHOWN_ISLANDS);
495
466
  const root = harness.root ?? DEFAULT_ROOT;
496
467
  const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
497
- await harness.open(page, { size, requested: LIGHT });
468
+ await openHarness(page, harness, { size, requested: LIGHT });
498
469
  await settle(page);
499
470
  const facts = await inPage(page, ariaProbe, { root });
500
471
  if (!facts) fail("aria", harness.host, [`no element matches the shell root selector "${root}"`]);
501
472
  const problems = ariaProblems(facts, harness.host);
473
+ problems.push(...menuGroupProblems(await menuEntries(page, root), harness.host, harness.themes));
502
474
  problems.push(...(await menuKeyboardProblems(page, root)));
503
475
  problems.push(...(await paletteKeyboardProblems(page, root)));
504
476
  problems.push(...(await paletteShortcutWalk(page, root)));
505
- problems.push(...(await tabSequenceFromMenu(page, root)));
477
+ problems.push(
478
+ ...(await tabSequenceFromMenu(page, root, harness.host === "web" && Boolean(facts.primary?.visible))),
479
+ );
506
480
  problems.push(...(await zoomLimitProblems(page, root)));
507
481
 
508
482
  const shown = new Set<string>();
@@ -519,6 +493,7 @@ const ariaCase: ShellConformanceCase = {
519
493
  if (withIslands) {
520
494
  final = withIslands;
521
495
  problems.push(...optionalIslandProblems(withIslands, shown));
496
+ if (shown.has("problems")) problems.push(...pillPopupAriaProblems(withIslands, harness.host));
522
497
  const known = new Set(problems);
523
498
  problems.push(
524
499
  ...ariaProblems(withIslands, harness.host)
@@ -538,7 +513,7 @@ const keysCase: ShellConformanceCase = {
538
513
  const root = harness.root ?? DEFAULT_ROOT;
539
514
  const host = harness.host;
540
515
  const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
541
- await harness.open(page, { size, requested: LIGHT });
516
+ await openHarness(page, harness, { size, requested: LIGHT });
542
517
  await settle(page);
543
518
  const problems: string[] = [];
544
519
 
@@ -733,15 +708,94 @@ async function paletteColorWalk(page: PageLike, root: string): Promise<string[]>
733
708
  return [...new Set(problems)];
734
709
  }
735
710
 
711
+ /** The web app's mode, its meta tags and the stored choice. */
712
+ function webMode(page: PageLike): Promise<WebModeFacts> {
713
+ return inPage(page, webModeProbe, { key: APPEARANCE_STORAGE_KEY });
714
+ }
715
+
716
+ const APPEARANCE_LABELS: Readonly<Record<AppearanceChoice, string>> = {
717
+ system: "System",
718
+ light: "Light",
719
+ dark: "Dark",
720
+ };
721
+
722
+ /**
723
+ * Guide §13.3 for a dark-capable web app: Dark, Light, then System through the Appearance group,
724
+ * each written to `<html>`, `color-scheme`, both `theme-color`s and `localStorage` in one frame and
725
+ * never half-dark; the theme colours are the ones the page showed under System.
726
+ */
727
+ async function appearanceSwitchProblems<P extends PageLike>(
728
+ page: P,
729
+ harness: ShellConformanceHarness<P>,
730
+ root: string,
731
+ requested: Appearance,
732
+ ): Promise<string[]> {
733
+ const problems: string[] = [];
734
+ const system = await webMode(page);
735
+ const media = (scheme: string) =>
736
+ system.themeColors.find((meta) => meta.media?.replace(/\s+/g, "") === `(prefers-color-scheme:${scheme})`)
737
+ ?.content;
738
+ const light = media("light");
739
+ const dark = media("dark");
740
+ const colors = light && dark ? { light, dark } : undefined;
741
+ for (const choice of ["dark", "light", "system"] as const) {
742
+ const label = APPEARANCE_LABELS[choice];
743
+ if (!(await openMenuByClick(page, root))) {
744
+ problems.push(`menu: no visible menu trigger to choose ${label}`);
745
+ break;
746
+ }
747
+ const option = await inPage(page, targetProbe, {
748
+ root,
749
+ selector: `[data-menu-id="appearance"] [data-menu-value="${choice}"]`,
750
+ });
751
+ if (!option) {
752
+ problems.push(`menu: no Appearance option "${choice}" to choose (guide §13.3)`);
753
+ await closeByEscape(page);
754
+ break;
755
+ }
756
+ await inPage(page, watchModeProbe, null);
757
+ await page.mouse.click(option.client.x, option.client.y);
758
+ await settle(page);
759
+ problems.push(...modeSwitchProblems(await inPage(page, modeChangesProbe, null), label));
760
+ problems.push(
761
+ ...appearanceChoiceProblems((await inPage(page, menuEntriesProbe, { root })) ?? [], choice),
762
+ );
763
+ await closeByEscape(page);
764
+ problems.push(...webModeProblems(await webMode(page), harness.themes, choice, colors));
765
+ const facts = await appearanceFacts(page, root);
766
+ if (facts) {
767
+ const expected = expectedWebMode(requested, harness.themes, choice);
768
+ problems.push(
769
+ ...appearanceProblems(facts, "web", requested, harness.themes, expected).problems.map(
770
+ (problem) => `[chose ${label}] ${problem}`,
771
+ ),
772
+ );
773
+ }
774
+ }
775
+ return problems;
776
+ }
777
+
736
778
  const appearanceCase: ShellConformanceCase = {
737
779
  name: "appearance",
738
780
  async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
739
781
  const root = harness.root ?? DEFAULT_ROOT;
740
782
  const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
741
783
  const problems: string[] = [];
742
- for (const requested of REQUESTED_APPEARANCES) {
784
+ const skipped: ShellSkip[] = [];
785
+ const web = harness.host === "web";
786
+ // Without emulateMedia the web app's system preference stays the browser's light default.
787
+ const emulated = !web || page.emulateMedia !== undefined;
788
+ if (!emulated) {
789
+ skipped.push({
790
+ hook: "open",
791
+ reason:
792
+ "page.emulateMedia is undefined: the web app's mode under a dark system and under high contrast was not verified",
793
+ });
794
+ }
795
+ let switched = false;
796
+ for (const requested of emulated ? REQUESTED_APPEARANCES : [LIGHT]) {
743
797
  const at = `[requested ${appearanceLabel(requested)}]`;
744
- await harness.open(page, { size, requested });
798
+ await openHarness(page, harness, { size, requested });
745
799
  if (harness.host === "embedded") {
746
800
  // The foreign page itself in that mode: a themed page around the modeler (guide §13.3).
747
801
  await inPage(page, hostPageModeProbe, {
@@ -755,21 +809,35 @@ const appearanceCase: ShellConformanceCase = {
755
809
  problems.push(`${at} no element matches the shell root selector "${root}"`);
756
810
  continue;
757
811
  }
758
- const verdict = appearanceProblems(facts, harness.host, requested, harness.themes);
812
+ const expected = web ? expectedWebMode(requested, harness.themes, "system") : undefined;
813
+ const verdict = appearanceProblems(facts, harness.host, requested, harness.themes, expected);
759
814
  problems.push(...verdict.problems.map((problem) => `${at} ${problem}`));
760
815
  const surround = await inPage(page, surroundProbe, { root });
761
816
  if (surround) problems.push(...surroundProblems(surround).map((problem) => `${at} ${problem}`));
762
817
  problems.push(...(await paletteColorWalk(page, root)).map((problem) => `${at} ${problem}`));
818
+ if (web) {
819
+ problems.push(
820
+ ...webModeProblems(await webMode(page), harness.themes, "system", undefined).map(
821
+ (problem) => `${at} ${problem}`,
822
+ ),
823
+ ...lightOnlyMenuProblems(await menuEntries(page, root), harness.themes).map(
824
+ (problem) => `${at} ${problem}`,
825
+ ),
826
+ );
827
+ if (harness.themes.includes("dark") && !switched && requested.theme === "dark") {
828
+ switched = true;
829
+ problems.push(
830
+ ...(await appearanceSwitchProblems(page, harness, root, requested)).map(
831
+ (problem) => `${at} ${problem}`,
832
+ ),
833
+ );
834
+ }
835
+ }
763
836
  }
764
- return finish("appearance", harness.host, problems, []);
837
+ return finish("appearance", harness.host, problems, skipped);
765
838
  },
766
839
  };
767
840
 
768
- /** The visible shapes on the canvas. */
769
- function shapes(page: PageLike, root: string): Promise<ShapeFacts[]> {
770
- return inPage(page, shapesProbe, { root });
771
- }
772
-
773
841
  /** Moves the palette's roving focus by keyboard to `action`; `false` when it cannot get there. */
774
842
  async function focusPaletteEntry(page: PageLike, root: string, action: string): Promise<boolean> {
775
843
  const before = await inPage(page, paletteFocusProbe, { root });
@@ -812,7 +880,7 @@ const createCase: ShellConformanceCase = {
812
880
  async run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>) {
813
881
  const root = harness.root ?? DEFAULT_ROOT;
814
882
  const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
815
- await harness.open(page, { size, requested: LIGHT });
883
+ await openHarness(page, harness, { size, requested: LIGHT });
816
884
  await settle(page);
817
885
  const entry = firstCreatingEntry(await inPage(page, paletteEntriesProbe, { root }));
818
886
  // A notation whose palette only switches tools has nothing to place.
@@ -831,14 +899,6 @@ const createCase: ShellConformanceCase = {
831
899
  },
832
900
  };
833
901
 
834
- /** The open menu's entries, opened by a click and closed again by Escape. */
835
- async function menuEntries(page: PageLike, root: string): Promise<MenuEntryFacts[]> {
836
- if (!(await openMenuByClick(page, root))) return [];
837
- const entries = (await inPage(page, menuEntriesProbe, { root })) ?? [];
838
- await closeByEscape(page);
839
- return entries;
840
- }
841
-
842
902
  /** A shape whose centre a pointer reaches (no chrome above it), found again by its id. */
843
903
  async function reachableShape(page: PageLike, root: string, id?: string): Promise<ShapeFacts | undefined> {
844
904
  const all = await shapes(page, root);
@@ -907,7 +967,7 @@ const readOnlyCase: ShellConformanceCase = {
907
967
  const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
908
968
  const problems: string[] = [];
909
969
  const skipped: ShellSkip[] = [];
910
- await harness.open(page, { size, requested: LIGHT });
970
+ await openHarness(page, harness, { size, requested: LIGHT });
911
971
  await settle(page);
912
972
  const menuBefore = await menuEntries(page, root);
913
973
 
@@ -971,7 +1031,7 @@ const cleanupCase: ShellConformanceCase = {
971
1031
  }
972
1032
  const root = harness.root ?? DEFAULT_ROOT;
973
1033
  const size = (harness.sizes ?? DEFAULT_SIZES)[0] ?? DEFAULT_SIZES[0]!;
974
- await harness.open(page, { size, requested: LIGHT });
1034
+ await openHarness(page, harness, { size, requested: LIGHT });
975
1035
  await settle(page);
976
1036
  const armed = await inPage(page, armCleanupProbe, { root });
977
1037
  if (!armed) fail("cleanup", harness.host, [`no element matches the shell root selector "${root}"`]);
@@ -1007,6 +1067,11 @@ const cleanupCase: ShellConformanceCase = {
1007
1067
  },
1008
1068
  };
1009
1069
 
1010
- export function createCases(): ShellConformanceCase[] {
1070
+ /** The 0.1 cases, extended for the web column. */
1071
+ export function createBaseCases(): ShellConformanceCase[] {
1011
1072
  return [geometryCase, ariaCase, keysCase, createCase, readOnlyCase, appearanceCase, cleanupCase];
1012
1073
  }
1074
+
1075
+ export function createCases(): ShellConformanceCase[] {
1076
+ return [...createBaseCases(), ...createWebCases()];
1077
+ }