Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 213 additions & 20 deletions docs/shell/implementation-notes.md

Large diffs are not rendered by default.

1,135 changes: 1,051 additions & 84 deletions packages/shell/API.md

Large diffs are not rendered by default.

193 changes: 162 additions & 31 deletions packages/shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

The shared, framework-free chrome around Miragon diagram-js modelers: the menu button with its
dropdown, the horizontal palette skin, the chrome tooltip, the zoom panel with its keys and fit, the
problems pill, the status box, the empty-file hint, legend and inspector slots, and the plumbing to
a VS Code webview. One implementation for every modeler and every host (VS Code webview, web app,
problems pill, the status box, the empty-file hint, legend and inspector slots, the plumbing to a
VS Code webview, and the web-app chrome (primary action, legal line, File and Appearance groups,
problems list, toasts, empty-state card, drop zone, the web app's mode). One implementation for every modeler and every host (VS Code webview, web app,
embedded in a foreign page), so the chrome looks and behaves the same everywhere.

- **Look and behaviour** follow the Miragon design system, guide
Expand Down Expand Up @@ -154,7 +155,7 @@ const shell = mountVsCodeShell(document.getElementById("app")!, {
menu: {
model: [/* Level; Type captions with keepEnabledWhenReadOnly, a view toggle */],
export: [/* Export as PNG, SVG */],
help: [/* Keyboard shortcuts… (the modeler's dialog in 0.1) */],
help: [/* Keyboard shortcuts… (the modeler's own dialog) */],
},
legend: {
render: renderLegend,
Expand Down Expand Up @@ -225,31 +226,94 @@ resize, delete, paste or label editor. Content the modeler adds to `shell.root`
shortcuts…" dialog) is the modeler's too: it removes it in its own teardown, for example on
`diagram.destroy`, since `shell.destroy()` removes only what the shell added. The complete Event
Storming example, including the export notification, is in [API.md][api]. Document sync, undo through
the document and diagnostics stay in the modeler for 0.1.
the document and diagnostics stay in the modeler for now.

## Usage in a web app

The page's head writes the mode before the first paint. A light-only modeler keeps
`<html lang="en" data-cd-theme="light">` static and lets the build put the metas and the contrast
line in place of a marker in `index.html`:

```ts
// vite.config.ts
import { webHeadMarkup } from "@miragon/modeler-shell/core";
import tokens from "./src/theme/cd-tokens.generated.json" with { type: "json" };

const themeColors = { light: tokens.fest["--cd-blau"], dark: tokens.dunkel["--cd-ui-grund"] };
const modePlugin = {
name: "miragon-mode",
transformIndexHtml: (html: string) =>
html.replace("<!-- miragon-mode -->", webHeadMarkup({ themes: ["light"], themeColors })),
};
```

```ts
import { mountShell } from "@miragon/modeler-shell";
// src/main.ts
import {
commandKeyLabel,
defaultMessages,
detectPlatform,
mountShell,
problemSummary,
} from "@miragon/modeler-shell";
import { connectDiagramJs, shellModule } from "@miragon/modeler-shell/diagram-js";

// index.html carries <html lang="en" data-cd-theme="light"> statically while the modeler is light-only
// The diagram first: a modeler keyboard on document must run before the shell's key listener.
const modeler = new Modeler({ width: "100%", height: "100%", additionalModules: [shellModule] });
modeler.get("canvas");
const platform = detectPlatform(navigator);
const shell = mountShell(document.getElementById("app")!, {
host: "web",
canvas: modeler.container,
menu: { export: [/* … */], help: [/* Keyboard shortcuts… */] },
lang: "en",
themes: ["light"], // with "dark": also appearance: { themeColors }, and the menu gets Appearance
primary: { onSelect: share }, // "Share", top right; the label collapses before the palette moves
legal: { links: [{ label: "GitHub", href: "https://github.com/Miragon/…" }] },
file: {
newDiagram: { onSelect: newDiagram },
// ⌘O or Ctrl+O; also the start screen's "Open a file…" link
open: { onSelect: pickFile, shortcut: commandKeyLabel("O", platform, defaultMessages.keyNames) },
showExample: { onSelect: example },
},
menu: { model: [/* … */], export: [/* … */], help: [/* Keyboard shortcuts… */] },
problems: { onSelectItem: (item) => selectElement(item.elementId) },
legend: { render: renderLegend, open: storedLegendOpen(), onToggle: storeLegendOpen },
emptyState: {
title: "BPMN Modeler",
text: "Start a new diagram, or explore the example.",
komet: kometUrl, // the vendored, unchanged miragon-komet-blau.svg
onNewDiagram: newDiagram,
onShowExample: example,
},
dropZone: { accept: [".bpmn", "application/xml", "text/xml"], onDrop: ([file]) => openFile(file) },
});
const { viewport } = connectDiagramJs(shell, modeler, { contentBounds: () => elementBounds() });
await modeler.importXML(xml);
viewport.afterImport();

// Every new document opens like the first, and the start screen goes with it.
async function openDocument(xml: string): Promise<void> {
viewport.newDocument();
const { warnings } = await modeler.importXML(xml);
viewport.afterImport();
shell.setEmptyState(false);
shell.setProblems(
problemSummary(warnings.map((warning) => ({ severity: "warning", message: warning.message }))),
);
}

// A fresh visit without a document in the link shows the start screen.
if (!linkedDocument) shell.setEmptyState(true);
shell.toast({ status: "success", text: "Link copied" }); // after a share
```

The `web` host prevents the browser's page zoom for the chrome keys (outside text fields and
dialogs), lifts pill and legend above the legal line and fits with the web insets. The rest of the
web-app chrome (primary action, legal line, File and Appearance groups, toasts, empty state) is not
part of 0.1.
The `web` host renders the whole web column of guide §5.2: the primary action at the top right,
the legal line at the bottom left with pill and legend above it, the File group first and, for a
dark-capable modeler, the Appearance group in the menu, the problems list above the pill, toasts at
the bottom centre (announced through two live regions), the start screen and a page-wide drop zone.
It prevents the browser's page zoom for the chrome keys (outside text fields, modal dialogs and the
start screen) and fits with the web insets. The web app's
own menu, zoom, fit, toast, legal and empty-state markup goes away; what stays the modeler's is its
content: the share link, file reading, examples, the Model and Export entries and the shortcuts
dialog. See [API.md][api], "Web host (0.2)", and the complete Event Storming example there.

## Usage in a foreign page

Expand All @@ -259,8 +323,9 @@ modeler, and the page's `<html>` stays untouched. Until the `@miragon/modeler-ap
its `appearance` surface (a later 1.x minor), an embedded modeler has no appearance channel, so the
root carries its own `data-cd-theme` (the first of `themes`, light) and stays light inside a dark
host page; it takes the page's `data-cd-kontrast="hoch"` over from the nearest mode carrier above
it. The page's element styles
(`button { text-transform: uppercase }`) do not reach the chrome.
it. The page's element styles (`button { text-transform: uppercase }`) do not reach the chrome.
Toasts show at the bottom centre of the modeler, and the pill opens the problems list unless the
modeler passes `problems.onActivate` (an embedding host with a list of its own).

## The conformance kit

Expand All @@ -269,7 +334,9 @@ the geometry of every island (±1px), ARIA and the keyboard models, that every c
once and never reaches VS Code, that the canvas's own keys (Cmd/Ctrl+A) stay on the canvas, that
nothing is ever half-dark (the painted canvas surround and the palette's colours included), that
Enter on a creating palette entry adds one selected element, that an invalid file locks the canvas,
and that `destroy()` leaves nothing behind.
and that `destroy()` leaves nothing behind. Five more cases cover the web column (`webChrome`,
`emptyState`, `toasts`, `problemsList`, `dropZone`); in the IDE and embedded hosts they assert that
none of it shows.

For the `ide` host the suite serves a test page that puts a stand-in for VS Code around the
modeler's webview code: VS Code's default webview styles, `<html>` as `renderWebviewHtml` writes it,
Expand Down Expand Up @@ -352,6 +419,60 @@ test.describe("shell conformance", () => {
});
```

A web app's harness opens its page and adds the web column's hooks and the files to drop. The kit
emulates the system's colour scheme and contrast (`page.emulateMedia`) before every `open`, so the
page loads without a stored mode choice:

```ts
// e2e/shell-conformance.spec.ts of a web app
registerShellConformance(
{
host: "web",
themes: ["light"],
absent: ["inspector", "invalid"],
async open(page, { size }) {
await page.setViewportSize(size);
await page.goto("/?example");
await page.waitForFunction(() => window.appReady === true);
},
async destroy(page) {
await page.evaluate(() => window.app.destroy());
},
show: {
async legend(page) {
/* … */
},
async problems(page) {
/* setProblems with items, so the list has rows */
},
async status(page) {
/* … */
},
async emptyState(page) {
/* the start screen of a fresh visit */
},
async toast(page, toast) {
await page.evaluate(
({ status, text, actionLabel }) =>
window.app.shell.toast({
status,
text,
...(actionLabel ? { action: { label: actionLabel, onSelect() {} } } : {}),
}),
toast,
);
},
},
drop: {
accepted: { name: "board.json", type: "application/json", text: EXAMPLE_JSON },
rejected: { name: "notes.md", type: "", text: "# Notes" },
},
},
test,
{ onSkip: failOnSkips },
);
```

The harness opens the modeler with the appearance the kit requests, the way its host would; for
the `embedded` host the kit puts the foreign page itself in that mode. A skip names the harness
field it misses (`{ hook: "legend", reason }`; `hook: "destroy"` without a `destroy`; `hook: "open"`
Expand Down Expand Up @@ -399,23 +520,32 @@ host requests (`theme`, `kontrast=hoch`; embedded: the foreign page's own mode),
inside the stub of `./testing/vscode-stub`, and keeps view and legend state through
`acquireVsCodeApi()`.

The web demo (`/web.html`) is the same modeler as a Miragon web app passes it to `mountShell`: the
whole web column, the head markup of `webHeadMarkup` through the Vite config, and `window.__webDemo`
for the harness. Its query parameters: `themes=light,dark`, `empty` (the start screen of a fresh
visit), `legend` (`legend=open`), `inspector`, `problems` (the pill with its list) and `invalid`.

The e2e suite runs the conformance kit for the `ide`, `web` and `embedded` hosts with a light-only
and a dark-capable modeler, switches VS Code themes and the `colorTheme` setting at runtime, checks
VS Code's reduce motion, the chrome's typography against VS Code's default styles and a hostile
foreign page, the canvas slot's paint and focus ring, and proves with saboteurs that every case of
the kit fails when its rule is broken.

| Directory | Module |
| --------------------------- | ------------------------------------------------------------------ |
| `src/core` | DOM-free: fit math, keymap, zoom readout, appearance, sizes, texts |
| `src/dom` | `mountShell`: menu, zoom panel, tooltip, pill, status, slots, keys |
| `src/diagram-js` | `shellModule`, viewport, `connectDiagramJs`, palette skin |
| `src/vscode` | extension side (HTML, theme bridge) and webview side |
| `src/testing` | the Playwright-driven conformance kit and the VS Code stub |
| `src/*.ts`, `src/shell.css` | the entry points |
| `test/` | package boundaries, token drift, the unit tests' helpers |
| `demo/`, `e2e/` | the demo modeler and the e2e suite |
| `fixtures/tokens` | the token files, vendored unchanged for the tests and the demo |
foreign page, the canvas slot's paint and focus ring, the web chrome's colours, radii and states
(primary action, legal line, toasts, drop zone, empty state, high contrast and forced colours) and
the web demo's own handlers, and proves with saboteurs that every case of the kit fails when its
rule is broken.

| Directory | Module |
| --------------------------------------- | ------------------------------------------------------------------------------------------ |
| `src/core` | DOM-free: fit math, keymap, zoom readout, appearance, sizes, texts |
| `src/dom` | `mountShell`: menu, zoom panel, tooltip, pill, status, slots, keys |
| `src/dom/web`, `src/core/web-chrome` | web column: primary action, legal line, File and Appearance groups, empty state, drop zone |
| `src/dom/feedback`, `src/core/feedback` | toasts, problems popover, the web app's mode |
| `src/diagram-js` | `shellModule`, viewport, `connectDiagramJs`, palette skin |
| `src/vscode` | extension side (HTML, theme bridge) and webview side |
| `src/testing` | the Playwright-driven conformance kit and the VS Code stub |
| `src/*.ts`, `src/shell.css` | the entry points |
| `test/` | package boundaries, token drift, the unit tests' helpers |
| `demo/`, `e2e/` | the demo modeler and the e2e suite |
| `fixtures/tokens` | the token files, vendored unchanged for the tests and the demo |

A module imports another one only through its `index.ts`; `src/core` imports nothing outside
itself and compiles without the DOM library. `test/package-boundaries.test.ts` enforces this, the
Expand All @@ -429,7 +559,8 @@ MIT. The inlined Lucide icons are ISC (© Lucide Icons and Contributors), and `c
Lucide's LICENSE lists them; see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). `dist` keeps that
notice as a legal comment in the chunk with the icons, but a bundler's `legalComments` setting may
drop it: a VSIX or a web bundle that contains the shell carries the Lucide and Feather notices of
THIRD_PARTY_NOTICES.md itself.
THIRD_PARTY_NOTICES.md itself. Vite 8 (Rolldown with the Oxc minifier) drops legal comments by
default; `build.rolldownOptions.output.comments.legal: true` keeps the notice in the bundle.

[guide]: https://github.com/Miragon/corporate-identity/blob/main/plugins/miragon-brand/skills/modeler-tool-design/assets/modeler-design-system.md
[notes]: https://github.com/Miragon/modeler-api/blob/main/docs/shell/implementation-notes.md
Expand Down
16 changes: 16 additions & 0 deletions packages/shell/demo/demo-diagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export interface DemoHooks {
isReadOnly(): boolean;
/** `viewport.freeRectCenter()`. */
freeRectCenter(): Point | undefined;
/**
* Further creating entries in a group of their own, so the palette is as wide as a notation's
* (Event Storming's sticky types) and the web app's top row gets tight above 760px.
*/
readonly extraShapes?: number;
}

/** Guide §5.5: a keyboard-created element moves 24px diagonally off an element already centred there. */
Expand Down Expand Up @@ -131,6 +136,17 @@ class DemoPaletteProvider {
className: "demo-entry-wide-box",
action: { dragstart: startCreate(200, 80), click: clickCreate(200, 80) },
},
...Object.fromEntries(
Array.from({ length: this.hooks.extraShapes ?? 0 }, (_, index) => [
`create.sticky-${index + 1}`,
{
group: "stickies",
title: `Sticky ${index + 1}`,
className: "demo-entry-box",
action: { dragstart: startCreate(120, 80), click: clickCreate(120, 80) },
},
]),
),
};
}

