@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
@@ -15,9 +15,10 @@ import {
15
15
  type ZoomButton,
16
16
  } from "./facts.js";
17
17
  import { GEOMETRY_TOLERANCE_PX, type Rectangle } from "./geometry.js";
18
- import type { ShellConformanceHarness, ShowHook } from "./harness.js";
18
+ import type { AbsentFeature, ShellConformanceHarness, ShowHook } from "./harness.js";
19
19
  import type { ElementFacts, LeftoverFacts, RecordedKey } from "./page-tools.js";
20
20
  import type { PageLike } from "./page.js";
21
+ import { legalAriaProblems, primaryAriaProblems } from "./web-checks.js";
21
22
 
22
23
  /*
23
24
  * The judgements of the cases, as pure functions of what the probes saw. Each returns readable
@@ -177,6 +178,7 @@ export function ariaProblems(facts: AriaFacts, host: ShellHost): string[] {
177
178
  for (const element of facts.titled) {
178
179
  problems.push(`${element} has a native title; the chrome tooltip replaces it (guide §5.6)`);
179
180
  }
181
+ problems.push(...primaryAriaProblems(facts, host), ...legalAriaProblems(facts, host));
180
182
  return problems;
181
183
  }
182
184
 
@@ -245,13 +247,19 @@ export interface TabStop {
245
247
  /**
246
248
  * Tab from the menu trigger (guide §5.14): the primary action (web app), the palette's one tab
247
249
  * stop, then the canvas `<svg>`; nothing else in between (a renderer's own `tabindex` on
248
- * `.djs-container` would be an extra, unnamed stop).
250
+ * `.djs-container` would be an extra, unnamed stop). With `primary` the web app shows one, and
251
+ * Tab from the trigger lands on it first.
249
252
  */
