@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
@@ -1,8 +1,8 @@
1
1
  import { T as Theme, A as Appearance } from './appearance-D3FUcbu6.cjs';
2
- import { S as ShellOptions, a as ShellHandle } from './types-OuFlqZp0.cjs';
3
- import { V as ViewportState } from './types-B6NfkiUG.cjs';
2
+ import { S as ShellOptions, a as ShellHandle } from './types-CnRrINRs.cjs';
3
+ import { V as ViewportState } from './toasts-Bp0REqdq.cjs';
4
4
  export { A as APPEARANCE_MESSAGE_TYPE, a as AppearanceMessage, b as appearanceMessage, i as isAppearanceMessage } from './protocol-DhYBakWL.cjs';
5
- import './keymap-CObQE6q6.cjs';
5
+ import './appearance-XX3JC3pI.cjs';
6
6
 
7
7
  /**
8
8
  * Writes `data-cd-theme` and `data-cd-kontrast` on `element` (default `<html>`) in one task, with
@@ -34,7 +34,9 @@ declare function connectAppearance(options: AppearanceListenerOptions): () => vo
34
34
  */
35
35
  declare function connectReducedMotion(): () => void;
36
36
 
37
- interface VsCodeShellOptions extends Omit<ShellOptions, "host" | "keyScope" | "themes">, AppearanceListenerOptions {
37
+ /** The web column's options (0.2): a VS Code webview has none of them (guide §5.2, §13.5). */
38
+ type WebHostOptionKey = "primary" | "legal" | "file" | "appearance" | "emptyState" | "dropZone";
39
+ interface VsCodeShellOptions extends Omit<ShellOptions, "host" | "keyScope" | "themes" | WebHostOptionKey>, AppearanceListenerOptions {
38
40
  }
39
41
  /**
40
42
  * `mountShell` for a VS Code webview: `host: "ide"`, the key listener on `document` (VS Code's own
@@ -1,8 +1,8 @@
1
1
  import { T as Theme, A as Appearance } from './appearance-D3FUcbu6.js';
2
- import { S as ShellOptions, a as ShellHandle } from './types-DnZn9CPw.js';
3
- import { V as ViewportState } from './types-B6NfkiUG.js';
2
+ import { S as ShellOptions, a as ShellHandle } from './types-PuDZczzy.js';
3
+ import { V as ViewportState } from './toasts-Bp0REqdq.js';
4
4
  export { A as APPEARANCE_MESSAGE_TYPE, a as AppearanceMessage, b as appearanceMessage, i as isAppearanceMessage } from './protocol-IeW8bf4f.js';
5
- import './keymap-DORzHnK-.js';
5
+ import './appearance-BBaM_4bD.js';
6
6
 
7
7
  /**
8
8
  * Writes `data-cd-theme` and `data-cd-kontrast` on `element` (default `<html>`) in one task, with
@@ -34,7 +34,9 @@ declare function connectAppearance(options: AppearanceListenerOptions): () => vo
34
34
  */
35
35
  declare function connectReducedMotion(): () => void;
36
36
 
37
- interface VsCodeShellOptions extends Omit<ShellOptions, "host" | "keyScope" | "themes">, AppearanceListenerOptions {
37
+ /** The web column's options (0.2): a VS Code webview has none of them (guide §5.2, §13.5). */
38
+ type WebHostOptionKey = "primary" | "legal" | "file" | "appearance" | "emptyState" | "dropZone";
39
+ interface VsCodeShellOptions extends Omit<ShellOptions, "host" | "keyScope" | "themes" | WebHostOptionKey>, AppearanceListenerOptions {
38
40
  }
39
41
  /**
40
42
  * `mountShell` for a VS Code webview: `host: "ide"`, the key listener on `document` (VS Code's own
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  mountShell
3
- } from "./chunk-6LQOFACK.js";
3
+ } from "./chunk-A4YGKH4Y.js";
4
4
  import {
5
5
  APPEARANCE_MESSAGE_TYPE,
6
6
  appearanceMessage,
@@ -12,7 +12,7 @@ import {
12
12
  appearanceFromBodyClasses,
13
13
  reducedMotionFromBodyClasses,
14
14
  resolveAppearance
15
- } from "./chunk-5JSZ4VGA.js";
15
+ } from "./chunk-OADW3GPA.js";
16
16
 
17
17
  // src/vscode/webview/appearance.ts
18
18
  var NO_TRANSITION_CLASS = "cd-shell-no-transition";
@@ -137,6 +137,12 @@ function withDestroyHook(shell, beforeDestroy) {
137
137
  get inspector() {
138
138
  return shell.inspector;
139
139
  },
140
+ get primary() {
141
+ return shell.primary;
142
+ },
143
+ get appearance() {
144
+ return shell.appearance;
145
+ },
140
146
  setZoomTarget: (target) => shell.setZoomTarget(target),
141
147
  fitInsets: () => shell.fitInsets(),
142
148
  registerPalette: (palette) => shell.registerPalette(palette),
@@ -145,6 +151,9 @@ function withDestroyHook(shell, beforeDestroy) {
145
151
  setEmptyHint: (visible) => shell.setEmptyHint(visible),
146
152
  setReadOnly: (readOnly, options) => shell.setReadOnly(readOnly, options),
147
153
  isReadOnly: () => shell.isReadOnly(),
154
+ setEmptyState: (shown) => shell.setEmptyState(shown),
155
+ isEmptyStateShown: () => shell.isEmptyStateShown(),
156
+ toast: (request) => shell.toast(request),
148
157
  destroy() {
149
158
  beforeDestroy();
150
159
  shell.destroy();
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/vscode/webview/appearance.ts","../src/vscode/webview/mount.ts","../src/vscode/webview/state.ts"],"sourcesContent":["import {\n REDUCED_MOTION_CLASS,\n appearanceAttributes,\n appearanceFromBodyClasses,\n reducedMotionFromBodyClasses,\n resolveAppearance,\n type Appearance,\n type Theme,\n} from \"../../core/index.js\";\nimport { isAppearanceMessage } from \"../protocol.js\";\n\nconst NO_TRANSITION_CLASS = \"cd-shell-no-transition\";\n\n/** The latest mode switch per `<html>`: only its second frame may lift the transition stop. */\nconst latestSwitch = new WeakMap<Element, number>();\n\nfunction showsAppearance(element: Element, appearance: Appearance): boolean {\n const attributes = appearanceAttributes(appearance);\n return (\n element.getAttribute(\"data-cd-theme\") === attributes[\"data-cd-theme\"] &&\n element.getAttribute(\"data-cd-kontrast\") === (attributes[\"data-cd-kontrast\"] ?? null)\n );\n}\n\n/**\n * Writes `data-cd-theme` and `data-cd-kontrast` on `element` (default `<html>`) in one task, with\n * transitions suppressed for one frame: adds `cd-shell-no-transition` to `<html>`, sets both\n * attributes and removes the class in a nested `requestAnimationFrame` (after the last of several\n * quick switches).\n */\nexport function applyAppearance(appearance: Appearance, element?: HTMLElement): void {\n const target = element ?? document.documentElement;\n const html = target.ownerDocument.documentElement;\n const attributes = appearanceAttributes(appearance);\n const kontrast = attributes[\"data-cd-kontrast\"];\n\n const switchId = (latestSwitch.get(html) ?? 0) + 1;\n latestSwitch.set(html, switchId);\n html.classList.add(NO_TRANSITION_CLASS);\n target.setAttribute(\"data-cd-theme\", attributes[\"data-cd-theme\"]);\n if (kontrast) target.setAttribute(\"data-cd-kontrast\", kontrast);\n else target.removeAttribute(\"data-cd-kontrast\");\n\n requestAnimationFrame(() =>\n requestAnimationFrame(() => {\n if (latestSwitch.get(html) !== switchId) return;\n latestSwitch.delete(html);\n html.classList.remove(NO_TRANSITION_CLASS);\n }),\n );\n}\n\nexport interface AppearanceListenerOptions {\n /** The modes the modeler can show; the body-class fallback resolves against them. */\n themes: readonly Theme[];\n}\n\n/**\n * Listens for `AppearanceMessage`s on `window` and applies them to `<html>`, resolved against\n * `themes` once more so a webview never shows a mode its bundle cannot. A page whose `<html>` has\n * no `data-cd-theme` at connect (no `renderWebviewHtml`) falls back to the VS Code body classes,\n * through a `MutationObserver` on `<body>` until the first message (resolved against `themes`).\n * With the attributes of `renderWebviewHtml` there is no fallback: the extension's bridge knows the\n * `colorTheme` setting, the body classes do not, and VS Code changes them before the bridge's\n * message arrives, which would flash the mode without the setting. Returns the disconnect\n * function. Throws when `themes` is empty.\n */\nexport function connectAppearance(options: AppearanceListenerOptions): () => void {\n const { themes } = options;\n if (themes.length === 0) {\n throw new Error(\"@miragon/modeler-shell: connectAppearance needs at least one theme\");\n }\n const html = document.documentElement;\n const show = (requested: Appearance): void => {\n const resolved = resolveAppearance(requested, themes);\n if (!showsAppearance(html, resolved)) applyAppearance(resolved, html);\n };\n\n let fallbackActive = !html.hasAttribute(\"data-cd-theme\");\n const applyBodyClasses = (): void => {\n if (!fallbackActive || !document.body) return;\n const requested = appearanceFromBodyClasses(document.body.classList);\n if (requested) show(requested);\n };\n const observer = new MutationObserver(applyBodyClasses);\n const startFallback = (): void => {\n if (!fallbackActive || !document.body) return;\n observer.observe(document.body, { attributes: true, attributeFilter: [\"class\"] });\n applyBodyClasses();\n };\n const stopFallback = (): void => {\n if (!fallbackActive) return;\n fallbackActive = false;\n observer.disconnect();\n document.removeEventListener(\"DOMContentLoaded\", startFallback);\n };\n\n const onMessage = (event: MessageEvent): void => {\n if (!isAppearanceMessage(event.data)) return;\n stopFallback();\n show(event.data.appearance);\n };\n\n window.addEventListener(\"message\", onMessage);\n if (document.body) startFallback();\n else if (fallbackActive) document.addEventListener(\"DOMContentLoaded\", startFallback, { once: true });\n\n return () => {\n window.removeEventListener(\"message\", onMessage);\n fallbackActive = false;\n observer.disconnect();\n document.removeEventListener(\"DOMContentLoaded\", startFallback);\n };\n}\n\n/**\n * Mirrors VS Code's `workbench.reduceMotion` (the body class `vscode-reduce-motion`, which VS Code\n * keeps up to date) as `REDUCED_MOTION_CLASS` on `<html>` for the whole session: the shell's\n * animations stop and an explicit fit jumps, as under `prefers-reduced-motion` (guide §5.14, §7).\n * Returns the disconnect function, which removes the class again.\n */\nexport function connectReducedMotion(): () => void {\n const html = document.documentElement;\n const apply = (): void => {\n if (document.body)\n html.classList.toggle(REDUCED_MOTION_CLASS, reducedMotionFromBodyClasses(document.body.classList));\n };\n const observer = new MutationObserver(apply);\n const start = (): void => {\n if (!document.body) return;\n observer.observe(document.body, { attributes: true, attributeFilter: [\"class\"] });\n apply();\n };\n if (document.body) start();\n else document.addEventListener(\"DOMContentLoaded\", start, { once: true });\n return () => {\n observer.disconnect();\n document.removeEventListener(\"DOMContentLoaded\", start);\n html.classList.remove(REDUCED_MOTION_CLASS);\n };\n}\n","import { mountShell, type ShellHandle, type ShellOptions } from \"../../dom/index.js\";\nimport { connectAppearance, connectReducedMotion, type AppearanceListenerOptions } from \"./appearance.js\";\n\nexport interface VsCodeShellOptions\n extends Omit<ShellOptions, \"host\" | \"keyScope\" | \"themes\">, AppearanceListenerOptions {}\n\n/** The shell's handle with a `destroy` that runs `beforeDestroy` first; every other member delegates. */\nfunction withDestroyHook(shell: ShellHandle, beforeDestroy: () => void): ShellHandle {\n return {\n get root() {\n return shell.root;\n },\n get canvas() {\n return shell.canvas;\n },\n get host() {\n return shell.host;\n },\n get lang() {\n return shell.lang;\n },\n get platform() {\n return shell.platform;\n },\n get messages() {\n return shell.messages;\n },\n get menu() {\n return shell.menu;\n },\n get tooltip() {\n return shell.tooltip;\n },\n get legend() {\n return shell.legend;\n },\n get inspector() {\n return shell.inspector;\n },\n setZoomTarget: (target) => shell.setZoomTarget(target),\n fitInsets: () => shell.fitInsets(),\n registerPalette: (palette) => shell.registerPalette(palette),\n setProblems: (summary) => shell.setProblems(summary),\n setStatus: (status, options) => shell.setStatus(status, options),\n setEmptyHint: (visible) => shell.setEmptyHint(visible),\n setReadOnly: (readOnly, options) => shell.setReadOnly(readOnly, options),\n isReadOnly: () => shell.isReadOnly(),\n destroy() {\n beforeDestroy();\n shell.destroy();\n },\n };\n}\n\n/**\n * `mountShell` for a VS Code webview: `host: \"ide\"`, the key listener on `document` (VS Code's own\n * listener sits on `window`, so handled keys are stopped before it), `cd-shell-document` on\n * `<html>`, plus `connectAppearance` and `connectReducedMotion`. `destroy()` also disconnects both.\n */\nexport function mountVsCodeShell(root: HTMLElement, options: VsCodeShellOptions): ShellHandle {\n const { themes } = options;\n const disconnectAppearance = connectAppearance({ themes });\n const disconnectReducedMotion = connectReducedMotion();\n const disconnect = (): void => {\n disconnectAppearance();\n disconnectReducedMotion();\n };\n let shell: ShellHandle;\n try {\n shell = mountShell(root, { ...options, host: \"ide\", keyScope: \"document\" });\n } catch (error) {\n disconnect();\n throw error;\n }\n return withDestroyHook(shell, disconnect);\n}\n","import type { Bounds, ViewportState } from \"../../core/index.js\";\n\n/** `acquireVsCodeApi()` as far as the state helpers need it. */\nexport interface WebviewStateApi {\n getState(): unknown;\n setState(state: unknown): void;\n}\n\n/** What the shell keeps across a webview rebuild (guide §13.5: legend state and view). */\nexport interface ShellWebviewState {\n legendOpen?: boolean | undefined;\n view?: ViewportState | undefined;\n}\n\n/** The key under which the shell's part lives in the webview state; other keys stay untouched. */\nexport const SHELL_STATE_KEY = \"modelerShell\";\n\ntype StateRecord = Record<string, unknown>;\n\nfunction isRecord(value: unknown): value is StateRecord {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction storedState(api: WebviewStateApi): StateRecord {\n const state = api.getState();\n return isRecord(state) ? state : {};\n}\n\nfunction viewboxFrom(value: unknown): Bounds | undefined {\n if (!isRecord(value)) return undefined;\n const { x, y, width, height } = value;\n if (typeof x !== \"number\" || typeof y !== \"number\") return undefined;\n if (typeof width !== \"number\" || typeof height !== \"number\") return undefined;\n if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(width) || !Number.isFinite(height)) {\n return undefined;\n }\n return width > 0 && height > 0 ? { x, y, width, height } : undefined;\n}\n\nfunction viewFrom(value: unknown): ViewportState | undefined {\n if (!isRecord(value) || typeof value.userMoved !== \"boolean\") return undefined;\n const viewbox = viewboxFrom(value.viewbox);\n return viewbox ? { viewbox, userMoved: value.userMoved } : undefined;\n}\n\n/** The shell's part of `api.getState()`; `{}` when there is none or it is malformed. */\nexport function readShellState(api: WebviewStateApi): ShellWebviewState {\n const part = storedState(api)[SHELL_STATE_KEY];\n if (!isRecord(part)) return {};\n const state: ShellWebviewState = {};\n if (typeof part.legendOpen === \"boolean\") state.legendOpen = part.legendOpen;\n const view = viewFrom(part.view);\n if (view) state.view = view;\n return state;\n}\n\n/**\n * Merges `patch` into the shell's part and writes the whole state back, keeping every other key.\n * A value that is `undefined` (for example `viewport.getState()` before the first import) or\n * malformed leaves the stored value alone; nothing is written when nothing changed.\n */\nexport function writeShellState(api: WebviewStateApi, patch: ShellWebviewState): void {\n const state = storedState(api);\n const part = isRecord(state[SHELL_STATE_KEY]) ? (state[SHELL_STATE_KEY] as StateRecord) : {};\n const next: StateRecord = { ...part };\n if (typeof patch.legendOpen === \"boolean\") next.legendOpen = patch.legendOpen;\n const view = viewFrom(patch.view);\n if (view) next.view = view;\n if (JSON.stringify(next) === JSON.stringify(state[SHELL_STATE_KEY])) return;\n api.setState({ ...state, [SHELL_STATE_KEY]: next });\n}\n\n/** What `persistView` reads of a viewport (`connectDiagramJs(…).viewport`). */\nexport interface PersistableView {\n onChange(listener: () => void): () => void;\n getState(): ViewportState | undefined;\n}\n\nexport interface PersistViewOptions {\n /** Quiet time before the write, default 250ms. */\n delayMs?: number | undefined;\n}\n\n/** A pan, a wheel zoom or a fit animation changes the view on every frame. */\nconst PERSIST_VIEW_DELAY_MS = 250;\n\n/**\n * Keeps the stored view (`writeShellState(api, { view })`) up to date once the view has rested for\n * `delayMs`, instead of one `vscode.setState` IPC message per animation frame. Returns the stop\n * function; it writes a pending change at once.\n */\nexport function persistView(\n api: WebviewStateApi,\n viewport: PersistableView,\n options: PersistViewOptions = {},\n): () => void {\n const delayMs = options.delayMs ?? PERSIST_VIEW_DELAY_MS;\n let timer: ReturnType<typeof setTimeout> | undefined;\n const write = (): void => {\n timer = undefined;\n writeShellState(api, { view: viewport.getState() });\n };\n const unsubscribe = viewport.onChange(() => {\n clearTimeout(timer);\n timer = setTimeout(write, delayMs);\n });\n let stopped = false;\n return () => {\n if (stopped) return;\n stopped = true;\n unsubscribe();\n if (timer === undefined) return;\n clearTimeout(timer);\n write();\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/vscode/webview/appearance.ts","../src/vscode/webview/mount.ts","../src/vscode/webview/state.ts"],"sourcesContent":["import {\n REDUCED_MOTION_CLASS,\n appearanceAttributes,\n appearanceFromBodyClasses,\n reducedMotionFromBodyClasses,\n resolveAppearance,\n type Appearance,\n type Theme,\n} from \"../../core/index.js\";\nimport { isAppearanceMessage } from \"../protocol.js\";\n\nconst NO_TRANSITION_CLASS = \"cd-shell-no-transition\";\n\n/** The latest mode switch per `<html>`: only its second frame may lift the transition stop. */\nconst latestSwitch = new WeakMap<Element, number>();\n\nfunction showsAppearance(element: Element, appearance: Appearance): boolean {\n const attributes = appearanceAttributes(appearance);\n return (\n element.getAttribute(\"data-cd-theme\") === attributes[\"data-cd-theme\"] &&\n element.getAttribute(\"data-cd-kontrast\") === (attributes[\"data-cd-kontrast\"] ?? null)\n );\n}\n\n/**\n * Writes `data-cd-theme` and `data-cd-kontrast` on `element` (default `<html>`) in one task, with\n * transitions suppressed for one frame: adds `cd-shell-no-transition` to `<html>`, sets both\n * attributes and removes the class in a nested `requestAnimationFrame` (after the last of several\n * quick switches).\n */\nexport function applyAppearance(appearance: Appearance, element?: HTMLElement): void {\n const target = element ?? document.documentElement;\n const html = target.ownerDocument.documentElement;\n const attributes = appearanceAttributes(appearance);\n const kontrast = attributes[\"data-cd-kontrast\"];\n\n const switchId = (latestSwitch.get(html) ?? 0) + 1;\n latestSwitch.set(html, switchId);\n html.classList.add(NO_TRANSITION_CLASS);\n target.setAttribute(\"data-cd-theme\", attributes[\"data-cd-theme\"]);\n if (kontrast) target.setAttribute(\"data-cd-kontrast\", kontrast);\n else target.removeAttribute(\"data-cd-kontrast\");\n\n requestAnimationFrame(() =>\n requestAnimationFrame(() => {\n if (latestSwitch.get(html) !== switchId) return;\n latestSwitch.delete(html);\n html.classList.remove(NO_TRANSITION_CLASS);\n }),\n );\n}\n\nexport interface AppearanceListenerOptions {\n /** The modes the modeler can show; the body-class fallback resolves against them. */\n themes: readonly Theme[];\n}\n\n/**\n * Listens for `AppearanceMessage`s on `window` and applies them to `<html>`, resolved against\n * `themes` once more so a webview never shows a mode its bundle cannot. A page whose `<html>` has\n * no `data-cd-theme` at connect (no `renderWebviewHtml`) falls back to the VS Code body classes,\n * through a `MutationObserver` on `<body>` until the first message (resolved against `themes`).\n * With the attributes of `renderWebviewHtml` there is no fallback: the extension's bridge knows the\n * `colorTheme` setting, the body classes do not, and VS Code changes them before the bridge's\n * message arrives, which would flash the mode without the setting. Returns the disconnect\n * function. Throws when `themes` is empty.\n */\nexport function connectAppearance(options: AppearanceListenerOptions): () => void {\n const { themes } = options;\n if (themes.length === 0) {\n throw new Error(\"@miragon/modeler-shell: connectAppearance needs at least one theme\");\n }\n const html = document.documentElement;\n const show = (requested: Appearance): void => {\n const resolved = resolveAppearance(requested, themes);\n if (!showsAppearance(html, resolved)) applyAppearance(resolved, html);\n };\n\n let fallbackActive = !html.hasAttribute(\"data-cd-theme\");\n const applyBodyClasses = (): void => {\n if (!fallbackActive || !document.body) return;\n const requested = appearanceFromBodyClasses(document.body.classList);\n if (requested) show(requested);\n };\n const observer = new MutationObserver(applyBodyClasses);\n const startFallback = (): void => {\n if (!fallbackActive || !document.body) return;\n observer.observe(document.body, { attributes: true, attributeFilter: [\"class\"] });\n applyBodyClasses();\n };\n const stopFallback = (): void => {\n if (!fallbackActive) return;\n fallbackActive = false;\n observer.disconnect();\n document.removeEventListener(\"DOMContentLoaded\", startFallback);\n };\n\n const onMessage = (event: MessageEvent): void => {\n if (!isAppearanceMessage(event.data)) return;\n stopFallback();\n show(event.data.appearance);\n };\n\n window.addEventListener(\"message\", onMessage);\n if (document.body) startFallback();\n else if (fallbackActive) document.addEventListener(\"DOMContentLoaded\", startFallback, { once: true });\n\n return () => {\n window.removeEventListener(\"message\", onMessage);\n fallbackActive = false;\n observer.disconnect();\n document.removeEventListener(\"DOMContentLoaded\", startFallback);\n };\n}\n\n/**\n * Mirrors VS Code's `workbench.reduceMotion` (the body class `vscode-reduce-motion`, which VS Code\n * keeps up to date) as `REDUCED_MOTION_CLASS` on `<html>` for the whole session: the shell's\n * animations stop and an explicit fit jumps, as under `prefers-reduced-motion` (guide §5.14, §7).\n * Returns the disconnect function, which removes the class again.\n */\nexport function connectReducedMotion(): () => void {\n const html = document.documentElement;\n const apply = (): void => {\n if (document.body)\n html.classList.toggle(REDUCED_MOTION_CLASS, reducedMotionFromBodyClasses(document.body.classList));\n };\n const observer = new MutationObserver(apply);\n const start = (): void => {\n if (!document.body) return;\n observer.observe(document.body, { attributes: true, attributeFilter: [\"class\"] });\n apply();\n };\n if (document.body) start();\n else document.addEventListener(\"DOMContentLoaded\", start, { once: true });\n return () => {\n observer.disconnect();\n document.removeEventListener(\"DOMContentLoaded\", start);\n html.classList.remove(REDUCED_MOTION_CLASS);\n };\n}\n","import { mountShell, type ShellHandle, type ShellOptions } from \"../../dom/index.js\";\nimport { connectAppearance, connectReducedMotion, type AppearanceListenerOptions } from \"./appearance.js\";\n\n/** The web column's options (0.2): a VS Code webview has none of them (guide §5.2, §13.5). */\ntype WebHostOptionKey = \"primary\" | \"legal\" | \"file\" | \"appearance\" | \"emptyState\" | \"dropZone\";\n\nexport interface VsCodeShellOptions\n extends Omit<ShellOptions, \"host\" | \"keyScope\" | \"themes\" | WebHostOptionKey>, AppearanceListenerOptions {}\n\n/** The shell's handle with a `destroy` that runs `beforeDestroy` first; every other member delegates. */\nfunction withDestroyHook(shell: ShellHandle, beforeDestroy: () => void): ShellHandle {\n return {\n get root() {\n return shell.root;\n },\n get canvas() {\n return shell.canvas;\n },\n get host() {\n return shell.host;\n },\n get lang() {\n return shell.lang;\n },\n get platform() {\n return shell.platform;\n },\n get messages() {\n return shell.messages;\n },\n get menu() {\n return shell.menu;\n },\n get tooltip() {\n return shell.tooltip;\n },\n get legend() {\n return shell.legend;\n },\n get inspector() {\n return shell.inspector;\n },\n get primary() {\n return shell.primary;\n },\n get appearance() {\n return shell.appearance;\n },\n setZoomTarget: (target) => shell.setZoomTarget(target),\n fitInsets: () => shell.fitInsets(),\n registerPalette: (palette) => shell.registerPalette(palette),\n setProblems: (summary) => shell.setProblems(summary),\n setStatus: (status, options) => shell.setStatus(status, options),\n setEmptyHint: (visible) => shell.setEmptyHint(visible),\n setReadOnly: (readOnly, options) => shell.setReadOnly(readOnly, options),\n isReadOnly: () => shell.isReadOnly(),\n setEmptyState: (shown) => shell.setEmptyState(shown),\n isEmptyStateShown: () => shell.isEmptyStateShown(),\n toast: (request) => shell.toast(request),\n destroy() {\n beforeDestroy();\n shell.destroy();\n },\n };\n}\n\n/**\n * `mountShell` for a VS Code webview: `host: \"ide\"`, the key listener on `document` (VS Code's own\n * listener sits on `window`, so handled keys are stopped before it), `cd-shell-document` on\n * `<html>`, plus `connectAppearance` and `connectReducedMotion`. `destroy()` also disconnects both.\n */\nexport function mountVsCodeShell(root: HTMLElement, options: VsCodeShellOptions): ShellHandle {\n const { themes } = options;\n const disconnectAppearance = connectAppearance({ themes });\n const disconnectReducedMotion = connectReducedMotion();\n const disconnect = (): void => {\n disconnectAppearance();\n disconnectReducedMotion();\n };\n let shell: ShellHandle;\n try {\n shell = mountShell(root, { ...options, host: \"ide\", keyScope: \"document\" });\n } catch (error) {\n disconnect();\n throw error;\n }\n return withDestroyHook(shell, disconnect);\n}\n","import type { Bounds, ViewportState } from \"../../core/index.js\";\n\n/** `acquireVsCodeApi()` as far as the state helpers need it. */\nexport interface WebviewStateApi {\n getState(): unknown;\n setState(state: unknown): void;\n}\n\n/** What the shell keeps across a webview rebuild (guide §13.5: legend state and view). */\nexport interface ShellWebviewState {\n legendOpen?: boolean | undefined;\n view?: ViewportState | undefined;\n}\n\n/** The key under which the shell's part lives in the webview state; other keys stay untouched. */\nexport const SHELL_STATE_KEY = \"modelerShell\";\n\ntype StateRecord = Record<string, unknown>;\n\nfunction isRecord(value: unknown): value is StateRecord {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction storedState(api: WebviewStateApi): StateRecord {\n const state = api.getState();\n return isRecord(state) ? state : {};\n}\n\nfunction viewboxFrom(value: unknown): Bounds | undefined {\n if (!isRecord(value)) return undefined;\n const { x, y, width, height } = value;\n if (typeof x !== \"number\" || typeof y !== \"number\") return undefined;\n if (typeof width !== \"number\" || typeof height !== \"number\") return undefined;\n if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(width) || !Number.isFinite(height)) {\n return undefined;\n }\n return width > 0 && height > 0 ? { x, y, width, height } : undefined;\n}\n\nfunction viewFrom(value: unknown): ViewportState | undefined {\n if (!isRecord(value) || typeof value.userMoved !== \"boolean\") return undefined;\n const viewbox = viewboxFrom(value.viewbox);\n return viewbox ? { viewbox, userMoved: value.userMoved } : undefined;\n}\n\n/** The shell's part of `api.getState()`; `{}` when there is none or it is malformed. */\nexport function readShellState(api: WebviewStateApi): ShellWebviewState {\n const part = storedState(api)[SHELL_STATE_KEY];\n if (!isRecord(part)) return {};\n const state: ShellWebviewState = {};\n if (typeof part.legendOpen === \"boolean\") state.legendOpen = part.legendOpen;\n const view = viewFrom(part.view);\n if (view) state.view = view;\n return state;\n}\n\n/**\n * Merges `patch` into the shell's part and writes the whole state back, keeping every other key.\n * A value that is `undefined` (for example `viewport.getState()` before the first import) or\n * malformed leaves the stored value alone; nothing is written when nothing changed.\n */\nexport function writeShellState(api: WebviewStateApi, patch: ShellWebviewState): void {\n const state = storedState(api);\n const part = isRecord(state[SHELL_STATE_KEY]) ? (state[SHELL_STATE_KEY] as StateRecord) : {};\n const next: StateRecord = { ...part };\n if (typeof patch.legendOpen === \"boolean\") next.legendOpen = patch.legendOpen;\n const view = viewFrom(patch.view);\n if (view) next.view = view;\n if (JSON.stringify(next) === JSON.stringify(state[SHELL_STATE_KEY])) return;\n api.setState({ ...state, [SHELL_STATE_KEY]: next });\n}\n\n/** What `persistView` reads of a viewport (`connectDiagramJs(…).viewport`). */\nexport interface PersistableView {\n onChange(listener: () => void): () => void;\n getState(): ViewportState | undefined;\n}\n\nexport interface PersistViewOptions {\n /** Quiet time before the write, default 250ms. */\n delayMs?: number | undefined;\n}\n\n/** A pan, a wheel zoom or a fit animation changes the view on every frame. */\nconst PERSIST_VIEW_DELAY_MS = 250;\n\n/**\n * Keeps the stored view (`writeShellState(api, { view })`) up to date once the view has rested for\n * `delayMs`, instead of one `vscode.setState` IPC message per animation frame. Returns the stop\n * function; it writes a pending change at once.\n */\nexport function persistView(\n api: WebviewStateApi,\n viewport: PersistableView,\n options: PersistViewOptions = {},\n): () => void {\n const delayMs = options.delayMs ?? PERSIST_VIEW_DELAY_MS;\n let timer: ReturnType<typeof setTimeout> | undefined;\n const write = (): void => {\n timer = undefined;\n writeShellState(api, { view: viewport.getState() });\n };\n const unsubscribe = viewport.onChange(() => {\n clearTimeout(timer);\n timer = setTimeout(write, delayMs);\n });\n let stopped = false;\n return () => {\n if (stopped) return;\n stopped = true;\n unsubscribe();\n if (timer === undefined) return;\n clearTimeout(timer);\n write();\n };\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@miragon/modeler-shell",
3
- "version": "0.1.0",
4
- "description": "The shared, framework-free chrome around Miragon diagram-js modelers: menu, palette skin, tooltip, zoom panel, keys, fit, problems pill, status box, legend and inspector slots, and the VS Code host plumbing.",
3
+ "version": "0.2.1",
4
+ "description": "The shared, framework-free chrome around Miragon diagram-js modelers: menu, palette skin, tooltip, zoom panel, keys, fit, problems pill, status box, legend and inspector slots, the VS Code host plumbing and the web-app chrome (primary action, legal line, toasts, empty state, drop zone).",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -0,0 +1,141 @@
1
+ import type { Theme } from "../appearance.js";
2
+
3
+ /** The web app's mode choice (guide §13.3): Appearance, System · Light · Dark. */
4
+ export type AppearanceChoice = "system" | "light" | "dark";
5
+
6
+ /** Where the web app keeps the choice: `light` or `dark`; "System" removes the entry (guide §13.3). */
7
+ export const APPEARANCE_STORAGE_KEY = "miragon.darstellung";
8
+
9
+ /**
10
+ * The two `theme-color` values (guide §11, §13.3): light is the app's own value (`#335DE5`, `fest`
11
+ * `--cd-blau` of the vendored `cd-tokens.generated.json`), dark `#1D1D1D` (`dunkel`
12
+ * `--cd-ui-grund`). The modeler reads both from its vendored JSON; the shell keeps no colour.
13
+ */
14
+ export interface ThemeColors {
15
+ light: string;
16
+ dark: string;
17
+ }
18
+
19
+ /** `"light"` and `"dark"` pass, everything else (a missing entry, a foreign value) is `"system"`. */
20
+ export function parseAppearanceChoice(stored: unknown): AppearanceChoice {
21
+ return stored === "light" || stored === "dark" ? stored : "system";
22
+ }
23
+
24
+ /** What `<html>` and the meta tags carry for a choice. */
25
+ export interface DocumentMode {
26
+ /** `data-cd-theme` on `<html>`. */
27
+ theme: "system" | Theme;
28
+ /** `<meta name="color-scheme" content>`. */
29
+ colorScheme: "light dark" | Theme;
30
+ /** The `content` of the `theme-color` metas with `media="(prefers-color-scheme: light|dark)"`. */
31
+ themeColor: ThemeColors;
32
+ }
33
+
34
+ /**
35
+ * The document mode for `choice` (guide §13.3). A modeler that cannot show dark (`themes` without
36
+ * `"dark"`) is `light` whatever the choice: theme `light`, color-scheme `light`, both theme
37
+ * colours `colors.light`. Dark-capable: `system` → theme `system`, color-scheme `light dark`,
38
+ * `colors` as they are; `light`/`dark` → that theme and color-scheme, both theme colours the
39
+ * chosen mode's, so the browser bar matches a fixed choice under the other system mode.
40
+ */
41
+ export function documentMode(
42
+ choice: AppearanceChoice,
43
+ themes: readonly Theme[],
44
+ colors: ThemeColors,
45
+ ): DocumentMode {
46
+ if (!themes.includes("dark")) return fixedMode("light", colors.light);
47
+ const parsed = parseAppearanceChoice(choice);
48
+ if (parsed === "system") {
49
+ return {
50
+ theme: "system",
51
+ colorScheme: "light dark",
52
+ themeColor: { light: colors.light, dark: colors.dark },
53
+ };
54
+ }
55
+ return fixedMode(parsed, colors[parsed]);
56
+ }
57
+
58
+ function fixedMode(theme: Theme, color: string): DocumentMode {
59
+ return { theme, colorScheme: theme, themeColor: { light: color, dark: color } };
60
+ }
61
+
62
+ export interface WebModeScriptOptions {
63
+ /** The modes the modeler can show, as passed to `mountShell`. */
64
+ themes: readonly Theme[];
65
+ themeColors: ThemeColors;
66
+ }
67
+
68
+ /**
69
+ * The body of the inline `<script>` in `<head>`, before the first stylesheet (guide §13.3). With
70
+ * `themes` including `"dark"`: the guide's script, which reads `APPEARANCE_STORAGE_KEY` inside
71
+ * `try`/`catch`, writes `data-cd-theme`, `color-scheme` and both `theme-color` metas for a fixed
72
+ * choice and sets `data-cd-kontrast="hoch"` under `(prefers-contrast: more)`. Light-only: the
73
+ * contrast line alone. Plain ES5 without the modeler's bundle, so it runs before the first paint.
74
+ */
75
+ export function webModeScript(options: WebModeScriptOptions): string {
76
+ const contrast = [
77
+ `if (window.matchMedia && window.matchMedia(${scriptString("(prefers-contrast: more)")}).matches)`,
78
+ ` document.documentElement.setAttribute("data-cd-kontrast", "hoch");`,
79
+ ];
80
+ if (!options.themes.includes("dark")) return contrast.join("\n");
81
+ const { light, dark } = options.themeColors;
82
+ return [
83
+ "(function () {",
84
+ " var html = document.documentElement;",
85
+ " var choice = null;",
86
+ " try {",
87
+ ` choice = window.localStorage.getItem(${scriptString(APPEARANCE_STORAGE_KEY)});`,
88
+ " } catch (error) {}",
89
+ ` if (choice === "light" || choice === "dark") {`,
90
+ ` var color = choice === "dark" ? ${scriptString(dark)} : ${scriptString(light)};`,
91
+ ` html.setAttribute("data-cd-theme", choice);`,
92
+ ` var scheme = document.querySelector("meta[name=color-scheme]");`,
93
+ ` if (scheme) scheme.setAttribute("content", choice);`,
94
+ ` var metas = document.querySelectorAll("meta[name=theme-color]");`,
95
+ ` for (var index = 0; index < metas.length; index++) metas[index].setAttribute("content", color);`,
96
+ " }",
97
+ ...contrast.map((line) => ` ${line}`),
98
+ "})();",
99
+ ].join("\n");
100
+ }
101
+
102
+ // The script sits inline in <head>: "<" never appears in a literal, so a value cannot end the
103
+ // element ("</script>"), and U+2028/U+2029 are escaped because ES5 forbids them in string literals.
104
+ function scriptString(value: string): string {
105
+ return JSON.stringify(value)
106
+ .replace(/</g, "\\u003c")
107
+ .replace(/\u2028/g, "\\u2028")
108
+ .replace(/\u2029/g, "\\u2029");
109
+ }
110
+
111
+ /**
112
+ * The `<head>` markup the web app's `index.html` carries for its mode, as one string (for a Vite
113
+ * `transformIndexHtml` hook or to paste): dark-capable `<meta name="color-scheme" content="light
114
+ * dark">`, two `theme-color` metas with `media` and the script; light-only one `theme-color`
115
+ * without `media` and the contrast script. `<html>` itself carries `lang` and
116
+ * `data-cd-theme="<documentMode("system", themes, colors).theme>"` statically.
117
+ */
118
+ export function webHeadMarkup(options: WebModeScriptOptions): string {
119
+ const mode = documentMode("system", options.themes, options.themeColors);
120
+ const script = `<script>\n${webModeScript(options)}\n</script>`;
121
+ if (mode.theme !== "system") {
122
+ return [`<meta name="theme-color" content="${escapeAttribute(mode.themeColor.light)}">`, script].join(
123
+ "\n",
124
+ );
125
+ }
126
+ return [
127
+ `<meta name="color-scheme" content="${escapeAttribute(mode.colorScheme)}">`,
128
+ `<meta name="theme-color" content="${escapeAttribute(mode.themeColor.light)}" media="(prefers-color-scheme: light)">`,
129
+ `<meta name="theme-color" content="${escapeAttribute(mode.themeColor.dark)}" media="(prefers-color-scheme: dark)">`,
130
+ script,
131
+ ].join("\n");
132
+ }
133
+
134
+ function escapeAttribute(value: string): string {
135
+ return value
136
+ .replace(/&/g, "&amp;")
137
+ .replace(/"/g, "&quot;")
138
+ .replace(/'/g, "&#39;")
139
+ .replace(/</g, "&lt;")
140
+ .replace(/>/g, "&gt;");
141
+ }
@@ -0,0 +1,23 @@
1
+ /** The severity of one problem; the pill and the list show `circle-x` or `triangle-alert` (guide §5.10). */
2
+ export type ProblemSeverity = "error" | "warning";
3
+
4
+ /** One row of the web app's problems list (guide §5.10). */
5
+ export interface ProblemItem {
6
+ severity: ProblemSeverity;
7
+ /** 13px `--cd-ui-text`: "Unknown sticky type “evnt”." */
8
+ message: string;
9
+ /** The place in 13px `--cd-ui-text-leise` below the message: "Line 12", "“Order Placed”". */
10
+ location?: string | undefined;
11
+ /** The diagram element a click on the row selects (`ProblemsOptions.onSelectItem`). */
12
+ elementId?: string | undefined;
13
+ }
14
+
15
+ /** `ShellHandle.setProblems` input built from the items: count, errors and the items themselves. */
16
+ export function problemSummary(items: readonly ProblemItem[]): {
17
+ count: number;
18
+ errors: number;
19
+ items: readonly ProblemItem[];
20
+ } {
21
+ const errors = items.filter((item) => item.severity === "error").length;
22
+ return { count: items.length, errors, items };
23
+ }
@@ -0,0 +1,118 @@
1
+ import { SHELL } from "../sizes.js";
2
+
3
+ /** The statuses of guide §3.3 and §9; each colours bar and dot through `--cd-ui-<status>`. */
4
+ export type ToastStatus = "success" | "info" | "warning" | "danger";
5
+
6
+ /** A ghost button before Close (guide §8); the toast then never closes by itself. */
7
+ export interface ToastAction {
8
+ label: string;
9
+ /** Runs after the toast closed. */
10
+ onSelect(): void;
11
+ }
12
+
13
+ export interface ToastRequest {
14
+ status: ToastStatus;
15
+ /** What happened and what you can do (guide §2): "Link copied", "Couldn't read this file. …". */
16
+ text: string;
17
+ action?: ToastAction | undefined;
18
+ /** Only to give a long text more time than `TOAST_DURATION_MS`; ignored with an `action`. */
19
+ durationMs?: number | undefined;
20
+ }
21
+
22
+ /** At most three toasts at once, the newest at the bottom (guide §8). */
23
+ export const MAX_TOASTS = 3;
24
+
25
+ /**
26
+ * How long a toast stays without hover or focus (guide §8: about 2.8 s, errors about 5 s). A
27
+ * warning asks you to read a sentence and act on it like an error, so it gets the error's 5 s.
28
+ */
29
+ export const TOAST_DURATION_MS: Readonly<Record<ToastStatus, number>> = {
30
+ success: 2800,
31
+ info: 2800,
32
+ warning: 5000,
33
+ danger: 5000,
34
+ };
35
+
36
+ /** The stack's widest toast: `min(480px, 100cqw − 24px)` (guide §8). */
37
+ export const TOAST_MAX_WIDTH = 480;
38
+
39
+ /**
40
+ * Milliseconds until the toast closes by itself: `undefined` with an `action` (it never closes by
41
+ * itself, guide §8), else `max(TOAST_DURATION_MS[status], durationMs ?? 0)`.
42
+ */
43
+ export function toastLifetime(request: ToastRequest): number | undefined {
44
+ if (request.action) return undefined;
45
+ const requested = request.durationMs;
46
+ // A NaN or an infinite duration would reach setTimeout, which fires at once for both.
47
+ const longer = requested !== undefined && Number.isFinite(requested) ? requested : 0;
48
+ return Math.max(TOAST_DURATION_MS[request.status], longer);
49
+ }
50
+
51
+ /** A toast in the stack as the eviction sees it, oldest first. */
52
+ export interface ToastSlot {
53
+ id: number;
54
+ hasAction: boolean;
55
+ /** Hovered or holding the focus. */
56
+ engaged: boolean;
57
+ }
58
+
59
+ /**
60
+ * Which toast leaves when a fourth arrives (`MAX_TOASTS`): the oldest that is neither engaged nor
61
+ * has an action, else the oldest that is not engaged, else the oldest. `undefined` while the
62
+ * stack has room.
63
+ */
64
+ export function toastToEvict(stack: readonly ToastSlot[]): number | undefined {
65
+ if (stack.length < MAX_TOASTS) return undefined;
66
+ const evicted =
67
+ stack.find((slot) => !slot.engaged && !slot.hasAction) ?? stack.find((slot) => !slot.engaged) ?? stack[0];
68
+ return evicted?.id;
69
+ }
70
+
71
+ /** Something in the bottom row the toast stack must clear, relative to the shell root. */
72
+ export interface BottomObstacle {
73
+ left: number;
74
+ right: number;
75
+ /** Distance of its top edge from the container's bottom edge. */
76
+ top: number;
77
+ }
78
+
79
+ export interface ToastStackInput {
80
+ containerWidth: number;
81
+ /** The visible zoom panel, legal line and problems pill (not the legend card). */
82
+ obstacles: readonly BottomObstacle[];
83
+ /**
84
+ * Measured width of the widest toast in the stack. The cards are sized to their text and centred
85
+ * in the stack, so only this column can touch an obstacle; absent = the whole `toastStackWidth`.
86
+ */
87
+ cardWidth?: number | undefined;
88
+ }
89
+
90
+ /** `min(TOAST_MAX_WIDTH, containerWidth − 2 · SHELL.edge)`. */
91
+ export function toastStackWidth(containerWidth: number): number {
92
+ return Math.max(0, Math.min(TOAST_MAX_WIDTH, containerWidth - 2 * SHELL.edge));
93
+ }
94
+
95
+ /**
96
+ * The stack's `bottom` in px. Guide §8 anchors it at `bottom: 12px`, centred; where the column of
97
+ * its widest card (`cardWidth`, at most `toastStackWidth`, centred) comes closer than `SHELL.gap`
98
+ * horizontally to an obstacle, the stack stands `SHELL.gap` above the highest such obstacle instead
99
+ * (above the legal line 40, above the zoom panel 60, above a raised zoom panel or the web pill 88).
100
+ * Guide §8 does not settle this collision of a narrow container; it is the shell's rule until it does.
101
+ */
102
+ export function toastStackBottom(input: ToastStackInput): number {
103
+ const stackWidth = toastStackWidth(input.containerWidth);
104
+ const { cardWidth } = input;
105
+ const width =
106
+ cardWidth !== undefined && Number.isFinite(cardWidth)
107
+ ? Math.min(stackWidth, Math.max(0, cardWidth))
108
+ : stackWidth;
109
+ const left = (input.containerWidth - width) / 2;
110
+ const right = left + width;
111
+ let highest: number | undefined;
112
+ for (const obstacle of input.obstacles) {
113
+ const distance = Math.max(obstacle.left - right, left - obstacle.right);
114
+ if (distance >= SHELL.gap) continue;
115
+ highest = highest === undefined ? obstacle.top : Math.max(highest, obstacle.top);
116
+ }
117
+ return highest === undefined ? SHELL.edge : Math.max(SHELL.edge, highest + SHELL.gap);
118
+ }
package/src/core/fit.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SHELL } from "./sizes.js";
2
+ import { LEGAL_LINE_HEIGHT, legalLineHeight } from "./web-chrome/bottom-row.js";
2
3
  import type { Bounds, Insets, PaletteRow, Point, ScaledViewbox, ShellHost, Size } from "./types.js";
3
4
 
4
5
  /** Fit never zooms above 100 % (guide §5.9). */
@@ -73,6 +74,17 @@ export interface FitLayout {
73
74
  paletteRow: PaletteRow;
74
75
  /** The problems pill is shown (only the web app reserves room for it). */
75
76
  problemsVisible: boolean;
77
+ /**
78
+ * Web app: the zoom panel stands above the legal line (`bottom: 40px`) because the two would
79
+ * meet in the bottom row of a narrow container (`zoomRowRaised`); the bottom then keeps 92px as
80
+ * with the pill.
81
+ */
82
+ zoomRaised?: boolean | undefined;
83
+ /**
84
+ * Web app: the measured height of the legal line (`LEGAL_LINE_HEIGHT`, more where a narrow
85
+ * container wraps it); the raised bottom row stands on it. Default one line.
86
+ */
87
+ legalHeight?: number | undefined;
76
88
  /** Rendered width of the open legend; `undefined` when it is closed or the modeler has none. */
77
89
  legendWidth?: number | undefined;
78
90
  /**
@@ -83,16 +95,19 @@ export interface FitLayout {
83
95
  }
84
96
 
85
97
  /**
86
- * The fit insets of guide §5.9: top 88 (second row 140), sides 24, bottom 64 (web app with pill 92),
98
+ * The fit insets of guide §5.9: top 88 (second row 140), sides 24, bottom 64 (web app with pill or
99
+ * with the zoom panel raised above the legal line 92, plus 20 per extra line of a wrapped legal line),
87
100
  * right 324 with an open side inspector, left 12 + legend width + 24 with an open legend above
88
101
  * `SHELL.veryNarrow`, bottom = distance to the sheet's top edge + 12 with an open bottom sheet.
89
102
  */
90
103
  export function fitInsets(layout: FitLayout): Insets {
91
- const { container, host, paletteRow, problemsVisible, legendWidth, inspector } = layout;
104
+ const { container, host, paletteRow, problemsVisible, zoomRaised, legendWidth, inspector } = layout;
105
+ const raisedBottomRow = host === "web" && (problemsVisible || zoomRaised === true);
106
+ const wrappedLegalLines = legalLineHeight(layout.legalHeight) - LEGAL_LINE_HEIGHT;
92
107
  const insets: Insets = {
93
108
  top: paletteRow === "second-row" ? SHELL.fitInsetTopSecondRow : SHELL.fitInsetTop,
94
109
  right: SHELL.fitInsetSide,
95
- bottom: host === "web" && problemsVisible ? SHELL.fitInsetBottomWeb : SHELL.fitInsetBottom,
110
+ bottom: raisedBottomRow ? SHELL.fitInsetBottomWeb + wrappedLegalLines : SHELL.fitInsetBottom,
96
111
  left: SHELL.fitInsetSide,
97
112
  };
98
113
 
package/src/core/index.ts CHANGED
@@ -52,6 +52,7 @@ export {
52
52
  TEXT_FIELD_SELECTOR,
53
53
  ariaKeyShortcuts,
54
54
  ariaKeyShortcutsFromLabel,
55
+ commandKeyLabel,
55
56
  decideKey,
56
57
  detectPlatform,
57
58
  isUndoRedoKey,
@@ -82,3 +83,45 @@ export {
82
83
  legendStartsOpen,
83
84
  placeTooltip,
84
85
  } from "./layout.js";
86
+
87
+ // ---- Web host (0.2) ----
88
+
89
+ export type { PrimaryWidths, TopRow, TopRowInput } from "./web-chrome/top-row.js";
90
+ export { decideTopRow } from "./web-chrome/top-row.js";
91
+ export type { BottomRowInput } from "./web-chrome/bottom-row.js";
92
+ export { LEGAL_LINE_HEIGHT, legalLineHeight, zoomRowRaised } from "./web-chrome/bottom-row.js";
93
+ export type { DragContext, DragItemLike, DragVerdict, DropForbiddenReason } from "./web-chrome/drop.js";
94
+ export { acceptsFile, dragVerdict } from "./web-chrome/drop.js";
95
+
96
+ export type {
97
+ BottomObstacle,
98
+ ToastAction,
99
+ ToastRequest,
100
+ ToastSlot,
101
+ ToastStackInput,
102
+ ToastStatus,
103
+ } from "./feedback/toasts.js";
104
+ export {
105
+ MAX_TOASTS,
106
+ TOAST_DURATION_MS,
107
+ TOAST_MAX_WIDTH,
108
+ toastLifetime,
109
+ toastStackBottom,
110
+ toastStackWidth,
111
+ toastToEvict,
112
+ } from "./feedback/toasts.js";
113
+ export type {
114
+ AppearanceChoice,
115
+ DocumentMode,
116
+ ThemeColors,
117
+ WebModeScriptOptions,
118
+ } from "./feedback/appearance.js";
119
+ export {
120
+ APPEARANCE_STORAGE_KEY,
121
+ documentMode,
122
+ parseAppearanceChoice,
123
+ webHeadMarkup,
124
+ webModeScript,
125
+ } from "./feedback/appearance.js";
126
+ export type { ProblemItem, ProblemSeverity } from "./feedback/problems.js";
127
+ export { problemSummary } from "./feedback/problems.js";
@@ -74,6 +74,11 @@ export interface KeyContext {
74
74
  modalOpen: boolean;
75
75
  /** `event.defaultPrevented` when the listener ran: the canvas (or the modeler) handled the key. */
76
76
  defaultPrevented: boolean;
77
+ /**
78
+ * The web app's start screen shows (`setEmptyState(true)`): there is no diagram to zoom, so the
79
+ * zoom keys and Shift+1 are left to the browser, which zooms the page.
80
+ */
81
+ startScreen?: boolean | undefined;
77
82
  }
78
83
 
79
84
  export type KeyAction = ChromeKey | "closeMenu";
@@ -91,6 +96,7 @@ export interface KeyDecision {
91
96
  * or a dialog/popover is open. During IME composition Escape belongs to the IME: no `closeMenu`.
92
97
  * 2. Zoom keys: executed and prevented unless composing, in a text field or with a modal dialog;
93
98
  * in the IDE host always stopped, also when not executed (VS Code must never zoom the window).
99
+ * Behind the web app's start screen untouched: the browser zooms the page (WCAG 1.4.4).
94
100
  * 3. Shift+1: `fit` under the same conditions; otherwise untouched (it types "!").
95
101
  * 4. IDE host, text field, undo/redo key: stopped only (the field keeps its native undo).
96
102
  * 5. IDE host, `defaultPrevented`: stopped (the canvas handled it; VS Code must not run it again).
@@ -109,6 +115,7 @@ export function decideKey(event: KeyLike, context: KeyContext): KeyDecision {
109
115
 
110
116
  const chromeKey = matchChromeKey(event);
111
117
  if (chromeKey !== undefined) {
118
+ if (context.startScreen === true && !stop) return { preventDefault: false, stopPropagation: false };
112
119
  const blocked = composing || context.inTextField || context.modalOpen;
113
120
  if (!blocked) return { action: chromeKey, preventDefault: true, stopPropagation: stop };
114
121
  // Blocked zoom keys are still kept from VS Code (it would zoom the window); a blocked Shift+1
@@ -162,6 +169,19 @@ export function shortcutLabel(
162
169
  }
163
170
  }
164
171
 
172
+ /**
173
+ * The chip of a Cmd/Ctrl key a modeler binds itself (the web app's File entries, guide §5.4): macOS
174
+ * `⌘O`, elsewhere `Ctrl+O` with the key name of the messages table (`Strg+O`). `key` as printed on
175
+ * the key cap; pass the result to `FileMenuAction.shortcut` or `MenuActionEntry.shortcut`.
176
+ */
177
+ export function commandKeyLabel(
178
+ key: string,
179
+ platform: Platform,
180
+ keyNames: Pick<ShellMessages["keyNames"], "ctrl">,
181
+ ): string {
182
+ return platform === "mac" ? `⌘${key}` : `${keyNames.ctrl}+${key}`;
183
+ }
184
+
165
185
  const MAC_SHORTCUT_LABELS: Readonly<Record<ChromeKey, string>> = {
166
186
  zoomIn: "⌘+",
167
187
  zoomOut: "⌘\u2212",