@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/src/dom/mount.ts CHANGED
@@ -1,38 +1,56 @@
1
1
  import {
2
+ LEGAL_LINE_HEIGHT,
2
3
  SHELL,
3
- decidePaletteRow,
4
+ decideTopRow,
4
5
  detectPlatform,
5
6
  fitInsets,
7
+ legalLineHeight,
6
8
  legendStartsOpen,
7
9
  resolveMessages,
10
+ toastStackBottom,
11
+ zoomRowRaised,
12
+ type BottomObstacle,
8
13
  type FitLayout,
9
14
  type Insets,
10
15
  type PaletteRow,
11
16
  type Size,
12
17
  type Theme,
18
+ type ToastRequest,
13
19
  type ZoomTarget,
14
20
  } from "../core/index.js";
15
21
  import { boxRelativeTo, createElement } from "./element.js";
22
+ import { connectWebAppearance } from "./feedback/appearance.js";
23
+ import { createProblemsPopover, type ProblemsPopover } from "./feedback/problems-popover.js";
24
+ import { createToaster } from "./feedback/toasts.js";
16
25
  import { createEmptyHint } from "./hint.js";
17
26
  import { createInspector } from "./inspector.js";
18
- import { createKeyListener } from "./keys.js";
27
+ import { createKeyListener, isModalOpen } from "./keys.js";
19
28
  import { Ledger } from "./ledger.js";
20
29
  import { createLegend } from "./legend.js";
21
- import { createMenu } from "./menu.js";
30
+ import { createMenu, type MenuGroupName } from "./menu.js";
22
31
  import { createProblemsPill } from "./problems.js";
23
32
  import { createStatusBox } from "./status.js";
24
33
  import { createTooltip } from "./tooltip.js";
25
34
  import type {
35
+ AppearanceHandle,
26
36
  InspectorHandle,
27
37
  LegendHandle,
28
38
  MenuCheckboxEntry,
39
+ MenuEntry,
29
40
  MenuHandle,
30
41
  PaletteReadOnlyState,
31
42
  PaletteRegistration,
43
+ PrimaryActionHandle,
32
44
  ShellHandle,
33
45
  ShellOptions,
46
+ ToastHandle,
34
47
  TooltipHandle,
35
48
  } from "./types.js";
49
+ import { createDropZone } from "./web/drop-zone.js";
50
+ import { createEmptyState } from "./web/empty-state.js";
51
+ import { createLegalLine } from "./web/legal.js";
52
+ import { appearanceMenuEntries, fileMenuEntries } from "./web/menu-groups.js";
53
+ import { createPrimaryAction } from "./web/primary.js";
36
54
  import { createZoomPanel } from "./zoom-panel.js";
37
55
 
38
56
  // Page-global: every modeler bundles its own copy of the shell (and the CJS entries inline one each),
