@miragon/modeler-shell 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/API.md +1051 -84
  2. package/README.md +162 -31
  3. package/dist/appearance-BBaM_4bD.d.ts +323 -0
  4. package/dist/appearance-XX3JC3pI.d.cts +323 -0
  5. package/dist/{chunk-6LQOFACK.js → chunk-66HCSJNY.js} +1247 -31
  6. package/dist/chunk-66HCSJNY.js.map +1 -0
  7. package/dist/{chunk-RJRFR6N3.js → chunk-KYDG5DMX.js} +2 -2
  8. package/dist/{chunk-5JSZ4VGA.js → chunk-OADW3GPA.js} +240 -6
  9. package/dist/chunk-OADW3GPA.js.map +1 -0
  10. package/dist/core.cjs +260 -6
  11. package/dist/core.cjs.map +1 -1
  12. package/dist/core.d.cts +81 -6
  13. package/dist/core.d.ts +81 -6
  14. package/dist/core.js +43 -3
  15. package/dist/diagram-js.cjs +34 -2
  16. package/dist/diagram-js.cjs.map +1 -1
  17. package/dist/diagram-js.d.cts +10 -3
  18. package/dist/diagram-js.d.ts +10 -3
  19. package/dist/diagram-js.js +14 -2
  20. package/dist/diagram-js.js.map +1 -1
  21. package/dist/index.cjs +1654 -197
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +18 -9
  24. package/dist/index.d.ts +18 -9
  25. package/dist/index.js +44 -4
  26. package/dist/shell.css +692 -3
  27. package/dist/testing-vscode-stub.js +2 -2
  28. package/dist/testing.cjs +2849 -550
  29. package/dist/testing.cjs.map +1 -1
  30. package/dist/testing.d.cts +75 -9
  31. package/dist/testing.d.ts +75 -9
  32. package/dist/testing.js +2744 -558
  33. package/dist/testing.js.map +1 -1
  34. package/dist/toasts-Bp0REqdq.d.cts +142 -0
  35. package/dist/toasts-Bp0REqdq.d.ts +142 -0
  36. package/dist/{types-OuFlqZp0.d.cts → types-CnRrINRs.d.cts} +170 -9
  37. package/dist/{types-DnZn9CPw.d.ts → types-PuDZczzy.d.ts} +170 -9
  38. package/dist/vscode-extension.js +2 -2
  39. package/dist/vscode-webview.cjs +1541 -178
  40. package/dist/vscode-webview.cjs.map +1 -1
  41. package/dist/vscode-webview.d.cts +6 -4
  42. package/dist/vscode-webview.d.ts +6 -4
  43. package/dist/vscode-webview.js +11 -2
  44. package/dist/vscode-webview.js.map +1 -1
  45. package/package.json +2 -2
  46. package/src/core/feedback/appearance.ts +141 -0
  47. package/src/core/feedback/problems.ts +23 -0
  48. package/src/core/feedback/toasts.ts +118 -0
  49. package/src/core/fit.ts +18 -3
  50. package/src/core/index.ts +43 -0
  51. package/src/core/keymap.ts +20 -0
  52. package/src/core/messages.ts +71 -0
  53. package/src/core/types.ts +24 -2
  54. package/src/core/web-chrome/bottom-row.ts +37 -0
  55. package/src/core/web-chrome/drop.ts +78 -0
  56. package/src/core/web-chrome/top-row.ts +66 -0
  57. package/src/diagram-js/service.ts +4 -1
  58. package/src/diagram-js/viewport.ts +19 -0
  59. package/src/dom/buttons.css +124 -0
  60. package/src/dom/feedback/appearance.ts +184 -0
  61. package/src/dom/feedback/feedback.css +435 -0
  62. package/src/dom/feedback/problems-popover.ts +287 -0
  63. package/src/dom/feedback/toasts.ts +431 -0
  64. package/src/dom/index.ts +13 -0
  65. package/src/dom/keys.ts +8 -0
  66. package/src/dom/menu.ts +28 -17
  67. package/src/dom/mount.ts +290 -20
  68. package/src/dom/problems.ts +5 -4
  69. package/src/dom/shell-dom.css +7 -4
  70. package/src/dom/types.ts +188 -6
  71. package/src/dom/web/drop-zone.ts +181 -0
  72. package/src/dom/web/empty-state.ts +144 -0
  73. package/src/dom/web/legal.ts +75 -0
  74. package/src/dom/web/menu-groups.ts +69 -0
  75. package/src/dom/web/primary.ts +124 -0
  76. package/src/dom/web/web.css +386 -0
  77. package/src/shell.css +3 -0
  78. package/src/testing/case-tools.ts +164 -0
  79. package/src/testing/cases.ts +209 -144
  80. package/src/testing/checks.ts +51 -15
  81. package/src/testing/facts.ts +56 -2
  82. package/src/testing/geometry.ts +206 -26
  83. package/src/testing/harness.ts +56 -7
  84. package/src/testing/index.ts +4 -0
  85. package/src/testing/page-tools.ts +24 -1
  86. package/src/testing/page.ts +10 -0
  87. package/src/testing/web-cases.ts +913 -0
  88. package/src/testing/web-checks.ts +1049 -0
  89. package/src/testing/web-facts.ts +713 -0
  90. package/src/vscode/webview/mount.ts +13 -1
  91. package/dist/chunk-5JSZ4VGA.js.map +0 -1
  92. package/dist/chunk-6LQOFACK.js.map +0 -1
  93. package/dist/keymap-CObQE6q6.d.cts +0 -151
  94. package/dist/keymap-DORzHnK-.d.ts +0 -151
  95. package/dist/types-B6NfkiUG.d.cts +0 -66
  96. package/dist/types-B6NfkiUG.d.ts +0 -66
  97. /package/dist/{chunk-RJRFR6N3.js.map → chunk-KYDG5DMX.js.map} +0 -0