Expand Down
8 changes: 7 additions & 1 deletion packages/shell/demo/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from "../src/vscode-webview.js";
import type ContextPad from "diagram-js/lib/features/context-pad/ContextPad";

import tokens from "../fixtures/tokens/cd-tokens.generated.json";
import { addSampleShapes, createDemoDiagram, demoContentBounds, type DemoFlaws } from "./demo-diagram.js";

/**
Expand Down Expand Up @@ -175,8 +176,13 @@ const options = {
...(params.has("inspector") ? { inspector: { label: "Properties" } } : {}),
};

// The whole web column is on /web.html; here the web host keeps its 0.1 parts, so a dark-capable
// modeler only needs its theme colours (mountShell throws without them).
const appearance = { themeColors: { light: tokens.fest["--cd-blau"], dark: tokens.dunkel["--cd-ui-grund"] } };
const shell: ShellHandle =
host === "ide" ? mountVsCodeShell(root, { ...options, themes }) : mountShell(root, { ...options, host });
host === "ide"
? mountVsCodeShell(root, { ...options, themes })
: mountShell(root, { ...options, host, ...(host === "web" ? { appearance } : {}) });

const connection: DiagramJsConnection = connectDiagramJs(shell, diagram, {
contentBounds: () => demoContentBounds(diagram),
Expand Down
31 changes: 30 additions & 1 deletion packages/shell/demo/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,37 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import { defineConfig, type Plugin } from "vite";

import { documentMode, webHeadMarkup, type Theme } from "../src/core/index.js";
import tokens from "../fixtures/tokens/cd-tokens.generated.json" with { type: "json" };

const themeColors = { light: tokens.fest["--cd-blau"], dark: tokens.dunkel["--cd-ui-grund"] };

/**
* The web app's mode before the first paint (guide §13.3), as a Miragon web app writes it into its
* index.html: `/web.html?themes=light,dark` gets the dark-capable head (static `system` and the
* whole script), every other `/web.html` the light-only one (static `light`, the contrast line).
*/
function webModePlugin(): Plugin {
return {
name: "miragon-mode",
transformIndexHtml: {
order: "pre",
handler(html, context) {
if (!context.path.endsWith("/web.html")) return html;
const url = new URL(context.originalUrl ?? context.path, "http://demo.invalid");
const themes = (url.searchParams.get("themes") ?? "light").split(",") as Theme[];
const mode = documentMode("system", themes, themeColors);
return html
.replace('data-cd-theme="light"', `data-cd-theme="${mode.theme}"`)
.replace("<!-- miragon-mode -->", webHeadMarkup({ themes, themeColors }));
},
},
};
}

// The demo bundles the shell from source, so it runs without a prior build.
export default defineConfig({
root: fileURLToPath(new URL(".", import.meta.url)),
server: { port: 5181, strictPort: true },
plugins: [webModePlugin()],
});
13 changes: 13 additions & 0 deletions packages/shell/demo/web.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="cd-shell-document" data-cd-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- miragon-mode -->
<title>@miragon/modeler-shell web demo</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./web.ts"></script>
</body>
</html>
Loading
Loading