250
- export function tabSequenceProblems(stops: readonly TabStop[]): string[] {
253
+ export function tabSequenceProblems(stops: readonly TabStop[], primary = false): string[] {
251
254
  const [first, second, third] = stops;
252
255
  const sequence = first?.kind === "primary" ? [second, third] : [first, second];
253
256
  const [palette, canvas] = sequence;
254
257
  const problems: string[] = [];
258
+ if (primary && first?.kind !== "primary") {
259
+ problems.push(
260
+ `tab order: Tab from the menu trigger lands on ${first?.description ?? "nothing"}, expected the primary action (top left, top right, top centre, guide §5.14)`,
261
+ );
262
+ }
255
263
  if (palette?.kind !== "palette") {
256
264
  problems.push(
257
265
  `tab order: Tab from the menu trigger lands on ${palette?.description ?? "nothing"}, expected the palette's roving entry (guide §5.14)`,
@@ -431,21 +439,37 @@ export function expectedEmbeddedAppearance(facts: AppearanceFacts, themes: reado
431
439
  return host?.kontrast === "hoch" ? { theme: fallback, kontrast: "hoch" } : { theme: fallback };
432
440
  }
433
441
 
442
+ /**
443
+ * The web app's own expectation (guide §13.3): `<html>` carries `carrier` (`system` for a
444
+ * dark-capable modeler without a stored choice) and the page shows `effective` under the emulated
445
+ * system preference.
446
+ */
447
+ export interface WebModeExpectation {
448
+ carrier: string;
449
+ effective: Appearance;
450
+ }
451
+
434
452
  /** No half-dark (guide §13.3, §13.5): one mode on `<html>` (own document) or the root (embedded). */
435
453
  export function appearanceProblems(
436
454
  facts: AppearanceFacts,
437
455
  host: ShellHost,
438
456
  requested: Appearance,
439
457
  themes: readonly Theme[],
458
+ web?: WebModeExpectation,
440
459
  ): AppearanceVerdict {
441
460
  const ownDocument = host !== "embedded";
442
- const expected = ownDocument
443
- ? expectedAppearance(requested, themes)
444
- : expectedEmbeddedAppearance(facts, themes);
461
+ const expected =
462
+ web?.effective ??
463
+ (ownDocument ? expectedAppearance(requested, themes) : expectedEmbeddedAppearance(facts, themes));
464
+ const carrierTheme = web?.carrier ?? expected.theme;
445
465
  const problems: string[] = [];
466
+ const described =
467
+ carrierTheme === expected.theme
468
+ ? describeAppearance(expected)
469
+ : `${carrierTheme} showing ${describeAppearance(expected)}`;
446
470
  const label = ownDocument
447
- ? `requested ${describeAppearance(requested)}, expected ${describeAppearance(expected)}`
448
- : `host page ${describeAppearance(requested)}, expected ${describeAppearance(expected)}`;
471
+ ? `requested ${describeAppearance(requested)}, expected ${described}`
472
+ : `host page ${describeAppearance(requested)}, expected ${described}`;
449
473
 
450
474
  const carrier = ownDocument
451
475
  ? facts.themed.find((element) => element.isDocument && element.theme !== null)
@@ -457,7 +481,7 @@ export function appearanceProblems(
457
481
  : `the embedded root has no data-cd-theme (${label}): it takes the host page's mode instead of its own (guide §13.3)`,
458
482
  );
459
483
  } else {
460
- if (carrier.theme !== expected.theme) {
484
+ if (carrier.theme !== carrierTheme) {
461
485
  problems.push(`${carrier.description} has data-cd-theme="${carrier.theme}" (${label})`);
462
486
  }
463
487
  if ((carrier.kontrast ?? undefined) !== expected.kontrast) {
@@ -534,7 +558,7 @@ export function appearanceProblems(
534
558
  return { problems };
535
559
  }
536
560
 
537
- /** The root attributes the shell writes (API.md, DOM). */
561
+ /** The root attributes the shell writes (API.md, DOM; the web host's state attributes included). */
538
562
  const SHELL_ROOT_ATTRIBUTES = [
539
563
  "data-cd-shell-host",
540
564
  "data-palette",
@@ -542,6 +566,10 @@ const SHELL_ROOT_ATTRIBUTES = [
542
566
  "data-inspector",
543
567
  "data-problems",
544
568
  "data-readonly",
569
+ "data-primary",
570
+ "data-zoom-row",
571
+ "data-empty-state",
572
+ "data-drop",
545
573
  ];
546
574
 
547
575
  /** What an embedded root carries on top (its own mode). */
@@ -589,14 +617,14 @@ export type AbsentIsland = Extract<ShowHook, "legend" | "inspector" | "problems"
589
617
  const ABSENT_ISLANDS: readonly AbsentIsland[] = ["legend", "inspector", "problems", "status"];
590
618
 
591
619
  /** The islands of `absent` (its state `invalid` has no island). */
592
- export function absentIslands(absent: readonly ShowHook[] | undefined): AbsentIsland[] {
620
+ export function absentIslands(absent: readonly AbsentFeature[] | undefined): AbsentIsland[] {
593
621
  return ABSENT_ISLANDS.filter((island) => absent?.includes(island));
594
622
  }
595
623
 
596
624
  /** An island the harness declares absent must never show. */
597
625
  export function absentIslandProblems(
598
626
  visible: ReadonlySet<string>,
599
- absent: readonly ShowHook[] | undefined,
627
+ absent: readonly AbsentFeature[] | undefined,
600
628
  ): string[] {
601
629
  return absentIslands(absent)
602
630
  .filter((island) => visible.has(island))
@@ -606,11 +634,19 @@ export function absentIslandProblems(
606
634
  /** A hook the harness both gives and lists as absent is a contradiction in the harness itself. */
607
635
  export function harnessHookProblems<P extends PageLike>(
608
636
  harness: ShellConformanceHarness<P>,
609
- hooks: readonly ShowHook[],
637
+ hooks: readonly AbsentFeature[],
610
638
  ): string[] {
611
639
  return hooks
612
- .filter((hook) => harness.absent?.includes(hook) && harness.show?.[hook])
613
- .map((hook) => `harness: absent lists "${hook}", but show.${hook} is given`);
640
+ .filter((hook) =>
641
+ hook === "drop"
642
+ ? harness.absent?.includes("drop") && harness.drop
643
+ : hook !== "problemSelection" && harness.absent?.includes(hook) && harness.show?.[hook],
644
+ )
645
+ .map((hook) =>
646
+ hook === "drop"
647
+ ? 'harness: absent lists "drop", but harness.drop is given'
648
+ : `harness: absent lists "${hook}", but show.${hook} is given`,
649
+ );
614
650
  }
615
651
 
616
652
  /** Guide §10.1: the surround of the canvas is a container painted in `--cd-ui-grund`, in both modes. */
@@ -7,7 +7,11 @@ import type { ElementFacts, RelativeBox, ToolWindow } from "./page-tools.js";
7
7
 
8
8
  export type ZoomButton = "out" | "actual" | "in" | "fit";
9
9
 
10
- /** The chrome parts in the DOM (and tab) order of guide §5.14. */
10
+ /**
11
+ * The chrome parts in the DOM (and tab) order of guide §5.14; the web app adds the primary action
12
+ * after the menu trigger, the start screen card after the hint, the legal line before the zoom
13
+ * panel and the toast stack last (API.md, Web host).
14
+ */
11
15
  export const DOM_ORDER = [
12
16
  "menu",
13
17
  "primary",
@@ -15,10 +19,13 @@ export const DOM_ORDER = [
15
19
  "canvas",
16
20
  "status",
17
21
  "hint",
22
+ "empty-state",
18
23
  "inspector",
19
24
  "problems",
20
25
  "legend",
26
+ "legal",
21
27
  "zoom",
28
+ "toasts",
22
29
  ] as const;
23
30
 
24
31
  export type OrderedPart = (typeof DOM_ORDER)[number];
@@ -27,6 +34,15 @@ export interface PaletteEntryFacts extends ElementFacts {
27
34
  group: string | null;
28
35
  }
29
36
 
37
+ /** A link of the web app's legal line. */
38
+ export interface LinkFacts {
39
+ text: string;
40
+ href: string;
41
+ lang: string | null;
42
+ target: string | null;
43
+ rel: string | null;
44
+ }
45
+
30
46
  export interface AriaFacts {
31
47
  platform: "mac" | "other";
32
48
  documentLang: string;
@@ -54,6 +70,15 @@ export interface AriaFacts {
54
70
  legendTitle: string | null;
55
71
  legendClose: ElementFacts | null;
56
72
  inspector: ElementFacts | null;
73
+ /** The web app's primary action (`[data-chrome="primary"]`). */
74
+ primary: ElementFacts | null;
75
+ /** The root's `data-primary="collapsed"`. */
76
+ primaryCollapsed: boolean;
77
+ /** The web app's legal line and its links, in order. */
78
+ legal: ElementFacts | null;
79
+ legalLinks: LinkFacts[];
80
+ /** The element the pill's `aria-controls` names (the web app's problems list). */
81
+ problemsControlled: ElementFacts | null;
57
82
  }
58
83
 
59
84
  export const ariaProbe = ({ root }: { root: string }): AriaFacts | null => {
@@ -76,6 +101,8 @@ export const ariaProbe = ({ root }: { root: string }): AriaFacts | null => {
76
101
  }
77
102
  const legend = find('[data-chrome="legend"]');
78
103
  const labelledBy = legend?.getAttribute("aria-labelledby");
104
+ const legal = find('[data-chrome="legal"]');
105
+ const pillControls = find('[data-chrome="problems"]')?.getAttribute("aria-controls");
79
106
  const parts: Array<[string, Element | null]> = [
80
107
  ["menu", trigger],
81
108
  ["primary", find('[data-chrome="primary"]')],
@@ -85,13 +112,17 @@ export const ariaProbe = ({ root }: { root: string }): AriaFacts | null => {
85
112
  ["hint", find(".cd-shell-hint")],
86
113
  ["inspector", find('[data-chrome="inspector"]')],
87
114
  ["problems", find('[data-chrome="problems"]')],
115
+ ["empty-state", find('[data-chrome="empty-state"]')],
88
116
  ["legend", legend],
117
+ ["legal", find('[data-chrome="legal"]')],
89
118
  ["zoom", zoom],
119
+ ["toasts", find('[data-chrome="toasts"]')],
90
120
  ];
91
121
  const present = parts.filter((part): part is [string, Element] => part[1] !== null);
92
122
  present.sort(([, a], [, b]) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
93
123
  const titledSelector = [
94
124
  '[data-chrome="menu"][title]',
125
+ '[data-chrome="primary"][title]',
95
126
  '[data-chrome="palette"] [data-action][title]',
96
127
  '[data-chrome="zoom"] [title]',
97
128
  '[data-chrome="problems"][title]',
@@ -130,6 +161,17 @@ export const ariaProbe = ({ root }: { root: string }): AriaFacts | null => {
130
161
  : null,
131
162
  legendClose: factsOf(legend?.querySelector("button")),
132
163
  inspector: factsOf(find('[data-chrome="inspector"]')),
164
+ primary: factsOf(find('[data-chrome="primary"]')),
165
+ primaryCollapsed: rootElement.getAttribute("data-primary") === "collapsed",
166
+ legal: factsOf(legal),
167
+ legalLinks: [...(legal?.querySelectorAll("a") ?? [])].map((link) => ({
168
+ text: (link.textContent ?? "").replace(/\s+/g, " ").trim(),
169
+ href: link.getAttribute("href") ?? "",
170
+ lang: link.getAttribute("lang"),
171
+ target: link.getAttribute("target"),
172
+ rel: link.getAttribute("rel"),
173
+ })),
174
+ problemsControlled: factsOf(pillControls ? document.getElementById(pillControls) : null),
133
175
  };
134
176
  };
135
177
 
@@ -559,8 +601,17 @@ export interface MenuEntryFacts {
559
601
  /** `data-menu-id` of the item or its radio group. */
560
602
  menuId: string | null;
561
603
  disabled: boolean;
562
- /** `data-menu-group` of the item or its radio group: the shell's `model`, `export` or `help`. */
604
+ /**
605
+ * `data-menu-group` of the item or its radio group: the shell's `model`, `export` and `help`,
606
+ * in the web app also `file` and `appearance`.
607
+ */
563
608
  group: string | null;
609
+ /** `data-menu-value` of a radio option. */
610
+ value: string | null;
611
+ /** `aria-checked` of a checkbox or radio entry. */
612
+ checked: string | null;
613
+ /** Whether the entry's icon column holds an icon (guide §5.4, only the common set of §5.14). */
614
+ icon: boolean;
564
615
  }
565
616
 
566
617
  export const menuEntriesProbe = ({ root }: { root: string }): MenuEntryFacts[] | null => {
@@ -581,6 +632,9 @@ export const menuEntriesProbe = ({ root }: { root: string }): MenuEntryFacts[] |
581
632
  menuId: element.closest("[data-menu-id]")?.getAttribute("data-menu-id") ?? null,
582
633
  disabled: element.getAttribute("aria-disabled") === "true",
583
634
  group: element.closest("[data-menu-group]")?.getAttribute("data-menu-group") ?? null,
635
+ value: element.getAttribute("data-menu-value"),
636
+ checked: element.getAttribute("aria-checked"),
637
+ icon: element.querySelector(".cd-shell-menu-item-icon svg") !== null,
584
638
  });
585
639
  }
586
640
  return entries;
@@ -1,7 +1,13 @@
1
1
  import {
2
2
  CHROME_ISLANDS,
3
+ LEGAL_LINE_HEIGHT,
3
4
  PALETTE_CLEARANCE,
4
5
  SHELL,
6
+ legalLineHeight,
7
+ toastStackBottom,
8
+ toastStackWidth,
9
+ zoomRowRaised,
10
+ type BottomObstacle,
5
11
  type ChromeIsland,
6
12
  type Insets,
7
13
  type PaletteRow,
@@ -31,8 +37,21 @@ export interface ChromeMeasurement {
31
37
  paletteRow: PaletteRow;
32
38
  /** The visible islands, found by `[data-chrome]`. */
33
39
  islands: Partial<Record<ChromeIsland, IslandBox>>;
34
- /** `[data-legend="open"]`, `[data-inspector="open"]`, `[data-problems="shown"]` on the root. */
35
- open: { legend: boolean; inspector: boolean; problems: boolean };
40
+ /**
41
+ * State attributes of the root: `[data-legend="open"]`, `[data-inspector="open"]`,
42
+ * `[data-problems="shown"]`, and in the web host `[data-empty-state="shown"]`,
43
+ * `[data-primary="collapsed"]` and `[data-zoom-row="raised"]`.
44
+ */
45
+ open: {
46
+ legend: boolean;
47
+ inspector: boolean;
48
+ problems: boolean;
49
+ emptyState: boolean;
50
+ primaryCollapsed: boolean;
51
+ zoomRaised: boolean;
52
+ };
53
+ /** Width of the widest visible toast card (`[data-status]` in the stack); absent without a toast. */
54
+ toastCardWidth?: number;
36
55
  }
37
56
 
38
57
  /** Constraints of one island; every given field must hold within `GEOMETRY_TOLERANCE_PX`. */
@@ -45,6 +64,8 @@ export interface ExpectedBox {
45
64
  height?: number;
46
65
  /** Horizontal centre relative to the root. */
47
66
  centerX?: number;
67
+ /** Vertical centre relative to the root (the web app's empty-state card). */
68
+ centerY?: number;
48
69
  }
49
70
 
50
71
  export const DEFAULT_ROOT = ".cd-shell";
@@ -52,8 +73,15 @@ export const DEFAULT_ROOT = ".cd-shell";
52
73
  /** `[−] [NN%] [+] | [Fit]` with a 56px readout (guide §5.1, §5.8): 1+3+32+2+56+2+32+2+5+2+32+3+1. */
53
74
  export const ZOOM_PANEL_WIDTH = 173;
54
75
 
55
- /** Height of the web app's legal line under the bottom-left stack (guide §5.10). */
56
- const LEGAL_LINE = 20;
76
+ /** Height of one line of the web app's legal line under the bottom-left stack (guide §5.10). */
77
+ export const LEGAL_LINE = LEGAL_LINE_HEIGHT;
78
+
79
+ /** The web app's start screen card: `min(400px, 100% − 24px)` wide (guide §8). */
80
+ const EMPTY_STATE_MAX_WIDTH = 400;
81
+
82
+ /** The web app's problems list above the pill: `min(320px, 100% − 24px)` wide, at most 320 high (guide §5.10). */
83
+ const POPOVER_MAX_WIDTH = 320;
84
+ const POPOVER_MAX_HEIGHT = 320;
57
85
 
58
86
  /** Minimum width of the legend card (guide §5.10). */
59
87
  const LEGEND_MIN_WIDTH = 184;
@@ -80,7 +108,11 @@ const measureProbe = ({ root, islands }: { root: string; islands: readonly strin
80
108
  }
81
109
  boxes[name] = tools.box(element, rootElement);
82
110
  }
111
+ const cardWidths = [...rootElement.querySelectorAll<HTMLElement>('[data-chrome="toasts"] > [data-status]')]
112
+ .filter((card) => tools.visible(card))
113
+ .map((card) => card.getBoundingClientRect().width);
83
114
  return {
115
+ ...(cardWidths.length > 0 ? { toastCardWidth: Math.max(...cardWidths) } : {}),
84
116
  container: tools.container(rootElement),
85
117
  paletteRow: rootElement.getAttribute("data-palette") === "second-row" ? "second-row" : "top",
86
118
  islands: boxes,
@@ -88,6 +120,9 @@ const measureProbe = ({ root, islands }: { root: string; islands: readonly strin
88
120
  legend: rootElement.getAttribute("data-legend") === "open",
89
121
  inspector: rootElement.getAttribute("data-inspector") === "open",
90
122
  problems: rootElement.getAttribute("data-problems") === "shown",
123
+ emptyState: rootElement.getAttribute("data-empty-state") === "shown",
124
+ primaryCollapsed: rootElement.getAttribute("data-primary") === "collapsed",
125
+ zoomRaised: rootElement.getAttribute("data-zoom-row") === "raised",
91
126
  },
92
127
  duplicates,
93
128
  };
@@ -106,6 +141,7 @@ export async function measureChrome(page: PageLike, root: string = DEFAULT_ROOT)
106
141
  paletteRow: result.paletteRow,
107
142
  islands: result.islands,
108
143
  open: result.open,
144
+ ...(result.toastCardWidth !== undefined ? { toastCardWidth: result.toastCardWidth } : {}),
109
145
  };
110
146
  }
111
147
 
@@ -142,28 +178,63 @@ function sheetOpen(measurement: ChromeMeasurement): boolean {
142
178
  return measurement.open.inspector && isNarrow(measurement.container);
143
179
  }
144
180
 
181
+ /** The bottom of an island as `toastStackBottom` sees it (guide §8, the shell's rule for the bottom row). */
182
+ export function bottomObstacle(box: IslandBox, container: Size): BottomObstacle {
183
+ return { left: box.left, right: box.left + box.width, top: container.height - box.top };
184
+ }
185
+
186
+ /** Why an island of the web column never shows in the IDE and embedded hosts (guide §5.2). */
187
+ const WEB_ONLY: Partial<Record<ChromeIsland, string>> = {
188
+ primary: "the top right stays empty in IDE and embedded hosts (guide §5.7)",
189
+ legal: "the legal line belongs to the web app; an IDE or an embedding host carries its own (guide §5.2)",
190
+ "empty-state": "IDE and embedded hosts have no start screen; they show the empty-file hint (guide §13.5)",
191
+ "drop-zone": "IDE and embedded hosts have no drop zone; their files belong to the host (guide §5.2)",
192
+ };
193
+
194
+ /** The work chrome the web app's start screen hides (guide §8: only the card and the legal line show). */
195
+ export const WORK_CHROME: readonly ChromeIsland[] = [
196
+ "menu",
197
+ "primary",
198
+ "palette",
199
+ "zoom",
200
+ "problems",
201
+ "legend",
202
+ "status",
203
+ "inspector",
204
+ ];
205
+
145
206
  /** The anchors and limits of guide §5.2 to §5.15 for `measurement` in `host`. */
146
207
  export function chromeExpectations(measurement: ChromeMeasurement, host: ShellHost): ChromeExpectations {
147
208
  const { container, islands, open, paletteRow } = measurement;
148
209
  const { edge, gap, row, free, inspectorWidth } = SHELL;
149
210
  const web = host === "web";
211
+ const startScreen = web && open.emptyState;
212
+ const raised = web && open.zoomRaised;
150
213
  const secondRow = paletteRow === "second-row";
151
214
  const sheet = sheetOpen(measurement);
152
215
  const sideInspector = open.inspector && !isNarrow(container);
216
+ // A narrow container wraps the legal line; the stack above it stands on its whole lines.
217
+ const legalHeight = legalLineHeight(web ? islands.legal?.height : undefined);
153
218
  const boxes: Partial<Record<ChromeIsland, ExpectedBox>> = {};
154
219
  const limits: Partial<Record<ChromeIsland, BoxLimits>> = {};
155
220
  const hidden: Partial<Record<ChromeIsland, string>> = {};
156
- const required: ChromeIsland[] = ["menu", "palette"];
221
+ // Behind the start screen the work chrome is hidden; where it shows anyway, it still stands at its anchor.
222
+ const required: ChromeIsland[] = startScreen ? ["empty-state"] : ["menu", "palette"];
223
+ if (web) required.push("legal");
157
224
 
158
225
  boxes.menu = { left: edge, top: edge, width: row, height: row };
159
226
 
160
227
  boxes.palette = { top: secondRow ? free : edge, height: row, centerX: container.width / 2 };
161
228
  const menu = islands.menu;
162
- limits.palette = secondRow
163
- ? { maxWidth: container.width - 2 * edge }
164
- : menu
165
- ? { minLeft: menu.left + menu.width + PALETTE_CLEARANCE }
166
- : {};
229
+ const primary = web ? islands.primary : undefined;
230
+ if (secondRow) {
231
+ limits.palette = { maxWidth: container.width - 2 * edge };
232
+ } else {
233
+ limits.palette = {};
234
+ if (menu) limits.palette.minLeft = menu.left + menu.width + PALETTE_CLEARANCE;
235
+ // Guide §5.15: 16px to every island of the top row, the primary action included.
236
+ if (primary) limits.palette.minRight = container.width - primary.left + PALETTE_CLEARANCE;
237
+ }
167
238
 
168
239
  if (sheet) {
169
240
  const reason = "hidden while the inspector is a bottom sheet (guide §5.11)";
@@ -171,13 +242,15 @@ export function chromeExpectations(measurement: ChromeMeasurement, host: ShellHo
171
242
  hidden.problems = reason;
172
243
  hidden.legend = reason;
173
244
  } else {
174
- required.push("zoom");
175
- boxes.zoom = { right: edge, bottom: edge, width: ZOOM_PANEL_WIDTH, height: row };
176
- const stackBase = web ? edge + LEGAL_LINE + gap : edge;
245
+ if (!startScreen) required.push("zoom");
246
+ const legalStack = edge + legalHeight + gap;
247
+ boxes.zoom = { right: edge, bottom: raised ? legalStack : edge, width: ZOOM_PANEL_WIDTH, height: row };
248
+ const stackBase = web ? legalStack : edge;
177
249
  if (islands.problems) boxes.problems = { left: edge, bottom: stackBase, height: row };
178
250
  if (islands.legend) {
179
251
  const besidePill = open.problems ? stackBase + row + gap : stackBase;
180
- const bottom = isVeryNarrow(container) ? Math.max(besidePill, edge + row + gap) : besidePill;
252
+ const aboveBottomRow = (raised ? legalStack : edge) + row + gap;
253
+ const bottom = isVeryNarrow(container) ? Math.max(besidePill, aboveBottomRow) : besidePill;
181
254
  boxes.legend = { left: edge, bottom };
182
255
  limits.legend = { minWidth: LEGEND_MIN_WIDTH };
183
256
  }
@@ -203,9 +276,54 @@ export function chromeExpectations(measurement: ChromeMeasurement, host: ShellHo
203
276
  }
204
277
 
205
278
  if (web) {
206
- if (islands.primary) boxes.primary = { top: edge, right: edge, height: row };
279
+ if (primary) {
280
+ boxes.primary = { top: edge, right: edge, height: row };
281
+ if (open.primaryCollapsed) boxes.primary.width = row;
282
+ }
283
+ boxes.legal = { left: edge, bottom: edge, height: legalHeight };
284
+ limits.legal = { maxWidth: container.width - 2 * edge };
285
+ if (islands["empty-state"]) {
286
+ boxes["empty-state"] = {
287
+ centerX: container.width / 2,
288
+ centerY: container.height / 2,
289
+ width: Math.min(EMPTY_STATE_MAX_WIDTH, container.width - 2 * edge),
290
+ };
291
+ }
292
+ if (islands["drop-zone"]) boxes["drop-zone"] = { left: edge, top: edge, right: edge, bottom: edge };
207
293
  } else {
208
- hidden.primary = "the top right stays empty in IDE and embedded hosts (guide §5.7)";
294
+ Object.assign(hidden, WEB_ONLY);
295
+ }
296
+
297
+ if (host === "ide") {
298
+ hidden.toasts = "the IDE host shows no toasts; the extension shows the host's notification (guide §13.5)";
299
+ hidden["problems-popover"] =
300
+ "the IDE host opens its Problems view, never a list of its own (guide §5.10)";
301
+ } else {
302
+ if (islands.toasts) {
303
+ const obstacles = (["zoom", "legal", "problems"] as const).flatMap((name) => {
304
+ const box = islands[name];
305
+ return box ? [bottomObstacle(box, container)] : [];
306
+ });
307
+ boxes.toasts = {
308
+ centerX: container.width / 2,
309
+ width: toastStackWidth(container.width),
310
+ bottom: toastStackBottom({
311
+ containerWidth: container.width,
312
+ obstacles,
313
+ cardWidth: measurement.toastCardWidth,
314
+ }),
315
+ };
316
+ }
317
+ const pill = islands.problems;
318
+ if (pill) {
319
+ boxes["problems-popover"] = { left: edge, bottom: container.height - pill.top + gap };
320
+ limits["problems-popover"] = {
321
+ maxWidth: Math.min(POPOVER_MAX_WIDTH, container.width - 2 * edge),
322
+ maxHeight: POPOVER_MAX_HEIGHT,
323
+ };
324
+ } else {
325
+ hidden["problems-popover"] = "the problems list opens only above a visible pill (guide §5.10)";
326
+ }
209
327
  }
210
328
 
211
329
  return { boxes, limits, required, hidden };
@@ -230,6 +348,7 @@ function sides(box: IslandBox, container: Size): Required<ExpectedBox> {
230
348
  width: box.width,
231
349
  height: box.height,
232
350
  centerX: box.left + box.width / 2,
351
+ centerY: box.top + box.height / 2,
233
352
  };
234
353
  }
235
354
 
@@ -319,12 +438,22 @@ export function geometryProblems(
319
438
  }
320
439
  const palette = islands.palette;
321
440
  const menu = islands.menu;
441
+ const primary = host === "web" ? islands.primary : undefined;
322
442
  if (paletteRow === "second-row" && !isNarrow(container) && palette && menu) {
323
443
  const clipped = palette.width >= container.width - 2 * SHELL.edge - tolerance;
324
444
  const topRowLeft = (container.width - palette.width) / 2;
325
- if (!clipped && topRowLeft >= menu.left + menu.width + PALETTE_CLEARANCE + tolerance) {
445
+ const toMenu = topRowLeft - menu.left - menu.width;
446
+ // Guide §5.15: the primary action loses its label before the palette moves, so it counts icon only.
447
+ const toPrimary = primary
448
+ ? container.width - SHELL.edge - SHELL.row - (topRowLeft + palette.width)
449
+ : Number.POSITIVE_INFINITY;
450
+ const fits = toMenu >= PALETTE_CLEARANCE + tolerance && toPrimary >= PALETTE_CLEARANCE + tolerance;
451
+ if (!clipped && fits) {
452
+ const kept = primary
453
+ ? `${round(toMenu)}px to the menu trigger and ${round(toPrimary)}px to the primary action's icon`
454
+ : `${round(toMenu)}px to the menu trigger`;
326
455
  problems.push(
327
- `palette: in the second row although the top row keeps ${round(topRowLeft - menu.left - menu.width)}px to the menu trigger (at least ${PALETTE_CLEARANCE}px suffice, guide §5.15)`,
456
+ `palette: in the second row although the top row keeps ${kept} (at least ${PALETTE_CLEARANCE}px suffice, guide §5.15)`,
328
457
  );
329
458
  }
330
459
  }
@@ -336,16 +465,62 @@ export function geometryProblems(
336
465
  }
337
466
  if (!flag && shown) problems.push(`${island}: visible, but the root lacks ${attribute}`);
338
467
  };
339
- state("problems", open.problems, 'data-problems="shown"', sheet);
340
- state("legend", open.legend, 'data-legend="open"', sheet);
341
- state("inspector", open.inspector, 'data-inspector="open"', false);
468
+ state("problems", open.problems, 'data-problems="shown"', sheet || open.emptyState);
469
+ state("legend", open.legend, 'data-legend="open"', sheet || open.emptyState);
470
+ state("inspector", open.inspector, 'data-inspector="open"', open.emptyState);
471
+ state("empty-state", open.emptyState, 'data-empty-state="shown"', false);
472
+ problems.push(...webRowProblems(measurement, host, tolerance));
342
473
 
343
474
  return problems;
344
475
  }
345
476
 
477
+ /**
478
+ * The web app's state attributes for its top and bottom rows: `data-primary="collapsed"` only with
479
+ * a visible primary action, and `data-zoom-row="raised"` exactly when the zoom panel at
480
+ * `bottom: 12px` would keep less than 8px to the legal line (`zoomRowRaised`, the shell's rule).
481
+ */
482
+ export function webRowProblems(
483
+ measurement: ChromeMeasurement,
484
+ host: ShellHost,
485
+ tolerance: number = GEOMETRY_TOLERANCE_PX,
486
+ ): string[] {
487
+ const { container, islands, open } = measurement;
488
+ const problems: string[] = [];
489
+ if (host !== "web") {
490
+ if (open.primaryCollapsed)
491
+ problems.push('primary: the root carries data-primary="collapsed" outside the web app');
492
+ if (open.zoomRaised) problems.push('zoom: the root carries data-zoom-row="raised" outside the web app');
493
+ return problems;
494
+ }
495
+ if (open.primaryCollapsed && !islands.primary && !open.emptyState) {
496
+ problems.push('primary: the root carries data-primary="collapsed", but no primary action is visible');
497
+ }
498
+ const legal = islands.legal;
499
+ const zoom = islands.zoom;
500
+ if (legal && zoom) {
501
+ const legalRight = legal.left + legal.width;
502
+ const gap = container.width - SHELL.edge - zoom.width - legalRight;
503
+ const raised = zoomRowRaised({ containerWidth: container.width, legalRight, zoomWidth: zoom.width });
504
+ // A gap within the tolerance of the 8px limit may go either way.
505
+ const borderline = Math.abs(gap - SHELL.gap) <= tolerance;
506
+ if (raised && !open.zoomRaised && !borderline) {
507
+ problems.push(
508
+ `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)`,
509
+ );
510
+ }
511
+ if (!raised && open.zoomRaised && !borderline) {
512
+ problems.push(
513
+ `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)`,
514
+ );
515
+ }
516
+ }
517
+ return problems;
518
+ }
519
+
346
520
  /**
347
521
  * The fit insets of guide §5.9 for what was measured, recomputed from the guide's table: top 88
348
- * (second row 140), sides 24, bottom 64 (web app with the pill 92); right 12 + 288 + 24 with a side
522
+ * (second row 140), sides 24, bottom 64 (web app with the pill or the zoom panel raised above the
523
+ * legal line 92); right 12 + 288 + 24 with a side
349
524
  * inspector; left 12 + legend width + 24 with an open legend in a container wider than 440px; with
350
525
  * a bottom sheet the bottom is the distance to its top edge plus 12, and the legend does not count.
351
526
  */
@@ -354,7 +529,10 @@ export function expectedFitInsets(measurement: ChromeMeasurement, host: ShellHos
354
529
  const insets: Insets = {
355
530
  top: paletteRow === "second-row" ? SHELL.fitInsetTopSecondRow : SHELL.fitInsetTop,
356
531
  right: SHELL.fitInsetSide,
357
- bottom: host === "web" && open.problems ? SHELL.fitInsetBottomWeb : SHELL.fitInsetBottom,
532
+ bottom:
533
+ host === "web" && (open.problems || open.zoomRaised)
534
+ ? SHELL.fitInsetBottomWeb + legalLineHeight(islands.legal?.height) - LEGAL_LINE
535
+ : SHELL.fitInsetBottom,
358
536
  left: SHELL.fitInsetSide,
359
537
  };
360
538
  if (sheetOpen(measurement)) {
@@ -423,8 +601,9 @@ export type TooltipRow = "top" | "second-row" | "bottom";
423
601
 
424
602
  /**
425
603
  * Where the chrome tooltip of a control stands (guide §5.6): 8px below the top row (`top: 60px`)
426
- * or the second row (`top: 112px`), 8px above the bottom row (`bottom: 60px`); centred on its
427
- * control and shifted to keep 12px to the sides.
604
+ * or the second row (`top: 112px`), 8px above the bottom row (`bottom: 60px`, above the web app's
605
+ * raised zoom row `bottom: 88px`: give its `bottomRow` of 40); centred on its control and shifted
606
+ * to keep 12px to the sides.
428
607
  */
429
608
  export function tooltipProblems(
430
609
  tooltip: IslandBox,
@@ -432,13 +611,14 @@ export function tooltipProblems(
432
611
  row: TooltipRow,
433
612
  container: Size,
434
613
  tolerance = GEOMETRY_TOLERANCE_PX,
614
+ bottomRow: number = SHELL.edge,
435
615
  ): string[] {
436
616
  const { edge, gap, row: rowHeight, free } = SHELL;
437
617
  const clamp = (centred: number) =>
438
618
  Math.max(edge, Math.min(centred, container.width - edge - tooltip.width));
439
619
  const expectedAt = (left: number): ExpectedBox =>
440
620
  row === "bottom"
441
- ? { left, bottom: edge + rowHeight + gap }
621
+ ? { left, bottom: bottomRow + rowHeight + gap }
442
622
  : { left, top: (row === "top" ? edge : free) + rowHeight + gap };
443
623
  let mismatches = boxMismatches(
444
624
  "tooltip",