@@ -1,3 +1,6 @@
1
+ import type { ProblemSeverity } from "./feedback/problems.js";
2
+ import type { DropForbiddenReason } from "./web-chrome/drop.js";
3
+
1
4
  /**
2
5
  * Every text the shell shows or announces, in one table (guide §2, shared terms). English by
3
6
  * default; a localized modeler overrides the whole table in its language, numbers arrive already
@@ -35,6 +38,49 @@ export interface ShellMessages {
35
38
  invalidFile(diagram: string): string;
36
39
  /** Key names in shortcut chips outside macOS (German: Strg, Umschalt, Plus, Minus). */
37
40
  keyNames: { ctrl: string; shift: string; plus: string; minus: string };
41
+
42
+ // ---- Web host (0.2): guide §2 shared terms, §5.4, §5.7, §5.10, §8, §9 ----
43
+
44
+ /** Default label and `aria-label` of the primary action (web app, top right). */
45
+ share: string;
46
+ /** `aria-label` of the legal line's `<nav>`. "Impressum" and "Datenschutz" are never translated (guide §2). */
47
+ legal: string;
48
+ /** First entry of the legal line, linking the Miragon home page: "© 2026 Miragon GmbH". */
49
+ copyright(year: number): string;
50
+ /** The description of every legal link for screen readers: they open in a new tab. */
51
+ opensInNewTab: string;
52
+ /** File group (web app) and the empty state's primary button. */
53
+ newDiagram: string;
54
+ /** File group: "Open…" (U+2026, it opens the browser's file dialog). */
55
+ openFile: string;
56
+ /** File group and the empty state's ghost button. */
57
+ showExample: string;
58
+ /** File group: "Import…". */
59
+ importFile: string;
60
+ /** Heading and `aria-label` of the Appearance radio group (web app with dark only, guide §13.3). */
61
+ appearance: string;
62
+ appearanceSystem: string;
63
+ appearanceLight: string;
64
+ appearanceDark: string;
65
+ /** Title of the problems popover (web app). */
66
+ problemsTitle: string;
67
+ /** A row's severity for screen readers, the `aria-label` of its icon: "Error", "Warning". */
68
+ problemSeverity(severity: ProblemSeverity): string;
69
+ /** `aria-label` of a Close button (toasts). */
70
+ close: string;
71
+ /** Chip of the drop zone while a drag can be dropped (guide §9 Drag-over). */
72
+ dropFile: string;
73
+ /** Chip of the drop zone while the drag cannot be dropped (guide §9 Drop forbidden). */
74
+ dropForbidden(reason: DropForbiddenReason): string;
75
+ /** The empty state's drag-and-drop hint when the modeler has a drop zone. */
76
+ dropHint: string;
77
+ /** The text link after the empty state's hint that runs the File group's "Open…". */
78
+ openFileLink: string;
79
+ /**
80
+ * Danger toast for a dropped file outside `DropZoneOptions.accept`; `accepted` is the accept list
81
+ * joined by `Intl.ListFormat` (disjunction) in `lang`: ".storm, .json, or .txt".
82
+ */
83
+ cannotOpenFile(name: string, accepted: string): string;
38
84
  }
39
85
 
40
86
  /** Every key optional; an `undefined` value keeps the default (a conditional override). */
