@miragon/modeler-shell 0.0.0-stage → 0.1.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 (126) hide show
  1. package/API.md +1902 -0
  2. package/LICENSE +21 -0
  3. package/README.md +435 -2
  4. package/THIRD_PARTY_NOTICES.md +52 -0
  5. package/dist/appearance-D3FUcbu6.d.cts +53 -0
  6. package/dist/appearance-D3FUcbu6.d.ts +53 -0
  7. package/dist/chunk-5JSZ4VGA.js +551 -0
  8. package/dist/chunk-5JSZ4VGA.js.map +1 -0
  9. package/dist/chunk-6LQOFACK.js +1576 -0
  10. package/dist/chunk-6LQOFACK.js.map +1 -0
  11. package/dist/chunk-N4QXFF3A.js +23 -0
  12. package/dist/chunk-N4QXFF3A.js.map +1 -0
  13. package/dist/chunk-RJRFR6N3.js +136 -0
  14. package/dist/chunk-RJRFR6N3.js.map +1 -0
  15. package/dist/core.cjs +619 -0
  16. package/dist/core.cjs.map +1 -0
  17. package/dist/core.d.cts +171 -0
  18. package/dist/core.d.ts +171 -0
  19. package/dist/core.js +91 -0
  20. package/dist/core.js.map +1 -0
  21. package/dist/diagram-js.cjs +1115 -0
  22. package/dist/diagram-js.cjs.map +1 -0
  23. package/dist/diagram-js.d.cts +163 -0
  24. package/dist/diagram-js.d.ts +163 -0
  25. package/dist/diagram-js.js +867 -0
  26. package/dist/diagram-js.js.map +1 -0
  27. package/dist/index.cjs +2175 -0
  28. package/dist/index.cjs.map +1 -0
  29. package/dist/index.d.cts +20 -0
  30. package/dist/index.d.ts +20 -0
  31. package/dist/index.js +103 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/keymap-CObQE6q6.d.cts +151 -0
  34. package/dist/keymap-DORzHnK-.d.ts +151 -0
  35. package/dist/protocol-DhYBakWL.d.cts +15 -0
  36. package/dist/protocol-IeW8bf4f.d.ts +15 -0
  37. package/dist/shell.css +914 -0
  38. package/dist/testing-vscode-stub.cjs +332 -0
  39. package/dist/testing-vscode-stub.cjs.map +1 -0
  40. package/dist/testing-vscode-stub.d.cts +65 -0
  41. package/dist/testing-vscode-stub.d.ts +65 -0
  42. package/dist/testing-vscode-stub.js +154 -0
  43. package/dist/testing-vscode-stub.js.map +1 -0
  44. package/dist/testing.cjs +2364 -0
  45. package/dist/testing.cjs.map +1 -0
  46. package/dist/testing.d.cts +165 -0
  47. package/dist/testing.d.ts +165 -0
  48. package/dist/testing.js +2297 -0
  49. package/dist/testing.js.map +1 -0
  50. package/dist/types-B6NfkiUG.d.cts +66 -0
  51. package/dist/types-B6NfkiUG.d.ts +66 -0
  52. package/dist/types-DnZn9CPw.d.ts +284 -0
  53. package/dist/types-OuFlqZp0.d.cts +284 -0
  54. package/dist/vscode-extension.cjs +215 -0
  55. package/dist/vscode-extension.cjs.map +1 -0
  56. package/dist/vscode-extension.d.cts +103 -0
  57. package/dist/vscode-extension.d.ts +103 -0
  58. package/dist/vscode-extension.js +24 -0
  59. package/dist/vscode-extension.js.map +1 -0
  60. package/dist/vscode-webview.cjs +2198 -0
  61. package/dist/vscode-webview.cjs.map +1 -0
  62. package/dist/vscode-webview.d.cts +82 -0
  63. package/dist/vscode-webview.d.ts +82 -0
  64. package/dist/vscode-webview.js +250 -0
  65. package/dist/vscode-webview.js.map +1 -0
  66. package/package.json +145 -4
  67. package/src/core/appearance.ts +104 -0
  68. package/src/core/fit.ts +205 -0
  69. package/src/core/index.ts +84 -0
  70. package/src/core/keymap.ts +275 -0
  71. package/src/core/layout.ts +69 -0
  72. package/src/core/messages.ts +88 -0
  73. package/src/core/sizes.ts +85 -0
  74. package/src/core/types.ts +84 -0
  75. package/src/core/zoom.ts +74 -0
  76. package/src/core.ts +2 -0
  77. package/src/diagram-js/connect.ts +75 -0
  78. package/src/diagram-js/index.ts +16 -0
  79. package/src/diagram-js/module.ts +20 -0
  80. package/src/diagram-js/palette-skin.ts +267 -0
  81. package/src/diagram-js/palette.css +269 -0
  82. package/src/diagram-js/service.ts +240 -0
  83. package/src/diagram-js/types.ts +67 -0
  84. package/src/diagram-js/viewport.ts +564 -0
  85. package/src/diagram-js.ts +2 -0
  86. package/src/dom/element.ts +51 -0
  87. package/src/dom/hint.ts +51 -0
  88. package/src/dom/icons.ts +309 -0
  89. package/src/dom/index.ts +29 -0
  90. package/src/dom/inspector.ts +35 -0
  91. package/src/dom/keys.ts +59 -0
  92. package/src/dom/ledger.ts +87 -0
  93. package/src/dom/legend.ts +85 -0
  94. package/src/dom/menu.ts +393 -0
  95. package/src/dom/mount.ts +461 -0
  96. package/src/dom/problems.ts +72 -0
  97. package/src/dom/shell-dom.css +966 -0
  98. package/src/dom/status.ts +72 -0
  99. package/src/dom/tooltip.ts +237 -0
  100. package/src/dom/types.ts +277 -0
  101. package/src/dom/zoom-panel.ts +154 -0
  102. package/src/index.ts +3 -0
  103. package/src/shell.css +6 -0
  104. package/src/testing/cases.ts +1012 -0
  105. package/src/testing/checks.ts +832 -0
  106. package/src/testing/facts.ts +587 -0
  107. package/src/testing/geometry.ts +472 -0
  108. package/src/testing/harness.ts +138 -0
  109. package/src/testing/index.ts +15 -0
  110. package/src/testing/page-tools.ts +434 -0
  111. package/src/testing/page.ts +17 -0
  112. package/src/testing/vscode-stub.ts +237 -0
  113. package/src/testing-vscode-stub.ts +2 -0
  114. package/src/testing.ts +2 -0
  115. package/src/vscode/extension/html.ts +107 -0
  116. package/src/vscode/extension/index.ts +8 -0
  117. package/src/vscode/extension/reveal.ts +9 -0
  118. package/src/vscode/extension/theme-bridge.ts +85 -0
  119. package/src/vscode/extension/types.ts +36 -0
  120. package/src/vscode/protocol.ts +31 -0
  121. package/src/vscode/webview/appearance.ts +141 -0
  122. package/src/vscode/webview/index.ts +8 -0
  123. package/src/vscode/webview/mount.ts +76 -0
  124. package/src/vscode/webview/state.ts +116 -0
  125. package/src/vscode-extension.ts +2 -0
  126. package/src/vscode-webview.ts +2 -0