@@ -48,6 +66,21 @@ function nextMountIndex(): number {
48
66
 
49
67
  const DEFAULT_THEMES: readonly Theme[] = ["light"];
50
68
 
69
+ /** The legal line's height on the root, which the stack above it (pill, legend, zoom row) adds. */
70
+ const LEGAL_HEIGHT_PROPERTY = "--modeler-shell-legal-height";
71
+
72
+ /** What `toast()` returns where no toast shows (the IDE host, after `destroy`). */
73
+ const INERT_TOAST: ToastHandle = Object.freeze({ dismiss() {} });
74
+
75
+ /** The element's box relative to the root while it is rendered and visible, else `undefined`. */
76
+ function visibleBoxIn(element: HTMLElement | undefined, root: HTMLElement) {
77
+ if (!element || element.hidden || !element.isConnected) return undefined;
78
+ if (typeof element.checkVisibility === "function" && !element.checkVisibility({ visibilityProperty: true }))
79
+ return undefined;
80
+ const box = boxRelativeTo(element, root);
81
+ return box.width > 0 && box.height > 0 ? box : undefined;
82
+ }
83
+
51
84
  // In a foreign page the closest [lang] is the page's language, not the tool's (guide §2).
52
85
  function languageOf(root: HTMLElement, host: ShellOptions["host"], requested: string | undefined): string {
53
86
  if (requested) return requested;
@@ -64,9 +97,11 @@ function hostKontrast(root: HTMLElement): "hoch" | null {
64
97
 
65
98
  /**
66
99
  * Turns `root` into the shell (`.cd-shell`, container `cd-shell`) and renders the chrome in the
67
- * DOM order of guide §5.14: menu trigger, canvas slot, status box, empty-file hint, inspector,
68
- * problems pill, legend, zoom panel, then the layer for dropdown and tooltip. Installs the one
69
- * `keydown` listener (see `ShellOptions.keyScope`) and the root `ResizeObserver`.
100
+ * DOM order of guide §5.14: menu trigger, primary action (web), canvas slot, status box,
101
+ * empty-file hint, empty state (web), inspector, problems pill, legend, legal line (web), zoom
102
+ * panel, then the layer for dropdown, tooltip, problems popover and drop zone, and last the toast
103
+ * stack (web, embedded). Installs the one `keydown` listener (see `ShellOptions.keyScope`) and the
104
+ * root `ResizeObserver`.
70
105
  *
71
106
  * `root` must get its size from outside (`container-type: size`): `height: 100%` in an own
72
107
  * document, an explicitly sized element in a foreign page. When rendering throws (a modeler's
@@ -97,6 +132,7 @@ function renderShell(
97
132
  const platform = options.platform ?? detectPlatform(globalThis.navigator);
98
133
  const lang = languageOf(root, host, options.lang);
99
134
  const keyScope = options.keyScope ?? (host === "embedded" ? "root" : "document");
135
+ const web = host === "web";
100
136
 
101
137
  ledger.addClass(root, "cd-shell");
102
138
  ledger.attribute(root, "data-cd-shell-host", host);
@@ -112,6 +148,13 @@ function renderShell(
112
148
  if (!ledger.isClosed) ledger.attribute(root, name, value);
113
149
  };
114
150
 
151
+ // The head script set the mode before the first paint; this follows contrast and the menu choice.
152
+ const appearance = web
153
+ ? connectWebAppearance({ ownerDocument, themes, options: options.appearance })
154
+ : undefined;
155
+ if (appearance) ledger.defer(() => appearance.destroy());
156
+ const darkCapable = themes.includes("dark");
157
+
115
158
  const layer = createElement(ownerDocument, "div", { class: "cd-shell-layer" });
116
159
  ledger.defer(() => layer.remove());
117
160
  const tooltip = createTooltip({ root, layer, id: `cd-shell-tooltip-${index}` });
@@ -152,6 +195,16 @@ function renderShell(
152
195
  }
153
196
  : undefined;
154
197
  const shellModelEntries: readonly MenuCheckboxEntry[] = legendEntry ? [legendEntry] : [];
198
+ const fileEntries: readonly MenuEntry[] =
199
+ web && options.file ? fileMenuEntries(options.file, messages) : [];
200
+ const appearanceEntries: readonly MenuEntry[] =
201
+ appearance && darkCapable ? appearanceMenuEntries(appearance, messages) : [];
202
+ const shellEntries = (group: MenuGroupName): readonly MenuEntry[] => {
203
+ if (group === "file") return fileEntries;
204
+ if (group === "model") return shellModelEntries;
205
+ if (group === "appearance") return appearanceEntries;
206
+ return [];
207
+ };
155
208
 
156
209
  const menu = createMenu({
157
210
  root,
@@ -160,65 +213,171 @@ function renderShell(
160
213
  messages,
161
214
  tooltip,
162
215
  groups: options.menu ?? {},
163
- shellModelEntries: () => shellModelEntries,
216
+ shellEntries,
164
217
  isReadOnly: () => readOnly,
165
218
  });
166
219
  ledger.defer(() => menu.destroy());
167
220
  ledger.defer(tooltip.attach(menu.trigger, () => (menu.isOpen() ? null : { label: messages.menu })));
168
221
 
222
+ const primary =
223
+ web && options.primary
224
+ ? createPrimaryAction({ ownerDocument, messages, tooltip, options: options.primary })
225
+ : undefined;
226
+ if (primary) ledger.defer(() => primary.destroy());
227
+
169
228
  const canvas = options.canvas ?? createElement(ownerDocument, "div");
170
229
  ledger.addClass(canvas, "cd-shell-canvas");
230
+ // An own page needs a main landmark; the diagram is its content. A foreign page has its own.
231
+ if (web && !canvas.hasAttribute("role")) ledger.attribute(canvas, "role", "main");
171
232
  if (!options.canvas) ledger.defer(() => canvas.remove());
172
233
 
173
- /** Where the focus goes when the island holding it hides: the canvas, else the menu trigger. */
234
+ /**
235
+ * The focus target when the element holding it goes away: on the start screen its "New
236
+ * diagram", else the canvas, else the menu trigger. A target that cannot take the focus (inert,
237
+ * hidden behind the start screen) is passed over, so the focus never falls to `<body>` unseen.
238
+ */
239
+ function focusCanvasOrTrigger(): void {
240
+ const took = (target: HTMLElement | SVGElement | null | undefined): boolean => {
241
+ target?.focus({ preventScroll: true });
242
+ return target !== null && target !== undefined && ownerDocument.activeElement === target;
243
+ };
244
+ if (emptyState?.isShown()) {
245
+ emptyState.focus();
246
+ if (emptyState.element.contains(ownerDocument.activeElement)) return;
247
+ }
248
+ if (took(canvas.querySelector<HTMLElement | SVGElement>("svg[tabindex]"))) return;
249
+ if (!menu.trigger.hidden) took(menu.trigger);
250
+ }
251
+
252
+ /** Where the focus goes when the island holding it hides. */
174
253
  function moveFocusOutOf(island: HTMLElement): void {
175
- if (!island.contains(ownerDocument.activeElement)) return;
176
- const canvasTarget = canvas.querySelector<HTMLElement | SVGElement>("svg[tabindex]");
177
- canvasTarget?.focus({ preventScroll: true });
178
- if (canvasTarget && ownerDocument.activeElement === canvasTarget) return;
179
- if (!menu.trigger.hidden) menu.trigger.focus({ preventScroll: true });
254
+ if (island.contains(ownerDocument.activeElement)) focusCanvasOrTrigger();
180
255
  }
181
256
 
182
257
  const status = createStatusBox(ownerDocument);
183
258
  ledger.defer(() => status.destroy());
184
259
  const hint = options.emptyHint ? createEmptyHint(ownerDocument, messages, options.emptyHint) : undefined;
185
260
  if (hint) ledger.defer(() => hint.destroy());
261
+ const emptyState =
262
+ web && options.emptyState
263
+ ? createEmptyState({
264
+ ownerDocument,
265
+ messages,
266
+ options: options.emptyState,
267
+ dropHint: options.dropZone ? (options.emptyState.dropHint ?? messages.dropHint) : undefined,
268
+ open: options.file?.open,
269
+ titleId: `cd-shell-empty-state-title-${index}`,
270
+ onLeave: () => setEmptyStateShown(false),
271
+ })
272
+ : undefined;
273
+ if (emptyState) ledger.defer(() => emptyState.destroy());
186
274
  const inspector = options.inspector
187
275
  ? createInspector(ownerDocument, options.inspector, (open) => {
188
276
  setState("data-inspector", open ? "open" : null);
277
+ // Up to 760px the sheet hides zoom panel and pill, so the toasts' bottom row changes.
278
+ updateBottomRow();
189
279
  placeHint();
190
280
  })
191
281
  : undefined;
192
282
  if (inspector) ledger.defer(() => inspector.destroy());
193
- const pill = options.problems
283
+ const problemsOptions = options.problems;
284
+ let popover: ProblemsPopover | undefined;
285
+ const pill = problemsOptions
194
286
  ? createProblemsPill({
195
287
  ownerDocument,
196
288
  messages,
197
289
  lang,
198
290
  tooltip,
199
291
  showTooltip: host === "ide",
200
- options: options.problems,
292
+ onActivate() {
293
+ if (popover) popover.toggle();
294
+ else problemsOptions.onActivate?.();
295
+ },
201
296
  })
202
297
  : undefined;
203
298
  if (pill) ledger.defer(() => pill.destroy());
299
+ // The web app lists its problems itself; an embedding host only when it has no list of its own.
300
+ if (pill && (web || (host === "embedded" && !problemsOptions?.onActivate))) {
301
+ const created = createProblemsPopover({
302
+ ownerDocument,
303
+ root,
304
+ layer,
305
+ pill: pill.element,
306
+ id: `cd-shell-problems-${index}`,
307
+ titleId: `cd-shell-problems-title-${index}`,
308
+ messages,
309
+ onSelectItem: problemsOptions?.onSelectItem,
310
+ });
311
+ popover = created;
312
+ ledger.defer(() => created.destroy());
313
+ }
314
+ const legal = web
315
+ ? createLegalLine({
316
+ ownerDocument,
317
+ messages,
318
+ options: options.legal ?? {},
319
+ idPrefix: `cd-shell-legal-${index}`,
320
+ })
321
+ : undefined;
322
+ if (legal) ledger.defer(() => legal.destroy());
204
323
  const zoomPanel = createZoomPanel({ ownerDocument, messages, lang, platform, tooltip });
205
324
  ledger.defer(() => zoomPanel.destroy());
206
-
207
- root.prepend(menu.trigger, canvas);
325
+ const toaster =
326
+ host === "ide"
327
+ ? undefined
328
+ : createToaster({
329
+ ownerDocument,
330
+ host,
331
+ messages,
332
+ idPrefix: `cd-shell-toast-${index}`,
333
+ focusFallback: focusCanvasOrTrigger,
334
+ onChange: () => updateBottomRow(),
335
+ });
336
+ if (toaster) ledger.defer(() => toaster.destroy());
337
+
338
+ const defined = (element: HTMLElement | undefined): element is HTMLElement => element !== undefined;
339
+ root.prepend(...[menu.trigger, primary?.element, canvas].filter(defined));
208
340
  root.append(
209
341
  ...[
210
342
  status.element,
211
343
  hint?.element,
344
+ emptyState?.element,
212
345
  inspector?.element,
213
346
  pill?.element,
214
347
  legend?.element,
348
+ legal?.element,
215
349
  zoomPanel.element,
216
- ].filter((element): element is HTMLElement => element !== undefined),
350
+ ].filter(defined),
217
351
  layer,
352
+ ...[toaster?.announcer, toaster?.element].filter(defined),
218
353
  );
219
354
 
355
+ function showToast(request: ToastRequest): ToastHandle {
356
+ return ledger.isClosed || !toaster ? INERT_TOAST : toaster.show(request);
357
+ }
358
+
359
+ const dropZone =
360
+ web && options.dropZone
361
+ ? createDropZone({
362
+ ownerDocument,
363
+ layer,
364
+ messages,
365
+ lang,
366
+ options: options.dropZone,
367
+ modalOpen: () => isModalOpen(ownerDocument),
368
+ toast: showToast,
369
+ setState: (state) => setState("data-drop", state),
370
+ })
371
+ : undefined;
372
+ if (dropZone) ledger.defer(() => dropZone.destroy());
373
+
220
374
  let palette: { registration: PaletteRegistration; detachTooltip: () => void } | undefined;
221
375
  let paletteRow: PaletteRow = "top";
376
+ /** Web: the zoom panel stands above the legal line (`data-zoom-row="raised"`). */
377
+ let zoomRaised = false;
378
+ /** Web: the legal line's measured height, more than one line where a narrow container wraps it. */
379
+ let legalHeight = LEGAL_LINE_HEIGHT;
380
+ if (legal) ledger.defer(() => root.style.removeProperty(LEGAL_HEIGHT_PROPERTY));
222
381
 
223
382
  function containerSize(): Size {
224
383
  return { width: root.clientWidth, height: root.clientHeight };
@@ -243,6 +402,10 @@ function renderShell(
243
402
  paletteRow,
244
403
  problemsVisible: pill?.isShown() ?? false,
245
404
  };
405
+ if (web) {
406
+ layout.zoomRaised = zoomRaised;
407
+ layout.legalHeight = legalHeight;
408
+ }
246
409
  if (legend?.isOpen()) layout.legendWidth = legend.element.getBoundingClientRect().width;
247
410
  if (inspector?.isOpen()) {
248
411
  layout.inspector =
@@ -260,19 +423,89 @@ function renderShell(
260
423
  const size = containerSize();
261
424
  // A hidden tab reports 0 × 0; it neither measures nor decides anything (guide §5.9).
262
425
  if (size.width === 0 || size.height === 0) return;
263
- paletteRow = decidePaletteRow({
426
+ const topRow = decideTopRow({
264
427
  containerWidth: size.width,
265
428
  paletteWidth: paletteWidth(),
266
429
  menuRight: menuRight(),
430
+ primary: primary?.widths(),
267
431
  });
432
+ paletteRow = topRow.paletteRow;
268
433
  setState("data-palette", paletteRow);
434
+ if (primary) {
435
+ const collapsed = topRow.primaryLabel === "collapsed";
436
+ primary.setCollapsed(collapsed);
437
+ setState("data-primary", collapsed ? "collapsed" : null);
438
+ }
269
439
  if (legend && !legend.isStarted()) legend.start(options.legend?.open ?? legendStartsOpen(size.height));
440
+ updateBottomRow();
270
441
  placeHint();
271
442
  }
272
443
 
444
+ /** Web and embedded: the raised zoom row (legal line) and the toast stack's bottom, from what is visible. */
445
+ function updateBottomRow(): void {
446
+ if (ledger.isClosed) return;
447
+ const size = containerSize();
448
+ if (size.width === 0 || size.height === 0) return;
449
+ if (legal) {
450
+ const legalBox = visibleBoxIn(legal.element, root);
451
+ const height = legalLineHeight(legalBox?.height);
452
+ if (height !== legalHeight || !root.style.getPropertyValue(LEGAL_HEIGHT_PROPERTY)) {
453
+ legalHeight = height;
454
+ root.style.setProperty(LEGAL_HEIGHT_PROPERTY, `${height}px`);
455
+ }
456
+ zoomRaised = zoomRowRaised({
457
+ containerWidth: size.width,
458
+ legalRight: legalBox && legalBox.x + legalBox.width,
459
+ zoomWidth: visibleBoxIn(zoomPanel.element, root)?.width,
460
+ });
461
+ setState("data-zoom-row", zoomRaised ? "raised" : null);
462
+ }
463
+ if (toaster) {
464
+ const obstacles = [zoomPanel.element, legal?.element, pill?.element].flatMap(
465
+ (element): BottomObstacle[] => {
466
+ const box = visibleBoxIn(element, root);
467
+ return box ? [{ left: box.x, right: box.x + box.width, top: size.height - box.y }] : [];
468
+ },
469
+ );
470
+ toaster.setBottom(
471
+ toastStackBottom({ containerWidth: size.width, obstacles, cardWidth: toaster.widestCard() }),
472
+ );
473
+ }
474
+ }
475
+
476
+ function setEmptyStateShown(shown: boolean): void {
477
+ if (ledger.isClosed || !emptyState || emptyState.isShown() === shown) return;
478
+ const active = ownerDocument.activeElement;
479
+ const hadFocus = emptyState.element.contains(active);
480
+ // The work chrome and the canvas are about to hide: a focus there would fall to <body>. The
481
+ // legal line and the toasts stay.
482
+ const workHadFocus =
483
+ shown &&
484
+ active !== null &&
485
+ root.contains(active) &&
486
+ ![emptyState.element, legal?.element, toaster?.element].some((kept) => kept?.contains(active));
487
+ emptyState.setShown(shown);
488
+ setState("data-empty-state", shown ? "shown" : null);
489
+ // Nothing behind the card takes a click or the Tab (guide §8: nothing else competes).
490
+ ledger.attribute(canvas, "inert", shown ? "" : null);
491
+ if (shown) {
492
+ menu.close();
493
+ popover?.close();
494
+ tooltip.hide();
495
+ if (workHadFocus) emptyState.focus();
496
+ } else if (hadFocus) {
497
+ focusCanvasOrTrigger();
498
+ }
499
+ updateLayout();
500
+ }
501
+
273
502
  const ResizeObserverClass = globalThis.ResizeObserver;
274
503
  const resizeObserver = ResizeObserverClass ? new ResizeObserverClass(() => updateLayout()) : undefined;
275
504
  resizeObserver?.observe(root);
505
+ // The label widens once the web font has loaded; the top row decides again then. The legal line
506
+ // may wrap onto a second line then, which lifts everything stacked above it.
507
+ if (primary) resizeObserver?.observe(primary.element);
508
+ if (legal) resizeObserver?.observe(legal.element);
276
509
  ledger.defer(() => resizeObserver?.disconnect());
277
510
 
278
511
  if (legend && options.legend?.open !== undefined) legend.start(options.legend.open);
@@ -283,12 +516,16 @@ function renderShell(
283
516
  host,
284
517
  dialogScope: keyTarget,
285
518
  menuOpen: () => menu.isOpen(),
519
+ startScreen: () => emptyState?.isShown() ?? false,
286
520
  closeMenu() {
287
521
  const active = ownerDocument.activeElement;
288
522
  menu.close({ restoreFocus: active === menu.trigger || menu.dropdown.contains(active) });
289
523
  },
290
524
  hideTooltip: () => tooltip.hide(),
291
- run: (key) => zoomPanel.run(key),
525
+ // Behind the start screen the keys reach the browser instead: there is no diagram to zoom.
526
+ run: (key) => {
527
+ if (!emptyState?.isShown()) zoomPanel.run(key);
528
+ },
292
529
  });
293
530
  keyTarget.addEventListener("keydown", onKeydown as EventListener);
294
531
  ledger.defer(() => keyTarget.removeEventListener("keydown", onKeydown as EventListener));
@@ -347,6 +584,26 @@ function renderShell(
347
584
  },
348
585
  };
349
586
 
587
+ const primaryHandle: PrimaryActionHandle | undefined = primary && {
588
+ element: primary.element,
589
+ isCollapsed: () => primary.isCollapsed(),
590
+ refresh: () => {
591
+ if (!ledger.isClosed) primary.refresh();
592
+ },
593
+ };
594
+
595
+ const appearanceHandle: AppearanceHandle | undefined =
596
+ appearance && darkCapable
597
+ ? {
598
+ choice: () => appearance.choice(),
599
+ setChoice: (choice) => {
600
+ if (ledger.isClosed) return;
601
+ appearance.setChoice(choice);
602
+ menu.refresh();
603
+ },
604
+ }
605
+ : undefined;
606
+
350
607
  const inspectorHandle: InspectorHandle | undefined = inspector && {
351
608
  element: inspector.element,
352
609
  isOpen: () => inspector.isOpen(),
@@ -371,6 +628,8 @@ function renderShell(
371
628
  tooltip: tooltipHandle,
372
629
  legend: legendHandle,
373
630
  inspector: inspectorHandle,
631
+ primary: primaryHandle,
632
+ appearance: appearanceHandle,
374
633
  setZoomTarget(target: ZoomTarget | null) {
375
634
  if (!ledger.isClosed) zoomPanel.setTarget(target);
376
635
  },
@@ -396,8 +655,16 @@ function renderShell(
396
655
  },
397
656
  setProblems(summary) {
398
657
  if (ledger.isClosed || !pill) return;
658
+ const listHadFocus = popover?.element.contains(ownerDocument.activeElement) ?? false;
399
659
  pill.set(summary);
400
660
  setState("data-problems", pill.isShown() ? "shown" : null);
661
+ popover?.setItems(summary.items ?? []);
662
+ if (!pill.isShown() && popover?.isOpen()) {
663
+ popover.close();
664
+ // The pill went with the list, so the focus cannot return to it.
665
+ if (listHadFocus) focusCanvasOrTrigger();
666
+ }
667
+ updateBottomRow();
401
668
  placeHint();
402
669
  },
403
670
  setStatus(next, statusOptions) {
@@ -428,6 +695,9 @@ function renderShell(
428
695
  applyReadOnly();
429
696
  },
430
697
  isReadOnly: () => readOnly,
698
+ setEmptyState: setEmptyStateShown,
699
+ isEmptyStateShown: () => emptyState?.isShown() ?? false,
700
+ toast: showToast,
431
701
  destroy() {
432
702
  ledger.close();
433
703
  },
@@ -2,7 +2,7 @@ import { formatNumber, type ShellMessages } from "../core/index.js";
2
2
  import { createElement } from "./element.js";
3
3
  import { iconElement } from "./icons.js";
4
4
  import type { TooltipController } from "./tooltip.js";
5
- import type { ProblemSummary, ProblemsOptions } from "./types.js";
5
+ import type { ProblemSummary } from "./types.js";
6
6
 
7
7
  export interface ProblemsPill {
8
8
  readonly element: HTMLButtonElement;
@@ -18,11 +18,12 @@ export interface ProblemsContext {
18
18
  tooltip: TooltipController;
19
19
  /** The IDE host opens its Problems view, so only there the pill explains itself. */
20
20
  showTooltip: boolean;
21
- options: ProblemsOptions;
21
+ /** A click: `ProblemsOptions.onActivate` (IDE host) or the problems popover's toggle (web host). */
22
+ onActivate(): void;
22
23
  }
23
24
 
24
25
  export function createProblemsPill(context: ProblemsContext): ProblemsPill {
25
- const { ownerDocument, messages, lang, tooltip, options } = context;
26
+ const { ownerDocument, messages, lang, tooltip } = context;
26
27
  const icon = createElement(ownerDocument, "span", { class: "cd-shell-problems-icon" });
27
28
  const text = createElement(ownerDocument, "span", { class: "cd-shell-problems-text" });
28
29
  const element = createElement(
@@ -33,7 +34,7 @@ export function createProblemsPill(context: ProblemsContext): ProblemsPill {
33
34
  text,
34
35
  );
35
36
  element.hidden = true;
36
- element.addEventListener("click", () => options.onActivate());
37
+ element.addEventListener("click", () => context.onActivate());
37
38
  const detachTooltip = context.showTooltip
38
39
  ? tooltip.attach(element, { label: messages.showProblems })
39
40
  : undefined;
@@ -491,9 +491,9 @@ html.cd-shell-document kbd {
491
491
  color: var(--cd-ui-warning);
492
492
  }
493
493
 
494
- /* The web app keeps its legal line (20px) below the pill. */
494
+ /* The web app keeps its legal line (20px, more where it wraps) below the pill. */
495
495
  .cd-shell[data-cd-shell-host="web"] .cd-shell-problems {
496
- bottom: calc(var(--cd-shell-rand) + 20px + var(--cd-shell-luecke));
496
+ bottom: calc(var(--cd-shell-rand) + var(--modeler-shell-legal-height, 20px) + var(--cd-shell-luecke));
497
497
  }
498
498
 
499
499
  /* ---- Status box under the palette (guide §5.12) --------------------------------------------- */
@@ -651,12 +651,15 @@ html.cd-shell-document kbd {
651
651
  }
652
652
 
653
653
  .cd-shell[data-cd-shell-host="web"] .cd-shell-legend {
654
- --modeler-shell-legend-bottom: calc(var(--cd-shell-rand) + 20px + var(--cd-shell-luecke));
654
+ --modeler-shell-legend-bottom: calc(
655
+ var(--cd-shell-rand) + var(--modeler-shell-legal-height, 20px) + var(--cd-shell-luecke)
656
+ );
655
657
  }
656
658
 
657
659
  .cd-shell[data-cd-shell-host="web"][data-problems="shown"] .cd-shell-legend {
658
660
  --modeler-shell-legend-bottom: calc(
659
- var(--cd-shell-rand) + 20px + var(--cd-shell-luecke) + var(--cd-shell-zeile) + var(--cd-shell-luecke)
661
+ var(--cd-shell-rand) + var(--modeler-shell-legal-height, 20px) + var(--cd-shell-luecke) +
662
+ var(--cd-shell-zeile) + var(--cd-shell-luecke)
660
663
  );
661
664
  }
662
665
 
@@ -43,6 +43,15 @@ export function createTooltip({ root, layer, id }: TooltipContext): TooltipContr
43
43
  // A clicked control keeps its tooltip hidden until the pointer leaves it.
44
44
  let pressed: HTMLElement | undefined;
45
45
  const detachers = new Set<() => void>();
46
+ // The tooltip grows when a web font arrives after it was placed; place it again so it keeps
47
+ // its edge distance instead of running past the container.
48
+ const resizeObserver =
49
+ typeof ResizeObserver === "function"
50
+ ? new ResizeObserver(() => {
51
+ if (shown) place(shown.control);
52
+ })
53
+ : undefined;
54
+ resizeObserver?.observe(element);
46
55
 
47
56
  function cancelPending(): void {
48
57
  if (pending) clearTimeout(pending.timer);
@@ -231,6 +240,7 @@ export function createTooltip({ root, layer, id }: TooltipContext): TooltipContr
231
240
  destroy() {
232
241
  hide();
233
242
  for (const detach of [...detachers]) detach();
243
+ resizeObserver?.disconnect();
234
244
  element.remove();
235
245
  },
236
246
  };