@@ -67,6 +113,31 @@ export const defaultMessages: ShellMessages = {
67
113
  invalidFile: (diagram) =>
68
114
  `This file isn't a valid ${diagram} right now. The diagram shows the last valid version and is read-only until you fix the text.`,
69
115
  keyNames: { ctrl: "Ctrl", shift: "Shift", plus: "Plus", minus: "Minus" },
116
+ share: "Share",
117
+ legal: "Legal",
118
+ copyright: (year) => `© ${year} Miragon GmbH`,
119
+ opensInNewTab: "Opens in a new tab",
120
+ newDiagram: "New diagram",
121
+ openFile: "Open…",
122
+ showExample: "Show example",
123
+ importFile: "Import…",
124
+ appearance: "Appearance",
125
+ appearanceSystem: "System",
126
+ appearanceLight: "Light",
127
+ appearanceDark: "Dark",
128
+ problemsTitle: "Problems",
129
+ problemSeverity: (severity) => (severity === "error" ? "Error" : "Warning"),
130
+ close: "Close",
131
+ dropFile: "Drop file here",
132
+ dropForbidden: (reason) =>
133
+ reason === "multiple"
134
+ ? "Drop one file at a time"
135
+ : reason === "dialog"
136
+ ? "Close the dialog to drop a file"
137
+ : "This modeler can't open this file type",
138
+ dropHint: "Or drop a file here",
139
+ openFileLink: "Open a file…",
140
+ cannotOpenFile: (name, accepted) => `Couldn't open “${name}”. Drop a ${accepted} file.`,
70
141
  };
71
142
 
72
143
  /** `defaultMessages` with `overrides` applied (`keyNames` merged key by key). */