@@ -0,0 +1,461 @@
1
+ import {
2
+ SHELL,
3
+ decidePaletteRow,
4
+ detectPlatform,
5
+ fitInsets,
6
+ legendStartsOpen,
7
+ resolveMessages,
8
+ type FitLayout,
9
+ type Insets,
10
+ type PaletteRow,
11
+ type Size,
12
+ type Theme,
13
+ type ZoomTarget,
14
+ } from "../core/index.js";
15
+ import { boxRelativeTo, createElement } from "./element.js";
16
+ import { createEmptyHint } from "./hint.js";
17
+ import { createInspector } from "./inspector.js";
18
+ import { createKeyListener } from "./keys.js";
19
+ import { Ledger } from "./ledger.js";
20
+ import { createLegend } from "./legend.js";
21
+ import { createMenu } from "./menu.js";
22
+ import { createProblemsPill } from "./problems.js";
23
+ import { createStatusBox } from "./status.js";
24
+ import { createTooltip } from "./tooltip.js";
25
+ import type {
26
+ InspectorHandle,
27
+ LegendHandle,
28
+ MenuCheckboxEntry,
29
+ MenuHandle,
30
+ PaletteReadOnlyState,
31
+ PaletteRegistration,
32
+ ShellHandle,
33
+ ShellOptions,
34
+ TooltipHandle,
35
+ } from "./types.js";
36
+ import { createZoomPanel } from "./zoom-panel.js";
37
+
38
+ // Page-global: every modeler bundles its own copy of the shell (and the CJS entries inline one each),
39
+ // and two shells on one foreign page must not share element ids.
40
+ const MOUNT_COUNTER = Symbol.for("@miragon/modeler-shell.mounts");
41
+
42
+ function nextMountIndex(): number {
43
+ const scope = globalThis as unknown as Record<symbol, number | undefined>;
44
+ const index = (scope[MOUNT_COUNTER] ?? 0) + 1;
45
+ scope[MOUNT_COUNTER] = index;
46
+ return index;
47
+ }
48
+
49
+ const DEFAULT_THEMES: readonly Theme[] = ["light"];
50
+
51
+ // In a foreign page the closest [lang] is the page's language, not the tool's (guide §2).
52
+ function languageOf(root: HTMLElement, host: ShellOptions["host"], requested: string | undefined): string {
53
+ if (requested) return requested;
54
+ const declared =
55
+ host === "embedded" ? root.getAttribute("lang") : root.closest("[lang]")?.getAttribute("lang");
56
+ return declared || "en";
57
+ }
58
+
59
+ /** The contrast of the host page around an embedded root: the nearest mode carrier above it. */
60
+ function hostKontrast(root: HTMLElement): "hoch" | null {
61
+ const carrier = root.parentElement?.closest("[data-cd-theme], [data-cd-kontrast]");
62
+ return carrier?.getAttribute("data-cd-kontrast") === "hoch" ? "hoch" : null;
63
+ }
64
+
65
+ /**
66
+ * 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`.
70
+ *
71
+ * `root` must get its size from outside (`container-type: size`): `height: 100%` in an own
72
+ * document, an explicitly sized element in a foreign page. When rendering throws (a modeler's
73
+ * `legend.render`), everything added so far is removed again before the error propagates.
74
+ */
75
+ export function mountShell(root: HTMLElement, options: ShellOptions): ShellHandle {
76
+ const themes = options.themes ?? DEFAULT_THEMES;
77
+ if (themes.length === 0) throw new Error("@miragon/modeler-shell: mountShell needs at least one theme");
78
+ const ledger = new Ledger();
79
+ try {
80
+ return renderShell(root, options, themes, ledger);
81
+ } catch (error) {
82
+ ledger.close();
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ function renderShell(
88
+ root: HTMLElement,
89
+ options: ShellOptions,
90
+ themes: readonly Theme[],
91
+ ledger: Ledger,
92
+ ): ShellHandle {
93
+ const ownerDocument = root.ownerDocument;
94
+ const { host } = options;
95
+ const index = nextMountIndex();
96
+ const messages = resolveMessages(options.messages);
97
+ const platform = options.platform ?? detectPlatform(globalThis.navigator);
98
+ const lang = languageOf(root, host, options.lang);
99
+ const keyScope = options.keyScope ?? (host === "embedded" ? "root" : "document");
100
+
101
+ ledger.addClass(root, "cd-shell");
102
+ ledger.attribute(root, "data-cd-shell-host", host);
103
+ ledger.attribute(root, "data-palette", "top");
104
+ if (host === "embedded") {
105
+ ledger.attribute(root, "lang", lang);
106
+ carryOwnMode(root, themes[0] ?? "light", ledger);
107
+ } else {
108
+ ledger.addClass(ownerDocument.documentElement, "cd-shell-document");
109
+ }
110
+
111
+ const setState = (name: string, value: string | null): void => {
112
+ if (!ledger.isClosed) ledger.attribute(root, name, value);
113
+ };
114
+
115
+ const layer = createElement(ownerDocument, "div", { class: "cd-shell-layer" });
116
+ ledger.defer(() => layer.remove());
117
+ const tooltip = createTooltip({ root, layer, id: `cd-shell-tooltip-${index}` });
118
+ ledger.defer(() => tooltip.destroy());
119
+
120
+ /** The lock of `setReadOnly(true)`; `readOnlyShown` its marks, which may follow after a delay. */
121
+ let readOnly = false;
122
+ let readOnlyShown = false;
123
+ let readOnlyTimer: ReturnType<typeof setTimeout> | undefined;
124
+ ledger.defer(() => clearTimeout(readOnlyTimer));
125
+
126
+ const legend = options.legend
127
+ ? createLegend({
128
+ ownerDocument,
129
+ messages,
130
+ tooltip,
131
+ titleId: `cd-shell-legend-title-${index}`,
132
+ options: options.legend,
133
+ onChange(open) {
134
+ setState("data-legend", open ? "open" : null);
135
+ placeHint();
136
+ menu.refresh();
137
+ },
138
+ focusAfterClose: () => menu.trigger.focus(),
139
+ })
140
+ : undefined;
141
+ if (legend) ledger.defer(() => legend.destroy());
142
+
143
+ const legendEntry: MenuCheckboxEntry | undefined =
144
+ legend && options.legend?.menuEntry !== false
145
+ ? {
146
+ kind: "checkbox",
147
+ id: "legend",
148
+ label: messages.legend,
149
+ checked: () => legend.isOpen(),
150
+ onToggle: (checked) => legend.setOpen(checked),
151
+ keepEnabledWhenReadOnly: true,
152
+ }
153
+ : undefined;
154
+ const shellModelEntries: readonly MenuCheckboxEntry[] = legendEntry ? [legendEntry] : [];
155
+
156
+ const menu = createMenu({
157
+ root,
158
+ layer,
159
+ id: `cd-shell-menu-${index}`,
160
+ messages,
161
+ tooltip,
162
+ groups: options.menu ?? {},
163
+ shellModelEntries: () => shellModelEntries,
164
+ isReadOnly: () => readOnly,
165
+ });
166
+ ledger.defer(() => menu.destroy());
167
+ ledger.defer(tooltip.attach(menu.trigger, () => (menu.isOpen() ? null : { label: messages.menu })));
168
+
169
+ const canvas = options.canvas ?? createElement(ownerDocument, "div");
170
+ ledger.addClass(canvas, "cd-shell-canvas");
171
+ if (!options.canvas) ledger.defer(() => canvas.remove());
172
+
173
+ /** Where the focus goes when the island holding it hides: the canvas, else the menu trigger. */
174
+ 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 });
180
+ }
181
+
182
+ const status = createStatusBox(ownerDocument);
183
+ ledger.defer(() => status.destroy());
184
+ const hint = options.emptyHint ? createEmptyHint(ownerDocument, messages, options.emptyHint) : undefined;
185
+ if (hint) ledger.defer(() => hint.destroy());
186
+ const inspector = options.inspector
187
+ ? createInspector(ownerDocument, options.inspector, (open) => {
188
+ setState("data-inspector", open ? "open" : null);
189
+ placeHint();
190
+ })
191
+ : undefined;
192
+ if (inspector) ledger.defer(() => inspector.destroy());
193
+ const pill = options.problems
194
+ ? createProblemsPill({
195
+ ownerDocument,
196
+ messages,
197
+ lang,
198
+ tooltip,
199
+ showTooltip: host === "ide",
200
+ options: options.problems,
201
+ })
202
+ : undefined;
203
+ if (pill) ledger.defer(() => pill.destroy());
204
+ const zoomPanel = createZoomPanel({ ownerDocument, messages, lang, platform, tooltip });
205
+ ledger.defer(() => zoomPanel.destroy());
206
+
207
+ root.prepend(menu.trigger, canvas);
208
+ root.append(
209
+ ...[
210
+ status.element,
211
+ hint?.element,
212
+ inspector?.element,
213
+ pill?.element,
214
+ legend?.element,
215
+ zoomPanel.element,
216
+ ].filter((element): element is HTMLElement => element !== undefined),
217
+ layer,
218
+ );
219
+
220
+ let palette: { registration: PaletteRegistration; detachTooltip: () => void } | undefined;
221
+ let paletteRow: PaletteRow = "top";
222
+
223
+ function containerSize(): Size {
224
+ return { width: root.clientWidth, height: root.clientHeight };
225
+ }
226
+
227
+ function menuRight(): number {
228
+ const box = boxRelativeTo(menu.trigger, root);
229
+ return box.width > 0 ? box.x + box.width : SHELL.edge + SHELL.row;
230
+ }
231
+
232
+ /** The palette's intrinsic border-box width: it does not shrink once the second row clips it. */
233
+ function paletteWidth(): number {
234
+ const element = palette?.registration.element;
235
+ return element ? element.offsetWidth - element.clientWidth + element.scrollWidth : 0;
236
+ }
237
+
238
+ function currentFitInsets(): Insets {
239
+ const container = containerSize();
240
+ const layout: FitLayout = {
241
+ container,
242
+ host,
243
+ paletteRow,
244
+ problemsVisible: pill?.isShown() ?? false,
245
+ };
246
+ if (legend?.isOpen()) layout.legendWidth = legend.element.getBoundingClientRect().width;
247
+ if (inspector?.isOpen()) {
248
+ layout.inspector =
249
+ container.width > SHELL.narrow ? {} : { sheetTop: boxRelativeTo(inspector.element, root).y };
250
+ }
251
+ return fitInsets(layout);
252
+ }
253
+
254
+ function placeHint(): void {
255
+ if (hint?.isShown()) hint.place(currentFitInsets());
256
+ }
257
+
258
+ function updateLayout(): void {
259
+ if (ledger.isClosed) return;
260
+ const size = containerSize();
261
+ // A hidden tab reports 0 × 0; it neither measures nor decides anything (guide §5.9).
262
+ if (size.width === 0 || size.height === 0) return;
263
+ paletteRow = decidePaletteRow({
264
+ containerWidth: size.width,
265
+ paletteWidth: paletteWidth(),
266
+ menuRight: menuRight(),
267
+ });
268
+ setState("data-palette", paletteRow);
269
+ if (legend && !legend.isStarted()) legend.start(options.legend?.open ?? legendStartsOpen(size.height));
270
+ placeHint();
271
+ }
272
+
273
+ const ResizeObserverClass = globalThis.ResizeObserver;
274
+ const resizeObserver = ResizeObserverClass ? new ResizeObserverClass(() => updateLayout()) : undefined;
275
+ resizeObserver?.observe(root);
276
+ ledger.defer(() => resizeObserver?.disconnect());
277
+
278
+ if (legend && options.legend?.open !== undefined) legend.start(options.legend.open);
279
+ updateLayout();
280
+
281
+ const keyTarget: HTMLElement | Document = keyScope === "root" ? root : ownerDocument;
282
+ const onKeydown = createKeyListener({
283
+ host,
284
+ dialogScope: keyTarget,
285
+ menuOpen: () => menu.isOpen(),
286
+ closeMenu() {
287
+ const active = ownerDocument.activeElement;
288
+ menu.close({ restoreFocus: active === menu.trigger || menu.dropdown.contains(active) });
289
+ },
290
+ hideTooltip: () => tooltip.hide(),
291
+ run: (key) => zoomPanel.run(key),
292
+ });
293
+ keyTarget.addEventListener("keydown", onKeydown as EventListener);
294
+ ledger.defer(() => keyTarget.removeEventListener("keydown", onKeydown as EventListener));
295
+
296
+ function readOnlyState(): PaletteReadOnlyState {
297
+ return { locked: readOnly, shown: readOnlyShown };
298
+ }
299
+
300
+ function applyReadOnly(): void {
301
+ setState("data-readonly", readOnlyShown ? "" : null);
302
+ palette?.registration.setReadOnly(readOnlyState());
303
+ menu.refresh();
304
+ }
305
+
306
+ function showReadOnly(): void {
307
+ readOnlyTimer = undefined;
308
+ if (ledger.isClosed || !readOnly) return;
309
+ readOnlyShown = true;
310
+ applyReadOnly();
311
+ }
312
+
313
+ function unregisterPalette(): void {
314
+ if (!palette) return;
315
+ palette.detachTooltip();
316
+ resizeObserver?.unobserve(palette.registration.element);
317
+ palette = undefined;
318
+ }
319
+ ledger.defer(unregisterPalette);
320
+
321
+ const menuHandle: MenuHandle = {
322
+ trigger: menu.trigger,
323
+ dropdown: menu.dropdown,
324
+ isOpen: () => menu.isOpen(),
325
+ open: (focus) => menu.open(focus),
326
+ close: (closeOptions) => menu.close(closeOptions),
327
+ refresh: () => menu.refresh(),
328
+ setGroups: (groups) => {
329
+ if (!ledger.isClosed) menu.setGroups(groups);
330
+ },
331
+ };
332
+
333
+ const tooltipHandle: TooltipHandle = {
334
+ attach: (control, content) => (ledger.isClosed ? () => {} : tooltip.attach(control, content)),
335
+ hide: () => tooltip.hide(),
336
+ };
337
+
338
+ const legendHandle: LegendHandle | undefined = legend && {
339
+ element: legend.element,
340
+ body: legend.body,
341
+ isOpen: () => legend.isOpen(),
342
+ setOpen: (open) => {
343
+ if (!ledger.isClosed) legend.setOpen(open);
344
+ },
345
+ toggle: () => {
346
+ if (!ledger.isClosed) legend.toggle();
347
+ },
348
+ };
349
+
350
+ const inspectorHandle: InspectorHandle | undefined = inspector && {
351
+ element: inspector.element,
352
+ isOpen: () => inspector.isOpen(),
353
+ open: () => {
354
+ if (!ledger.isClosed) inspector.open();
355
+ },
356
+ close: () => {
357
+ if (ledger.isClosed) return;
358
+ if (inspector.isOpen()) moveFocusOutOf(inspector.element);
359
+ inspector.close();
360
+ },
361
+ };
362
+
363
+ return {
364
+ root,
365
+ canvas,
366
+ host,
367
+ lang,
368
+ platform,
369
+ messages,
370
+ menu: menuHandle,
371
+ tooltip: tooltipHandle,
372
+ legend: legendHandle,
373
+ inspector: inspectorHandle,
374
+ setZoomTarget(target: ZoomTarget | null) {
375
+ if (!ledger.isClosed) zoomPanel.setTarget(target);
376
+ },
377
+ fitInsets: currentFitInsets,
378
+ registerPalette(registration) {
379
+ if (ledger.isClosed) return () => {};
380
+ unregisterPalette();
381
+ palette = {
382
+ registration,
383
+ detachTooltip: tooltip.delegate(registration.element, ".entry", (entry) =>
384
+ registration.entryTooltip(entry),
385
+ ),
386
+ };
387
+ resizeObserver?.observe(registration.element);
388
+ registration.setReadOnly(readOnlyState());
389
+ updateLayout();
390
+ const registered = palette;
391
+ return () => {
392
+ if (palette !== registered) return;
393
+ unregisterPalette();
394
+ updateLayout();
395
+ };
396
+ },
397
+ setProblems(summary) {
398
+ if (ledger.isClosed || !pill) return;
399
+ pill.set(summary);
400
+ setState("data-problems", pill.isShown() ? "shown" : null);
401
+ placeHint();
402
+ },
403
+ setStatus(next, statusOptions) {
404
+ if (!ledger.isClosed) status.set(next, statusOptions?.delayMs ?? 0);
405
+ },
406
+ setEmptyHint(visible) {
407
+ if (ledger.isClosed || !hint) return;
408
+ if (!visible && hint.isShown()) moveFocusOutOf(hint.element);
409
+ hint.setShown(visible);
410
+ placeHint();
411
+ },
412
+ setReadOnly(next, readOnlyOptions) {
413
+ if (ledger.isClosed) return;
414
+ if (!next) {
415
+ clearTimeout(readOnlyTimer);
416
+ readOnlyTimer = undefined;
417
+ readOnly = false;
418
+ readOnlyShown = false;
419
+ } else {
420
+ readOnly = true;
421
+ const delayMs = readOnlyOptions?.delayMs ?? 0;
422
+ // Repeated invalid imports while typing keep the first timer, like the status box.
423
+ if (!readOnlyShown && readOnlyTimer === undefined) {
424
+ if (delayMs > 0) readOnlyTimer = setTimeout(showReadOnly, delayMs);
425
+ else readOnlyShown = true;
426
+ }
427
+ }
428
+ applyReadOnly();
429
+ },
430
+ isReadOnly: () => readOnly,
431
+ destroy() {
432
+ ledger.close();
433
+ },
434
+ };
435
+ }
436
+
437
+ /**
438
+ * An embedded root carries its own mode (guide §13.3): the token CSS resolves the roles on the
439
+ * nearest `[data-cd-theme]`, so without it a light-only modeler would inherit a dark host page and
440
+ * be half-dark against its light-drawn diagram. The root's own `data-cd-theme` would also drop the
441
+ * host's high contrast, so `data-cd-kontrast` is mirrored from the host for as long as it lives.
442
+ */
443
+ function carryOwnMode(root: HTMLElement, theme: Theme, ledger: Ledger): void {
444
+ ledger.attribute(root, "data-cd-theme", theme);
445
+ const mirror = (): void => {
446
+ if (ledger.isClosed) return;
447
+ const kontrast = hostKontrast(root);
448
+ if (root.getAttribute("data-cd-kontrast") !== kontrast)
449
+ ledger.attribute(root, "data-cd-kontrast", kontrast);
450
+ };
451
+ mirror();
452
+ const MutationObserverClass = globalThis.MutationObserver;
453
+ if (!MutationObserverClass) return;
454
+ const observer = new MutationObserverClass(mirror);
455
+ observer.observe(root.ownerDocument.documentElement, {
456
+ attributes: true,
457
+ attributeFilter: ["data-cd-theme", "data-cd-kontrast"],
458
+ subtree: true,
459
+ });
460
+ ledger.defer(() => observer.disconnect());
461
+ }
@@ -0,0 +1,72 @@
1
+ import { formatNumber, type ShellMessages } from "../core/index.js";
2
+ import { createElement } from "./element.js";
3
+ import { iconElement } from "./icons.js";
4
+ import type { TooltipController } from "./tooltip.js";
5
+ import type { ProblemSummary, ProblemsOptions } from "./types.js";
6
+
7
+ export interface ProblemsPill {
8
+ readonly element: HTMLButtonElement;
9
+ isShown(): boolean;
10
+ set(summary: ProblemSummary): void;
11
+ destroy(): void;
12
+ }
13
+
14
+ export interface ProblemsContext {
15
+ ownerDocument: Document;
16
+ messages: ShellMessages;
17
+ lang: string;
18
+ tooltip: TooltipController;
19
+ /** The IDE host opens its Problems view, so only there the pill explains itself. */
20
+ showTooltip: boolean;
21
+ options: ProblemsOptions;
22
+ }
23
+
24
+ export function createProblemsPill(context: ProblemsContext): ProblemsPill {
25
+ const { ownerDocument, messages, lang, tooltip, options } = context;
26
+ const icon = createElement(ownerDocument, "span", { class: "cd-shell-problems-icon" });
27
+ const text = createElement(ownerDocument, "span", { class: "cd-shell-problems-text" });
28
+ const element = createElement(
29
+ ownerDocument,
30
+ "button",
31
+ { type: "button", class: "cd-shell-problems cd-shell-island", "data-chrome": "problems" },
32
+ icon,
33
+ text,
34
+ );
35
+ element.hidden = true;
36
+ element.addEventListener("click", () => options.onActivate());
37
+ const detachTooltip = context.showTooltip
38
+ ? tooltip.attach(element, { label: messages.showProblems })
39
+ : undefined;
40
+
41
+ let severity: "danger" | "warning" | undefined;
42
+
43
+ return {
44
+ element,
45
+ isShown: () => !element.hidden,
46
+ set({ count, errors }) {
47
+ const shown = count > 0;
48
+ element.hidden = !shown;
49
+ if (!shown) {
50
+ tooltip.hide();
51
+ return;
52
+ }
53
+ const formattedCount = formatNumber(count, lang);
54
+ text.textContent = messages.problems(count, formattedCount);
55
+ element.setAttribute(
56
+ "aria-label",
57
+ messages.problemsLabel(count, errors, formattedCount, formatNumber(errors, lang)),
58
+ );
59
+ // The shape carries the severity as well, colour is never the only signal (guide §5.10).
60
+ const next = errors > 0 ? "danger" : "warning";
61
+ if (next !== severity) {
62
+ severity = next;
63
+ element.dataset.severity = next;
64
+ icon.replaceChildren(iconElement(next === "danger" ? "circle-x" : "triangle-alert"));
65
+ }
66
+ },
67
+ destroy() {
68
+ detachTooltip?.();
69
+ element.remove();
70
+ },
71
+ };
72
+ }