diff --git a/docs/shell/implementation-notes.md b/docs/shell/implementation-notes.md index 8b480dac..5fac9134 100644 --- a/docs/shell/implementation-notes.md +++ b/docs/shell/implementation-notes.md @@ -3,9 +3,10 @@ `@miragon/modeler-shell` is the shared, framework-free implementation of everything around a Miragon diagram-js modeler: menu button, tool palette, tooltips, zoom panel, keys, fit, problems pill, legend, status box and the plumbing to its host (a VS Code webview, a web app, or an embedding host -such as designIQ). **Status: 0.1** in [`packages/shell`](../../packages/shell), Phase 4 of -[#4](https://github.com/Miragon/modeler-api/issues/4); its public API is documented in -[`packages/shell/API.md`](../../packages/shell/API.md). Where building 0.1 showed a note to be wrong or +such as designIQ). **Status: 0.2** in [`packages/shell`](../../packages/shell), Phase 4 of +[#4](https://github.com/Miragon/modeler-api/issues/4): 0.1 brought the IDE host, 0.2 the web app's +chrome (see [Web app host](#web-app-host)); its public API is documented in +[`packages/shell/API.md`](../../packages/shell/API.md). Where building it showed a note to be wrong or incomplete, the note was corrected here. The source of truth for look and behaviour is the Miragon design system: the Claude skill @@ -94,14 +95,14 @@ content. | legend content | legend (§5.10) | | entries of the Model menu group | menu (§5.4) | | palette entries with name and shortcut (`title`, `shortcut`) | palette and its tooltip (§5.5, §5.6) | -| the list of keyboard shortcuts | "Keyboard shortcuts…" dialog (§5.4, 0.2) | +| the list of keyboard shortcuts | "Keyboard shortcuts…" dialog (§5.4, later) | | the inspector | inspector (§5.11) | What stays with the modeler, because only it knows its elements: keyboard creation from the palette at `viewport.freeRectCenter()` (see [Palette](#palette)), the canvas lock of a read-only file (context pad, move, resize, delete, paste, label editor; see [Status box](#status-box)), select all -on the canvas (see [VS Code: key interception](#vs-code-key-interception)) and, in 0.1, the -"Keyboard shortcuts…" dialog itself (see [Modeler dialogs](#modeler-dialogs)). +on the canvas (see [VS Code: key interception](#vs-code-key-interception)) and, until the shell has +a dialog component, the "Keyboard shortcuts…" dialog itself (see [Modeler dialogs](#modeler-dialogs)). The shell talks to diagram-js only through public services and events (`canvas`, `zoomScroll`, `keyboard`, `palette`, `eventBus`). It never reads private fields such as `palette._container`, which @@ -306,8 +307,8 @@ tab order. of the container minus the fit insets, in diagram coordinates, as fit computes it), creates the shape there with `modeling.createShape`, selects it, calls `canvas.focus()` and opens its label editor. The provider is built before `connectDiagramJs`, so it receives a function and reads the - viewport when the entry fires. The 24px step is the provider's in 0.1; a shared placement helper is - a 0.2 candidate. Shell conformance (`create`) presses Enter on the first creating entry and expects + viewport when the entry fires. The 24px step is the provider's; a shared placement helper is a + later candidate. Shell conformance (`create`) presses Enter on the first creating entry and expects exactly one new, selected shape whose centre lies in the free rectangle. - **Read-only** (guide §13.5). Only Lasso and Hand stay active; every other entry changes the diagram and is vetoed, also tools of group `tools` such as draw, space or connect. `palette.trigger` @@ -490,7 +491,8 @@ the modeler know the same key. **Guide [§5.8][guide-5.8].** Rule: in its own document the keys work wherever the focus is, except in text fields and while a dialog is open; embedded in a foreign page only while the focus is inside the modeler. In the web app Cmd/Ctrl + `+`, `-` and `0` zoom the diagram, not the page, except in text -fields and dialogs. +fields and dialogs. The shell reads "dialogs" as modal dialogs: a non-modal popover (the problems +list) does not block the keys; whether §5.8 means that too is an open question for the guide. - Own document (VS Code webview, web app, iframe): one `keydown` listener on `document`, bubble phase. The diagram-js keyboard on the `` has run by then, so `defaultPrevented` tells the @@ -504,8 +506,10 @@ fields and dialogs. - Foreign page (embedded without iframe): the listener sits on the shell root. `keydown` is dispatched to the focused element, so it only reaches the root while the focus is inside it. - Web app: call `preventDefault()` on Cmd/Ctrl + `+`, `-` and `0` unless the focus is in a text field - or a dialog, so the browser does not zoom the page. In text fields and dialogs do nothing: the - browser zooms the page. Zooming via the browser menu always stays possible. + or a modal dialog is open, so the browser does not zoom the page. In text fields and modal dialogs + do nothing: the browser zooms the page. Behind the start screen do nothing either: there is no + diagram to zoom, so Ctrl+ is the browser's (WCAG 1.4.4), and the diagram-js guard returns `false` + so diagram-js zooms nothing. Zooming via the browser menu always stays possible. - What the listener additionally stops in an IDE host: see [VS Code: key interception](#vs-code-key-interception). @@ -605,8 +609,8 @@ fit does nothing. centre of the free rectangle. That write is the viewport's own, not a user move, so the first content still fits; later re-imports and `restore`s of the empty file keep the view. At 0 × 0 (a tab hidden at the first import) the 100 % waits for the first real size. A kit check that an empty - document opens at 100 % with the hint is a 0.2 candidate (it needs a harness that opens more than - one document). + document opens at 100 % with the hint is a later candidate (it needs a harness that opens more + than one document). ### Resize @@ -749,7 +753,7 @@ it the palette lies above context pad and label editor. palette while they are open, as diagram-js's own popup menu does. Everything stays inside `.djs-container`, and so below every island. This is the rule of guide §5.13 since design system 1.13.0. Event Storming shipped its note toolbar and colour picker at 300, - above the palette, and nothing flagged it; a kit check for it is a 0.2 candidate. A flat "100 at + above the palette, and nothing flagged it; a kit check for it is a later candidate. A flat "100 at most" would flag diagram-js's popup menu itself, hence the split into persistent and transient. ## Modeler dialogs @@ -759,8 +763,8 @@ it the palette lies above context pad and label editor. header with Close; it holds the focus and returns it; Escape and the backdrop close it. The Help group's "Keyboard shortcuts…" is such a dialog, with one table per group (View, Edit, Tools). -0.1 has no dialog component, so a modeler's dialog meets these rules on its own (a shared -`cd-shell-dialog` class and `showShortcutsDialog` are 0.2 candidates): +0.2 has no dialog component, so a modeler's dialog meets these rules on its own (a shared +`cd-shell-dialog` class and `showShortcutsDialog` are later candidates): - Open it with `showModal()`: the top layer puts it above every island without a layer token, the browser makes the rest of the page inert, and `:modal` tells the shell's key listener that a modal @@ -817,7 +821,8 @@ The resulting DOM order of the shell root: ``` The palette comes before the `` because the shell moves it there (see [Palette](#palette)); the -primary action exists only in the web app. Dropdowns, popovers and the tooltip live in the layer +primary action exists only in the web app, which also puts the empty-state card after the hint and +the legal line before the zoom panel (see [Web app host](#web-app-host)). Dropdowns, popovers and the tooltip live in the layer element described under [Tooltip](#tooltip) and are reached through their triggers, not by Tab. ### The canvas as a tab stop @@ -928,6 +933,192 @@ the contract's `appearance` surface (a later 1.x minor) an embedded modeler stay - Appearance is view state: `onContentChanged` never fires for it, and it has no effect on `exportText` or image exports. +## Web app host + +The web column of guide §5.2 (0.2): what a Miragon web app shows around the canvas that the IDE and +embedded hosts leave to their host. `mountShell({ host: "web" })` renders it; the API is in +[`API.md`](../../packages/shell/API.md), "Web host (0.2)". The Wardley Maps web app was the +reference implementation for this chrome; these notes record where the shell had to settle a case +the guide leaves open. + +### Primary action and the label collapse + +**Guide [§5.7][guide-5.7], [§5.15][guide-5.15].** Rule: the primary action is a blue primary button at +`top: 12px; right: 12px`, 40px high; when the top row gets tight, it loses its label first (the +`aria-label` stays), only then does the palette move. + +- `decideTopRow` (`./core`) tries the labelled width, then the icon-only width (40), then moves the + palette to the second row, where the label comes back if it keeps 16px to the menu trigger. The + layout pass runs it on every resize of the root, the palette and the primary action itself: the + label widens once Geist has loaded, so a decision made with the fallback font would stay wrong. +- While the label shows, its width is measured afresh each pass. While collapsed, the label shows + for one synchronous layout (`offsetWidth` with the label, then hidden again before the browser + paints), so the decision can bring it back; the result is cached until `primary.refresh()`. +- The 40 × 40 style keys on an internal `data-collapsed` on the button, not on the root's + `data-primary`, so measuring never touches root state (and never fires a root mutation). +- Collapsed, the chrome tooltip shows the label without `aria-describedby`: the tooltip repeats the + accessible name. Expanded, it has no tooltip at all. + +### Legal line and the bottom row + +**Guide [§5.10][guide-5.10], [§2][guide-2].** Rule: the web app's bottom left stacks the legal line at +`bottom: 12px`, then the pill at 40, then the legend at 40 or 88; the line is 13px/20px on glass +without border and shadow, its links in `--cd-ui-link`; "Impressum" and "Datenschutz" stay German +with `lang="de"`. + +- Copyright, URLs and labels are the docs-site kit's (`miragon/legal.ts` in corporate-identity): + `https://www.miragon.io/`, `/impressum/`, `/datenschutz/`, each `target="_blank"` with + `rel="noopener noreferrer"`, separated by `aria-hidden` middle dots, in a ``. + +### Start screen + +**Guide [§8][guide-8].** Rule: on a fresh start the web app shows the empty-state card and hides the +work chrome; only the card and the legal line are visible; once someone starts, the chrome appears. + +- The work chrome (menu trigger, primary action, palette, zoom panel, pill, legend, status box, hint, + inspector) gets `visibility: hidden`, which also removes it from the tab order and the + accessibility tree; the canvas slot gets `inert`, so neither a click nor Tab reaches the diagram. + The chrome keys go to the browser: with no diagram to zoom, a swallowed Ctrl+ would leave a + low-vision user without page zoom and without a way to reset a stored one with Ctrl+0. +- The menu is hidden and a drop needs a pointer, so the hint ends in a text link to the File + group's "Open…" (with its shortcut chip and `aria-keyshortcuts`): the keyboard's way to a file on + the start screen. It is part of the hint, not a third action beside the two buttons of §8, and it + leaves the card up until the modeler's import ends the start screen (a cancelled file picker + changes nothing). +- A closing toast that held the focus on the start screen hands it to "New diagram": the canvas is + inert and the menu trigger `visibility: hidden`, so both `focus()` calls failed silently and the + focus fell to ``. The fallback now checks that a target really took the focus. +- When the card hides through one of its buttons, the focus goes to the canvas `` before the + handler runs. When the card shows while the focus is in the work chrome or the canvas, the focus + goes to "New diagram"; a focus on the legal line or a toast stays where it is. +- The Komet is `draggable="false"`: in Chromium an image drag can carry a file and would open the + drop zone over the card. + +### Drop zone + +**Guide [§9][guide-9].** Rule: a file dragged over the page shows a 12px inset overlay with a dashed +border and the chip "Drop file here"; a drop the modeler cannot take shows the forbidden state with +the cursor `not-allowed`. + +- The listeners are on `window`: the page is the drop target, not the canvas. `dragenter` and + `dragleave` fire for every element the pointer crosses, so only the outermost pair counts (a depth + counter); `dragover` also shows a drag that entered before the listener existed. +- Only drags with items of kind `"file"` count, so diagram-js's palette drags and text dragged into + the label editor stay alone. Every file drag is `preventDefault`-ed: unprevented, the browser + opens the file in place of the app. +- Before the drop the browser hides the file names and offers only MIME types, which may be empty + (often empty for an extension the system does not know, such as `.storm`). `dragVerdict` therefore + forbids by type only when every file has a MIME type that none of `accept`'s MIME entries + matches; an empty type never forbids. At the drop `acceptsFile` decides by extension and type. +- `dropEffect = "none"` on the last `dragover` shows the `not-allowed` cursor, and the browser then + fires no `drop`. A synthetic `drop` (a test) is split by `acceptsFile` like any other; one + forbidden for `multiple` or an open dialog opens nothing. +- A constructed `DataTransfer` ignores writes to `dropEffect` in Chromium, so the kit hands the + handlers a proxy that records them. +- The overlay paints a 12px `--cd-ui-grund` rim around itself (`box-shadow` spread), so the corners + of the islands under it do not show beside its rounded frame. + +### Mode before the first paint + +**Guide [§13.3][guide-13.3].** Rule: a light-only modeler writes `data-cd-theme="light"`, one +`theme-color` without `media` and of the script only the contrast line; a dark-capable one writes +`system`, the whole script and both `theme-color`s; the choice lives in +`localStorage["miragon.darstellung"]`, "System" removes it; switching sets one frame of +`transition: none`. + +- `webHeadMarkup` returns the metas and the inline script for the page's `` (a Vite + `transformIndexHtml` hook, before the first stylesheet). The script is ES5 in an immediately + invoked function, reads the storage inside `try`/`catch` (it throws when storage is blocked), + tolerates a missing `color-scheme` meta, and JSON-escapes every literal with `<`, U+2028 and + U+2029 escaped as well, so a colour value can never end the ` + + +``` + +`src/main.ts`, shortened to what touches the shell (the real file is +`apps/webapp/src/main.ts` in Miragon/event-storming-modeler): + +```ts +import "@fontsource-variable/geist/wght.css"; +import "@fontsource-variable/geist-mono/wght.css"; +import "@miragon/event-storming-renderer/assets/event-storming.css"; // the tokens, diagram-js.css +import "@miragon/modeler-shell/shell.css"; // last +import { + Modeler, + boardMenu, + createShortcutsDialog, + importWarningText, + type ImportWarning, +} from "@miragon/event-storming-renderer"; +import { + commandKeyLabel, + defaultMessages, + detectPlatform, + mountShell, + problemSummary, +} from "@miragon/modeler-shell"; +import { connectDiagramJs, shellModule } from "@miragon/modeler-shell/diagram-js"; +import { copyTextWhenReady } from "./clipboard"; // ClipboardItem with a promise: Safari keeps the gesture +import { hashHoldsBoard, readHashMap, shareUrl, writeHashMap } from "./share"; +import { THEMES } from "./themes"; // ["light"] + +// 1. The diagram first (its keyboard listens on document); the web app keeps the canvas undo keys. +const modeler = new Modeler({ + width: "100%", + height: "100%", + additionalModules: [shellModule], + freeRectInsets: () => shell.fitInsets(), // keyboard creation lands in the free rectangle +}); +modeler.get("canvas"); +const platform = detectPlatform(navigator); +const openKey = commandKeyLabel("O", platform, defaultMessages.keyNames); // "⌘O" / "Ctrl+O" +const saveKey = commandKeyLabel("S", platform, defaultMessages.keyNames); + +// 2. The chrome, the whole web column. +const shell = mountShell(document.getElementById("app")!, { + host: "web", + canvas: modeler.container, + lang: "en", + platform, + themes: THEMES, + messages: { canvas: "Event Storming board" }, + primary: { onSelect: () => share() }, // "Share", share-2 + legal: { links: [{ label: "GitHub", href: "https://github.com/Miragon/event-storming-modeler" }] }, + file: { + newDiagram: { onSelect: () => void newDiagram() }, + open: { onSelect: () => pickFile(), shortcut: openKey }, // also the start screen's "Open a file…" + showExample: { onSelect: () => void showExample() }, + }, + // Model (level, type captions), Export and Help: the same entries the VS Code webview shows. + menu: boardMenu({ + levels, + typeCaptions, + exports: { + png: { onSelect: () => void exportPicture("png") }, + svg: { onSelect: () => void exportPicture("svg") }, + transparentPng: { onSelect: () => void exportPicture("png-transparent") }, + json: { onSelect: () => exportBoardText("json") }, + storm: { onSelect: () => exportBoardText("storm"), shortcut: saveKey }, + }, + showKeyboardShortcuts: () => shortcutsDialog.open(), + }), + problems: { onSelectItem: (item) => selectElement(item.elementId) }, + emptyState: { + title: "Event Storming Modeler", + text: "Start a new diagram, or explore the example.", + komet: "/miragon-komet-blau.svg", // vendored unchanged from corporate-identity + onNewDiagram: () => void newDiagram(), + onShowExample: () => void showExample(), + dropHint: "Or drop a .storm, .json, or .txt file here", // the types the reject toast names + }, + dropZone: { + accept: [".storm", ".json", ".txt", "application/json", "text/plain"], + onDrop: ([file]) => file && void openBoardFile(file), + }, +}); +const shortcutsDialog = createShortcutsDialog(shell, { fileKeys: true }); + +// 3. Viewport, palette, keys. +const { viewport } = connectDiagramJs(shell, modeler, { + isEmpty, + contentBounds: () => (isEmpty() ? undefined : boardBounds.contentBounds(0)), +}); + +// 4. Every new document opens like the first (§5.9); the start screen goes with it. +async function openDocument(load: () => Promise, announce: boolean) { + viewport.newDocument(); + let warnings: readonly ImportWarning[]; + try { + warnings = await load(); + } finally { + viewport.afterImport(); + } + shell.setEmptyState(false); + shell.menu.refresh(); + updateDocumentTitle(); // "Order Checkout · Event Storming Modeler" (§5.4) + // The parser names lines: "Line 4 · A -> Nowhere" below the message, no element to select. + shell.setProblems(problemSummary(problemItems(warnings))); + for (const warning of warnings) console.warn(importWarningText(warning)); + // A toast only after an import the user started, with the count (§9), never on a restore. + if (announce && warnings.length > 0) { + const pill = shell.messages.problems(warnings.length, String(warnings.length)); + shell.toast({ + status: "warning", + text: `Imported with ${warnings.length} warnings. Details under “${pill}” bottom left.`, + }); + } +} + +// Replacing a board clears the canvas history, so instead of confirm() it offers the board back +// (§9 asks for a confirmation; the undo toast is the open guide question): "Board replaced · Undo", +// also on Cmd/Ctrl+Z while the canvas has nothing else to undo. An edit withdraws the offer. +async function replaceBoard(load: () => Promise) { + const previous = boardIsEmpty() ? undefined : modeler.exportDSL(); + await openDocument(load, true); + withdrawReplaceUndo(); + if (previous === undefined || previous === modeler.exportDSL()) return; + const undo = () => void openDocument(async () => (await modeler.importDSL(previous)).warnings, false); + replaceUndo = { + toast: shell.toast({ status: "info", text: "Board replaced", action: { label: "Undo", onSelect: undo } }), + undo, + }; +} + +// Copy inside the click: Safari drops the user activation at the first await before writeText. +function share(): void { + const dsl = modeler.exportDSL(); + const copied = copyTextWhenReady(shareUrl(dsl)); + void (async () => { + try { + await writeHashMap(dsl); + await copied; + shell.toast({ status: "success", text: "Link copied" }); + } catch { + shell.toast({ + status: "danger", + text: "Couldn't copy the link. Copy it from the address bar instead.", + }); + } + })(); +} + +// The app's own keys: Cmd/Ctrl+O opens a file, Cmd/Ctrl+S exports the .storm file (never the +// browser's dialogs, also not over a modal dialog; on the start screen there is nothing to save). +window.addEventListener("keydown", onAppKey); + +modeler.on("commandStack.changed", (event: { trigger?: string }) => { + if (event.trigger !== "clear") { + shell.setEmptyState(false); // the first edit is a start as well + withdrawReplaceUndo(); // an edit makes the undo offer stale + } + shell.menu.refresh(); // the level is undoable + updateDocumentTitle(); +}); + +// 5. Start: the board from the link, else the start screen on an empty board. +const linked = await readHashMap(); +if (linked !== null) await openDocument(async () => (await modeler.importDSL(linked)).warnings, true); +else { + await modeler.importMap(createEmptyBoard("New board")); + viewport.afterImport(); // an empty board at 100 % + shell.setEmptyState(true); + if (hashHoldsBoard()) + shell.toast({ + status: "danger", + text: "Couldn't read the board in this link. Check that you copied the whole link.", + }); +} +``` + +`openBoardFile(file)` reads the file before the canvas is touched and then runs `replaceBoard`; a +parse error shows the danger toast "Couldn't read “name”. Check that it contains a valid Event +Storming board." and keeps the board. `pickFile` opens the hidden file input with the same +`accept`. The JSON export writes `JSON.stringify(modeler.exportMap(), null, 2)`, the board's own +element order (the importer stacks overlapping stickies by it). The web app's own `style.css`, +`toast.ts` and the hand-written menu, zoom, fit and legal markup go away. + +## Out of scope for 0.2 -0.2 candidates unless noted: +Later candidates unless noted: - **VS Code document plumbing**: two-way sync with echo suppression, minimal `WorkspaceEdit`s, undo/redo through the document's history, "Insert example" as one edit, diagnostics to the @@ -1869,16 +2834,17 @@ Document sync, echo suppression, minimal edits and undo stay in the modeler for `KeyboardBindings` off undo/redo in the IDE host, stops undo/redo keys in text fields and stops keys the canvas handled. - An ES-module webview bundle (`renderWebviewHtml` writes a classic script; a `scriptType` option). -- **Web-app chrome**: primary action (`data-chrome="primary"`) with label collapse, legal line, - File and Appearance menu groups, problems popover, toasts, empty-state card, drop zone, the - `localStorage` mode script. `host: "web"` in 0.1 covers keys (page zoom), fit insets and the - pill/legend offsets only. -- **Dialog component and the shared shortcuts dialog** (§5.4, §8): an exported `cd-shell-dialog` +- **More of the web column**: further top-right actions left of the primary action (§5.7), the + loading state (§9 "Laden": spinner or skeleton during asynchronous work), share-link helpers + (each modeler keeps its own hash encoding), the legend's state across sessions (a modeler persists + it from `legend.onToggle` in `localStorage`, §5.10). +- **Dialog component and the shared shortcuts dialog** (§5.4, §8; also the confirmation of §9 + "Destruktiv" where an undo toast does not fit): an exported `cd-shell-dialog` class (panel, header with Close, body, backdrop with `grayscale(1)`, the high-contrast and forced-colours rules, the top layer through `showModal()`, focus trap and focus return), and on it `shell.showShortcutsDialog(groups: ShortcutGroup[])` with one table per group and the chips of the tooltip's `kbd`. The shell fills the View group itself from the chrome keys. New - `ShellMessages`: `shortcutsTitle` ("Keyboard shortcuts"), `close` ("Close") and the group names + `ShellMessages`: `shortcutsTitle` ("Keyboard shortcuts") and the group names "View", "Edit" and "Tools". Popovers (§5.4) follow on the same class. Until then a modeler meets the rules of [Modeler dialogs](#modeler-dialogs) itself; one component lets every modeler migrate once. @@ -1887,8 +2853,9 @@ Document sync, echo suppression, minimal edits and undo stay in the modeler for - the `appearance` surface of the modeler-api contract (a later 1.x minor) for embedded hosts and the `miragon..colorTheme` setting UI (the bridge already honours `settingKey`). - **Kit**: - - cases for reduced motion and forced colours (they need `page.emulateMedia`, which `PageLike` - does not have yet); the shell's CSS covers both, the e2e suite checks VS Code's reduce motion; + - cases for reduced motion and forced colours on every host (`PageLike.emulateMedia` exists since + 0.2, but only the web `appearance` case uses it); the shell's CSS covers both, its own e2e + suite checks VS Code's reduce motion and spot-checks forced colours on the web chrome; - a CSP helper: serve the production bundle under `renderWebviewHtml`'s CSP inside the VS Code stub, record every `securitypolicyviolation` and fail on any (until then the recipe under `./vscode/extension`); diff --git a/packages/shell/README.md b/packages/shell/README.md index 70ab9836..bd80b0e0 100644 --- a/packages/shell/README.md +++ b/packages/shell/README.md @@ -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 @@ -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, @@ -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 +`` 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("", 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 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 { + 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 @@ -259,8 +323,9 @@ modeler, and the page's `` 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 @@ -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, `` as `renderWebviewHtml` writes it, @@ -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"` @@ -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 @@ -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 diff --git a/packages/shell/demo/demo-diagram.ts b/packages/shell/demo/demo-diagram.ts index 9eae490a..a965504a 100644 --- a/packages/shell/demo/demo-diagram.ts +++ b/packages/shell/demo/demo-diagram.ts @@ -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. */ @@ -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) }, + }, + ]), + ), }; } diff --git a/packages/shell/demo/main.ts b/packages/shell/demo/main.ts index ab7cbb5d..13dcf032 100644 --- a/packages/shell/demo/main.ts +++ b/packages/shell/demo/main.ts @@ -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"; /** @@ -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), diff --git a/packages/shell/demo/vite.config.ts b/packages/shell/demo/vite.config.ts index 19900e80..a426123f 100644 --- a/packages/shell/demo/vite.config.ts +++ b/packages/shell/demo/vite.config.ts @@ -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("", 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()], }); diff --git a/packages/shell/demo/web.html b/packages/shell/demo/web.html new file mode 100644 index 00000000..48e6cc18 --- /dev/null +++ b/packages/shell/demo/web.html @@ -0,0 +1,13 @@ + + + + + + + @miragon/modeler-shell web demo + + +
+ + + diff --git a/packages/shell/demo/web.ts b/packages/shell/demo/web.ts new file mode 100644 index 00000000..64170282 --- /dev/null +++ b/packages/shell/demo/web.ts @@ -0,0 +1,294 @@ +import "../fixtures/tokens/cd-tokens.generated.css"; +import "@fontsource-variable/geist/wght.css"; +import "@fontsource-variable/geist-mono/wght.css"; +import "diagram-js/assets/diagram-js.css"; +import "../src/shell.css"; +import "./demo.css"; + +import type ContextPad from "diagram-js/lib/features/context-pad/ContextPad"; +import type ElementRegistry from "diagram-js/lib/core/ElementRegistry"; +import type Modeling from "diagram-js/lib/features/modeling/Modeling"; +import type Selection from "diagram-js/lib/features/selection/Selection"; +import type { Element } from "diagram-js/lib/model/Types"; + +import kometUrl from "../fixtures/brand/miragon-komet-blau.svg?url"; +import tokens from "../fixtures/tokens/cd-tokens.generated.json"; +import { + INVALID_STATUS_DELAY_MS, + mountShell, + problemSummary, + type MenuGroups, + type ShellHandle, + type Theme, + type ToastStatus, +} from "../src/index.js"; +import { connectDiagramJs, type DiagramJsConnection } from "../src/diagram-js.js"; +import { addSampleShapes, createDemoDiagram, demoContentBounds, type DemoFlaws } from "./demo-diagram.js"; + +/** + * The web-app variant of the demo (`/web.html`): every 0.2 web-host option of `mountShell`, the way + * a Miragon web app passes them (Event Storming's pilot, API.md "Usage: Event Storming in the web + * app"). The head's mode markup comes from `webHeadMarkup` through the Vite config. Query + * parameters: + * - `themes`: the modes the demo modeler declares, comma-separated (default `light`); + * - `empty`: open on the start screen, as a fresh visit without a document; + * - `legend` (`legend=open`: open at start), `inspector`: mount those slots; + * - `problems`: show the pill with its list at start; `invalid`: the invalid-file state. + * + * `window.__webDemo` is the harness's handle; `calls` records what the chrome asked the page to do. + */ +const params = new URLSearchParams(location.search); +const themes = (params.get("themes") ?? "light").split(",") as Theme[]; +const calls: string[] = []; +let level = "big-picture"; +let captions = true; + +const app = document.getElementById("app"); +if (!app) throw new Error("web demo: #app is missing"); + +const flaws: DemoFlaws = { noKeyboardCreate: false, canvasUnlocked: false, noSelectAll: false }; + +// The diagram first, so a modeler's document keydown listeners would precede the shell's. +const canvasElement = document.createElement("div"); +const diagram = createDemoDiagram(canvasElement, { + flaws, + isReadOnly: () => shell.isReadOnly(), + freeRectCenter: () => connection.viewport.freeRectCenter(), + // As wide as Event Storming's palette (648px): the primary action collapses below 892px, the + // palette moves below 784px. + extraShapes: 14, +}); +const modeling = diagram.get("modeling"); +const elementRegistry = diagram.get("elementRegistry"); +const selection = diagram.get("selection"); + +function shapes(): Element[] { + return elementRegistry.filter((element) => "parent" in element && Boolean(element.parent)) as Element[]; +} + +/** Every new document opens like the first (guide §5.9); the start screen goes with it. */ +function openDocument(fill: () => void): void { + connection.viewport.newDocument(); + const existing = shapes(); + if (existing.length > 0) modeling.removeElements(existing); + fill(); + connection.viewport.afterImport(); + shell.setEmptyState(false); + shell.setProblems({ count: 0, errors: 0 }); + shell.menu.refresh(); +} + +function openSample(): void { + openDocument(() => addSampleShapes(diagram)); +} + +function newDiagram(): void { + calls.push("new-diagram"); + openDocument(() => {}); +} + +async function openDropped(files: readonly File[]): Promise { + const [file] = files; + if (!file) return; + await file.text(); + calls.push(`drop:${file.name}`); + openSample(); +} + +const menu: MenuGroups = { + model: [ + { + kind: "radio", + id: "level", + label: "Level", + options: [ + { value: "big-picture", label: "Big picture" }, + { value: "process", label: "Process modelling" }, + { value: "design", label: "Software design" }, + ], + value: () => level, + onSelect: (value) => { + level = value; + calls.push(`level:${value}`); + }, + }, + { + kind: "checkbox", + id: "type-captions", + label: "Type captions", + checked: () => captions, + onToggle: (checked) => { + captions = checked; + calls.push(`type-captions:${checked}`); + }, + keepEnabledWhenReadOnly: true, + }, + ], + export: [ + { + kind: "action", + id: "export-png", + label: "Export as PNG", + icon: "file-image", + onSelect: () => calls.push("export-png"), + }, + { + kind: "action", + id: "export-svg", + label: "Export as SVG", + icon: "file-code-corner", + onSelect: () => calls.push("export-svg"), + }, + ], + help: [ + { + kind: "dialog", + id: "shortcuts", + label: "Keyboard shortcuts…", + icon: "keyboard", + onSelect: () => calls.push("shortcuts"), + }, + ], +}; + +const shell: ShellHandle = mountShell(app, { + host: "web", + canvas: canvasElement, + lang: "en", + themes, + messages: { canvas: "Demo diagram" }, + menu, + primary: { + onSelect: () => { + calls.push("share"); + shell.toast({ status: "success", text: "Link copied" }); + }, + }, + legal: { links: [{ label: "GitHub", href: "https://github.com/Miragon/modeler-api" }] }, + file: { + newDiagram: { onSelect: () => newDiagram() }, + open: { onSelect: () => calls.push("open"), shortcut: "⌘O" }, + showExample: { onSelect: () => openSample() }, + import: { onSelect: () => calls.push("import") }, + }, + appearance: { + themeColors: { light: tokens.fest["--cd-blau"], dark: tokens.dunkel["--cd-ui-grund"] }, + }, + problems: { + onSelectItem: (item) => { + calls.push(`select:${item.elementId ?? ""}`); + const element = item.elementId ? elementRegistry.get(item.elementId) : undefined; + if (element) selection.select(element); + }, + }, + ...(params.has("legend") + ? { + legend: { + render(body: HTMLElement) { + body.innerHTML = `
Box
`; + }, + onToggle: (open: boolean) => calls.push(`legend:${open}`), + open: params.get("legend") === "open" ? true : undefined, + }, + } + : {}), + ...(params.has("inspector") ? { inspector: { label: "Properties" } } : {}), + emptyState: { + title: "Demo Modeler", + text: "Start a new diagram, or explore the example.", + komet: kometUrl, + onNewDiagram: () => newDiagram(), + onShowExample: () => openSample(), + dropHint: "Or drop a .demo or .json file here", + }, + dropZone: { + accept: [".demo", ".json", "application/json"], + onDrop: (files) => void openDropped(files), + }, +}); + +const connection: DiagramJsConnection = connectDiagramJs(shell, diagram, { + contentBounds: () => demoContentBounds(diagram), +}); + +/** Two problems for the list (guide §5.10): an error on the first box, a warning without an element. */ +function showProblems(): void { + const [first] = shapes(); + shell.setProblems( + problemSummary([ + { severity: "error", message: "The box has no name.", location: "Box 1", elementId: first?.id }, + { severity: "warning", message: "Two boxes overlap.", location: "Boxes 2 and 3" }, + ]), + ); +} + +/** A fresh visit without a document: an empty board at 100 % behind the start screen. */ +function startScreen(): void { + connection.viewport.newDocument(); + const existing = shapes(); + if (existing.length > 0) modeling.removeElements(existing); + connection.viewport.afterImport(); + shell.setEmptyState(true); +} + +/** The invalid-file state of guide §13.5, as a host shows it after an import that failed to parse. */ +function setInvalid(invalid: boolean): void { + if (!invalid) { + shell.setReadOnly(false); + shell.setStatus(null); + return; + } + shell.setReadOnly(true, { delayMs: INVALID_STATUS_DELAY_MS }); + shell.setStatus( + { severity: "danger", text: shell.messages.invalidFile("demo diagram") }, + { delayMs: INVALID_STATUS_DELAY_MS }, + ); + if (!flaws.canvasUnlocked) diagram.get("contextPad").close(); +} + +if (params.has("empty")) { + connection.viewport.afterImport(); + shell.setEmptyState(true); +} else { + addSampleShapes(diagram); + connection.viewport.afterImport(); +} +if (params.has("problems")) showProblems(); +if (params.has("invalid")) setInvalid(true); + +let destroyed = false; + +const webDemo = { + ready: true, + host: "web" as const, + themes, + calls, + shell, + connection, + diagram, + flaws, + showProblems, + startScreen, + setInvalid, + toast(request: { status: ToastStatus; text: string; actionLabel?: string | undefined }): void { + shell.toast({ + status: request.status, + text: request.text, + ...(request.actionLabel ? { action: { label: request.actionLabel, onSelect() {} } } : {}), + }); + }, + destroy(): void { + if (destroyed) return; + destroyed = true; + connection.destroy(); + diagram.destroy(); + shell.destroy(); + }, +}; + +declare global { + interface Window { + __webDemo?: typeof webDemo; + } +} +window.__webDemo = webDemo; diff --git a/packages/shell/e2e/conformance.spec.ts b/packages/shell/e2e/conformance.spec.ts index f27ea141..c2ca699c 100644 --- a/packages/shell/e2e/conformance.spec.ts +++ b/packages/shell/e2e/conformance.spec.ts @@ -3,12 +3,14 @@ import { test, type Page } from "@playwright/test"; import type { ShellHost } from "../src/index.js"; import { registerShellConformance, type ShellConformanceHarness } from "../src/testing.js"; import { demoHarness } from "./demo-harness.js"; +import { webDemoHarness } from "./web-harness.js"; -// The kit against the demo page, as a modeler's e2e suite runs it against its webview. The demo -// feeds every hook or declares it absent, so a skip is a failure here. +// The kit against the demo pages, as a modeler's e2e suite runs it against its webview or its web +// app: the IDE and embedded hosts on the 0.1 demo, the web host on the web demo (/web.html, the +// whole web column). The demos feed every hook or declare it absent, so a skip is a failure here. const suites: ReadonlyArray<{ title: string; host: ShellHost; harness: ShellConformanceHarness }> = [ { title: "ide host", host: "ide", harness: demoHarness("ide") }, - { title: "web host", host: "web", harness: demoHarness("web") }, + { title: "web host", host: "web", harness: webDemoHarness() }, { title: "embedded host", host: "embedded", harness: demoHarness("embedded") }, // the modeler of the shared dark-mode release: the same rules, the requested dark now shows { @@ -19,7 +21,7 @@ const suites: ReadonlyArray<{ title: string; host: ShellHost; harness: ShellConf { title: "web host, dark-capable modeler", host: "web", - harness: demoHarness("web", { themes: ["light", "dark"] }), + harness: webDemoHarness({ themes: ["light", "dark"] }), }, // a modeler without legend and inspector declares them absent: nothing is skipped, nothing shows { diff --git a/packages/shell/e2e/demo-harness.ts b/packages/shell/e2e/demo-harness.ts index b7cde787..76ad5da6 100644 --- a/packages/shell/e2e/demo-harness.ts +++ b/packages/shell/e2e/demo-harness.ts @@ -58,6 +58,18 @@ export function demoHarness( async invalid(page) { await page.evaluate(() => window.__demo?.setInvalid(true)); }, + // Embedded: toasts until the contract has a channel to the host (guide §5.2); IDE: inert. + async toast(page, toast) { + await page.evaluate( + ({ status, text, actionLabel }) => + window.__demo?.shell.toast({ + status, + text, + ...(actionLabel ? { action: { label: actionLabel, onSelect() {} } } : {}), + }), + toast, + ); + }, }; for (const slot of without) delete show[slot]; return { diff --git a/packages/shell/e2e/demo-window.d.ts b/packages/shell/e2e/demo-window.d.ts index ab784ebe..c5b14bab 100644 --- a/packages/shell/e2e/demo-window.d.ts +++ b/packages/shell/e2e/demo-window.d.ts @@ -1,6 +1,6 @@ // The demo's debug handle, as far as the e2e specs read it. import type { DiagramJsConnection } from "../src/diagram-js.js"; -import type { ShellHandle } from "../src/index.js"; +import type { ShellHandle, ToastStatus } from "../src/index.js"; import type { VsCodeWebviewStub } from "../src/testing-vscode-stub.js"; declare global { @@ -31,5 +31,20 @@ declare global { setInvalid(invalid: boolean): void; destroy(): void; }; + /** The web demo's handle (`/web.html`, demo/web.ts). */ + __webDemo?: { + ready: boolean; + calls: string[]; + shell: ShellHandle; + connection: DiagramJsConnection; + flaws: NonNullable["flaws"]; + /** The pill with two items: an error on the first box, a warning without an element. */ + showProblems(): void; + /** A fresh visit without a document: an empty board behind the start screen. */ + startScreen(): void; + setInvalid(invalid: boolean): void; + toast(request: { status: ToastStatus; text: string; actionLabel?: string | undefined }): void; + destroy(): void; + }; } } diff --git a/packages/shell/e2e/saboteurs.spec.ts b/packages/shell/e2e/saboteurs.spec.ts index 1c852a6d..d5023a22 100644 --- a/packages/shell/e2e/saboteurs.spec.ts +++ b/packages/shell/e2e/saboteurs.spec.ts @@ -1,7 +1,13 @@ import { expect, test, type Page } from "@playwright/test"; -import { shellConformanceCases, type ShellConformanceHarness } from "../src/testing.js"; +import { + shellConformanceCases, + type ShellConformanceCase, + type ShellConformanceHarness, +} from "../src/testing.js"; +import { createWebCases } from "../src/testing/web-cases.js"; import { demoHarness } from "./demo-harness.js"; +import { webDemoHarness, type WebDemoHarnessOptions } from "./web-harness.js"; /* * The kit's own proof: each saboteur breaks the demo at runtime in one way the guide forbids, and @@ -11,8 +17,15 @@ import { demoHarness } from "./demo-harness.js"; const WIDE_ONLY = [{ width: 1280, height: 900 }] as const; +/** Every case of the kit, the web column's included while it is not yet registered in createCases. */ +function allCases(): ShellConformanceCase[] { + const registered = shellConformanceCases(); + const names = new Set(registered.map((conformanceCase) => conformanceCase.name)); + return [...registered, ...createWebCases().filter((conformanceCase) => !names.has(conformanceCase.name))]; +} + function runCase(name: string, page: Page, harness: ShellConformanceHarness) { - const found = shellConformanceCases().find((conformanceCase) => conformanceCase.name === name); + const found = allCases().find((conformanceCase) => conformanceCase.name === name); if (!found) throw new Error(`no conformance case "${name}"`); return found.run(page, harness); } @@ -20,7 +33,7 @@ function runCase(name: string, page: Page, harness: ShellConformanceHarness): Promise> { const failed = new Map(); - for (const conformanceCase of shellConformanceCases()) { + for (const conformanceCase of allCases()) { try { const outcome = await conformanceCase.run(page, harness); const skipped = outcome?.skipped ?? []; @@ -64,6 +77,22 @@ function sabotaged( }; } +/** The web demo's harness (`/web.html`) with `sabotage` applied right after every open. */ +function webSabotaged( + sabotage: () => void, + options: WebDemoHarnessOptions = {}, +): ShellConformanceHarness { + const harness = webDemoHarness(options); + return { + ...harness, + sizes: WIDE_ONLY, + async open(page, context) { + await harness.open(page, context); + await page.evaluate(sabotage); + }, + }; +} + test.describe("saboteurs", () => { test.describe.configure({ timeout: 60_000 }); @@ -112,6 +141,157 @@ test.describe("saboteurs", () => { ]); }); + test("webChrome fails when the primary action never collapses its label", async ({ page }) => { + const harness = webSabotaged(() => { + const root = document.querySelector(".cd-shell")!; + const expand = () => { + root.removeAttribute("data-primary"); + const primary = root.querySelector('[data-chrome="primary"]'); + primary?.removeAttribute("data-collapsed"); + primary?.querySelector("[hidden]")?.removeAttribute("hidden"); + }; + new MutationObserver(expand).observe(root, { + subtree: true, + attributes: true, + attributeFilter: ["data-primary", "data-collapsed", "hidden"], + }); + }); + await expectOnlyCaseFails(page, harness, "webChrome", [ + /\[\d+px wide\] primary: shows its label, but the 648px palette fits the top row only beside the icon; the label collapses first/, + ]); + }); + + test("webChrome fails when a narrow legal line scrolls instead of wrapping", async ({ page }) => { + const harness = webSabotaged(() => { + const style = document.createElement("style"); + style.textContent = ".cd-shell-legal { flex-wrap: nowrap !important; overflow-x: auto; }"; + document.head.append(style); + }); + await expectOnlyCaseFails(page, harness, "webChrome", [ + /legal: \[320px wide\] "[^"]+" is cut off or scrolled out of the legal line; it wraps instead/, + ]); + }); + + test("emptyState fails when the start screen leaves the palette visible", async ({ page }) => { + const harness = webSabotaged(() => { + const style = document.createElement("style"); + style.textContent = + '.cd-shell[data-empty-state="shown"] .djs-palette { visibility: visible !important; }'; + document.head.append(style); + }); + await expectOnlyCaseFails(page, harness, "emptyState", [ + /empty-state: palette is visible behind the start screen; it hides the work chrome/, + ]); + }); + + test("emptyState fails when the start screen swallows the browser's zoom keys", async ({ page }) => { + const harness = webSabotaged(() => { + document.addEventListener("keydown", (event) => { + const shown = document.querySelector('.cd-shell[data-empty-state="shown"]') !== null; + if (shown && (event.ctrlKey || event.metaKey) && (event.key === "=" || event.key === "-")) + event.preventDefault(); + }); + }); + await expectOnlyCaseFails(page, harness, "emptyState", [ + /empty-state: Control\+Equal behind the start screen arrived prevented; with no diagram to zoom the browser keeps its page zoom/, + ]); + }); + + test("toasts fails when toasts never close by themselves", async ({ page }) => { + const harness = webSabotaged(() => { + const original = window.setTimeout.bind(window); + window.setTimeout = ((handler: TimerHandler, timeout?: number, ...rest: unknown[]) => + timeout !== undefined && timeout >= 1400 && timeout <= 6000 + ? 0 + : original(handler, timeout, ...rest)) as typeof window.setTimeout; + }); + await expectOnlyCaseFails(page, harness, "toasts", [ + /toasts: "Conformance: a plain toast" is still there 3700ms after it appeared; it closes after its lifetime/, + ]); + }); + + test("toasts fails when the live regions come only with the toast", async ({ page }) => { + const harness = webSabotaged(() => document.querySelector(".cd-shell-live")?.remove()); + await expectOnlyCaseFails(page, harness, "toasts", [ + /toasts: no role="status" live region in the root before the first toast/, + /toasts: no role="alert" live region in the root before the first toast/, + ]); + }); + + test("problemsList fails when a text row cannot take the focus", async ({ page }) => { + const harness = webSabotaged(() => { + const unfocusable = () => { + for (const row of document.querySelectorAll("div.cd-shell-problem[tabindex]")) + row.removeAttribute("tabindex"); + }; + unfocusable(); + new MutationObserver(unfocusable).observe(document.body, { childList: true, subtree: true }); + }); + await expectOnlyCaseFails(page, harness, "problemsList", [ + /problems-popover: ArrowDown reaches row 1, expected row 2 of 2; the keyboard reads every row, text rows too/, + ]); + }); + + test("dropZone fails when the drop zone ignores a forbidden drag", async ({ page }) => { + const harness = webSabotaged(() => { + const root = document.querySelector(".cd-shell")!; + new MutationObserver(() => { + if (root.getAttribute("data-drop") === "forbidden") root.setAttribute("data-drop", "over"); + }).observe(root, { attributes: true, attributeFilter: ["data-drop"] }); + }); + await expectOnlyCaseFails(page, harness, "dropZone", [ + /drop-zone: a drag of two files sets data-drop="over", expected "forbidden"/, + /drop-zone: a file drag with a modal dialog open sets data-drop="over", expected "forbidden"/, + ]); + }); + + test("aria fails for Impressum and Datenschutz without lang", async ({ page }) => { + const harness = webSabotaged(() => { + for (const link of document.querySelectorAll('[data-chrome="legal"] a[lang]')) + link.removeAttribute("lang"); + }); + await expectOnlyCaseFails(page, harness, "aria", [ + /legal: "Impressum" has no lang, expected lang="de"/, + /legal: "Datenschutz" has no lang, expected lang="de"/, + ]); + }); + + test("problemsList fails for a problems list not labelled by its title", async ({ page }) => { + const harness = webSabotaged(() => { + document.querySelector('[data-chrome="problems-popover"]')?.removeAttribute("aria-labelledby"); + }); + await expectOnlyCaseFails(page, harness, "problemsList", [ + /problems-popover: not labelled by its title \(aria-labelledby\)/, + ]); + }); + + test("appearance fails when the web app does not store the chosen mode", async ({ page }) => { + const harness = webSabotaged( + () => { + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function (this: Storage, key: string, value: string) { + if (key !== "miragon.darstellung") setItem.call(this, key, value); + }; + }, + { themes: ["light", "dark"] }, + ); + await expectOnlyCaseFails(page, harness, "appearance", [ + /mode: with Dark localStorage holds miragon\.darstellung=\(none\), expected "dark"/, + ]); + }); + + test("geometry fails for a toast stack off its bottom", async ({ page }) => { + const harness = webSabotaged(() => { + const style = document.createElement("style"); + style.textContent = '[data-chrome="toasts"] { translate: 0 -10px; }'; + document.head.append(style); + }); + await expectOnlyCaseFails(page, harness, "geometry", [ + // 10px above toastStackBottom, which depends on the legal line's measured width + /\[1280 × 900, with a toast\] toasts: bottom is (\d+)px, expected (\d+)px \(±1px\)/, + ]); + }); + test("appearance fails for a renderer colour on the armed tool's icon", async ({ page }) => { // the lasso icon of Event Storming computed to rgb(29, 30, 33) from a renderer rule const harness = sabotaged(() => { diff --git a/packages/shell/e2e/web-chrome-style.spec.ts b/packages/shell/e2e/web-chrome-style.spec.ts new file mode 100644 index 00000000..fa424ffc --- /dev/null +++ b/packages/shell/e2e/web-chrome-style.spec.ts @@ -0,0 +1,285 @@ +import { expect, test, type Page } from "@playwright/test"; + +/* + * Computed styles of the web column (guide §5.7, §5.10, §6, §8, §9, §12) on the web demo + * (/web.html). The conformance kit measures boxes, states and roles; these are the colours, radii, + * shadows and type it does not look at, plus spot checks under high contrast and forced colours. + */ + +async function openWeb(page: Page, query = ""): Promise { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(`/web.html${query}`); + await page.waitForFunction(() => window.__webDemo?.ready === true); +} + +/** `value` for `property` as the browser computes it inside the shell root (tokens and system colours). */ +function computed(page: Page, property: string, value: string): Promise { + return page.evaluate( + ({ property, value }) => { + const probe = document.createElement("div"); + probe.style.setProperty(property, value); + probe.style.position = "absolute"; + probe.style.visibility = "hidden"; + document.querySelector(".cd-shell")!.append(probe); + const result = getComputedStyle(probe).getPropertyValue(property); + probe.remove(); + return result; + }, + { property, value }, + ); +} + +function styleOf(page: Page, selector: string, properties: readonly string[], pseudo?: string) { + return page.evaluate( + ({ selector, properties, pseudo }) => { + const element = document.querySelector(selector); + if (!element) throw new Error(`no ${selector}`); + const style = getComputedStyle(element, pseudo ?? null); + const rect = element.getBoundingClientRect(); + return { + ...Object.fromEntries(properties.map((property) => [property, style.getPropertyValue(property)])), + boxTop: rect.top, + boxWidth: rect.width, + boxHeight: rect.height, + } as Record & { boxTop: number; boxWidth: number; boxHeight: number }; + }, + { selector, properties, pseudo }, + ); +} + +/** Dispatches a file drag (enter and over) on the page, as the conformance kit does. */ +function dragFiles(page: Page, count: number): Promise { + return page.evaluate((count) => { + const transfer = new DataTransfer(); + for (let index = 0; index < count; index++) { + transfer.items.add(new File(["{}"], `board-${index}.json`, { type: "application/json" })); + } + for (const type of ["dragenter", "dragover"]) { + document.body.dispatchEvent( + new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: transfer }), + ); + } + }, count); +} + +const TOAST_STATUSES = ["success", "info", "warning", "danger"] as const; + +test.describe("web chrome styles", () => { + test("the primary action is blue with white text and lifts on hover (guide §5.7, §8)", async ({ page }) => { + await openWeb(page); + const primary = '[data-chrome="primary"]'; + const properties = [ + "background-color", + "color", + "box-shadow", + "border-top-left-radius", + "font-size", + "font-weight", + ]; + const rest = await styleOf(page, primary, properties); + expect(rest["background-color"]).toBe(await computed(page, "background-color", "var(--cd-blau)")); + expect(rest.color).toBe(await computed(page, "color", "var(--cd-weiss)")); + expect(rest["box-shadow"]).toBe(await computed(page, "box-shadow", "var(--cd-shadow-1)")); + expect(rest["border-top-left-radius"]).toBe("12px"); + expect(rest["font-size"]).toBe("13px"); + expect(rest["font-weight"]).toBe("600"); + expect(rest.boxHeight).toBe(40); + + await page.locator(primary).hover(); + await page.waitForTimeout(400); + const hovered = await styleOf(page, primary, properties); + expect(hovered["background-color"]).toBe(await computed(page, "background-color", "var(--cd-blau-link)")); + expect(hovered["box-shadow"]).toBe(await computed(page, "box-shadow", "var(--cd-shadow-2)")); + expect(hovered.boxTop).toBeCloseTo(rest.boxTop - 1, 1); + }); + + test("the legal line is 13px on a 20px line with its links in --cd-ui-link (guide §5.10)", async ({ + page, + }) => { + await openWeb(page); + const legal = await styleOf(page, '[data-chrome="legal"]', [ + "font-size", + "line-height", + "border-top-left-radius", + "box-shadow", + ]); + expect(legal["font-size"]).toBe("13px"); + expect(legal["line-height"]).toBe("20px"); + expect(legal["border-top-left-radius"]).toBe("6px"); + expect(legal["box-shadow"]).toBe("none"); + const link = await computed(page, "color", "var(--cd-ui-link)"); + const colours = await page.evaluate(() => + [...document.querySelectorAll('[data-chrome="legal"] a')].map( + (anchor) => getComputedStyle(anchor).color, + ), + ); + expect(colours.length).toBeGreaterThanOrEqual(3); + for (const colour of colours) expect(colour).toBe(link); + }); + + for (const status of TOAST_STATUSES) { + test(`a ${status} toast has its 4px bar and 8px dot in --cd-ui-${status} (guide §8)`, async ({ + page, + }) => { + await openWeb(page); + await page.evaluate((status) => window.__webDemo?.toast({ status, text: `Styled ${status}` }), status); + const toast = `.cd-shell-toast[data-status="${status}"]`; + await expect(page.locator(toast)).toBeVisible(); + await page.waitForTimeout(400); + const colour = await computed(page, "background-color", `var(--cd-ui-${status})`); + const bar = await styleOf(page, toast, ["width", "background-color"], "::before"); + expect(bar.width).toBe("4px"); + expect(bar["background-color"]).toBe(colour); + const dot = await styleOf(page, `${toast} .cd-shell-toast-dot`, ["background-color"]); + expect([dot.boxWidth, dot.boxHeight, dot["background-color"]]).toEqual([8, 8, colour]); + const card = await styleOf(page, toast, [ + "background-color", + "color", + "border-top-left-radius", + "box-shadow", + "font-size", + ]); + expect(card["background-color"]).toBe( + await computed(page, "background-color", "var(--cd-ui-flaeche-hoch)"), + ); + expect(card.color).toBe(await computed(page, "color", "var(--cd-ui-text)")); + expect(card["border-top-left-radius"]).toBe("12px"); + expect(card["box-shadow"]).toBe(await computed(page, "box-shadow", "var(--cd-shadow-3)")); + expect(card["font-size"]).toBe("13px"); + const close = await styleOf(page, `${toast} button[aria-label="Close"]`, []); + expect([close.boxWidth, close.boxHeight]).toEqual([32, 32]); + }); + } + + test("the drop zone shows drag-over and forbidden in the colours of guide §9", async ({ page }) => { + await openWeb(page); + const zone = '[data-chrome="drop-zone"]'; + const properties = [ + "background-color", + "border-top-style", + "border-top-width", + "border-top-color", + "border-top-left-radius", + ]; + await dragFiles(page, 1); + await page.waitForTimeout(300); + const over = await styleOf(page, zone, properties); + expect(over["background-color"]).toBe(await computed(page, "background-color", "var(--cd-ui-hover)")); + expect([over["border-top-style"], over["border-top-width"]]).toEqual(["dashed", "2px"]); + expect(over["border-top-color"]).toBe(await computed(page, "color", "var(--cd-ui-interaktion)")); + expect(over["border-top-left-radius"]).toBe("16px"); + const chip = await styleOf(page, `${zone} .cd-shell-drop-zone-chip`, [ + "background-color", + "font-size", + "font-weight", + ]); + expect(chip["background-color"]).toBe( + await computed(page, "background-color", "var(--cd-ui-flaeche-hoch)"), + ); + expect([chip["font-size"], chip["font-weight"]]).toEqual(["13px", "600"]); + + await dragFiles(page, 2); + await page.waitForTimeout(300); + const forbidden = await styleOf(page, zone, properties); + expect(forbidden["background-color"]).toBe( + await computed(page, "background-color", "var(--cd-ui-danger-flaeche)"), + ); + expect(forbidden["border-top-style"]).toBe("dashed"); + // The not-allowed cursor comes from dropEffect "none" during the drag (the kit's dropZone case). + expect(forbidden["border-top-color"]).toBe(await computed(page, "color", "var(--cd-ui-danger)")); + }); + + test("the start screen card is glass with radius lg and shadow-2, Komet 64, title 21/600 (guide §8)", async ({ + page, + }) => { + await openWeb(page, "?empty"); + const card = '[data-chrome="empty-state"]'; + const style = await styleOf(page, card, [ + "border-top-left-radius", + "box-shadow", + "backdrop-filter", + "padding-top", + "padding-left", + ]); + expect(style["border-top-left-radius"]).toBe("16px"); + expect(style["box-shadow"]).toBe(await computed(page, "box-shadow", "var(--cd-shadow-2)")); + expect(style["backdrop-filter"]).toBe("blur(8px)"); + expect([style["padding-top"], style["padding-left"]]).toEqual(["32px", "40px"]); + const komet = await styleOf(page, `${card} img`, []); + expect([komet.boxWidth, komet.boxHeight]).toEqual([64, 64]); + const title = await styleOf(page, `${card} h1`, ["font-size", "font-weight"]); + expect([title["font-size"], title["font-weight"]]).toEqual(["21px", "600"]); + const buttons = await page.evaluate(() => + [...document.querySelectorAll('[data-chrome="empty-state"] .cd-shell-empty-state-actions button')].map( + (button) => ({ + variant: button.getAttribute("data-variant"), + height: button.getBoundingClientRect().height, + background: getComputedStyle(button).backgroundColor, + }), + ), + ); + expect(buttons.map((button) => [button.variant, button.height])).toEqual([ + ["primary", 36], + ["ghost", 36], + ]); + expect(buttons[0]?.background).toBe(await computed(page, "background-color", "var(--cd-blau)")); + expect(buttons[1]?.background).toBe("rgba(0, 0, 0, 0)"); + // The File group's Open… as a text link in the hint: link colour, the hint's 13px, no button skin. + const open = await styleOf(page, `${card} .cd-shell-empty-state-open`, [ + "color", + "font-size", + "background-color", + "border-top-width", + ]); + expect(open.color).toBe(await computed(page, "color", "var(--cd-ui-link)")); + expect([open["font-size"], open["background-color"], open["border-top-width"]]).toEqual([ + "13px", + "rgba(0, 0, 0, 0)", + "0px", + ]); + }); + + test("high contrast drops the shadows and widens the focus ring to 3px (guide §5.3, §5.14)", async ({ + page, + }) => { + await page.emulateMedia({ contrast: "more" }); + await openWeb(page, "?empty"); + expect(await page.evaluate(() => document.documentElement.getAttribute("data-cd-kontrast"))).toBe("hoch"); + expect((await styleOf(page, '[data-chrome="empty-state"]', ["box-shadow"]))["box-shadow"]).toBe("none"); + await page.locator('[data-chrome="empty-state"] button[data-variant="primary"]').click(); + await page.evaluate(() => window.__webDemo?.toast({ status: "info", text: "High contrast" })); + await expect(page.locator(".cd-shell-toast")).toBeVisible(); + expect((await styleOf(page, ".cd-shell-toast", ["box-shadow"]))["box-shadow"]).toBe("none"); + expect((await styleOf(page, '[data-chrome="primary"]', ["box-shadow"]))["box-shadow"]).toBe("none"); + await page.locator('[data-chrome="menu"]').focus(); + await page.keyboard.press("Tab"); + const ring = await styleOf(page, '[data-chrome="primary"]', ["outline-style", "outline-width"]); + expect([ring["outline-style"], ring["outline-width"]]).toEqual(["solid", "3px"]); + }); + + test("forced colours keep borders, bars and rings in system colours (guide §5.14, §12)", async ({ + page, + }) => { + await page.emulateMedia({ forcedColors: "active" }); + await openWeb(page); + await page.locator('[data-chrome="menu"]').focus(); + await page.keyboard.press("Tab"); + const primary = await styleOf(page, '[data-chrome="primary"]', [ + "outline-color", + "outline-style", + "border-top-color", + ]); + expect(primary["outline-style"]).toBe("solid"); + expect(primary["outline-color"]).toBe(await computed(page, "color", "Highlight")); + expect(primary["border-top-color"]).toBe(await computed(page, "color", "ButtonText")); + await page.evaluate(() => window.__webDemo?.toast({ status: "danger", text: "Forced colours" })); + await expect(page.locator(".cd-shell-toast")).toBeVisible(); + const bar = await styleOf(page, ".cd-shell-toast", ["background-color", "width"], "::before"); + expect(bar.width).toBe("4px"); + expect(bar["background-color"]).toBe(await computed(page, "color", "CanvasText")); + await dragFiles(page, 1); + await page.waitForTimeout(300); + const zone = await styleOf(page, '[data-chrome="drop-zone"]', ["border-top-style", "border-top-width"]); + expect([zone["border-top-style"], zone["border-top-width"]]).toEqual(["dashed", "2px"]); + }); +}); diff --git a/packages/shell/e2e/web-demo.spec.ts b/packages/shell/e2e/web-demo.spec.ts new file mode 100644 index 00000000..f511e1ba --- /dev/null +++ b/packages/shell/e2e/web-demo.spec.ts @@ -0,0 +1,59 @@ +import { expect, test, type Page } from "@playwright/test"; + +/* + * The web demo's own wiring (/web.html): the kit sees that the chrome clicks and closes, not what a + * modeler's handlers do. These specs read `window.__webDemo.calls`, which the demo's handlers fill. + */ + +async function openWeb(page: Page, query = ""): Promise { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(`/web.html${query}`); + await page.waitForFunction(() => window.__webDemo?.ready === true); +} + +function calls(page: Page): Promise { + return page.evaluate(() => [...(window.__webDemo?.calls ?? [])]); +} + +test.describe("web demo wiring", () => { + test("Share runs on a click and on Enter and confirms with a toast", async ({ page }) => { + await openWeb(page); + const primary = page.locator('[data-chrome="primary"]'); + await primary.click(); + await primary.focus(); + await page.keyboard.press("Enter"); + expect(await calls(page)).toEqual(["share", "share"]); + await expect(page.locator('[data-chrome="toasts"] [data-status]').first()).toContainText("Link copied"); + }); + + test("the File entries run their handlers after the menu closed", async ({ page }) => { + await openWeb(page); + for (const id of ["new-diagram", "open", "import"]) { + await page.locator('[data-chrome="menu"]').click(); + await page.locator(`[role="menu"] [data-menu-id="${id}"]`).click(); + await expect(page.locator('[role="menu"]')).toBeHidden(); + } + expect(await calls(page)).toEqual(["new-diagram", "open", "import"]); + }); + + test("a dropped board leaves the start screen; a problem row selects its element", async ({ page }) => { + await openWeb(page, "?empty"); + await page.evaluate(() => { + const transfer = new DataTransfer(); + transfer.items.add(new File(["{}"], "board.json", { type: "application/json" })); + for (const type of ["dragenter", "dragover", "drop"]) { + document.body.dispatchEvent( + new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: transfer }), + ); + } + }); + await expect(page.locator('[data-chrome="empty-state"]')).toBeHidden(); + expect(await calls(page)).toEqual(["drop:board.json"]); + + await page.evaluate(() => window.__webDemo?.showProblems()); + await page.locator('[data-chrome="problems"]').click(); + await page.locator('[data-chrome="problems-popover"] button.cd-shell-problem').first().click(); + expect((await calls(page)).at(-1)).toMatch(/^select:.+/); + await expect(page.locator(".djs-shape.selected")).toHaveCount(1); + }); +}); diff --git a/packages/shell/e2e/web-harness.ts b/packages/shell/e2e/web-harness.ts new file mode 100644 index 00000000..6ce412bb --- /dev/null +++ b/packages/shell/e2e/web-harness.ts @@ -0,0 +1,87 @@ +import type { Page } from "@playwright/test"; + +import type { Theme } from "../src/index.js"; +import type { DropFixtures, ShellConformanceHarness, ShowHooks } from "../src/testing.js"; + +export interface WebDemoHarnessOptions { + /** The modes the demo modeler declares; default `["light"]`, as every modeler today. */ + themes?: readonly Theme[]; + /** Slots the demo leaves out; the harness drops their show hooks and declares them `absent`. */ + without?: ReadonlyArray<"legend" | "inspector">; +} + +/** What the web demo opens and rejects (its `dropZone.accept`: `.demo`, `.json`, `application/json`). */ +export const WEB_DROP: DropFixtures = { + accepted: { name: "board.json", type: "application/json", text: '{ "boxes": 3 }' }, + // no MIME type: the drag cannot be told apart before the drop, so the drop itself rejects it + rejected: { name: "notes.md", type: "", text: "# Notes" }, +}; + +/** Pages that start every visit without a stored appearance choice (`miragon.darstellung`). */ +const freshPages = new WeakSet(); + +/** + * The kit's harness for the web demo (`/web.html`), as a Miragon web app's e2e suite writes it: + * the kit emulates the system's mode and contrast before `open`, `open` loads the page without a + * stored choice, and the show hooks drive `window.__webDemo`. + */ +export function webDemoHarness(options: WebDemoHarnessOptions = {}): ShellConformanceHarness { + const themes = options.themes ?? ["light"]; + const without = options.without ?? []; + const show: ShowHooks = { + async problems(page) { + await page.evaluate(() => window.__webDemo?.showProblems()); + }, + async status(page) { + await page.evaluate(() => { + const shell = window.__webDemo?.shell; + shell?.setStatus({ severity: "danger", text: shell.messages.invalidFile("demo diagram") }); + }); + }, + async legend(page) { + await page.evaluate(() => window.__webDemo?.shell.legend?.setOpen(true)); + }, + async inspector(page) { + await page.evaluate(() => window.__webDemo?.shell.inspector?.open()); + }, + async invalid(page) { + await page.evaluate(() => window.__webDemo?.setInvalid(true)); + }, + async emptyState(page) { + await page.evaluate(() => window.__webDemo?.startScreen()); + }, + async toast(page, toast) { + await page.evaluate((request) => window.__webDemo?.toast(request), toast); + }, + }; + for (const slot of without) delete show[slot]; + return { + host: "web", + themes, + async open(page, { size }) { + if (!freshPages.has(page)) { + freshPages.add(page); + await page.addInitScript(() => { + try { + localStorage.removeItem("miragon.darstellung"); + } catch { + // a blocked storage holds no choice either + } + }); + } + await page.setViewportSize({ width: size.width, height: size.height }); + const query = new URLSearchParams({ themes: themes.join(",") }); + for (const slot of ["legend", "inspector"] as const) { + if (!without.includes(slot)) query.set(slot, ""); + } + await page.goto(`/web.html?${query.toString()}`); + await page.waitForFunction(() => window.__webDemo?.ready === true); + }, + async destroy(page) { + await page.evaluate(() => window.__webDemo?.destroy()); + }, + show, + drop: WEB_DROP, + absent: without, + }; +} diff --git a/packages/shell/fixtures/brand/README.md b/packages/shell/fixtures/brand/README.md new file mode 100644 index 00000000..989e0811 --- /dev/null +++ b/packages/shell/fixtures/brand/README.md @@ -0,0 +1,8 @@ +# Brand fixture + +`miragon-komet-blau.svg`, the official Miragon icon (green Komet on the blue tile), vendored unchanged +from `Miragon/corporate-identity` (`docs/public/logo/miragon-komet-blau.svg`, design system 1.13.0) for +the web demo's empty-state card (guide §2, §8, §11). Never edit it by hand: re-copy it from the source. + +The shell package itself ships no brand asset; a web app vendors its own copy and passes its URL as +`emptyState.komet`. diff --git a/packages/shell/fixtures/brand/miragon-komet-blau.svg b/packages/shell/fixtures/brand/miragon-komet-blau.svg new file mode 100644 index 00000000..ae7939ae --- /dev/null +++ b/packages/shell/fixtures/brand/miragon-komet-blau.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/shell/package.json b/packages/shell/package.json index e6e03268..f373c4fc 100644 --- a/packages/shell/package.json +++ b/packages/shell/package.json @@ -1,7 +1,7 @@ { "name": "@miragon/modeler-shell", "version": "0.1.0", - "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.", + "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).", "license": "MIT", "repository": { "type": "git", diff --git a/packages/shell/src/core/feedback/appearance.test.ts b/packages/shell/src/core/feedback/appearance.test.ts new file mode 100644 index 00000000..7cdff8b8 --- /dev/null +++ b/packages/shell/src/core/feedback/appearance.test.ts @@ -0,0 +1,274 @@ +import { Script, createContext } from "node:vm"; +import { describe, expect, it } from "vitest"; +import { + APPEARANCE_STORAGE_KEY, + documentMode, + parseAppearanceChoice, + webHeadMarkup, + webModeScript, + type AppearanceChoice, + type ThemeColors, +} from "./appearance.js"; + +const colors: ThemeColors = { light: "#335DE5", dark: "#1D1D1D" }; +const LIGHT_ONLY = ["light"] as const; +const DARK_CAPABLE = ["light", "dark"] as const; + +describe("parseAppearanceChoice", () => { + it("passes light and dark, everything else is system", () => { + expect(parseAppearanceChoice("light")).toBe("light"); + expect(parseAppearanceChoice("dark")).toBe("dark"); + for (const stored of [null, undefined, "", "system", "Dark", "auto", 1, {}]) { + expect(parseAppearanceChoice(stored)).toBe("system"); + } + }); + + it("stores under miragon.darstellung (guide §13.3)", () => { + expect(APPEARANCE_STORAGE_KEY).toBe("miragon.darstellung"); + }); +}); + +describe("documentMode (guide §13.3)", () => { + it.each<[AppearanceChoice, readonly ("light" | "dark")[], ReturnType]>([ + [ + "system", + LIGHT_ONLY, + { theme: "light", colorScheme: "light", themeColor: { light: "#335DE5", dark: "#335DE5" } }, + ], + [ + "light", + LIGHT_ONLY, + { theme: "light", colorScheme: "light", themeColor: { light: "#335DE5", dark: "#335DE5" } }, + ], + [ + "dark", + LIGHT_ONLY, + { theme: "light", colorScheme: "light", themeColor: { light: "#335DE5", dark: "#335DE5" } }, + ], + [ + "system", + DARK_CAPABLE, + { theme: "system", colorScheme: "light dark", themeColor: { light: "#335DE5", dark: "#1D1D1D" } }, + ], + [ + "light", + DARK_CAPABLE, + { theme: "light", colorScheme: "light", themeColor: { light: "#335DE5", dark: "#335DE5" } }, + ], + [ + "dark", + DARK_CAPABLE, + { theme: "dark", colorScheme: "dark", themeColor: { light: "#1D1D1D", dark: "#1D1D1D" } }, + ], + ])("%s with themes %j", (choice, themes, expected) => { + expect(documentMode(choice, themes, colors)).toEqual(expected); + }); + + it("treats a foreign choice from untyped code as system", () => { + expect(documentMode("sepia" as AppearanceChoice, DARK_CAPABLE, colors).theme).toBe("system"); + }); +}); + +interface FakeMeta { + name: string; + media: string | undefined; + content: string; +} + +interface PageSetup { + stored?: string | null; + storage?: "ok" | "throws on access" | "throws on read"; + contrast?: boolean; + colorSchemeMeta?: boolean; +} + +/** Runs the head script against a minimal page in a fresh V8 context (no Node globals in reach). */ +function runOnFakePage(script: string, setup: PageSetup = {}) { + const attributes = new Map([["data-cd-theme", "system"]]); + const metas: FakeMeta[] = [ + ...(setup.colorSchemeMeta === false + ? [] + : [{ name: "color-scheme", media: undefined, content: "light dark" }]), + { name: "theme-color", media: "(prefers-color-scheme: light)", content: colors.light }, + { name: "theme-color", media: "(prefers-color-scheme: dark)", content: colors.dark }, + ]; + const element = (meta: FakeMeta) => ({ + setAttribute(name: string, value: string) { + if (name === "content") meta.content = value; + }, + }); + const named = (selector: string): FakeMeta[] => { + const match = /^meta\[name="?([\w-]+)"?\]$/.exec(selector); + if (!match) throw new Error(`unexpected selector ${selector}`); + return metas.filter((meta) => meta.name === match[1]); + }; + const storageReads: string[] = []; + const storage = { + getItem(key: string) { + storageReads.push(key); + if (setup.storage === "throws on read") throw new Error("SecurityError"); + return key === APPEARANCE_STORAGE_KEY ? (setup.stored ?? null) : null; + }, + }; + const window = { + get localStorage() { + if (setup.storage === "throws on access") throw new Error("SecurityError"); + return storage; + }, + matchMedia: (query: string) => ({ + matches: query === "(prefers-contrast: more)" && setup.contrast === true, + }), + }; + const document = { + documentElement: { + setAttribute: (name: string, value: string) => attributes.set(name, value), + }, + querySelector: (selector: string) => { + const [first] = named(selector); + return first ? element(first) : null; + }, + querySelectorAll: (selector: string) => named(selector).map(element), + }; + new Script(script).runInContext(createContext({ window, document })); + return { + theme: attributes.get("data-cd-theme"), + kontrast: attributes.get("data-cd-kontrast"), + metas: Object.fromEntries(metas.map((meta) => [`${meta.name} ${meta.media ?? ""}`.trim(), meta.content])), + storageReads, + }; +} + +const SYSTEM_METAS = { + "color-scheme": "light dark", + "theme-color (prefers-color-scheme: light)": "#335DE5", + "theme-color (prefers-color-scheme: dark)": "#1D1D1D", +}; + +describe("webModeScript (guide §13.3)", () => { + const darkScript = webModeScript({ themes: DARK_CAPABLE, themeColors: colors }); + const lightScript = webModeScript({ themes: LIGHT_ONLY, themeColors: colors }); + + it("is plain ES5", () => { + for (const script of [darkScript, lightScript]) { + expect(() => new Script(script)).not.toThrow(); + expect(script).not.toMatch(/=>|`|\.\.\.|\b(?:const|let|class)\b|\bfor\s*\([^)]*\bof\b/); + } + }); + + it("light-only: the contrast line alone, no storage, no theme", () => { + expect(lightScript).not.toContain("localStorage"); + expect(lightScript).not.toContain("data-cd-theme"); + const page = runOnFakePage(lightScript, { stored: "dark", contrast: true }); + expect(page).toEqual({ theme: "system", kontrast: "hoch", metas: SYSTEM_METAS, storageReads: [] }); + expect(runOnFakePage(lightScript).kontrast).toBeUndefined(); + }); + + it("dark-capable with dark stored: theme, color-scheme and both theme-colors dark", () => { + const page = runOnFakePage(darkScript, { stored: "dark" }); + expect(page.theme).toBe("dark"); + expect(page.metas).toEqual({ + "color-scheme": "dark", + "theme-color (prefers-color-scheme: light)": "#1D1D1D", + "theme-color (prefers-color-scheme: dark)": "#1D1D1D", + }); + expect(page.storageReads).toEqual(["miragon.darstellung"]); + }); + + it("dark-capable with light stored: both theme-colors the app's", () => { + const page = runOnFakePage(darkScript, { stored: "light" }); + expect(page.theme).toBe("light"); + expect(page.metas).toEqual({ + "color-scheme": "light", + "theme-color (prefers-color-scheme: light)": "#335DE5", + "theme-color (prefers-color-scheme: dark)": "#335DE5", + }); + }); + + it("dark-capable with nothing or a foreign value stored: leaves the static system markup", () => { + for (const stored of [null, "system", "sepia"]) { + const page = runOnFakePage(darkScript, { stored }); + expect(page.theme).toBe("system"); + expect(page.metas).toEqual(SYSTEM_METAS); + expect(page.kontrast).toBeUndefined(); + } + }); + + it("dark-capable with a storage that throws: system, and the contrast line still runs", () => { + for (const storage of ["throws on access", "throws on read"] as const) { + const page = runOnFakePage(darkScript, { storage, stored: "dark", contrast: true }); + expect(page.theme).toBe("system"); + expect(page.metas).toEqual(SYSTEM_METAS); + expect(page.kontrast).toBe("hoch"); + } + }); + + it("dark-capable under prefers-contrast: more sets data-cd-kontrast next to the choice", () => { + expect(runOnFakePage(darkScript, { stored: "dark", contrast: true })).toMatchObject({ + theme: "dark", + kontrast: "hoch", + }); + }); + + it("survives a page without a color-scheme meta", () => { + const page = runOnFakePage(darkScript, { stored: "dark", colorSchemeMeta: false }); + expect(page.theme).toBe("dark"); + expect(page.metas["theme-color (prefers-color-scheme: light)"]).toBe("#1D1D1D"); + }); + + it("keeps a colour from ending the script element", () => { + const script = webModeScript({ + themes: DARK_CAPABLE, + themeColors: { light: "", + ].join("\n"), + ); + expect(markup).not.toContain("color-scheme"); + }); + + it("dark-capable: color-scheme, both theme-colors with media, then the script", () => { + const markup = webHeadMarkup({ themes: DARK_CAPABLE, themeColors: colors }); + expect(markup).toBe( + [ + '', + '', + '', + "", + ].join("\n"), + ); + }); + + it("escapes the attribute values", () => { + const markup = webHeadMarkup({ + themes: DARK_CAPABLE, + themeColors: { light: `">`, dark: "a&b" }, + }); + expect(markup).toContain( + 'content=""><script>alert('x')</script>" media="(prefers-color-scheme: light)"', + ); + expect(markup).toContain('content="a&b<c>" media="(prefers-color-scheme: dark)"'); + expect(markup.match(/<\/script>/g)).toHaveLength(1); + expect( + webHeadMarkup({ themes: LIGHT_ONLY, themeColors: { light: '"x', dark: "y" } }).split("\n")[0], + ).toBe(''); + }); +}); diff --git a/packages/shell/src/core/feedback/appearance.ts b/packages/shell/src/core/feedback/appearance.ts new file mode 100644 index 00000000..ad65184e --- /dev/null +++ b/packages/shell/src/core/feedback/appearance.ts @@ -0,0 +1,141 @@ +import type { Theme } from "../appearance.js"; + +/** The web app's mode choice (guide §13.3): Appearance, System · Light · Dark. */ +export type AppearanceChoice = "system" | "light" | "dark"; + +/** Where the web app keeps the choice: `light` or `dark`; "System" removes the entry (guide §13.3). */ +export const APPEARANCE_STORAGE_KEY = "miragon.darstellung"; + +/** + * The two `theme-color` values (guide §11, §13.3): light is the app's own value (`#335DE5`, `fest` + * `--cd-blau` of the vendored `cd-tokens.generated.json`), dark `#1D1D1D` (`dunkel` + * `--cd-ui-grund`). The modeler reads both from its vendored JSON; the shell keeps no colour. + */ +export interface ThemeColors { + light: string; + dark: string; +} + +/** `"light"` and `"dark"` pass, everything else (a missing entry, a foreign value) is `"system"`. */ +export function parseAppearanceChoice(stored: unknown): AppearanceChoice { + return stored === "light" || stored === "dark" ? stored : "system"; +} + +/** What `` and the meta tags carry for a choice. */ +export interface DocumentMode { + /** `data-cd-theme` on ``. */ + theme: "system" | Theme; + /** ``. */ + colorScheme: "light dark" | Theme; + /** The `content` of the `theme-color` metas with `media="(prefers-color-scheme: light|dark)"`. */ + themeColor: ThemeColors; +} + +/** + * The document mode for `choice` (guide §13.3). A modeler that cannot show dark (`themes` without + * `"dark"`) is `light` whatever the choice: theme `light`, color-scheme `light`, both theme + * colours `colors.light`. Dark-capable: `system` → theme `system`, color-scheme `light dark`, + * `colors` as they are; `light`/`dark` → that theme and color-scheme, both theme colours the + * chosen mode's, so the browser bar matches a fixed choice under the other system mode. + */ +export function documentMode( + choice: AppearanceChoice, + themes: readonly Theme[], + colors: ThemeColors, +): DocumentMode { + if (!themes.includes("dark")) return fixedMode("light", colors.light); + const parsed = parseAppearanceChoice(choice); + if (parsed === "system") { + return { + theme: "system", + colorScheme: "light dark", + themeColor: { light: colors.light, dark: colors.dark }, + }; + } + return fixedMode(parsed, colors[parsed]); +} + +function fixedMode(theme: Theme, color: string): DocumentMode { + return { theme, colorScheme: theme, themeColor: { light: color, dark: color } }; +} + +export interface WebModeScriptOptions { + /** The modes the modeler can show, as passed to `mountShell`. */ + themes: readonly Theme[]; + themeColors: ThemeColors; +} + +/** + * The body of the inline `"), and U+2028/U+2029 are escaped because ES5 forbids them in string literals. +function scriptString(value: string): string { + return JSON.stringify(value) + .replace(/` markup the web app's `index.html` carries for its mode, as one string (for a Vite + * `transformIndexHtml` hook or to paste): dark-capable ``, two `theme-color` metas with `media` and the script; light-only one `theme-color` + * without `media` and the contrast script. `` itself carries `lang` and + * `data-cd-theme=""` statically. + */ +export function webHeadMarkup(options: WebModeScriptOptions): string { + const mode = documentMode("system", options.themes, options.themeColors); + const script = ``; + if (mode.theme !== "system") { + return [``, script].join( + "\n", + ); + } + return [ + ``, + ``, + ``, + script, + ].join("\n"); +} + +function escapeAttribute(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} diff --git a/packages/shell/src/core/feedback/problems.test.ts b/packages/shell/src/core/feedback/problems.test.ts new file mode 100644 index 00000000..1cd83428 --- /dev/null +++ b/packages/shell/src/core/feedback/problems.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { problemSummary, type ProblemItem } from "./problems.js"; + +describe("problemSummary", () => { + it("counts the items and the errors among them", () => { + const items: ProblemItem[] = [ + { severity: "error", message: "Unknown sticky type “evnt”.", location: "Line 12" }, + { severity: "warning", message: "A box without a name.", elementId: "Box_2" }, + { severity: "error", message: "Arrow to nowhere.", location: "Line 14" }, + ]; + const summary = problemSummary(items); + expect(summary).toEqual({ count: 3, errors: 2, items }); + expect(summary.items).toBe(items); + }); + + it("is zero for no items and for warnings only", () => { + expect(problemSummary([])).toEqual({ count: 0, errors: 0, items: [] }); + expect(problemSummary([{ severity: "warning", message: "Unused." }])).toMatchObject({ + count: 1, + errors: 0, + }); + }); +}); diff --git a/packages/shell/src/core/feedback/problems.ts b/packages/shell/src/core/feedback/problems.ts new file mode 100644 index 00000000..ac958a37 --- /dev/null +++ b/packages/shell/src/core/feedback/problems.ts @@ -0,0 +1,23 @@ +/** The severity of one problem; the pill and the list show `circle-x` or `triangle-alert` (guide §5.10). */ +export type ProblemSeverity = "error" | "warning"; + +/** One row of the web app's problems list (guide §5.10). */ +export interface ProblemItem { + severity: ProblemSeverity; + /** 13px `--cd-ui-text`: "Unknown sticky type “evnt”." */ + message: string; + /** The place in 13px `--cd-ui-text-leise` below the message: "Line 12", "“Order Placed”". */ + location?: string | undefined; + /** The diagram element a click on the row selects (`ProblemsOptions.onSelectItem`). */ + elementId?: string | undefined; +} + +/** `ShellHandle.setProblems` input built from the items: count, errors and the items themselves. */ +export function problemSummary(items: readonly ProblemItem[]): { + count: number; + errors: number; + items: readonly ProblemItem[]; +} { + const errors = items.filter((item) => item.severity === "error").length; + return { count: items.length, errors, items }; +} diff --git a/packages/shell/src/core/feedback/toasts.test.ts b/packages/shell/src/core/feedback/toasts.test.ts new file mode 100644 index 00000000..2cc96369 --- /dev/null +++ b/packages/shell/src/core/feedback/toasts.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_TOASTS, + TOAST_DURATION_MS, + TOAST_MAX_WIDTH, + toastLifetime, + toastStackBottom, + toastStackWidth, + toastToEvict, + type BottomObstacle, + type ToastSlot, +} from "./toasts.js"; + +describe("constants (guide §8)", () => { + it("keep at most three toasts, 480px wide, 2.8s and 5s for errors and warnings", () => { + expect(MAX_TOASTS).toBe(3); + expect(TOAST_MAX_WIDTH).toBe(480); + expect(TOAST_DURATION_MS).toEqual({ success: 2800, info: 2800, warning: 5000, danger: 5000 }); + }); +}); + +describe("toastLifetime", () => { + it("is the status's duration", () => { + expect(toastLifetime({ status: "success", text: "Link copied" })).toBe(2800); + expect(toastLifetime({ status: "info", text: "Opened" })).toBe(2800); + expect(toastLifetime({ status: "warning", text: "Imported with 2 warnings" })).toBe(5000); + expect(toastLifetime({ status: "danger", text: "Couldn't read this file." })).toBe(5000); + }); + + it("only lengthens with durationMs", () => { + expect(toastLifetime({ status: "success", text: "A long text", durationMs: 9000 })).toBe(9000); + expect(toastLifetime({ status: "success", text: "Short", durationMs: 1000 })).toBe(2800); + expect(toastLifetime({ status: "danger", text: "Short", durationMs: 0 })).toBe(5000); + }); + + it("ignores a duration setTimeout cannot take", () => { + expect(toastLifetime({ status: "info", text: "x", durationMs: Number.NaN })).toBe(2800); + expect(toastLifetime({ status: "info", text: "x", durationMs: Number.POSITIVE_INFINITY })).toBe(2800); + }); + + it("is undefined with an action, whatever durationMs says (it never closes by itself)", () => { + const action = { label: "Undo", onSelect() {} }; + expect(toastLifetime({ status: "info", text: "Board replaced", action })).toBeUndefined(); + expect( + toastLifetime({ status: "info", text: "Board replaced", action, durationMs: 9000 }), + ).toBeUndefined(); + }); +}); + +describe("toastToEvict", () => { + const slot = (id: number, flags: Partial> = {}): ToastSlot => ({ + id, + hasAction: false, + engaged: false, + ...flags, + }); + + it("is undefined while the stack has room", () => { + expect(toastToEvict([])).toBeUndefined(); + expect(toastToEvict([slot(1), slot(2)])).toBeUndefined(); + }); + + it("evicts the oldest when nothing is engaged and nothing has an action", () => { + expect(toastToEvict([slot(1), slot(2), slot(3)])).toBe(1); + }); + + it("prefers the oldest that is neither engaged nor has an action", () => { + expect(toastToEvict([slot(1, { hasAction: true }), slot(2), slot(3)])).toBe(2); + expect(toastToEvict([slot(1, { engaged: true }), slot(2, { hasAction: true }), slot(3)])).toBe(3); + }); + + it("then the oldest that is not engaged, an action toast included", () => { + expect( + toastToEvict([slot(1, { engaged: true }), slot(2, { hasAction: true }), slot(3, { hasAction: true })]), + ).toBe(2); + }); + + it("then the oldest", () => { + expect( + toastToEvict([ + slot(1, { engaged: true, hasAction: true }), + slot(2, { engaged: true }), + slot(3, { engaged: true }), + ]), + ).toBe(1); + }); +}); + +describe("toastStackWidth", () => { + it("is min(480, width − 24)", () => { + expect(toastStackWidth(1280)).toBe(480); + expect(toastStackWidth(504)).toBe(480); + expect(toastStackWidth(503)).toBe(479); + expect(toastStackWidth(420)).toBe(396); + }); + + it("never goes below 0", () => { + expect(toastStackWidth(24)).toBe(0); + expect(toastStackWidth(10)).toBe(0); + expect(toastStackWidth(0)).toBe(0); + }); +}); + +describe("toastStackBottom", () => { + // Boxes as the shell measures them in the web host: legal line 20 high at bottom 12, zoom panel + // 173 × 40 at right 12 (bottom 12, raised 40), the web pill 40 high at bottom 40. + const legal = (right = 342): BottomObstacle => ({ left: 12, right, top: 32 }); + const zoom = (width: number, raised = false): BottomObstacle => ({ + left: width - 12 - 173, + right: width - 12, + top: raised ? 80 : 52, + }); + const webPill = (right = 132): BottomObstacle => ({ left: 12, right, top: 80 }); + + it("stands at 12px without obstacles (guide §8)", () => { + expect(toastStackBottom({ containerWidth: 1280, obstacles: [] })).toBe(12); + }); + + it("1280: the centred column clears the whole bottom row", () => { + // Column 400…880. + expect(toastStackBottom({ containerWidth: 1280, obstacles: [legal(), webPill(), zoom(1280)] })).toBe(12); + }); + + it("1000: stands 8px above the legal line it would touch", () => { + // Column 260…740; the legal line ends at 342, the zoom panel starts at 815. + expect(toastStackBottom({ containerWidth: 1000, obstacles: [legal(), webPill(), zoom(1000)] })).toBe(40); + }); + + it("420: stands 8px above the highest of the bottom row", () => { + // Column 12…408 spans the whole row. + expect(toastStackBottom({ containerWidth: 420, obstacles: [legal(), zoom(420)] })).toBe(60); + expect(toastStackBottom({ containerWidth: 420, obstacles: [legal(), zoom(420, true)] })).toBe(88); + expect(toastStackBottom({ containerWidth: 420, obstacles: [legal(), webPill(), zoom(420)] })).toBe(88); + }); + + it("420 in the IDE column: above the zoom panel and the pill at 12", () => { + const pill: BottomObstacle = { left: 12, right: 132, top: 52 }; + expect(toastStackBottom({ containerWidth: 420, obstacles: [pill, zoom(420)] })).toBe(60); + }); + + it("counts an obstacle closer than 8px, not one exactly 8px away", () => { + // Column 400…880 at 1280. + expect(toastStackBottom({ containerWidth: 1280, obstacles: [legal(392)] })).toBe(12); + expect(toastStackBottom({ containerWidth: 1280, obstacles: [legal(393)] })).toBe(40); + expect(toastStackBottom({ containerWidth: 1280, obstacles: [{ left: 888, right: 900, top: 52 }] })).toBe( + 12, + ); + expect(toastStackBottom({ containerWidth: 1280, obstacles: [{ left: 887, right: 900, top: 52 }] })).toBe( + 60, + ); + }); + + it("tests the column of the widest card, not the whole stack", () => { + // 1280 with the GitHub link: the legal line ends at 392.5, the 480px stack starts at 400. + const obstacles = [legal(392.5), zoom(1280)]; + expect(toastStackBottom({ containerWidth: 1280, obstacles })).toBe(40); + // "Link copied", 146px wide: 567…713. + expect(toastStackBottom({ containerWidth: 1280, obstacles, cardWidth: 146 })).toBe(12); + // A card of the full 480px meets the legal line again. + expect(toastStackBottom({ containerWidth: 1280, obstacles, cardWidth: 480 })).toBe(40); + // A card is never wider than the stack. + expect(toastStackBottom({ containerWidth: 1280, obstacles, cardWidth: 900 })).toBe(40); + }); + + it("falls back to the stack's width for a card width that is no number", () => { + const obstacles = [legal(392.5)]; + expect(toastStackBottom({ containerWidth: 1280, obstacles, cardWidth: Number.NaN })).toBe(40); + expect(toastStackBottom({ containerWidth: 1280, obstacles, cardWidth: undefined })).toBe(40); + }); + + it("ignores obstacles off to the side, however high", () => { + expect( + toastStackBottom({ containerWidth: 1280, obstacles: [{ left: 12, right: 200, top: 400 }, zoom(1280)] }), + ).toBe(12); + }); + + it("never sinks below 12px", () => { + expect(toastStackBottom({ containerWidth: 1280, obstacles: [{ left: 500, right: 600, top: 0 }] })).toBe( + 12, + ); + }); +}); diff --git a/packages/shell/src/core/feedback/toasts.ts b/packages/shell/src/core/feedback/toasts.ts new file mode 100644 index 00000000..fc66c81a --- /dev/null +++ b/packages/shell/src/core/feedback/toasts.ts @@ -0,0 +1,118 @@ +import { SHELL } from "../sizes.js"; + +/** The statuses of guide §3.3 and §9; each colours bar and dot through `--cd-ui-`. */ +export type ToastStatus = "success" | "info" | "warning" | "danger"; + +/** A ghost button before Close (guide §8); the toast then never closes by itself. */ +export interface ToastAction { + label: string; + /** Runs after the toast closed. */ + onSelect(): void; +} + +export interface ToastRequest { + status: ToastStatus; + /** What happened and what you can do (guide §2): "Link copied", "Couldn't read this file. …". */ + text: string; + action?: ToastAction | undefined; + /** Only to give a long text more time than `TOAST_DURATION_MS`; ignored with an `action`. */ + durationMs?: number | undefined; +} + +/** At most three toasts at once, the newest at the bottom (guide §8). */ +export const MAX_TOASTS = 3; + +/** + * How long a toast stays without hover or focus (guide §8: about 2.8 s, errors about 5 s). A + * warning asks you to read a sentence and act on it like an error, so it gets the error's 5 s. + */ +export const TOAST_DURATION_MS: Readonly> = { + success: 2800, + info: 2800, + warning: 5000, + danger: 5000, +}; + +/** The stack's widest toast: `min(480px, 100cqw − 24px)` (guide §8). */ +export const TOAST_MAX_WIDTH = 480; + +/** + * Milliseconds until the toast closes by itself: `undefined` with an `action` (it never closes by + * itself, guide §8), else `max(TOAST_DURATION_MS[status], durationMs ?? 0)`. + */ +export function toastLifetime(request: ToastRequest): number | undefined { + if (request.action) return undefined; + const requested = request.durationMs; + // A NaN or an infinite duration would reach setTimeout, which fires at once for both. + const longer = requested !== undefined && Number.isFinite(requested) ? requested : 0; + return Math.max(TOAST_DURATION_MS[request.status], longer); +} + +/** A toast in the stack as the eviction sees it, oldest first. */ +export interface ToastSlot { + id: number; + hasAction: boolean; + /** Hovered or holding the focus. */ + engaged: boolean; +} + +/** + * Which toast leaves when a fourth arrives (`MAX_TOASTS`): the oldest that is neither engaged nor + * has an action, else the oldest that is not engaged, else the oldest. `undefined` while the + * stack has room. + */ +export function toastToEvict(stack: readonly ToastSlot[]): number | undefined { + if (stack.length < MAX_TOASTS) return undefined; + const evicted = + stack.find((slot) => !slot.engaged && !slot.hasAction) ?? stack.find((slot) => !slot.engaged) ?? stack[0]; + return evicted?.id; +} + +/** Something in the bottom row the toast stack must clear, relative to the shell root. */ +export interface BottomObstacle { + left: number; + right: number; + /** Distance of its top edge from the container's bottom edge. */ + top: number; +} + +export interface ToastStackInput { + containerWidth: number; + /** The visible zoom panel, legal line and problems pill (not the legend card). */ + obstacles: readonly BottomObstacle[]; + /** + * Measured width of the widest toast in the stack. The cards are sized to their text and centred + * in the stack, so only this column can touch an obstacle; absent = the whole `toastStackWidth`. + */ + cardWidth?: number | undefined; +} + +/** `min(TOAST_MAX_WIDTH, containerWidth − 2 · SHELL.edge)`. */ +export function toastStackWidth(containerWidth: number): number { + return Math.max(0, Math.min(TOAST_MAX_WIDTH, containerWidth - 2 * SHELL.edge)); +} + +/** + * The stack's `bottom` in px. Guide §8 anchors it at `bottom: 12px`, centred; where the column of + * its widest card (`cardWidth`, at most `toastStackWidth`, centred) comes closer than `SHELL.gap` + * horizontally to an obstacle, the stack stands `SHELL.gap` above the highest such obstacle instead + * (above the legal line 40, above the zoom panel 60, above a raised zoom panel or the web pill 88). + * Guide §8 does not settle this collision of a narrow container; it is the shell's rule until it does. + */ +export function toastStackBottom(input: ToastStackInput): number { + const stackWidth = toastStackWidth(input.containerWidth); + const { cardWidth } = input; + const width = + cardWidth !== undefined && Number.isFinite(cardWidth) + ? Math.min(stackWidth, Math.max(0, cardWidth)) + : stackWidth; + const left = (input.containerWidth - width) / 2; + const right = left + width; + let highest: number | undefined; + for (const obstacle of input.obstacles) { + const distance = Math.max(obstacle.left - right, left - obstacle.right); + if (distance >= SHELL.gap) continue; + highest = highest === undefined ? obstacle.top : Math.max(highest, obstacle.top); + } + return highest === undefined ? SHELL.edge : Math.max(SHELL.edge, highest + SHELL.gap); +} diff --git a/packages/shell/src/core/fit.test.ts b/packages/shell/src/core/fit.test.ts index ef1663b9..486d37a0 100644 --- a/packages/shell/src/core/fit.test.ts +++ b/packages/shell/src/core/fit.test.ts @@ -180,6 +180,21 @@ describe("fitInsets (guide §5.9)", () => { expect(fitInsets(layout({ host: "embedded", problemsVisible: true })).bottom).toBe(64); }); + it("bottom 92 in the web app while the zoom panel stands above the legal line", () => { + expect(fitInsets(layout({ host: "web", zoomRaised: true })).bottom).toBe(92); + expect(fitInsets(layout({ host: "web", zoomRaised: false })).bottom).toBe(64); + expect(fitInsets(layout({ host: "ide", zoomRaised: true })).bottom).toBe(64); + }); + + it("raises the web app's bottom row by every extra line of a wrapped legal line", () => { + expect(fitInsets(layout({ host: "web", zoomRaised: true, legalHeight: 20 })).bottom).toBe(92); + expect(fitInsets(layout({ host: "web", zoomRaised: true, legalHeight: 40 })).bottom).toBe(112); + expect(fitInsets(layout({ host: "web", problemsVisible: true, legalHeight: 39.5 })).bottom).toBe(112); + // Without the raised row the legal line stays below the fit's 64. + expect(fitInsets(layout({ host: "web", legalHeight: 40 })).bottom).toBe(64); + expect(fitInsets(layout({ host: "ide", problemsVisible: true, legalHeight: 40 })).bottom).toBe(64); + }); + it("right 324 (12 + 288 + 24) with an open side inspector in containers wider than 760", () => { expect(fitInsets(layout({ container: { width: 761, height: 900 }, inspector: {} }))).toEqual({ top: 88, diff --git a/packages/shell/src/core/fit.ts b/packages/shell/src/core/fit.ts index 0df90725..2484461c 100644 --- a/packages/shell/src/core/fit.ts +++ b/packages/shell/src/core/fit.ts @@ -1,4 +1,5 @@ import { SHELL } from "./sizes.js"; +import { LEGAL_LINE_HEIGHT, legalLineHeight } from "./web-chrome/bottom-row.js"; import type { Bounds, Insets, PaletteRow, Point, ScaledViewbox, ShellHost, Size } from "./types.js"; /** Fit never zooms above 100 % (guide §5.9). */ @@ -73,6 +74,17 @@ export interface FitLayout { paletteRow: PaletteRow; /** The problems pill is shown (only the web app reserves room for it). */ problemsVisible: boolean; + /** + * Web app: the zoom panel stands above the legal line (`bottom: 40px`) because the two would + * meet in the bottom row of a narrow container (`zoomRowRaised`); the bottom then keeps 92px as + * with the pill. + */ + zoomRaised?: boolean | undefined; + /** + * Web app: the measured height of the legal line (`LEGAL_LINE_HEIGHT`, more where a narrow + * container wraps it); the raised bottom row stands on it. Default one line. + */ + legalHeight?: number | undefined; /** Rendered width of the open legend; `undefined` when it is closed or the modeler has none. */ legendWidth?: number | undefined; /** @@ -83,16 +95,19 @@ export interface FitLayout { } /** - * The fit insets of guide §5.9: top 88 (second row 140), sides 24, bottom 64 (web app with pill 92), + * The fit insets of guide §5.9: top 88 (second row 140), sides 24, bottom 64 (web app with pill or + * with the zoom panel raised above the legal line 92, plus 20 per extra line of a wrapped legal line), * right 324 with an open side inspector, left 12 + legend width + 24 with an open legend above * `SHELL.veryNarrow`, bottom = distance to the sheet's top edge + 12 with an open bottom sheet. */ export function fitInsets(layout: FitLayout): Insets { - const { container, host, paletteRow, problemsVisible, legendWidth, inspector } = layout; + const { container, host, paletteRow, problemsVisible, zoomRaised, legendWidth, inspector } = layout; + const raisedBottomRow = host === "web" && (problemsVisible || zoomRaised === true); + const wrappedLegalLines = legalLineHeight(layout.legalHeight) - LEGAL_LINE_HEIGHT; const insets: Insets = { top: paletteRow === "second-row" ? SHELL.fitInsetTopSecondRow : SHELL.fitInsetTop, right: SHELL.fitInsetSide, - bottom: host === "web" && problemsVisible ? SHELL.fitInsetBottomWeb : SHELL.fitInsetBottom, + bottom: raisedBottomRow ? SHELL.fitInsetBottomWeb + wrappedLegalLines : SHELL.fitInsetBottom, left: SHELL.fitInsetSide, }; diff --git a/packages/shell/src/core/index.ts b/packages/shell/src/core/index.ts index bdbd3933..95ad031b 100644 --- a/packages/shell/src/core/index.ts +++ b/packages/shell/src/core/index.ts @@ -52,6 +52,7 @@ export { TEXT_FIELD_SELECTOR, ariaKeyShortcuts, ariaKeyShortcutsFromLabel, + commandKeyLabel, decideKey, detectPlatform, isUndoRedoKey, @@ -82,3 +83,45 @@ export { legendStartsOpen, placeTooltip, } from "./layout.js"; + +// ---- Web host (0.2) ---- + +export type { PrimaryWidths, TopRow, TopRowInput } from "./web-chrome/top-row.js"; +export { decideTopRow } from "./web-chrome/top-row.js"; +export type { BottomRowInput } from "./web-chrome/bottom-row.js"; +export { LEGAL_LINE_HEIGHT, legalLineHeight, zoomRowRaised } from "./web-chrome/bottom-row.js"; +export type { DragContext, DragItemLike, DragVerdict, DropForbiddenReason } from "./web-chrome/drop.js"; +export { acceptsFile, dragVerdict } from "./web-chrome/drop.js"; + +export type { + BottomObstacle, + ToastAction, + ToastRequest, + ToastSlot, + ToastStackInput, + ToastStatus, +} from "./feedback/toasts.js"; +export { + MAX_TOASTS, + TOAST_DURATION_MS, + TOAST_MAX_WIDTH, + toastLifetime, + toastStackBottom, + toastStackWidth, + toastToEvict, +} from "./feedback/toasts.js"; +export type { + AppearanceChoice, + DocumentMode, + ThemeColors, + WebModeScriptOptions, +} from "./feedback/appearance.js"; +export { + APPEARANCE_STORAGE_KEY, + documentMode, + parseAppearanceChoice, + webHeadMarkup, + webModeScript, +} from "./feedback/appearance.js"; +export type { ProblemItem, ProblemSeverity } from "./feedback/problems.js"; +export { problemSummary } from "./feedback/problems.js"; diff --git a/packages/shell/src/core/keymap.test.ts b/packages/shell/src/core/keymap.test.ts index c52cbf18..5c53375b 100644 --- a/packages/shell/src/core/keymap.test.ts +++ b/packages/shell/src/core/keymap.test.ts @@ -3,6 +3,7 @@ import { TEXT_FIELD_SELECTOR, ariaKeyShortcuts, ariaKeyShortcutsFromLabel, + commandKeyLabel, decideKey, detectPlatform, isUndoRedoKey, @@ -457,6 +458,40 @@ describe("decideKey (guide §5.8, §13.5)", () => { }; expect(decideKey(row.event, { ...nothingOpen, ...row.context, host })).toStrictEqual(expected); }); + + it("leaves the zoom keys and Shift + 1 to the browser behind the web app's start screen", () => { + const startScreen: KeyContext = { ...nothingOpen, host: "web", startScreen: true }; + for (const event of [zoomIn, press("-", "Minus", meta), press("0", "Digit0", meta), fit]) { + expect(decideKey(event, startScreen)).toEqual({ preventDefault: false, stopPropagation: false }); + } + expect(decideKey(escape, { ...startScreen, menuOpen: true })).toEqual({ + action: "closeMenu", + preventDefault: true, + stopPropagation: false, + }); + expect(decideKey(zoomIn, { ...startScreen, startScreen: false })).toEqual({ + action: "zoomIn", + preventDefault: true, + stopPropagation: false, + }); + // The IDE host has no start screen; its zoom keys never reach VS Code. + expect(decideKey(zoomIn, { ...nothingOpen, startScreen: true })).toMatchObject({ stopPropagation: true }); + }); +}); + +describe("commandKeyLabel", () => { + it("writes ⌘ on macOS and the localized Ctrl elsewhere", () => { + expect(commandKeyLabel("O", "mac", defaultMessages.keyNames)).toBe("⌘O"); + expect(commandKeyLabel("S", "other", defaultMessages.keyNames)).toBe("Ctrl+S"); + expect(commandKeyLabel("O", "other", { ctrl: "Strg" })).toBe("Strg+O"); + }); + + it("gives labels that name the same keys for aria-keyshortcuts", () => { + expect(ariaKeyShortcutsFromLabel(commandKeyLabel("O", "mac", defaultMessages.keyNames))).toBe("Meta+O"); + expect(ariaKeyShortcutsFromLabel(commandKeyLabel("S", "other", defaultMessages.keyNames))).toBe( + "Control+S", + ); + }); }); describe("detectPlatform", () => { diff --git a/packages/shell/src/core/keymap.ts b/packages/shell/src/core/keymap.ts index 801416df..b3b2e82f 100644 --- a/packages/shell/src/core/keymap.ts +++ b/packages/shell/src/core/keymap.ts @@ -74,6 +74,11 @@ export interface KeyContext { modalOpen: boolean; /** `event.defaultPrevented` when the listener ran: the canvas (or the modeler) handled the key. */ defaultPrevented: boolean; + /** + * The web app's start screen shows (`setEmptyState(true)`): there is no diagram to zoom, so the + * zoom keys and Shift+1 are left to the browser, which zooms the page. + */ + startScreen?: boolean | undefined; } export type KeyAction = ChromeKey | "closeMenu"; @@ -91,6 +96,7 @@ export interface KeyDecision { * or a dialog/popover is open. During IME composition Escape belongs to the IME: no `closeMenu`. * 2. Zoom keys: executed and prevented unless composing, in a text field or with a modal dialog; * in the IDE host always stopped, also when not executed (VS Code must never zoom the window). + * Behind the web app's start screen untouched: the browser zooms the page (WCAG 1.4.4). * 3. Shift+1: `fit` under the same conditions; otherwise untouched (it types "!"). * 4. IDE host, text field, undo/redo key: stopped only (the field keeps its native undo). * 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 { const chromeKey = matchChromeKey(event); if (chromeKey !== undefined) { + if (context.startScreen === true && !stop) return { preventDefault: false, stopPropagation: false }; const blocked = composing || context.inTextField || context.modalOpen; if (!blocked) return { action: chromeKey, preventDefault: true, stopPropagation: stop }; // 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( } } +/** + * The chip of a Cmd/Ctrl key a modeler binds itself (the web app's File entries, guide §5.4): macOS + * `⌘O`, elsewhere `Ctrl+O` with the key name of the messages table (`Strg+O`). `key` as printed on + * the key cap; pass the result to `FileMenuAction.shortcut` or `MenuActionEntry.shortcut`. + */ +export function commandKeyLabel( + key: string, + platform: Platform, + keyNames: Pick, +): string { + return platform === "mac" ? `⌘${key}` : `${keyNames.ctrl}+${key}`; +} + const MAC_SHORTCUT_LABELS: Readonly> = { zoomIn: "⌘+", zoomOut: "⌘\u2212", diff --git a/packages/shell/src/core/messages.test.ts b/packages/shell/src/core/messages.test.ts index c83ad8f2..1a48919a 100644 --- a/packages/shell/src/core/messages.test.ts +++ b/packages/shell/src/core/messages.test.ts @@ -39,6 +39,33 @@ describe("defaultMessages (guide §2)", () => { "This file isn't a valid Event Storming board right now. The diagram shows the last valid version and is read-only until you fix the text.", ); }); + + it("names the web chrome with the shared English terms, Impressum and Datenschutz aside", () => { + expect(defaultMessages.share).toBe("Share"); + expect(defaultMessages.legal).toBe("Legal"); + expect(defaultMessages.copyright(2026)).toBe("© 2026 Miragon GmbH"); + expect(defaultMessages.newDiagram).toBe("New diagram"); + expect(defaultMessages.openFile).toBe("Open…"); + expect(defaultMessages.showExample).toBe("Show example"); + expect(defaultMessages.importFile).toBe("Import…"); + expect(defaultMessages.appearance).toBe("Appearance"); + expect([ + defaultMessages.appearanceSystem, + defaultMessages.appearanceLight, + defaultMessages.appearanceDark, + ]).toEqual(["System", "Light", "Dark"]); + expect(defaultMessages.problemsTitle).toBe("Problems"); + expect([defaultMessages.problemSeverity("error"), defaultMessages.problemSeverity("warning")]).toEqual([ + "Error", + "Warning", + ]); + expect(defaultMessages.close).toBe("Close"); + expect(defaultMessages.dropFile).toBe("Drop file here"); + expect(defaultMessages.dropForbidden("multiple")).toBe("Drop one file at a time"); + expect(defaultMessages.cannotOpenFile("map.png", ".storm or .json")).toBe( + "Couldn't open “map.png”. Drop a .storm or .json file.", + ); + }); }); describe("resolveMessages", () => { @@ -99,6 +126,27 @@ describe("resolveMessages", () => { insertExample: "Beispiel einfügen", invalidFile: (diagram) => `Die Datei ist gerade kein gültiges ${diagram}.`, keyNames: { ctrl: "Strg", shift: "Umschalt", plus: "Plus", minus: "Minus" }, + share: "Teilen", + legal: "Rechtliches", + copyright: (year) => `© ${year} Miragon GmbH`, + newDiagram: "Neues Diagramm", + openFile: "Öffnen …", + showExample: "Beispiel zeigen", + importFile: "Importieren …", + appearance: "Darstellung", + appearanceSystem: "System", + appearanceLight: "Hell", + appearanceDark: "Dunkel", + problemsTitle: "Probleme", + problemSeverity: (severity) => (severity === "error" ? "Fehler" : "Warnung"), + close: "Schließen", + dropFile: "Datei hier ablegen", + dropForbidden: () => "Hier kannst du nichts ablegen", + dropHint: "Oder lege eine Datei hier ab", + openFileLink: "Datei öffnen…", + opensInNewTab: "Öffnet in einem neuen Tab", + cannotOpenFile: (name, accepted) => + `„${name}“ konnte nicht geöffnet werden. Lege eine ${accepted}-Datei ab.`, }; expect(resolveMessages(german)).toEqual(german); }); diff --git a/packages/shell/src/core/messages.ts b/packages/shell/src/core/messages.ts index 00f275cc..13b69146 100644 --- a/packages/shell/src/core/messages.ts +++ b/packages/shell/src/core/messages.ts @@ -1,3 +1,6 @@ +import type { ProblemSeverity } from "./feedback/problems.js"; +import type { DropForbiddenReason } from "./web-chrome/drop.js"; + /** * Every text the shell shows or announces, in one table (guide §2, shared terms). English by * default; a localized modeler overrides the whole table in its language, numbers arrive already @@ -35,6 +38,49 @@ export interface ShellMessages { invalidFile(diagram: string): string; /** Key names in shortcut chips outside macOS (German: Strg, Umschalt, Plus, Minus). */ keyNames: { ctrl: string; shift: string; plus: string; minus: string }; + + // ---- Web host (0.2): guide §2 shared terms, §5.4, §5.7, §5.10, §8, §9 ---- + + /** Default label and `aria-label` of the primary action (web app, top right). */ + share: string; + /** `aria-label` of the legal line's `