package/src/core/types.ts CHANGED
@@ -36,9 +36,26 @@ export interface Insets {
36
36
  */
37
37
  export type ShellHost = "ide" | "web" | "embedded";
38
38
 
39
- /** The `data-chrome` names of the islands (guide §5.2, §13.4). `primary` is reserved for the web app. */
39
+ /**
40
+ * The `data-chrome` names of the chrome (guide §5.2, §13.4). The web column adds `primary`, `legal`,
41
+ * `toasts`, `empty-state`, `drop-zone` and `problems-popover` (0.2); IDE and embedded hosts never
42
+ * show `primary`, `legal`, `empty-state` and `drop-zone`, the IDE host never `toasts` and
43
+ * `problems-popover`.
44
+ */
40
45
  export type ChromeIsland =
41
- "menu" | "palette" | "primary" | "zoom" | "problems" | "legend" | "inspector" | "status";
46
+ | "menu"
47
+ | "palette"
48
+ | "primary"
49
+ | "zoom"
50
+ | "problems"
51
+ | "legend"
52
+ | "inspector"
53
+ | "status"
54
+ | "legal"
55
+ | "toasts"
56
+ | "empty-state"
57
+ | "drop-zone"
58
+ | "problems-popover";
42
59
 
43
60
  export const CHROME_ISLANDS: readonly ChromeIsland[] = [
44
61
  "menu",
@@ -49,6 +66,11 @@ export const CHROME_ISLANDS: readonly ChromeIsland[] = [
49
66
  "legend",
50
67
  "inspector",
51
68
  "status",
69
+ "legal",
70
+ "toasts",
71
+ "empty-state",
72
+ "drop-zone",
73
+ "problems-popover",
52
74
  ];
53
75
 
54
76
  /** Where the palette sits: in the top row, or in the second row below it (guide §5.15). */
@@ -0,0 +1,37 @@
1
+ import { SHELL } from "../sizes.js";
2
+
3
+ /**
4
+ * Height of one line of the web app's legal line: 13px text on a 20px line (guide §5.10). Where a
5
+ * narrow container cannot hold it in one line, it wraps (40 for two lines) and everything stacked
6
+ * above it rises by as much: the pill, the legend, the raised zoom panel and the fit's bottom.
7
+ */
8
+ export const LEGAL_LINE_HEIGHT = 20;
9
+
10
+ /** A measured legal line's height in whole lines, at least one: what the stack above it stands on. */
11
+ export function legalLineHeight(measured: number | undefined): number {
12
+ if (measured === undefined || !Number.isFinite(measured) || measured <= 0) return LEGAL_LINE_HEIGHT;
13
+ return Math.max(1, Math.round(measured / LEGAL_LINE_HEIGHT)) * LEGAL_LINE_HEIGHT;
14
+ }
15
+
16
+ export interface BottomRowInput {
17
+ containerWidth: number;
18
+ /** Measured right edge of the legal line relative to the shell root; absent while it is not shown. */
19
+ legalRight?: number | undefined;
20
+ /** Measured width of the zoom panel (173 with a 56px readout); absent while it is not shown. */
21
+ zoomWidth?: number | undefined;
22
+ }
23
+
24
+ /**
25
+ * Web app, bottom row: true when the zoom panel at `right: 12px` would keep less than
26
+ * `SHELL.gap` (8px) to the legal line at `left: 12px`. The zoom panel then stands above the legal
27
+ * line at `bottom: 40px` (12 + 20 + 8; a wrapped line lifts it further), like the pill
28
+ * (`data-zoom-row="raised"` on the root), and the fit keeps 92px at the bottom (`FitLayout.zoomRaised`). Guide §5.10 and §5.15 do not settle
29
+ * this collision of a narrow container; it is the shell's rule until they do.
30
+ * False when either of the two is not shown.
31
+ */
32
+ export function zoomRowRaised(input: BottomRowInput): boolean {
33
+ const { containerWidth, legalRight, zoomWidth } = input;
34
+ if (legalRight === undefined || zoomWidth === undefined) return false;
35
+ const zoomLeft = containerWidth - SHELL.edge - zoomWidth;
36
+ return legalRight + SHELL.gap > zoomLeft;
37
+ }
@@ -0,0 +1,78 @@
1
+ /** Why a drag cannot be dropped (guide §9 Drop forbidden); `messages.dropForbidden` words each one. */
2
+ export type DropForbiddenReason = "multiple" | "type" | "dialog";
3
+
4
+ /** One entry of `DataTransfer.items` as a drag shows it (names are not readable before the drop). */
5
+ export interface DragItemLike {
6
+ kind: string;
7
+ type: string;
8
+ }
9
+
10
+ export interface DragContext {
11
+ /** `DropZoneOptions.accept`: extensions (".storm") and MIME types ("application/json", "text/*"). */
12
+ accept: readonly string[];
13
+ /** `DropZoneOptions.multiple`. */
14
+ multiple: boolean;
15
+ /** A modal dialog is open (the shell's dialog test, as for the keys). */
16
+ dialogOpen: boolean;
17
+ }
18
+
19
+ /** What the drop zone shows for the drag over the page right now (guide §9). */
20
+ export type DragVerdict =
21
+ { state: "none" } | { state: "over" } | { state: "forbidden"; reason: DropForbiddenReason };
22
+
23
+ interface AcceptEntries {
24
+ extensions: readonly string[];
25
+ mimeTypes: readonly string[];
26
+ }
27
+
28
+ /** `<input accept>` tokens: trimmed, lower case, blanks dropped; "." starts an extension, "/" marks a MIME type. */
29
+ function acceptEntries(accept: readonly string[]): AcceptEntries {
30
+ const entries = accept.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== "");
31
+ return {
32
+ extensions: entries.filter((entry) => entry.startsWith(".")),
33
+ mimeTypes: entries.filter((entry) => !entry.startsWith(".") && entry.includes("/")),
34
+ };
35
+ }
36
+
37
+ function matchesMimeType(type: string, mimeTypes: readonly string[]): boolean {
38
+ const normalized = type.trim().toLowerCase();
39
+ if (normalized === "") return false;
40
+ return mimeTypes.some((entry) =>
41
+ entry.endsWith("/*") ? normalized.startsWith(entry.slice(0, -1)) : normalized === entry,
42
+ );
43
+ }
44
+
45
+ /**
46
+ * The drop zone's state for a drag (guide §9, Drag-over and Drop forbidden):
47
+ *
48
+ * - `none`: no item of kind `file` (a palette drag, selected text): the drop zone stays out of it.
49
+ * - `forbidden` / `dialog`: a modal dialog is open.
50
+ * - `forbidden` / `multiple`: more than one file and `multiple` is false.
51
+ * - `forbidden` / `type`: `accept` lists at least one MIME type and every file item carries a
52
+ * non-empty type that none of them matches (`type/*` wildcards included). Extensions cannot be
53
+ * checked before the drop, and an empty type (".storm" has none) never forbids.
54
+ * - `over` otherwise.
55
+ */
56
+ export function dragVerdict(items: readonly DragItemLike[], context: DragContext): DragVerdict {
57
+ const files = items.filter((item) => item.kind === "file");
58
+ if (files.length === 0) return { state: "none" };
59
+ if (context.dialogOpen) return { state: "forbidden", reason: "dialog" };
60
+ if (files.length > 1 && !context.multiple) return { state: "forbidden", reason: "multiple" };
61
+ const { mimeTypes } = acceptEntries(context.accept);
62
+ const foreignType = (item: DragItemLike) =>
63
+ item.type.trim() !== "" && !matchesMimeType(item.type, mimeTypes);
64
+ if (mimeTypes.length > 0 && files.every(foreignType)) return { state: "forbidden", reason: "type" };
65
+ return { state: "over" };
66
+ }
67
+
68
+ /**
69
+ * A dropped file matches `accept` like `<input accept>`: an entry starting with "." matches the
70
+ * end of the name (case-insensitive), an entry with "/" the MIME type exactly or as `type/*`; an
71
+ * empty `accept` matches every file.
72
+ */
73
+ export function acceptsFile(file: { name: string; type: string }, accept: readonly string[]): boolean {
74
+ const { extensions, mimeTypes } = acceptEntries(accept);
75
+ if (extensions.length === 0 && mimeTypes.length === 0) return true;
76
+ const name = file.name.toLowerCase();
77
+ return extensions.some((extension) => name.endsWith(extension)) || matchesMimeType(file.type, mimeTypes);
78
+ }
@@ -0,0 +1,66 @@
1
+ import { PALETTE_CLEARANCE, decidePaletteRow } from "../layout.js";
2
+ import { SHELL } from "../sizes.js";
3
+ import type { PaletteRow } from "../types.js";
4
+
5
+ /** Border-box widths of the web app's primary action, measured with and without its label. */
6
+ export interface PrimaryWidths {
7
+ /** Icon and label: padding `0 16px`, icon 16px, gap 8px, label 13px/600 (guide §5.7). */
8
+ withLabel: number;
9
+ /** The icon alone: `SHELL.row` (40) square. */
10
+ iconOnly: number;
11
+ }
12
+
13
+ export interface TopRowInput {
14
+ containerWidth: number;
15
+ /** The palette's intrinsic border-box width, as for `decidePaletteRow`. */
16
+ paletteWidth: number;
17
+ /** Right edge of the menu trigger, relative to the shell root. */
18
+ menuRight: number;
19
+ /** The primary action at `right: 12px`; absent in IDE and embedded hosts and without one. */
20
+ primary?: PrimaryWidths | undefined;
21
+ }
22
+
23
+ export interface TopRow {
24
+ paletteRow: PaletteRow;
25
+ /** `collapsed`: the primary action shows its icon only; its `aria-label` keeps the name. */
26
+ primaryLabel: "shown" | "collapsed";
27
+ }
28
+
29
+ /**
30
+ * The top row of guide §5.15 with a primary action: when it gets tight, the primary action loses
31
+ * its label first, and only then the palette moves to the second row.
32
+ *
33
+ * 1. Without `primary`: `decidePaletteRow` alone, label `shown`.
34
+ * 2. With the label (`primaryLeft = containerWidth − SHELL.edge − withLabel`): palette `top` →
35
+ * `{ top, shown }`.
36
+ * 3. Icon only (`primaryLeft = containerWidth − SHELL.edge − iconOnly`): palette `top` →
37
+ * `{ top, collapsed }`.
38
+ * 4. Otherwise the palette is in the second row and the top row holds only menu trigger and
39
+ * primary action: the label shows again when `menuRight + PALETTE_CLEARANCE` stays left of the
40
+ * labelled button, else it stays collapsed.
41
+ *
42
+ * Up to `SHELL.narrow` the palette is always in the second row (step 4).
43
+ */
44
+ export function decideTopRow(input: TopRowInput): TopRow {
45
+ const { containerWidth, paletteWidth, menuRight, primary } = input;
46
+ if (!primary) {
47
+ return {
48
+ paletteRow: decidePaletteRow({ containerWidth, paletteWidth, menuRight }),
49
+ primaryLabel: "shown",
50
+ };
51
+ }
52
+ const paletteRowBeside = (primaryWidth: number): PaletteRow =>
53
+ decidePaletteRow({
54
+ containerWidth,
55
+ paletteWidth,
56
+ menuRight,
57
+ primaryLeft: containerWidth - SHELL.edge - primaryWidth,
58
+ });
59
+ if (paletteRowBeside(primary.withLabel) === "top") return { paletteRow: "top", primaryLabel: "shown" };
60
+ if (paletteRowBeside(primary.iconOnly) === "top") return { paletteRow: "top", primaryLabel: "collapsed" };
61
+ const labelledLeft = containerWidth - SHELL.edge - primary.withLabel;
62
+ return {
63
+ paletteRow: "second-row",
64
+ primaryLabel: menuRight + PALETTE_CLEARANCE <= labelledLeft ? "shown" : "collapsed",
65
+ };
66
+ }
@@ -195,7 +195,10 @@ export class ShellDiagramService {
195
195
  private readonly onKeyboardKeydown = (context: KeyboardEventContext): boolean | undefined => {
196
196
  const event = context.keyEvent;
197
197
  if (!event) return undefined;
198
- if (matchChromeKey(event)) return !isTextField(event.target);
198
+ // `false` keeps every other diagram-js listener off the key without preventing it: in a text
199
+ // field the field keeps it, behind the web app's start screen the browser zooms the page.
200
+ if (matchChromeKey(event))
201
+ return !isTextField(event.target) && this.shell?.isEmptyStateShown?.() !== true;
199
202
  if (this.shell?.host === "ide" && isUndoRedoKey(event)) return false;
200
203
  return undefined;
201
204
  };
@@ -62,6 +62,13 @@ export interface Viewport extends ZoomTarget {
62
62
  * the user created on the canvas does not (guide §5.9), so the next import keeps the view.
63
63
  */
64
64
  afterImport(): void;
65
+ /**
66
+ * The web app opens another document (New diagram, Open…, Import…, Show example, a shared link,
67
+ * a dropped file): the next `afterImport()` counts as the first again (guide §5.9). Forgets the
68
+ * user's moves, a stored view and a pending restore, and stops a running fit animation, so
69
+ * non-empty content fits (jump) and an empty document shows 100 %. Call it before the import.
70
+ */
71
+ newDocument(): void;
65
72
  /** Restores a viewbox after a re-import (external change): reference updated, user-move state kept. */
66
73
  restore(viewbox: Bounds): void;
67
74
  /** The current viewbox (x, y, width, height), `undefined` before the canvas has a size. */
@@ -229,6 +236,18 @@ export class DiagramViewport implements Viewport {
229
236
  this.notify();
230
237
  }
231
238
 
239
+ newDocument(): void {
240
+ if (this.destroyed) return;
241
+ this.cancelAnimation();
242
+ this.userMoved = false;
243
+ this.firstFitPending = true;
244
+ this.emptyViewPending = true;
245
+ this.emptyViewDue = false;
246
+ this.autoFitDue = false;
247
+ this.pendingView = undefined;
248
+ this.fittable = undefined;
249
+ }
250
+
232
251
  restore(viewbox: Bounds): void {
233
252
  if (this.destroyed) return;
234
253
  this.importing = false;
@@ -0,0 +1,124 @@
1
+ /*
2
+ * Buttons of the web chrome (guide §8): `.cd-shell-button` with `data-variant="primary"` (the top
3
+ * right primary action, the empty state's "New diagram") or `data-variant="ghost"` ("Show
4
+ * example", a toast's action). 36px by default (dialogs, panels, empty state); the primary action
5
+ * of the top row sets its own 40px.
6
+ *
7
+ * The one stylesheet of the shell that reads fixed palette tokens, and only these three: the
8
+ * primary button is --cd-blau with --cd-weiss text in both modes, hover and active --cd-blau-link
9
+ * (guide §8: "Feste Werte nimmst du nur für die Fläche von Primär- und Destruktiv-Button"). The
10
+ * stylesheet test allows them here and nowhere else.
11
+ */
12
+
13
+ .cd-shell-button {
14
+ box-sizing: border-box;
15
+ display: inline-flex;
16
+ flex: none;
17
+ align-items: center;
18
+ justify-content: center;
19
+ gap: var(--cd-shell-luecke);
20
+ height: var(--cd-shell-eintrag);
21
+ margin: 0;
22
+ padding: 0 16px;
23
+ color: var(--cd-ui-text);
24
+ font: inherit;
25
+ font-size: 13px;
26
+ font-weight: 600;
27
+ line-height: 1;
28
+ letter-spacing: normal;
29
+ text-transform: none;
30
+ white-space: nowrap;
31
+ background: transparent;
32
+ border: 1px solid transparent;
33
+ border-radius: var(--cd-radius-md);
34
+ cursor: pointer;
35
+ user-select: none;
36
+ -webkit-user-select: none;
37
+ transition:
38
+ background-color var(--cd-motion-fast) var(--cd-ease),
39
+ border-color var(--cd-motion-fast) var(--cd-ease),
40
+ box-shadow var(--cd-motion-fast) var(--cd-ease),
41
+ color var(--cd-motion-fast) var(--cd-ease),
42
+ transform var(--cd-motion-fast) var(--cd-ease);
43
+ }
44
+
45
+ .cd-shell-button[hidden] {
46
+ display: none;
47
+ }
48
+
49
+ .cd-shell-button svg {
50
+ display: block;
51
+ flex: none;
52
+ }
53
+
54
+ .cd-shell-button[data-variant="primary"] {
55
+ color: var(--cd-weiss);
56
+ background: var(--cd-blau);
57
+ box-shadow: var(--cd-shadow-1);
58
+ }
59
+
60
+ .cd-shell-button[data-variant="primary"]:is(:hover, :active):not([aria-disabled="true"]) {
61
+ background: var(--cd-blau-link);
62
+ box-shadow: var(--cd-shadow-2);
63
+ transform: translateY(-1px);
64
+ }
65
+
66
+ .cd-shell-button[data-variant="ghost"]:is(:hover, :active):not([aria-disabled="true"]) {
67
+ background: var(--cd-ui-hover);
68
+ }
69
+
70
+ /* Disabled is opaque and neutral, never through opacity (guide §8). */
71
+ .cd-shell-button[aria-disabled="true"] {
72
+ color: var(--cd-ui-text-leise);
73
+ cursor: not-allowed;
74
+ }
75
+
76
+ .cd-shell-button[data-variant="primary"][aria-disabled="true"] {
77
+ background: var(--cd-ui-hover);
78
+ border-color: var(--cd-ui-linie);
79
+ box-shadow: none;
80
+ }
81
+
82
+ .cd-shell-button:focus {
83
+ outline: none;
84
+ }
85
+
86
+ .cd-shell-button:focus-visible {
87
+ outline: 2px solid var(--cd-ui-fokus);
88
+ outline-offset: 2px;
89
+ }
90
+
91
+ /* No lift under reduced motion (guide §7): the hover shows in fill and shadow alone. */
92
+ @media (prefers-reduced-motion: reduce) {
93
+ .cd-shell .cd-shell-button[data-variant]:is(:hover, :active) {
94
+ transform: none;
95
+ }
96
+ }
97
+
98
+ /* The host asks for it although the OS does not: VS Code's workbench.reduceMotion. */
99
+ html.cd-shell-reduce-motion .cd-shell .cd-shell-button[data-variant]:is(:hover, :active) {
100
+ transform: none;
101
+ }
102
+
103
+ .cd-shell:is([data-cd-kontrast="hoch"], [data-cd-kontrast="hoch"] *) .cd-shell-button {
104
+ box-shadow: none;
105
+ }
106
+
107
+ .cd-shell:is([data-cd-kontrast="hoch"], [data-cd-kontrast="hoch"] *) .cd-shell-button:focus-visible {
108
+ outline-width: 3px;
109
+ }
110
+
111
+ @media (forced-colors: active) {
112
+ .cd-shell .cd-shell-button {
113
+ border-color: ButtonText;
114
+ }
115
+
116
+ .cd-shell .cd-shell-button:focus-visible {
117
+ outline-color: Highlight;
118
+ }
119
+
120
+ .cd-shell .cd-shell-button[aria-disabled="true"] {
121
+ color: GrayText;
122
+ border-color: GrayText;
123
+ }
124
+ }
@@ -0,0 +1,184 @@
1
+ import {
2
+ APPEARANCE_STORAGE_KEY,
3
+ documentMode,
4
+ parseAppearanceChoice,
5
+ type AppearanceChoice,
6
+ type Theme,
7
+ } from "../../core/index.js";
8
+ import { createElement, toggleAttribute } from "../element.js";
9
+ import type { AppearanceHandle, StorageLike, WebAppearanceOptions } from "../types.js";
10
+
11
+ export interface WebAppearanceContext {
12
+ ownerDocument: Document;
13
+ themes: readonly Theme[];
14
+ /** Required when `themes` includes `"dark"`; read only then. */
15
+ options: WebAppearanceOptions | undefined;
16
+ }
17
+
18
+ export interface WebAppearanceController extends AppearanceHandle {
19
+ /** Stops following `prefers-contrast` and other tabs' `storage` events; leaves `<html>` as it is. */
20
+ destroy(): void;
21
+ }
22
+
23
+ /** On `<html>` for one frame of a mode switch (guide §7); shell-dom.css turns every transition off. */
24
+ export const NO_TRANSITION_CLASS = "cd-shell-no-transition";
25
+
26
+ const CONTRAST_QUERY = "(prefers-contrast: more)";
27
+ const SCHEME_MEDIA: Readonly<Record<Theme, string>> = {
28
+ light: "(prefers-color-scheme: light)",
29
+ dark: "(prefers-color-scheme: dark)",
30
+ };
31
+
32
+ /** `change` on a media query list; Safari before 14 only has `addListener`. */
33
+ function onMediaChange(query: MediaQueryList, listener: () => void): () => void {
34
+ if (typeof query.addEventListener === "function") {
35
+ query.addEventListener("change", listener);
36
+ return () => query.removeEventListener("change", listener);
37
+ }
38
+ query.addListener(listener);
39
+ return () => query.removeListener(listener);
40
+ }
41
+
42
+ /**
43
+ * The web host's mode at runtime (guide §13.3), after the head script (`webModeScript`) set it
44
+ * before the first paint. Always: mirrors `(prefers-contrast: more)` as `data-cd-kontrast="hoch"`
45
+ * on `<html>`, at once and on every change. With `"dark"` in `themes` (throws without `options`):
46
+ * `choice()` is the stored choice, read once through `parseAppearanceChoice` (a blocked storage
47
+ * reads `"system"`) and then kept by `setChoice` and other tabs' `storage` events, so a choice the
48
+ * storage refused still holds for the page. `setChoice` writes it (`"system"` removes the key; a
49
+ * throwing storage is ignored) and applies `documentMode` on `<html data-cd-theme>`,
50
+ * `<meta name="color-scheme">` and both `theme-color` metas (created where missing) in one task,
51
+ * with `cd-shell-no-transition` on `<html>` until a nested `requestAnimationFrame`; a `storage`
52
+ * event for the key applies the same way. Light-only: `choice()` is `"system"`, `setChoice` does
53
+ * nothing.
54
+ */
55
+ export function connectWebAppearance(context: WebAppearanceContext): WebAppearanceController {
56
+ const { ownerDocument, themes, options } = context;
57
+ const darkCapable = themes.includes("dark");
58
+ if (darkCapable && !options) {
59
+ throw new Error(
60
+ '@miragon/modeler-shell: a web modeler whose themes include "dark" needs the appearance option ' +
61
+ "({ themeColors } from its vendored cd-tokens.generated.json)",
62
+ );
63
+ }
64
+ const html = ownerDocument.documentElement;
65
+ const view = ownerDocument.defaultView;
66
+ const cleanups: (() => void)[] = [];
67
+
68
+ const contrast = typeof view?.matchMedia === "function" ? view.matchMedia(CONTRAST_QUERY) : undefined;
69
+ if (contrast) {
70
+ const mirrorContrast = (): void => toggleAttribute(html, "data-cd-kontrast", contrast.matches && "hoch");
71
+ mirrorContrast();
72
+ cleanups.push(onMediaChange(contrast, mirrorContrast));
73
+ }
74
+
75
+ function destroy(): void {
76
+ for (const cleanup of cleanups.splice(0)) cleanup();
77
+ }
78
+
79
+ if (!darkCapable || !options) {
80
+ return { choice: () => "system", setChoice() {}, destroy };
81
+ }
82
+ const { themeColors } = options;
83
+
84
+ function storage(): StorageLike | undefined {
85
+ if (options?.storage) return options.storage;
86
+ try {
87
+ return globalThis.localStorage;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ function readStored(): AppearanceChoice {
94
+ try {
95
+ return parseAppearanceChoice(storage()?.getItem(APPEARANCE_STORAGE_KEY));
96
+ } catch {
97
+ return "system";
98
+ }
99
+ }
100
+
101
+ function store(choice: AppearanceChoice): void {
102
+ try {
103
+ const target = storage();
104
+ if (choice === "system") target?.removeItem(APPEARANCE_STORAGE_KEY);
105
+ else target?.setItem(APPEARANCE_STORAGE_KEY, choice);
106
+ } catch {
107
+ // Blocked or full: the choice still applies for this page.
108
+ }
109
+ }
110
+
111
+ function nextFrame(callback: () => void): () => void {
112
+ if (view && typeof view.requestAnimationFrame === "function") {
113
+ const frame = view.requestAnimationFrame(() => callback());
114
+ return () => view.cancelAnimationFrame(frame);
115
+ }
116
+ const timer = setTimeout(callback, 16);
117
+ return () => clearTimeout(timer);
118
+ }
119
+
120
+ let cancelTransitionFrame: (() => void) | undefined;
121
+ function allowTransitions(): void {
122
+ cancelTransitionFrame?.();
123
+ cancelTransitionFrame = undefined;
124
+ html.classList.remove(NO_TRANSITION_CLASS);
125
+ }
126
+ cleanups.push(() => {
127
+ if (cancelTransitionFrame) allowTransitions();
128
+ });
129
+
130
+ // The first frame still computes styles with the new colours; only after it may they fade again.
131
+ function suspendTransitions(): void {
132
+ cancelTransitionFrame?.();
133
+ html.classList.add(NO_TRANSITION_CLASS);
134
+ cancelTransitionFrame = nextFrame(() => {
135
+ cancelTransitionFrame = nextFrame(allowTransitions);
136
+ });
137
+ }
138
+
139
+ function metaFor(name: string, media?: string): HTMLMetaElement {
140
+ const normalize = (value: string | null): string => (value ?? "").replace(/\s+/g, "").toLowerCase();
141
+ const existing = [...ownerDocument.querySelectorAll<HTMLMetaElement>(`meta[name="${name}"]`)].find(
142
+ (meta) => (media === undefined ? true : normalize(meta.getAttribute("media")) === normalize(media)),
143
+ );
144
+ if (existing) return existing;
145
+ const created = createElement(ownerDocument, "meta", { name, media });
146
+ (ownerDocument.head ?? html).append(created);
147
+ return created;
148
+ }
149
+
150
+ function apply(choice: AppearanceChoice): void {
151
+ const mode = documentMode(choice, themes, themeColors);
152
+ suspendTransitions();
153
+ html.setAttribute("data-cd-theme", mode.theme);
154
+ metaFor("color-scheme").setAttribute("content", mode.colorScheme);
155
+ metaFor("theme-color", SCHEME_MEDIA.light).setAttribute("content", mode.themeColor.light);
156
+ metaFor("theme-color", SCHEME_MEDIA.dark).setAttribute("content", mode.themeColor.dark);
157
+ }
158
+
159
+ let current = readStored();
160
+
161
+ const onStorage = (event: StorageEvent): void => {
162
+ // A null key is a clear() of the whole storage: the choice is gone, so "system".
163
+ if (event.key !== null && event.key !== APPEARANCE_STORAGE_KEY) return;
164
+ const next = event.key === null ? "system" : parseAppearanceChoice(event.newValue);
165
+ if (next === current) return;
166
+ current = next;
167
+ apply(next);
168
+ };
169
+ if (view) {
170
+ view.addEventListener("storage", onStorage);
171
+ cleanups.push(() => view.removeEventListener("storage", onStorage));
172
+ }
173
+
174
+ return {
175
+ choice: () => current,
176
+ setChoice(choice) {
177
+ const next = parseAppearanceChoice(choice);
178
+ current = next;
179
+ store(next);
180
+ apply(next);
181
+ },
182
+ destroy,
183
+ };
184
+ }