diff --git a/.gitignore b/.gitignore
index 252e52a..d218ddf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -149,6 +149,8 @@ webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
+webui/dist-conformance/
+webui/test-results/
# Security audit reports
audit-reports/
diff --git a/webui/conformance/ConformanceApp.tsx b/webui/conformance/ConformanceApp.tsx
new file mode 100644
index 0000000..294c75a
--- /dev/null
+++ b/webui/conformance/ConformanceApp.tsx
@@ -0,0 +1,210 @@
+import { useMemo, useState } from "react";
+import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react";
+import { useLocation } from "react-router";
+import ActionToolbar, { ToolbarGroup } from "../src/components/ActionToolbar";
+import Button from "../src/components/Button";
+import Card from "../src/components/Card";
+import ContentGrid, { FormGrid } from "../src/components/ContentGrid";
+import ContentSection from "../src/components/ContentSection";
+import CountBadge from "../src/components/CountBadge";
+import DefinitionNodeIcon from "../src/components/DefinitionNodeIcon";
+import DefinitionPalette, { DefinitionPaletteGroup, DefinitionPaletteItem } from "../src/components/DefinitionPalette";
+import DescriptionList, { DescriptionItem } from "../src/components/DescriptionList";
+import Dialog from "../src/components/Dialog";
+import { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
+import FilterBar from "../src/components/FilterBar";
+import FloatingStatus from "../src/components/FloatingStatus";
+import FormSection from "../src/components/FormSection";
+import MetricCard from "../src/components/MetricCard";
+import MetricGrid from "../src/components/MetricGrid";
+import PageLayout from "../src/components/PageLayout";
+import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList";
+import StatePanel from "../src/components/StatePanel";
+import WorkspaceFrame from "../src/components/WorkspaceFrame";
+import WorkspaceLayout from "../src/components/WorkspaceLayout";
+import BreadcrumbBar from "../src/layout/BreadcrumbBar";
+import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
+import {
+ createQuickAccessLaunchContext,
+ quickAccessLaunchState
+} from "../src/platform/launchContext";
+import type { AuthInfo } from "../src/types";
+
+export default function ConformanceApp() {
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ return (
+
+ setDialogOpen(true)}>Prüfdialog öffnen}
+ >
+
+
+
+ Kennzahlen und Eigenschaften
+
+
+
+
+
+
+
+ Nachvollziehbare Entscheidung
+
+ A-2026-004218
+ Fachbereich Öffentlicher Raum und nachhaltige Mobilität
+ 18. August 2026, 14:42 Uhr
+
+
+
+
+
+
+
+ Definitionsarbeitsbereich
+
+
+
+ } label="Bedingung prüfen" />
+ } label="Freigabe anfordern" />
+
+
+
+
+ Automatische Prüfung läuft …
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function LaunchContextScenario() {
+ const location = useLocation();
+ const navigate = useGuardedNavigate();
+ const launchContext = useMemo(() => createQuickAccessLaunchContext({
+ pathname: location.pathname,
+ search: location.search,
+ hash: location.hash,
+ historyIndex: browserHistoryIndex(),
+ auth: CONFORMANCE_AUTH,
+ activeObject: {
+ ownerModule: "cases",
+ kind: "case",
+ objectId: "case-1",
+ tenantId: "tenant-1",
+ label: "RPP-2026-0001 · Anwohnerparkausweis",
+ version: "4",
+ path: "/cases/case-1"
+ },
+ temporalContext: { validityMode: "current", validAt: null, recordedAt: null }
+ }), [location.hash, location.pathname, location.search]);
+
+ return (
+
+ Start- und Rücksprungkontext
+ Ein begrenzter Objektverweis öffnet das vollständige Werkzeug und erhält einen eindeutigen Rücksprung zum Vorgang.
+
+
+
+
+
+ );
+}
+
+const CONFORMANCE_AUTH = {
+ user: { id: "user-1", account_id: "account-1", email: "case@example.test" },
+ tenant: { id: "tenant-1", slug: "reference", name: "Referenzkommune" },
+ scopes: [],
+ roles: [],
+ groups: [],
+ profile_loaded: true,
+ roles_loaded: true,
+ groups_loaded: true
+} satisfies AuthInfo;
+
+function browserHistoryIndex(): number | null {
+ const value = (window.history.state as { idx?: unknown } | null)?.idx;
+ return typeof value === "number" && Number.isInteger(value) ? value : null;
+}
diff --git a/webui/conformance/conformance.css b/webui/conformance/conformance.css
new file mode 100644
index 0000000..67eb8a6
--- /dev/null
+++ b/webui/conformance/conformance.css
@@ -0,0 +1,33 @@
+:root {
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color: var(--text);
+ background: var(--bg);
+}
+
+* { box-sizing: border-box; }
+body { margin: 0; min-width: 320px; background: var(--bg); }
+button, input, select, textarea { font: inherit; }
+
+.conformance-root { min-height: 100vh; }
+.conformance-section { display: grid; gap: 14px; padding-block: 20px; border-top: var(--border-line); }
+.conformance-section:first-child { border-top: 0; }
+.conformance-section > h2, .conformance-section h3 { margin: 0; color: var(--text-strong); }
+.conformance-section p { margin-block: 0 12px; }
+.conformance-section label { display: grid; gap: 5px; color: var(--text-label); font-size: 13px; font-weight: 700; }
+.conformance-workspace { height: 430px; }
+.conformance-definition { display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); min-height: 330px; gap: 12px; }
+.conformance-canvas { position: relative; min-height: 330px; overflow: hidden; }
+
+@media (max-width: 760px) {
+ .conformance-definition { grid-template-columns: 1fr; }
+ .conformance-workspace { height: auto; min-height: 560px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .conformance-root *, .conformance-root *::before, .conformance-root *::after {
+ scroll-behavior: auto !important;
+ transition-duration: .01ms !important;
+ animation-duration: .01ms !important;
+ animation-iteration-count: 1 !important;
+ }
+}
diff --git a/webui/conformance/index.html b/webui/conformance/index.html
new file mode 100644
index 0000000..55df65d
--- /dev/null
+++ b/webui/conformance/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ GovOPlaN WebUI Conformance Lab
+
+
+
+
+
+
diff --git a/webui/conformance/main.tsx b/webui/conformance/main.tsx
new file mode 100644
index 0000000..9c7a1d1
--- /dev/null
+++ b/webui/conformance/main.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router";
+import ConformanceApp from "./ConformanceApp";
+import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
+import "../src/styles/tokens.css";
+import "../src/styles/layout.css";
+import "../src/styles/forms.css";
+import "../src/styles/tables.css";
+import "../src/styles/badges.css";
+import "../src/styles/components.css";
+import "../src/styles/dialogs.css";
+import "./conformance.css";
+
+const theme = new URLSearchParams(window.location.search).get("theme");
+if (theme === "dark" || theme === "light") document.documentElement.dataset.theme = theme;
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+
+);
diff --git a/webui/conformance/tests/snapshots/shared-ui-dark-desktop.png b/webui/conformance/tests/snapshots/shared-ui-dark-desktop.png
new file mode 100644
index 0000000..cb595c4
Binary files /dev/null and b/webui/conformance/tests/snapshots/shared-ui-dark-desktop.png differ
diff --git a/webui/conformance/tests/snapshots/shared-ui-light-desktop.png b/webui/conformance/tests/snapshots/shared-ui-light-desktop.png
new file mode 100644
index 0000000..f0ebc64
Binary files /dev/null and b/webui/conformance/tests/snapshots/shared-ui-light-desktop.png differ
diff --git a/webui/conformance/tests/snapshots/shared-ui-light-narrow.png b/webui/conformance/tests/snapshots/shared-ui-light-narrow.png
new file mode 100644
index 0000000..940a279
Binary files /dev/null and b/webui/conformance/tests/snapshots/shared-ui-light-narrow.png differ
diff --git a/webui/conformance/tests/ui-conformance.spec.ts b/webui/conformance/tests/ui-conformance.spec.ts
new file mode 100644
index 0000000..f489140
--- /dev/null
+++ b/webui/conformance/tests/ui-conformance.spec.ts
@@ -0,0 +1,75 @@
+import { createRequire } from "node:module";
+import { expect, test } from "@playwright/test";
+
+const require = createRequire(import.meta.url);
+const axePath = require.resolve("axe-core/axe.min.js");
+
+async function expectNoAccessibilityViolations(page: import("@playwright/test").Page) {
+ await page.addScriptTag({ path: axePath });
+ const violations = await page.evaluate(async () => {
+ const axe = (window as typeof window & { axe: { run: (options: unknown) => Promise<{ violations: Array<{ id: string; impact?: string | null; help: string; nodes: Array<{ target: unknown; failureSummary?: string }> }> }> } }).axe;
+ const result = await axe.run({ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa"] } });
+ return result.violations.map(({ id, impact, help, nodes }) => ({
+ id,
+ impact,
+ help,
+ nodes: nodes.map((node: { target: unknown; failureSummary?: string }) => ({ target: node.target, failureSummary: node.failureSummary }))
+ }));
+ });
+ expect(violations).toEqual([]);
+}
+
+test("shared components remain accessible and keyboard operable", async ({ page }) => {
+ await page.goto("/?theme=light");
+ await expect(page.getByRole("heading", { level: 1, name: "Zentrale GovOPlaN-Oberflächen" })).toBeVisible();
+ await expectNoAccessibilityViolations(page);
+
+ const opener = page.getByTestId("open-dialog");
+ await opener.focus();
+ await page.keyboard.press("Enter");
+ await expect(page.getByRole("dialog", { name: "Konsequenz vor Ausführung prüfen" })).toBeVisible();
+ await expectNoAccessibilityViolations(page);
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog")).toBeHidden();
+ await expect(opener).toBeFocused();
+});
+
+test("full-page tool launch preserves a bounded return context", async ({ page }) => {
+ await page.goto("/?theme=light");
+ await page.getByTestId("launch-full-page").click();
+ await expect(page).toHaveURL(/\/files$/);
+ const returnButton = page.getByRole("button", { name: /RPP-2026-0001.*Anwohnerparkausweis/ });
+ await expect(returnButton).toBeVisible();
+ await returnButton.click();
+ await expect(page).toHaveURL(/\/?\?theme=light$/);
+});
+
+test("light and dark desktop geometry remains stable", async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.goto("/?theme=light");
+ await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-desktop.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
+
+ await page.goto("/?theme=dark");
+ await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-dark-desktop.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
+});
+
+test("narrow layout preserves task order without horizontal overflow", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/?theme=light");
+ await expect(page.getByText("Bedingung prüfen")).toBeVisible();
+ const overflowing = await page.evaluate(() => Array.from(document.querySelectorAll("body *"))
+ .map((element) => {
+ const rect = element.getBoundingClientRect();
+ return {
+ element: `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`,
+ left: Math.round(rect.left),
+ right: Math.round(rect.right),
+ scrollWidth: element.scrollWidth,
+ clientWidth: element.clientWidth
+ };
+ })
+ .filter(({ left, right }) => left < -1 || right > window.innerWidth + 1)
+ .slice(0, 20));
+ expect(overflowing).toEqual([]);
+ await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-narrow.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
+});
diff --git a/webui/package-lock.json b/webui/package-lock.json
index b5c6937..57d8ea4 100644
--- a/webui/package-lock.json
+++ b/webui/package-lock.json
@@ -55,10 +55,13 @@
"@tiptap/starter-kit": "^3.29.2"
},
"devDependencies": {
+ "@playwright/test": "^1.62.1",
+ "@types/node": "^26.2.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"@xyflow/react": "^12.11.2",
+ "axe-core": "^4.13.0",
"lucide-react": "^1.23.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -576,7 +579,7 @@
},
"../../govoplan-records/webui": {
"name": "@govoplan/records-webui",
- "version": "0.1.18",
+ "version": "0.1.19",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
@@ -659,7 +662,7 @@
},
"../../govoplan-tasks/webui": {
"name": "@govoplan/tasks-webui",
- "version": "0.1.18",
+ "version": "0.1.19",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
@@ -1716,6 +1719,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -2662,6 +2681,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "26.2.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
+ "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -2761,6 +2790,16 @@
"d3-zoom": "^3.0.0"
}
},
+ "node_modules/axe-core": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
+ "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.38",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
@@ -3241,6 +3280,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
@@ -3585,6 +3671,13 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/unzipper-esm": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/unzipper-esm/-/unzipper-esm-0.13.2.tgz",
diff --git a/webui/package.json b/webui/package.json
index e4e31cc..d7bd5c3 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -26,8 +26,11 @@
},
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
+ "dev:conformance": "vite --config vite.conformance.config.ts --host 127.0.0.1 --port 4174",
"prebuild": "npm run audit:i18n-structural && npm run test:theme-contract && npm run test:core-interface-patterns",
"build": "tsc && vite build && node scripts/check-bundle-budget.mjs",
+ "build:conformance": "tsc -p tsconfig.conformance.json && vite build --config vite.conformance.config.ts",
+ "test:conformance": "playwright test --config playwright.conformance.config.ts",
"check:bundle-budget": "node scripts/check-bundle-budget.mjs",
"preview": "vite preview --host 127.0.0.1 --port 4173",
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
@@ -40,7 +43,7 @@
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
"test:layout-primitives": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/layout-primitives.test.js",
- "test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/definition-graph.test.js",
+ "test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/launch-context.test.js && node .module-test-build/tests/definition-graph.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
@@ -56,13 +59,13 @@
},
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
- "@govoplan/approvals-webui": "file:../../govoplan-approvals/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
+ "@govoplan/approvals-webui": "file:../../govoplan-approvals/webui",
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
- "@govoplan/cases-webui": "file:../../govoplan-cases/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
+ "@govoplan/cases-webui": "file:../../govoplan-cases/webui",
"@govoplan/committee-webui": "file:../../govoplan-committee/webui",
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
@@ -71,10 +74,10 @@
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
"@govoplan/encryption-webui": "file:../../govoplan-encryption/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",
- "@govoplan/forms-webui": "file:../../govoplan-forms/webui",
"@govoplan/forms-runtime-webui": "file:../../govoplan-forms-runtime/webui",
- "@govoplan/idm-webui": "file:../../govoplan-idm/webui",
+ "@govoplan/forms-webui": "file:../../govoplan-forms/webui",
"@govoplan/identity-trust-webui": "file:../../govoplan-identity-trust/webui",
+ "@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
@@ -84,14 +87,14 @@
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
"@govoplan/projects-webui": "file:../../govoplan-projects/webui",
"@govoplan/quick-access-webui": "file:../../govoplan-quick-access/webui",
- "@govoplan/reporting-webui": "file:../../govoplan-reporting/webui",
"@govoplan/records-webui": "file:../../govoplan-records/webui",
+ "@govoplan/reporting-webui": "file:../../govoplan-reporting/webui",
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/search-webui": "file:../../govoplan-search/webui",
- "@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
- "@govoplan/templates-webui": "file:../../govoplan-templates/webui",
"@govoplan/tasks-webui": "file:../../govoplan-tasks/webui",
+ "@govoplan/templates-webui": "file:../../govoplan-templates/webui",
+ "@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/voting-webui": "file:../../govoplan-voting/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
@@ -102,10 +105,13 @@
"@tiptap/starter-kit": "^3.29.2"
},
"devDependencies": {
+ "@playwright/test": "^1.62.1",
+ "@types/node": "^26.2.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"@xyflow/react": "^12.11.2",
+ "axe-core": "^4.13.0",
"lucide-react": "^1.23.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
diff --git a/webui/playwright.conformance.config.ts b/webui/playwright.conformance.config.ts
new file mode 100644
index 0000000..9357c62
--- /dev/null
+++ b/webui/playwright.conformance.config.ts
@@ -0,0 +1,47 @@
+import { existsSync, readdirSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { chromium, defineConfig } from "@playwright/test";
+
+function chromiumExecutable(): string | undefined {
+ const configured = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
+ if (configured) return configured;
+ const managed = chromium.executablePath();
+ if (existsSync(managed)) return managed;
+
+ const roots = [
+ join(homedir(), ".cache", "ms-playwright"),
+ join(homedir(), ".var", "app", "com.vscodium.codium", "cache", "ms-playwright")
+ ];
+ for (const root of roots) {
+ if (!existsSync(root)) continue;
+ const installations = readdirSync(root).filter((entry) => entry.startsWith("chromium-")).sort().reverse();
+ for (const installation of installations) {
+ const candidate = join(root, installation, "chrome-linux64", "chrome");
+ if (existsSync(candidate)) return candidate;
+ }
+ }
+ return undefined;
+}
+
+export default defineConfig({
+ testDir: "./conformance/tests",
+ snapshotPathTemplate: "{testDir}/snapshots/{arg}{ext}",
+ fullyParallel: false,
+ workers: 1,
+ reporter: "line",
+ use: {
+ baseURL: "http://127.0.0.1:4174",
+ browserName: "chromium",
+ colorScheme: "light",
+ locale: "de-DE",
+ reducedMotion: "reduce",
+ launchOptions: chromiumExecutable() ? { executablePath: chromiumExecutable() } : undefined
+ },
+ webServer: {
+ command: "npm run dev:conformance",
+ url: "http://127.0.0.1:4174",
+ reuseExistingServer: false,
+ timeout: 120_000
+ }
+});
diff --git a/webui/src/App.tsx b/webui/src/App.tsx
index e220142..f7e116c 100644
--- a/webui/src/App.tsx
+++ b/webui/src/App.tsx
@@ -12,6 +12,7 @@ import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebMo
import { PlatformModulesProvider } from "./platform/ModuleContext";
import { PlatformViewProvider } from "./platform/ViewContext";
import { PlatformTemporalProvider } from "./platform/TemporalContext";
+import { PlatformActiveObjectProvider } from "./platform/ActiveObjectContext";
import { PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT } from "./platform/temporal";
import {
PLATFORM_VIEW_CHANGED_EVENT,
@@ -538,6 +539,7 @@ export default function App() {
+
@@ -583,6 +585,7 @@ export default function App() {
}
+
diff --git a/webui/src/components/PageLayout.tsx b/webui/src/components/PageLayout.tsx
index 4db1dff..71ace4f 100644
--- a/webui/src/components/PageLayout.tsx
+++ b/webui/src/components/PageLayout.tsx
@@ -82,7 +82,7 @@ export default function PageLayout({
notices,
mode = "standalone",
scrollable,
- stickyHeader = true,
+ stickyHeader,
showHeader = true,
documentationType = "user",
className = "",
@@ -96,6 +96,7 @@ export default function PageLayout({
}: PageLayoutProps) {
const shouldScroll = scrollable ?? mode === "standalone";
const shouldInset = mode !== "embedded";
+ const shouldStickHeader = stickyHeader ?? mode !== "embedded";
const layoutClasses = [
"page-layout",
`page-layout-${mode}`,
@@ -120,7 +121,7 @@ export default function PageLayout({
description={description}
actions={actions}
loading={headerLoading ?? loading}
- sticky={stickyHeader}
+ sticky={shouldStickHeader}
className={headerClassName}
/>
)}
diff --git a/webui/src/components/UnsavedChangesGuard.tsx b/webui/src/components/UnsavedChangesGuard.tsx
index 982a782..cadabe3 100644
--- a/webui/src/components/UnsavedChangesGuard.tsx
+++ b/webui/src/components/UnsavedChangesGuard.tsx
@@ -171,8 +171,6 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
open
role="alertdialog"
title={registration.title ?? "i18n:govoplan-core.unsaved_changes.29267269"}
- className="unsaved-changes-dialog"
- footerClassName="unsaved-changes-actions"
closeOnBackdrop={!saving}
closeDisabled={saving}
onClose={() => setPendingAction(null)}
diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts
index 3b9caf4..6b199a5 100644
--- a/webui/src/i18n/generatedTranslations.ts
+++ b/webui/src/i18n/generatedTranslations.ts
@@ -78,13 +78,21 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.application_notices": "Application notices",
"i18n:govoplan-core.more_tools": "More tools",
"i18n:govoplan-core.product_area.work": "Work",
+ "i18n:govoplan-core.product_area.services_cases": "Services and cases",
"i18n:govoplan-core.product_area.communication": "Communication",
"i18n:govoplan-core.product_area.records_documents": "Records and documents",
"i18n:govoplan-core.product_area.meetings_decisions": "Meetings and decisions",
+ "i18n:govoplan-core.product_area.data_assurance": "Data and assurance",
+ "i18n:govoplan-core.product_area.people_responsibility": "People and responsibility",
"i18n:govoplan-core.product_area.work_description": "Tasks, approvals, handoffs, and other work requiring attention.",
+ "i18n:govoplan-core.product_area.services_cases_description": "Services, forms, requests, and the cases that deliver them.",
"i18n:govoplan-core.product_area.communication_description": "Messages, campaigns, notifications, and institutional communication.",
"i18n:govoplan-core.product_area.records_documents_description": "Documents, files, evidence, templates, and records.",
"i18n:govoplan-core.product_area.meetings_decisions_description": "Scheduling, meetings, participation, and institutional decisions.",
+ "i18n:govoplan-core.product_area.data_assurance_description": "Data sources, processing, reporting, risk, and assurance.",
+ "i18n:govoplan-core.product_area.people_responsibility_description": "People, organizations, identities, and responsibility assignments.",
+ "i18n:govoplan-core.quick_access.return_to_origin": "Return to {label}",
+ "i18n:govoplan-core.quick_access.previous_context": "previous context",
"i18n:govoplan-core.application.b291beb8": "Application",
"i18n:govoplan-core.attachment_base_path.66e9940b": "Attachment base path",
"i18n:govoplan-core.attachment_value.01801a54": "Attachment {value0}",
@@ -745,13 +753,21 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.application_notices": "Anwendungshinweise",
"i18n:govoplan-core.more_tools": "Weitere Werkzeuge",
"i18n:govoplan-core.product_area.work": "Arbeit",
+ "i18n:govoplan-core.product_area.services_cases": "Leistungen und Vorgänge",
"i18n:govoplan-core.product_area.communication": "Kommunikation",
"i18n:govoplan-core.product_area.records_documents": "Akten und Dokumente",
"i18n:govoplan-core.product_area.meetings_decisions": "Termine und Entscheidungen",
+ "i18n:govoplan-core.product_area.data_assurance": "Daten und Qualitätssicherung",
+ "i18n:govoplan-core.product_area.people_responsibility": "Personen und Verantwortung",
"i18n:govoplan-core.product_area.work_description": "Aufgaben, Freigaben, Übergaben und andere zu bearbeitende Arbeit.",
+ "i18n:govoplan-core.product_area.services_cases_description": "Leistungen, Formulare, Anträge und die zugehörigen Vorgänge.",
"i18n:govoplan-core.product_area.communication_description": "Nachrichten, Kampagnen, Benachrichtigungen und institutionelle Kommunikation.",
"i18n:govoplan-core.product_area.records_documents_description": "Akten, Dokumente, Nachweise, Vorlagen und Dateien.",
"i18n:govoplan-core.product_area.meetings_decisions_description": "Terminplanung, Sitzungen, Beteiligung und institutionelle Entscheidungen.",
+ "i18n:govoplan-core.product_area.data_assurance_description": "Datenquellen, Verarbeitung, Berichte, Risiken und Qualitätssicherung.",
+ "i18n:govoplan-core.product_area.people_responsibility_description": "Personen, Organisationen, Identitäten und Verantwortungszuordnungen.",
+ "i18n:govoplan-core.quick_access.return_to_origin": "Zurück zu {label}",
+ "i18n:govoplan-core.quick_access.previous_context": "vorherigem Kontext",
"i18n:govoplan-core.application.b291beb8": "Application",
"i18n:govoplan-core.attachment_base_path.66e9940b": "Attachment base path",
"i18n:govoplan-core.attachment_value.01801a54": "Attachment {value0}",
diff --git a/webui/src/index.ts b/webui/src/index.ts
index 0a71dc4..64d861a 100644
--- a/webui/src/index.ts
+++ b/webui/src/index.ts
@@ -34,6 +34,8 @@ export * from "./platform/ViewContext";
export * from "./platform/views";
export * from "./platform/temporal";
export * from "./platform/TemporalContext";
+export * from "./platform/ActiveObjectContext";
+export * from "./platform/launchContext";
export * from "./platform/wizards";
export * from "./utils/permissions";
diff --git a/webui/src/layout/AppShell.tsx b/webui/src/layout/AppShell.tsx
index 211a371..95a4264 100644
--- a/webui/src/layout/AppShell.tsx
+++ b/webui/src/layout/AppShell.tsx
@@ -6,6 +6,9 @@ import Titlebar from "./Titlebar";
import BreadcrumbBar from "./BreadcrumbBar";
import { usePlatformModules, usePlatformUiCapability } from "../platform/ModuleContext";
import { useEffectiveView, useViewSurfaces } from "../platform/ViewContext";
+import { useTemporalDataContext } from "../platform/TemporalContext";
+import { useActiveObject } from "../platform/ActiveObjectContext";
+import { createQuickAccessLaunchContext } from "../platform/launchContext";
import { isViewSurfaceVisible } from "../platform/views";
import { hasAnyScope, hasScope } from "../utils/permissions";
@@ -37,6 +40,21 @@ export default function AppShell({
const quickAccess = usePlatformUiCapability("quickAccess.runtime");
const projection = useEffectiveView();
const surfaces = useViewSurfaces();
+ const temporal = useTemporalDataContext();
+ const activeObject = useActiveObject();
+ const launchContext = useMemo(
+ () => auth ? createQuickAccessLaunchContext({
+ pathname: location.pathname,
+ search: location.search,
+ hash: location.hash,
+ historyIndex: browserHistoryIndex(),
+ auth,
+ activeObject,
+ temporalContext: temporal.selection,
+ viewContext: projection
+ }) : null,
+ [activeObject, auth, location.hash, location.pathname, location.search, projection, temporal.selection]
+ );
const quickAccessTools = useMemo(
() => modules.flatMap((module) => module.quickAccessTools ?? []).filter((tool) => {
if (tool.allOf?.length && !tool.allOf.every((scope) => hasScope(auth, scope))) return false;
@@ -73,12 +91,18 @@ export default function AppShell({
/>
-
+
{children}
- {QuickAccessRail && auth && (
-
+ {QuickAccessRail && auth && launchContext && (
+
)}
);
}
+
+function browserHistoryIndex(): number | null {
+ if (typeof window === "undefined") return null;
+ const value = (window.history.state as { idx?: unknown } | null)?.idx;
+ return typeof value === "number" && Number.isInteger(value) ? value : null;
+}
diff --git a/webui/src/layout/BreadcrumbBar.tsx b/webui/src/layout/BreadcrumbBar.tsx
index d09e176..9a0a215 100644
--- a/webui/src/layout/BreadcrumbBar.tsx
+++ b/webui/src/layout/BreadcrumbBar.tsx
@@ -1,11 +1,37 @@
import { Link } from "react-router";
-import { ChevronRight } from "lucide-react";
-import { usePlatformLanguage } from "../i18n/LanguageContext";
+import { ArrowLeft, ChevronRight } from "lucide-react";
+import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext";
+import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
+import {
+ quickAccessLaunchContextFromState,
+ quickAccessReturnPath
+} from "../platform/launchContext";
-export default function BreadcrumbBar({ pathname }: {pathname: string;}) {
+export default function BreadcrumbBar({
+ pathname,
+ locationState
+}: {
+ pathname: string;
+ locationState?: unknown;
+}) {
const parts = pathname.split("/").filter(Boolean);
const labels = parts.length ? parts : ["campaigns"];
const { translateText } = usePlatformLanguage();
+ const navigate = useGuardedNavigate();
+ const launchContext = quickAccessLaunchContextFromState(locationState);
+
+ function returnToOrigin() {
+ if (!launchContext) return;
+ const historyIndex = browserHistoryIndex();
+ if (
+ launchContext.origin.historyIndex !== null
+ && historyIndex === launchContext.origin.historyIndex + 1
+ ) {
+ navigate(-1);
+ return;
+ }
+ navigate(quickAccessReturnPath(launchContext), { replace: true });
+ }
return (
@@ -20,10 +46,29 @@ export default function BreadcrumbBar({ pathname }: {pathname: string;}) {
})}
+ {launchContext ? (
+
+ ) : null}
);
}
+function browserHistoryIndex(): number | null {
+ if (typeof window === "undefined") return null;
+ const value = (window.history.state as { idx?: unknown } | null)?.idx;
+ return typeof value === "number" && Number.isInteger(value) ? value : null;
+}
+
const campaignRouteLabels: Record = {
data: "i18n:govoplan-core.sender_recipients.922c6d24",
campaign: "i18n:govoplan-core.sender_recipients.922c6d24",
diff --git a/webui/src/platform/ActiveObjectContext.tsx b/webui/src/platform/ActiveObjectContext.tsx
new file mode 100644
index 0000000..d0db650
--- /dev/null
+++ b/webui/src/platform/ActiveObjectContext.tsx
@@ -0,0 +1,55 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode
+} from "react";
+import type { ActiveObjectReference } from "../types";
+
+
+type ActiveObjectContextValue = {
+ activeObject: ActiveObjectReference | null;
+ register: (reference: ActiveObjectReference) => () => void;
+};
+
+const fallbackContext: ActiveObjectContextValue = {
+ activeObject: null,
+ register: () => () => undefined
+};
+
+const ActiveObjectContext = createContext(fallbackContext);
+
+export function PlatformActiveObjectProvider({ children }: { children: ReactNode }) {
+ const nextId = useRef(1);
+ const [registrations, setRegistrations] = useState>([]);
+ const activeObject = registrations[registrations.length - 1]?.reference ?? null;
+ const register = useCallback((reference: ActiveObjectReference) => {
+ const id = nextId.current;
+ nextId.current += 1;
+ setRegistrations((current) => [...current, { id, reference }]);
+ return () => setRegistrations((current) => current.filter((item) => item.id !== id));
+ }, []);
+ const value = useMemo(() => ({ activeObject, register }), [activeObject, register]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useActiveObject(): ActiveObjectReference | null {
+ return useContext(ActiveObjectContext).activeObject;
+}
+
+export function useRegisterActiveObject(reference: ActiveObjectReference | null): void {
+ const { register } = useContext(ActiveObjectContext);
+ useEffect(() => reference ? register(reference) : undefined, [reference, register]);
+}
diff --git a/webui/src/platform/launchContext.ts b/webui/src/platform/launchContext.ts
new file mode 100644
index 0000000..e4659dc
--- /dev/null
+++ b/webui/src/platform/launchContext.ts
@@ -0,0 +1,126 @@
+import type {
+ ActiveObjectReference,
+ AuthInfo,
+ EffectiveViewProjection,
+ QuickAccessLaunchContext,
+ QuickAccessResult
+} from "../types";
+import type { TemporalDataSelection } from "./temporal";
+
+
+export const QUICK_ACCESS_LAUNCH_CONTEXT_VERSION = "1" as const;
+export const QUICK_ACCESS_LAUNCH_STATE_KEY = "govoplanQuickAccessLaunch";
+export const QUICK_ACCESS_RESULT_EVENT = "govoplan:quick-access-result";
+
+export type QuickAccessLaunchContextInput = {
+ pathname: string;
+ search?: string;
+ hash?: string;
+ historyIndex?: number | null;
+ auth: AuthInfo;
+ activeObject?: ActiveObjectReference | null;
+ temporalContext: TemporalDataSelection;
+ viewContext?: EffectiveViewProjection | null;
+};
+
+export function createQuickAccessLaunchContext({
+ pathname,
+ search = "",
+ hash = "",
+ historyIndex = null,
+ auth,
+ activeObject = null,
+ temporalContext,
+ viewContext = null
+}: QuickAccessLaunchContextInput): QuickAccessLaunchContext {
+ const tenant = auth.active_tenant ?? auth.tenant;
+ const principal = auth.principal;
+ return {
+ contractVersion: QUICK_ACCESS_LAUNCH_CONTEXT_VERSION,
+ origin: {
+ pathname: normalizePathname(pathname),
+ search: normalizeSearch(search),
+ hash: normalizeHash(hash),
+ historyIndex: Number.isInteger(historyIndex) ? historyIndex ?? null : null
+ },
+ tenantId: tenant.id,
+ accountId: auth.user.account_id,
+ activeObject: activeObject?.tenantId === tenant.id ? activeObject : null,
+ actingContext: principal?.acting_assignment_id || principal?.acting_for_account_id
+ ? {
+ assignmentId: principal.acting_assignment_id ?? null,
+ actingForAccountId: principal.acting_for_account_id ?? null
+ }
+ : null,
+ temporalContext: {
+ validityMode: temporalContext.validityMode,
+ validAt: temporalContext.validAt ?? null,
+ recordedAt: temporalContext.recordedAt ?? null
+ },
+ viewContext: viewContext?.activeViewId
+ ? {
+ viewId: viewContext.activeViewId,
+ revisionId: viewContext.activeRevisionId,
+ name: viewContext.activeViewName
+ }
+ : null
+ };
+}
+
+export function quickAccessLaunchState(
+ context: QuickAccessLaunchContext
+): Record {
+ return { [QUICK_ACCESS_LAUNCH_STATE_KEY]: context };
+}
+
+export function quickAccessLaunchContextFromState(
+ value: unknown
+): QuickAccessLaunchContext | null {
+ if (!isRecord(value)) return null;
+ const candidate = value[QUICK_ACCESS_LAUNCH_STATE_KEY];
+ if (!isRecord(candidate) || candidate.contractVersion !== QUICK_ACCESS_LAUNCH_CONTEXT_VERSION) {
+ return null;
+ }
+ if (!isRecord(candidate.origin) || typeof candidate.origin.pathname !== "string") {
+ return null;
+ }
+ if (
+ typeof candidate.tenantId !== "string"
+ || typeof candidate.accountId !== "string"
+ || !candidate.origin.pathname.startsWith("/")
+ ) {
+ return null;
+ }
+ return candidate as QuickAccessLaunchContext;
+}
+
+export function quickAccessReturnPath(context: QuickAccessLaunchContext): string {
+ return `${context.origin.pathname}${context.origin.search}${context.origin.hash}`;
+}
+
+export function dispatchQuickAccessResult(
+ toolId: string,
+ launchContext: QuickAccessLaunchContext,
+ result: QuickAccessResult
+): void {
+ if (typeof window === "undefined") return;
+ window.dispatchEvent(new CustomEvent(QUICK_ACCESS_RESULT_EVENT, {
+ detail: { toolId, launchContext, result }
+ }));
+}
+
+function normalizePathname(value: string): string {
+ return value.startsWith("/") ? value : "/";
+}
+
+function normalizeSearch(value: string): string {
+ return !value ? "" : value.startsWith("?") ? value : "";
+}
+
+function normalizeHash(value: string): string {
+ return !value ? "" : value.startsWith("#") ? value : "";
+}
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
+}
diff --git a/webui/src/styles/auth-gate.css b/webui/src/styles/auth-gate.css
index 28eae19..29da139 100644
--- a/webui/src/styles/auth-gate.css
+++ b/webui/src/styles/auth-gate.css
@@ -49,7 +49,7 @@
.public-card h1 {
margin: 0;
- max-width: 620px;
+ max-width: 680px;
font-size: clamp(30px, 4vw, 46px);
line-height: 1.08;
color: var(--text-strong);
@@ -115,7 +115,7 @@
font-weight: 700;
padding: 8px 10px;
margin: -8px -10px;
- border-radius: 7px;
+ border-radius: var(--radius-md);
cursor: pointer;
transition: background-color .12s ease, color .12s ease, box-shadow .12s ease;
}
@@ -125,7 +125,7 @@
.account-pill {
padding: 8px 10px;
margin: 0;
- border-radius: 7px;
+ border-radius: var(--radius-md);
transition: background-color .12s ease, color .12s ease, box-shadow .12s ease;
cursor: pointer;
}
@@ -145,7 +145,7 @@
}
.dropdown-item {
- border-radius: 6px;
+ border-radius: var(--radius-compact);
transition: background-color .12s ease, color .12s ease;
}
diff --git a/webui/src/styles/badges.css b/webui/src/styles/badges.css
index 7f011b1..b65f7a6 100644
--- a/webui/src/styles/badges.css
+++ b/webui/src/styles/badges.css
@@ -1,4 +1,4 @@
-.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; }
+.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: var(--radius-pill); padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; }
.status-ready, .status-sent, .status-appended, .status-success, .status-active { background: var(--success-soft); color: var(--success-text-strong); }
.status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); }
.status-blocked, .status-error, .status-danger, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); }
diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css
index d297ab3..f301aeb 100644
--- a/webui/src/styles/components.css
+++ b/webui/src/styles/components.css
@@ -20,7 +20,7 @@
width: 18px;
height: 13px;
border: 2px solid var(--loading-line);
- border-radius: 3px;
+ border-radius: var(--radius-tight);
background: var(--loading-envelope-bg);
animation: loading-envelope-float .74s ease-in-out infinite alternate;
}
@@ -65,7 +65,7 @@
gap: 7px;
max-width: 100%;
border: 1px solid var(--control-border);
- border-radius: 999px;
+ border-radius: var(--radius-pill);
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end));
box-shadow: var(--shadow-control-strong);
padding: 5px 8px 5px 11px;
@@ -98,7 +98,7 @@
width: 18px;
height: 18px;
border: 0;
- border-radius: 999px;
+ border-radius: var(--radius-pill);
background: var(--hover-tint);
color: var(--control-text-muted);
cursor: pointer;
@@ -221,7 +221,7 @@
width: 28px;
height: 28px;
border: 0;
- border-radius: 4px;
+ border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
cursor: pointer;
@@ -264,7 +264,7 @@
color: var(--muted);
font-size: 0.875rem;
}
-@media (max-width: 520px) {
+@media (max-width: 560px) {
.password-generator-character-sets {
grid-template-columns: 1fr;
}
@@ -326,7 +326,7 @@
width: 18px;
height: 18px;
border: var(--border-line-dark);
- border-radius: 4px;
+ border-radius: var(--radius-sm);
box-shadow: var(--shadow-inset-highlight);
}
@@ -341,7 +341,7 @@
width: 30px;
height: 30px;
border: 0;
- border-radius: 4px;
+ border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
cursor: pointer;
@@ -372,7 +372,7 @@
min-width: 220px;
padding: 10px;
border: var(--border-line-dark);
- border-radius: 8px;
+ border-radius: var(--radius-md);
background: var(--panel);
box-shadow: var(--shadow-menu);
}
@@ -387,7 +387,7 @@
width: 28px;
height: 28px;
border: 1px solid var(--black-border-soft);
- border-radius: 6px;
+ border-radius: var(--radius-compact);
cursor: pointer;
box-shadow: var(--shadow-inset-highlight);
}
@@ -420,7 +420,7 @@
width: 32px;
height: 32px;
border: var(--border-line);
- border-radius: 4px;
+ border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
cursor: pointer;
@@ -448,7 +448,7 @@
min-width: 30px;
height: 30px;
border: 1px solid transparent;
- border-radius: 5px;
+ border-radius: var(--radius-xs);
background: transparent;
color: var(--text);
cursor: pointer;
@@ -491,7 +491,7 @@
.admin-overview-link {
border: var(--border-line);
- border-radius: 6px;
+ border-radius: var(--radius-compact);
background: var(--panel-soft);
padding: 14px;
text-align: left;
@@ -528,7 +528,7 @@
align-items: center;
gap: 18px;
border: var(--border-line);
- border-radius: 6px;
+ border-radius: var(--radius-compact);
background: var(--panel);
box-shadow: var(--shadow);
padding: 14px 16px;
@@ -612,7 +612,7 @@
align-items: center;
gap: 14px;
border: var(--border-line);
- border-radius: 6px;
+ border-radius: var(--radius-compact);
background: var(--panel);
box-shadow: var(--shadow);
padding: 14px 16px;
@@ -713,7 +713,7 @@
display: grid;
gap: 10px;
border: var(--border-line);
- border-radius: 6px;
+ border-radius: var(--radius-compact);
background: var(--panel-soft);
padding: 12px 14px;
}
@@ -752,7 +752,7 @@
.module-install-preflight-issue {
min-height: 30px;
padding: 6px 8px;
- border-radius: 4px;
+ border-radius: var(--radius-sm);
background: var(--panel-glass);
}
@@ -783,7 +783,7 @@
align-content: start;
min-height: 86px;
border: var(--border-line);
- border-radius: 6px;
+ border-radius: var(--radius-compact);
background: var(--panel-glass);
padding: 9px 10px;
}
@@ -795,7 +795,7 @@
.module-install-checklist-item span {
justify-self: start;
- border-radius: 999px;
+ border-radius: var(--radius-pill);
background: var(--panel);
color: var(--muted);
font-size: 11px;
@@ -840,7 +840,7 @@
gap: 12px;
align-items: end;
border: var(--border-line);
- border-radius: 6px;
+ border-radius: var(--radius-compact);
background: var(--panel);
box-shadow: var(--shadow);
padding: 14px 16px;
@@ -915,7 +915,7 @@
align-items: center;
gap: 14px;
border: var(--border-line);
- border-radius: 6px;
+ border-radius: var(--radius-compact);
background: var(--panel);
box-shadow: var(--shadow);
padding: 14px 16px;
@@ -1063,7 +1063,7 @@
align-items: start;
gap: 9px;
padding: 8px 9px;
- border-radius: 7px;
+ border-radius: var(--radius-md);
cursor: pointer;
}
@@ -1180,7 +1180,7 @@
min-width: 0;
padding: 15px;
border: var(--border-line);
- border-radius: 7px;
+ border-radius: var(--radius-md);
background: var(--panel);
}
@@ -1205,7 +1205,7 @@
.advanced-options-panel {
border: var(--border-line);
- border-radius: 7px;
+ border-radius: var(--radius-md);
background: var(--panel);
}
@@ -1255,7 +1255,7 @@
gap: 10px;
padding: 12px 13px;
border: 1px solid color-mix(in srgb, var(--amber) 45%, var(--line));
- border-radius: 7px;
+ border-radius: var(--radius-md);
background: color-mix(in srgb, var(--amber) 12%, var(--panel));
}
@@ -1330,7 +1330,7 @@
.guided-review-list.is-empty {
padding: 12px;
border: 1px dashed var(--line);
- border-radius: 7px;
+ border-radius: var(--radius-md);
color: var(--muted);
}
@@ -1344,7 +1344,7 @@
border: var(--border-line);
border-left-width: 4px;
border-left-color: var(--line-dark);
- border-radius: 7px;
+ border-radius: var(--radius-md);
background: var(--panel);
}
@@ -1390,7 +1390,7 @@
line-height: 1.4;
}
-@media (max-width: 840px) {
+@media (max-width: 900px) {
.guided-config-shell {
grid-template-columns: 1fr;
}
@@ -1421,7 +1421,7 @@
gap: 7px;
min-height: 44px;
border: var(--border-line);
- border-radius: 8px;
+ border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-control-surface);
padding: 7px 8px;
@@ -1473,7 +1473,7 @@
width: 28px;
height: 28px;
border: 1px solid var(--control-border);
- border-radius: 999px;
+ border-radius: var(--radius-pill);
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-muted));
color: var(--control-text);
cursor: pointer;
@@ -1495,7 +1495,7 @@
display: grid;
gap: 10px;
border: var(--border-line-dark);
- border-radius: 8px;
+ border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-popover);
padding: 14px;
@@ -1520,7 +1520,7 @@
gap: 4px;
max-width: 560px;
border: var(--border-line);
- border-radius: 8px;
+ border-radius: var(--radius-md);
background: var(--surface);
box-shadow: 0 8px 24px var(--hover-tint);
padding: 6px;
@@ -1531,7 +1531,7 @@
justify-content: space-between;
gap: 12px;
border: 0;
- border-radius: 6px;
+ border-radius: var(--radius-compact);
background: transparent;
color: var(--text-strong);
cursor: pointer;
@@ -1744,7 +1744,7 @@
max-height: 360px;
overflow: auto;
border: var(--border-line-dark);
- border-radius: 8px;
+ border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-popover);
padding: 5px;
@@ -1773,7 +1773,7 @@
gap: 16px;
width: 100%;
border: 0;
- border-radius: 6px;
+ border-radius: var(--radius-compact);
background: transparent;
color: var(--text);
cursor: pointer;
@@ -1831,7 +1831,7 @@
gap: 12px;
width: 100%;
border: var(--border-line);
- border-radius: 8px;
+ border-radius: var(--radius-md);
background: var(--surface-subtle);
padding: 12px;
}
@@ -1896,7 +1896,7 @@
width: 42px;
height: 24px;
border: 1px solid var(--toggle-track-border);
- border-radius: 999px;
+ border-radius: var(--radius-pill);
background: var(--toggle-track-bg);
box-shadow: var(--shadow-control-inset-strong);
transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease;
@@ -1907,7 +1907,7 @@
left: 2px;
width: 18px;
height: 18px;
- border-radius: 999px;
+ border-radius: var(--radius-pill);
background: var(--surface);
box-shadow: var(--shadow-thumb);
transition: transform .16s ease;
@@ -1988,7 +1988,7 @@
justify-content: center;
width: 16px;
height: 16px;
- border-radius: 999px;
+ border-radius: var(--radius-pill);
color: var(--text-subtle);
background: var(--line-dark);
font-size: 11px;
@@ -2005,7 +2005,7 @@
flex: 0 0 auto;
width: 18px;
height: 18px;
- border-radius: 3px;
+ border-radius: var(--radius-tight);
color: var(--text-subtle);
text-decoration: none;
}
@@ -2119,7 +2119,7 @@
max-width: min(320px, calc(100vw - 48px));
transform: translate(-50%, 3px);
border: var(--border-line-dark);
- border-radius: 7px;
+ border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-popover);
color: var(--text);
@@ -2220,7 +2220,7 @@
width: min(280px, 72vw);
padding: 9px 11px;
border: 1px solid var(--border-danger-soft);
- border-radius: var(--radius-sm, 8px);
+ border-radius: var(--radius-md);
color: var(--danger-text-tooltip);
background: var(--danger-muted-bg);
box-shadow: var(--shadow-popover);
@@ -2253,7 +2253,7 @@
place-items: center;
min-height: 120px;
padding: 1.25rem;
- border-radius: var(--radius-lg, 18px);
+ border-radius: var(--radius-lg);
background: var(--transparent-surface);
backdrop-filter: blur(1.5px);
margin: -10px;
@@ -2265,7 +2265,7 @@
gap: 0.65rem;
padding: 0.75rem 1rem;
border: 1px solid var(--border-soft);
- border-radius: 999px;
+ border-radius: var(--radius-pill);
color: var(--text);
background: var(--panel-glass-strong);
box-shadow: var(--shadow-soft);
@@ -2284,7 +2284,7 @@
.module-load-error .alert {
width: min(640px, 100%);
- max-width: 640px;
+ max-width: 680px;
margin: 0;
}
@@ -2314,7 +2314,7 @@
width: 30px;
height: 30px;
border: 1px solid var(--control-border);
- border-radius: 999px;
+ border-radius: var(--radius-pill);
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-muted));
color: var(--control-text);
cursor: pointer;
@@ -2408,7 +2408,7 @@
width: 100%;
margin: 0;
border: 1px solid var(--border-soft);
- border-radius: 12px;
+ border-radius: var(--radius-lg);
box-shadow: var(--shadow-floating);
pointer-events: auto;
}
@@ -2421,7 +2421,7 @@
cursor: pointer;
line-height: 1;
padding: 2px;
- border-radius: 999px;
+ border-radius: var(--radius-pill);
flex: 0 0 auto;
}
@@ -2500,7 +2500,7 @@
cursor: pointer;
font-weight: 800;
padding: 4px 6px;
- border-radius: 7px;
+ border-radius: var(--radius-md);
}
.file-breadcrumb:hover:not(:disabled),
@@ -2651,7 +2651,7 @@
grid-template-columns: 26px minmax(0, 1fr);
align-items: center;
gap: 2px;
- border-radius: 9px;
+ border-radius: var(--radius-md);
}
.explorer-tree-node-wrap.has-actions {
@@ -2671,7 +2671,7 @@
place-items: center;
width: 26px;
height: 32px;
- border-radius: 9px;
+ border-radius: var(--radius-md);
color: var(--muted);
}
@@ -2692,7 +2692,7 @@
min-width: 0;
width: 100%;
padding: 8px 9px;
- border-radius: 9px;
+ border-radius: var(--radius-md);
font: inherit;
text-align: left;
user-select: none;
@@ -2844,7 +2844,7 @@
.file-tree-select {
width: 24px;
height: 24px;
- border-radius: 999px;
+ border-radius: var(--radius-pill);
color: var(--text-strong);
font-weight: 900;
line-height: 1;
@@ -2890,7 +2890,7 @@
max-width: 100%;
overflow: hidden;
border: var(--border-line-dark);
- border-radius: 8px;
+ border-radius: var(--radius-md);
background: var(--line-dark);
box-shadow: var(--shadow-control);
}
@@ -2931,11 +2931,11 @@
}
.segmented-control-option:first-child {
- border-radius: 7px 0 0 7px;
+ border-radius: var(--radius-md) 0 0 var(--radius-md);
}
.segmented-control-option:last-child {
- border-radius: 0 7px 7px 0;
+ border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
.segmented-control-option:hover,
@@ -3052,7 +3052,7 @@
min-width: 21px;
height: 21px;
padding: 0 6px;
- border-radius: 999px;
+ border-radius: var(--radius-pill);
font-size: 12px;
font-weight: 800;
line-height: 1;
@@ -3060,7 +3060,7 @@
}
.count-badge-compact { min-width: 18px; height: 18px; padding-inline: 5px; font-size: 10px; }
-.count-badge-accent { background: var(--accent); color: var(--on-accent); }
+.count-badge-accent { background: var(--accent); color: var(--badge-accent-text); }
.count-badge-neutral { border: var(--border-line); background: var(--surface-subtle); color: var(--text); }
.count-badge-danger { background: var(--red); color: var(--on-accent); }
@@ -3094,7 +3094,7 @@
.definition-graph-canvas .react-flow__edge.selected .react-flow__edge-path,
.definition-graph-canvas .react-flow__edge:hover .react-flow__edge-path { stroke: var(--accent); stroke-width: 3; }
.definition-graph-canvas-empty { position: absolute; inset: 0; pointer-events: none; }
-.definition-node-icon { display: grid; width: 28px; height: 28px; flex: 0 0 28px; place-items: center; border-radius: 5px; background: var(--panel-soft); color: var(--text-strong); }
+.definition-node-icon { display: grid; width: 28px; height: 28px; flex: 0 0 28px; place-items: center; border-radius: var(--radius-xs); background: var(--panel-soft); color: var(--text-strong); }
.definition-node-handle { width: 13px; height: 13px; border: 3px solid var(--panel); background: var(--line-dark); }
.definition-node-handle:hover,
.definition-node-handle.connectingto,
@@ -3202,7 +3202,7 @@
.theme-preview-header i {
width: 18px;
height: 18px;
- border-radius: 50%;
+ border-radius: var(--radius-round);
background: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
@@ -3223,7 +3223,7 @@
.theme-preview-body span {
display: block;
height: 8px;
- border-radius: 4px;
+ border-radius: var(--radius-sm);
background: var(--preview-line);
}
@@ -3517,7 +3517,7 @@
}
.mail-server-toggle-row {
border: var(--border-line);
- border-radius: 8px;
+ border-radius: var(--radius-md);
background: var(--panel-soft);
}
.mail-server-plain-toggle-row {
@@ -3547,40 +3547,32 @@
align-items: center;
min-height: 24px;
border: var(--border-line);
- border-radius: 99px;
+ border-radius: var(--radius-pill);
background: var(--panel-soft);
padding: 3px 9px;
color: var(--text-strong);
font-size: 12px;
font-weight: 700;
}
-@media (max-width: 1000px) {
+@media (max-width: 1100px) {
.mail-server-settings-grid { grid-template-columns: 1fr; }
}
@media (max-width: 900px) {
.mail-server-segmented-control { width: min(320px, 100%); }
}
-@media (max-width: 720px) {
+@media (max-width: 760px) {
.credential-panel-grid { grid-template-columns: 1fr; }
.mail-server-section-heading.split { align-items: flex-start; flex-direction: column; }
.mail-server-segmented-control { width: 100%; }
}
-.unsaved-changes-dialog {
- max-width: 520px;
-}
-
-.unsaved-changes-actions {
- justify-content: flex-end;
-}
-
.file-drop-zone {
display: grid;
place-items: center;
gap: 8px;
min-height: 170px;
border: 1px dashed var(--line-dark);
- border-radius: 8px;
+ border-radius: var(--radius-md);
background: var(--panel-soft);
color: var(--muted);
text-align: center;
@@ -3614,7 +3606,7 @@
place-items: center;
width: 58px;
height: 58px;
- border-radius: 50%;
+ border-radius: var(--radius-round);
background: conic-gradient(var(--primary) var(--file-drop-progress), var(--primary-soft-strong) 0);
color: var(--text-strong);
font-size: 12px;
@@ -3625,7 +3617,7 @@
content: "";
position: absolute;
inset: 6px;
- border-radius: 50%;
+ border-radius: var(--radius-round);
background: var(--panel-soft);
box-shadow: var(--primary-inset-ring);
}
@@ -3650,7 +3642,7 @@
.placeholder-stack span {
display: block;
border: 1px dashed var(--line-dark);
- border-radius: 6px;
+ border-radius: var(--radius-compact);
background: var(--panel-soft);
padding: 10px 12px;
color: var(--muted);
@@ -3661,7 +3653,7 @@
gap: 12px;
}
.settings-target-row {
- max-width: 520px;
+ max-width: 560px;
}
.admin-secret {
display: block;
@@ -3901,12 +3893,12 @@ a.dashboard-contribution-row:hover {
.description-list-inline.description-list-collapse-wide > div { grid-template-columns: minmax(0, 1fr); }
}
-@media (max-width: 980px) {
+@media (max-width: 1100px) {
.description-list-collapse-standard { grid-template-columns: 1fr; }
.description-list-inline.description-list-collapse-standard > div { grid-template-columns: minmax(0, 1fr); }
}
-@media (max-width: 700px) {
+@media (max-width: 760px) {
.description-list-collapse-narrow { grid-template-columns: 1fr; }
.description-list-inline.description-list-collapse-narrow > div { grid-template-columns: minmax(0, 1fr); }
}
@@ -3962,7 +3954,7 @@ button.stage-rail-item:focus-visible {
height: 38px;
place-items: center;
border: 1px solid var(--stage-rail-color);
- border-radius: 50%;
+ border-radius: var(--radius-round);
background: color-mix(in srgb, var(--stage-rail-color) 8%, var(--surface));
color: color-mix(in srgb, var(--stage-rail-color) 82%, var(--text));
transition: background .16s ease, box-shadow .16s ease;
@@ -4015,7 +4007,7 @@ button.stage-rail-item:focus-visible {
min-width: 0;
overflow: hidden;
border: var(--border-line);
- border-radius: 5px;
+ border-radius: var(--radius-xs);
background: var(--surface);
box-shadow: var(--shadow-control-inset);
transition: border-color .16s ease, box-shadow .16s ease;
@@ -4160,7 +4152,7 @@ button.stage-rail-item:focus-visible {
.wysiwyg-prosemirror pre {
overflow: auto;
padding: 10px 12px;
- border-radius: 4px;
+ border-radius: var(--radius-sm);
background: var(--code-bg);
color: var(--code-text);
}
@@ -4189,7 +4181,7 @@ button.stage-rail-item:focus-visible {
margin: 0 2px;
padding: 1px 6px;
border: 1px solid var(--primary-border);
- border-radius: 4px;
+ border-radius: var(--radius-sm);
background: var(--primary-soft);
color: var(--info-text-deep);
font-family: var(--font-mono, monospace);
diff --git a/webui/src/styles/dialogs.css b/webui/src/styles/dialogs.css
index 2a92eb6..bdc6f01 100644
--- a/webui/src/styles/dialogs.css
+++ b/webui/src/styles/dialogs.css
@@ -16,7 +16,7 @@
display: flex;
flex-direction: column;
border: var(--border-line);
- border-radius: 8px;
+ border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-modal);
}
@@ -80,7 +80,7 @@
display: flex;
flex-direction: column;
border: 1px solid var(--line);
- border-radius: var(--radius-lg, 18px);
+ border-radius: var(--radius-lg);
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-strong);
@@ -117,7 +117,7 @@
width: 2rem;
height: 2rem;
border: 0;
- border-radius: 999px;
+ border-radius: var(--radius-pill);
display: inline-flex;
align-items: center;
justify-content: center;
diff --git a/webui/src/styles/forms.css b/webui/src/styles/forms.css
index 65826a3..ceb0b89 100644
--- a/webui/src/styles/forms.css
+++ b/webui/src/styles/forms.css
@@ -28,7 +28,7 @@
.form-field { display: grid; gap: 7px; }
.form-label { font-weight: 700; font-size: 13px; color: var(--text-label); }
.form-help { font-size: 12px; color: var(--muted); }
-input:not([type="checkbox"]):not([type="radio"]), select, textarea { border: var(--border-line); border-radius: 5px; background: var(--surface); font: inherit; padding: 10px 12px; color: var(--text); width: 100%; box-shadow: var(--shadow-control-inset); }
+input:not([type="checkbox"]):not([type="radio"]), select, textarea { border: var(--border-line); border-radius: var(--radius-xs); background: var(--surface); font: inherit; padding: 10px 12px; color: var(--text); width: 100%; box-shadow: var(--shadow-control-inset); }
input[type="checkbox"]:not(.toggle-switch-input),
input[type="radio"] {
accent-color: var(--primary);
@@ -49,7 +49,7 @@ textarea { resize: vertical; }
.content-grid-collapse-wide > .content-grid-item-two { grid-column: auto; }
}
-@media (max-width: 980px) {
+@media (max-width: 1100px) {
.content-grid-collapse-standard { grid-template-columns: minmax(0, 1fr); }
.content-grid-collapse-standard > .content-grid-item-two { grid-column: auto; }
}
@@ -59,7 +59,7 @@ textarea { resize: vertical; }
.content-grid-collapse-workspace > .content-grid-item-two { grid-column: auto; }
}
-@media (max-width: 700px) {
+@media (max-width: 760px) {
.content-grid-collapse-narrow { grid-template-columns: minmax(0, 1fr); }
.content-grid-collapse-narrow > .content-grid-item-two { grid-column: auto; }
.form-section-header { flex-direction: column; }
@@ -93,8 +93,8 @@ textarea[readonly]::placeholder {
color: var(--control-disabled-placeholder);
opacity: 1;
}
-.btn { align-items: center; border: var(--border-line-dark); border-radius: 4px; cursor: pointer; display: inline-flex; font: inherit; font-weight: 700; gap: 7px; justify-content: center; padding: 9px 14px; background: var(--control-bg); color: var(--text); box-shadow: 0 1px 2px var(--hover-tint-strong); }
-.btn-primary { background: var(--green); border-color: var(--success-border); color: var(--on-accent); }
+.btn { align-items: center; border: var(--border-line-dark); border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; font: inherit; font-weight: 700; gap: 7px; justify-content: center; padding: 9px 14px; background: var(--control-bg); color: var(--text); box-shadow: 0 1px 2px var(--hover-tint-strong); }
+.btn-primary { background: var(--action-primary-bg); border-color: var(--action-primary-border); color: var(--action-primary-text); }
.btn-ghost { border-color: transparent; background: transparent; box-shadow: none; }
-.btn-danger { background: var(--red); color: var(--on-accent); border-color: var(--danger-border-strong); }
+.btn-danger { background: var(--action-danger-bg); color: var(--action-danger-text); border-color: var(--danger-border-strong); }
.btn:disabled { opacity: .55; cursor: not-allowed; }
diff --git a/webui/src/styles/layout.css b/webui/src/styles/layout.css
index 16a1a60..a28e6d2 100644
--- a/webui/src/styles/layout.css
+++ b/webui/src/styles/layout.css
@@ -2,13 +2,13 @@
.icon-rail { width: 58px; background: var(--rail-bg); color: var(--rail-text); display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: var(--shadow-rail); z-index: 1000; transition: width .16s ease; }
.icon-rail.expanded { width: 208px; align-items: stretch; }
.icon-rail-header { width: 100%; min-height: 63px; display: flex; flex: 0 0 auto; align-items: center; justify-content: center; padding: 0 10px; box-sizing: border-box; }
-.brand-mark { width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; background: conic-gradient(var(--accent) 0 20%, var(--amber) 0 40%, var(--green) 0 60%, var(--blue) 0 80%, var(--muted) 0); color: transparent; font-size: 0; position: relative; }
+.brand-mark { width: 34px; height: 34px; flex: 0 0 auto; border-radius: var(--radius-round); background: conic-gradient(var(--accent) 0 20%, var(--amber) 0 40%, var(--green) 0 60%, var(--blue) 0 80%, var(--muted) 0); color: transparent; font-size: 0; position: relative; }
.brand-mark::after { position: absolute;
top: 9px;
left: 9px;
width: 15px;
height: 15px;
- border-radius: 50%;
+ border-radius: var(--radius-round);
content: "";
background-color: var(--rail-bg);
}
@@ -46,7 +46,7 @@
.small-note { font-size: 12px; }
.titlebar-spacer { flex: 1; }
.titlebar-link, .titlebar-icon-link, .account-pill { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: inherit; }
-.titlebar-icon-link { width: 34px; height: 34px; justify-content: center; border-radius: 4px; cursor: pointer; }
+.titlebar-icon-link { width: 34px; height: 34px; justify-content: center; border-radius: var(--radius-sm); cursor: pointer; }
.titlebar-icon-link:hover, .titlebar-link:hover { background: var(--titlebar-hover-bg); color: var(--text-strong); }
.titlebar-icon-link.is-context-active { background: var(--accent-hover-bg); color: var(--accent); }
.titlebar-icon-link.is-context-active:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
@@ -54,7 +54,7 @@
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; border: 2px solid var(--titlebar-bg); transform: translate(35%, -35%); }
.account-pill { color: var(--text); }
.maintenance-topbar-link,
-.backend-offline-topbar-alert { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); min-height: 32px; max-width: min(560px, calc(100vw - 24px)); box-sizing: border-box; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; padding: 0 14px; overflow: hidden; font: inherit; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; box-shadow: 0 1px 2px var(--hover-tint); z-index: 1; }
+.backend-offline-topbar-alert { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); min-height: 32px; max-width: min(560px, calc(100vw - 24px)); box-sizing: border-box; border-radius: var(--radius-compact); display: inline-flex; align-items: center; justify-content: center; padding: 0 14px; overflow: hidden; font: inherit; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; box-shadow: 0 1px 2px var(--hover-tint); z-index: 1; }
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); cursor: pointer; }
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--red); color: var(--on-accent); }
@@ -79,6 +79,9 @@
.breadcrumbs { display: flex; gap: 6px; text-transform: uppercase; font-weight: 700; font-size: 13px; color: var(--text-breadcrumb); }
.crumb { display: inline-flex; align-items: center; gap: 4px; }
.breadcrumb-actions { margin-left: auto; display: flex; gap: 10px; }
+.launch-return-button { margin-left: auto; display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; cursor: pointer; }
+.launch-return-button:hover,
+.launch-return-button:focus-visible { color: var(--text-strong); }
.ghost-button { border: 0; background: transparent; color: var(--muted); font-weight: 700; }
.action-toolbar { min-width: 0; display: flex; align-items: center; gap: 10px; }
.action-toolbar-density-compact { gap: 7px; }
@@ -298,7 +301,7 @@
.stepper { list-style: none; padding: 22px 0; margin: 0; background: var(--panel-soft); border-right: var(--border-line); }
.step button { width: 100%; min-height: 72px; border: 0; background: transparent; display: flex; gap: 14px; text-align: left; padding: 12px 20px; color: var(--muted); cursor: pointer; }
.step.active button { background: var(--surface); color: var(--text-strong); }
-.step-number { width: 32px; height: 32px; border-radius: 50%; background: var(--step-number-bg); color: var(--on-accent); display: grid; place-items: center; font-weight: 800; flex: 0 0 auto; }
+.step-number { width: 32px; height: 32px; border-radius: var(--radius-round); background: var(--step-number-bg); color: var(--on-accent); display: grid; place-items: center; font-weight: 800; flex: 0 0 auto; }
.step.active .step-number { background: var(--accent); }
.step small { display: block; margin-top: 3px; color: var(--muted); }
.step-intro h2 { margin: 0; color: var(--text-strong); }
@@ -321,12 +324,14 @@
.table-row-link { display: grid; grid-template-columns: 1fr auto; text-decoration: none; color: var(--text); padding: 14px 16px; border-bottom: var(--border-line); }
.table-row-link:hover { background: var(--panel-soft); }
.mono-small { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); }
-.code-panel { background: var(--code-bg); color: var(--code-text); padding: 18px; border-radius: 4px; overflow: auto; }
+.code-panel { background: var(--code-bg); color: var(--code-text); padding: 18px; border-radius: var(--radius-sm); overflow: auto; }
@media (max-width: 900px) {
.api-mini { display: none; }
.workspace { grid-template-columns: 1fr; }
.workspace-layout-navigation > .workspace-layout-primary { display: none; }
- .workspace-layout-split { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); }
+ .workspace-layout-split,
+ .workspace-layout-split.workspace-layout-primary-compact,
+ .workspace-layout-split.workspace-layout-primary-wide { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); }
.workspace-layout-surface-contained > .workspace-layout-primary { border-right: 0; border-bottom: var(--border-line); }
.section-sidebar { display: none; }
.wizard-card { grid-template-columns: 1fr; }
@@ -338,15 +343,15 @@
.metric-group-collapse-wide { grid-template-columns: minmax(0, 1fr); }
}
-@media (max-width: 980px) {
+@media (max-width: 1100px) {
.metric-group-collapse-standard { grid-template-columns: minmax(0, 1fr); }
}
-@media (max-width: 700px) {
+@media (max-width: 760px) {
.metric-group-collapse-narrow { grid-template-columns: minmax(0, 1fr); }
}
-@media (max-width: 700px) {
+@media (max-width: 760px) {
.action-toolbar-wrap-responsive { align-items: stretch; flex-wrap: wrap; }
.action-toolbar-wrap-responsive > .action-toolbar-group { flex: 1 1 100%; }
.action-toolbar-wrap-responsive > .action-toolbar-group-end { margin-inline-start: 0; justify-content: flex-start; }
@@ -359,24 +364,24 @@
/* Layout additions: login and help dropdown */
.tenant-selector.disabled { opacity: .86; cursor: default; }
.context-menu-wrap { position: relative; }
-.dropdown-menu { position: absolute; right: 0; top: calc(100% + 10px); min-width: 230px; background: var(--surface); border: var(--border-line); border-radius: 6px; box-shadow: var(--shadow-menu); padding: 8px; z-index: 5000; }
+.dropdown-menu { position: absolute; right: 0; top: calc(100% + 10px); min-width: 230px; background: var(--surface); border: var(--border-line); border-radius: var(--radius-compact); box-shadow: var(--shadow-menu); padding: 8px; z-index: 5000; }
.dropdown-menu hr { border: 0; border-top: var(--border-line); margin: 8px 0; }
-.dropdown-item { width: 100%; min-height: 34px; border: 0; background: transparent; display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 4px; text-align: left; color: var(--text); font: inherit; text-decoration: none; cursor: pointer; }
+.dropdown-item { width: 100%; min-height: 34px; border: 0; background: transparent; display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: var(--radius-sm); text-align: left; color: var(--text); font: inherit; text-decoration: none; cursor: pointer; }
.dropdown-item:hover { background: var(--panel-soft); color: var(--text-strong); }
.dropdown-item small { margin-left: auto; color: var(--muted); }
.account-menu { min-width: 260px; }
.account-menu-header { padding: 8px 9px 10px; border-bottom: var(--border-line); margin-bottom: 8px; }
.account-menu-header strong { display: block; color: var(--text-strong); }
.account-menu-header span { color: var(--muted); font-size: 13px; }
-.login-hint { background: var(--panel-soft); border: var(--border-line); padding: 10px 12px; border-radius: 4px; color: var(--muted); font-size: 13px; }
+.login-hint { background: var(--panel-soft); border: var(--border-line); padding: 10px 12px; border-radius: var(--radius-sm); color: var(--muted); font-size: 13px; }
.help-panel-section { margin-bottom: 16px; }
.help-panel-section h3 { margin: 0 0 7px; color: var(--text-strong); }
-.about-logo { width: 52px; height: 52px; border-radius: 50%; background: conic-gradient(var(--accent) 0 20%, var(--amber) 0 40%, var(--green) 0 60%, var(--blue) 0 80%, var(--muted) 0); margin-bottom: 12px; }
-.kbd { display: inline-flex; min-width: 22px; height: 22px; align-items: center; justify-content: center; border: var(--border-line-dark); border-bottom-width: 2px; border-radius: 4px; padding: 0 6px; background: var(--surface); font-size: 12px; font-weight: 700; color: var(--text-soft); }
+.about-logo { width: 52px; height: 52px; border-radius: var(--radius-round); background: conic-gradient(var(--accent) 0 20%, var(--amber) 0 40%, var(--green) 0 60%, var(--blue) 0 80%, var(--muted) 0); margin-bottom: 12px; }
+.kbd { display: inline-flex; min-width: 22px; height: 22px; align-items: center; justify-content: center; border: var(--border-line-dark); border-bottom-width: 2px; border-radius: var(--radius-sm); padding: 0 6px; background: var(--surface); font-size: 12px; font-weight: 700; color: var(--text-soft); }
.titlebar .tenant-selector strong { max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Campaign workspace polish */
-.crumb-link { color: inherit; text-decoration: none; border-radius: 4px; padding: 4px 5px; margin: -4px -5px; }
+.crumb-link { color: inherit; text-decoration: none; border-radius: var(--radius-sm); padding: 4px 5px; margin: -4px -5px; }
.crumb-link:hover { background: var(--sidebar-hover-bg); color: var(--text-strong); }
.compact-actions { margin: 0; align-items: center; }
.below-grid { margin-top: 18px; }
@@ -387,12 +392,12 @@
.next-action-warning { border-left-color: var(--amber); }
.next-action-info { border-left-color: var(--blue); }
.summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
-.summary-tile { background: var(--panel-soft); border: var(--border-line); border-radius: 6px; padding: 14px; }
+.summary-tile { background: var(--panel-soft); border: var(--border-line); border-radius: var(--radius-compact); padding: 14px; }
.summary-tile span { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .04em; }
.summary-tile strong { display: block; margin-top: 6px; color: var(--text-strong); font-size: 24px; }
.empty-state { min-height: 220px; display: grid; place-items: center; text-align: center; padding: 32px; }
.empty-state h2 { margin: 0; color: var(--text-strong); }
-.empty-state p { max-width: 520px; margin: 8px auto 18px; color: var(--muted); }
+.empty-state p { max-width: 560px; margin: 8px auto 18px; color: var(--muted); }
.state-panel { box-sizing: border-box; min-width: 0; display: grid; justify-items: center; align-content: center; gap: 8px; color: var(--muted); text-align: center; }
.state-panel-size-inline { min-height: 0; padding: 8px 10px; }
.state-panel-size-compact { min-height: 110px; padding: 18px; }
@@ -407,7 +412,7 @@
.state-panel-icon { display: grid; place-items: center; color: currentColor; }
.state-panel-title { margin: 0; color: var(--text-strong); font-size: 18px; letter-spacing: 0; }
.state-panel-description,
-.state-panel-content { max-width: 520px; line-height: 1.45; }
+.state-panel-content { max-width: 560px; line-height: 1.45; }
.state-panel-description > :first-child,
.state-panel-content > :first-child { margin-top: 0; }
.state-panel-description > :last-child,
@@ -444,21 +449,21 @@
.docs-tree-node { min-width: 0; }
.docs-tree-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: start; gap: 3px; }
.docs-tree-toggle, .docs-tree-toggle-placeholder { width: 22px; height: 32px; display: inline-grid; place-items: center; flex: 0 0 auto; }
-.docs-tree-toggle { border: 0; border-radius: 4px; background: transparent; color: var(--text-subtle); cursor: pointer; }
+.docs-tree-toggle { border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-subtle); cursor: pointer; }
.docs-tree-toggle:hover { background: var(--sidebar-hover-bg); color: var(--text-strong); }
.docs-tree-toggle-placeholder { opacity: 0; }
.docs-tree-children { display: grid; gap: 2px; }
-.docs-tree-page { width: 100%; min-height: 32px; border: 0; border-radius: 4px; background: transparent; color: var(--text-subtle); font: inherit; font-size: 13px; line-height: 1.25; text-align: left; cursor: pointer; padding: 7px 9px; overflow-wrap: anywhere; }
+.docs-tree-page { width: 100%; min-height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-subtle); font: inherit; font-size: 13px; line-height: 1.25; text-align: left; cursor: pointer; padding: 7px 9px; overflow-wrap: anywhere; }
.docs-tree-page:hover, .docs-tree-page.is-active { background: var(--sidebar-hover-bg-strong); color: var(--text-strong); }
.docs-tree-page.is-active { box-shadow: inset 3px 0 var(--accent); font-weight: 700; }
.docs-tree-empty { color: var(--muted); font-size: 12px; line-height: 1.35; padding: 8px 9px; }
.docs-page { min-height: 100%; background: var(--panel); }
.docs-content { width: min(1180px, 100%); margin: 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) 260px; align-items: start; gap: 32px; }
.docs-page-main { min-width: 0; }
-.docs-page-outline-box { position: sticky; top: 24px; border: var(--border-line); border-radius: 6px; background: var(--surface-raised); padding: 16px; box-shadow: var(--shadow-xs); }
+.docs-page-outline-box { position: sticky; top: 24px; border: var(--border-line); border-radius: var(--radius-compact); background: var(--surface-raised); padding: 16px; box-shadow: var(--shadow-xs); }
.docs-page-outline-box h2 { margin: 0 0 10px; color: var(--text-strong); font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
.docs-page-outline-box nav { display: grid; gap: 4px; }
-.docs-page-outline-box a { border-radius: 4px; color: var(--muted); font-size: 13px; line-height: 1.3; padding: 5px 6px; text-decoration: none; }
+.docs-page-outline-box a { border-radius: var(--radius-sm); color: var(--muted); font-size: 13px; line-height: 1.3; padding: 5px 6px; text-decoration: none; }
.docs-page-outline-box a:hover { background: var(--panel); color: var(--text-strong); }
.docs-section { scroll-margin-top: 24px; padding-bottom: 34px; border-bottom: var(--border-line); }
.docs-section:last-child { border-bottom: 0; }
diff --git a/webui/src/styles/retention-policies.css b/webui/src/styles/retention-policies.css
index 8111a59..8b36b66 100644
--- a/webui/src/styles/retention-policies.css
+++ b/webui/src/styles/retention-policies.css
@@ -4,7 +4,7 @@
}
.retention-policy-target-row {
- max-width: 520px;
+ max-width: 560px;
}
.retention-policy-editor {
diff --git a/webui/src/styles/tables.css b/webui/src/styles/tables.css
index e5f9154..a9ffa2d 100644
--- a/webui/src/styles/tables.css
+++ b/webui/src/styles/tables.css
@@ -2,7 +2,7 @@
.mapping-table {
margin-top: 18px;
border: var(--border-line);
- border-radius: 6px;
+ border-radius: var(--radius-compact);
overflow: hidden;
}
.mapping-header,
@@ -31,7 +31,7 @@
margin: 18px 0;
background: var(--surface);
border: var(--border-line);
- border-radius: 6px;
+ border-radius: var(--radius-compact);
padding: 18px;
}
.attachment-rule-header {
@@ -358,7 +358,7 @@
align-items: center;
justify-content: center;
flex: 0 0 auto;
- border-radius: 999px;
+ border-radius: var(--radius-pill);
}
.data-grid-resize-handle {
@@ -420,7 +420,7 @@
background: transparent;
color: var(--muted);
cursor: pointer;
- border-radius: 999px;
+ border-radius: var(--radius-pill);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -610,7 +610,7 @@
.data-grid-list-option-remove,
.data-grid-list-option-add button {
border: 0;
- border-radius: 999px;
+ border-radius: var(--radius-pill);
background: transparent;
color: var(--muted);
width: 28px;
@@ -744,7 +744,7 @@
width: 32px;
height: 32px;
border: var(--border-line);
- border-radius: var(--radius-xs, 5px);
+ border-radius: var(--radius-xs);
background: var(--surface);
color: var(--text);
display: inline-grid;
@@ -762,7 +762,7 @@
cursor: not-allowed;
}
-@media (max-width: 700px) {
+@media (max-width: 760px) {
.data-grid-pagination {
flex-wrap: wrap;
justify-content: space-between;
@@ -882,7 +882,7 @@
font-weight: 500;
}
-@media (max-width: 820px) {
+@media (max-width: 760px) {
.connection-tree {
overflow-x: auto;
}
diff --git a/webui/src/styles/tokens.css b/webui/src/styles/tokens.css
index e1ad1fb..be18917 100644
--- a/webui/src/styles/tokens.css
+++ b/webui/src/styles/tokens.css
@@ -48,7 +48,7 @@
--text: #48494c;
--text-strong: #303135;
- --muted: #8a8781;
+ --muted: #686560;
--muted-text: var(--muted);
--text-label: #6b6863;
--text-subtle: #686560;
@@ -132,6 +132,12 @@
--danger-text-tooltip: #7a241c;
--on-accent: #ffffff;
--on-dark: #ffffff;
+ --action-primary-bg: #34796d;
+ --action-primary-border: #315f55;
+ --action-primary-text: #ffffff;
+ --action-danger-bg: #b13e35;
+ --action-danger-text: #ffffff;
+ --badge-accent-text: #242424;
--code-bg: #292929;
--code-text: #f1f1f1;
--status-neutral-bg: #e7e4df;
@@ -195,13 +201,35 @@
--shadow-campaign-soft: 0 1px 2px rgba(38, 35, 30, .04);
--shadow-campaign-small: 0 4px 12px rgba(0,0,0,.09);
--shadow-footer: 0 -8px 24px rgba(15, 23, 42, .06);
+ --shadow-drawer-side: -12px 0 30px rgba(0, 0, 0, .18);
+ --shadow-drawer-bottom: 0 -12px 30px rgba(0, 0, 0, .18);
+ --radius-hairline: 2px;
+ --radius-tight: 3px;
--radius: 8px;
--radius-xs: 5px;
--radius-sm: 4px;
+ --radius-compact: 6px;
--radius-md: var(--radius);
--radius-lg: 12px;
+ --radius-xl: 16px;
+ --radius-round: 50%;
--radius-pill: 999px;
+ /* Theme-aware categorical colors. Modules retain the meaning and mapping. */
+ --data-category-blue: #3d6f9e;
+ --data-category-green: #2f7d6d;
+ --data-category-amber: #b7791f;
+ --data-category-purple: #76569b;
+ --data-category-rose: #9d4e63;
+ --data-series-1: #2f7d6e;
+ --data-series-2: #3366a8;
+ --data-series-3: #c28b2c;
+ --data-series-4: #9a4f71;
+ --data-series-5: #5f7f3a;
+ --data-series-6: #b85c3b;
+ --data-series-7: #586176;
+ --data-series-8: #2e8b9a;
+
--theme-preview-light-bg: #e9e7e4;
--theme-preview-light-bar: #d4d0ca;
--theme-preview-light-surface: #ffffff;
@@ -346,6 +374,12 @@
--info-soft: #243d4e;
--info-text: #dce9f3;
--info-text-strong: #d8e8f4;
+ --action-primary-bg: #63b8a7;
+ --action-primary-border: #79c8b8;
+ --action-primary-text: #171819;
+ --action-danger-bg: #e06a5f;
+ --action-danger-text: #171819;
+ --badge-accent-text: #171819;
--shadow: 0 1px 2px rgba(0,0,0,.35), 0 10px 30px rgba(0,0,0,.18);
--shadow-menu: 0 18px 44px rgba(0,0,0,.42);
--shadow-popover: 0 8px 22px rgba(0,0,0,.36);
@@ -365,6 +399,22 @@
--shadow-campaign-soft: 0 1px 2px rgba(0,0,0,.24);
--shadow-campaign-small: 0 4px 12px rgba(0,0,0,.32);
--shadow-footer: 0 -8px 24px rgba(0,0,0,.28);
+ --shadow-drawer-side: -12px 0 30px rgba(0, 0, 0, .46);
+ --shadow-drawer-bottom: 0 -12px 30px rgba(0, 0, 0, .46);
+
+ --data-category-blue: #79a7d3;
+ --data-category-green: #63b8a7;
+ --data-category-amber: #d6a64a;
+ --data-category-purple: #aa8bd0;
+ --data-category-rose: #cf7a91;
+ --data-series-1: #63b8a7;
+ --data-series-2: #79a7d3;
+ --data-series-3: #d6a64a;
+ --data-series-4: #cf86aa;
+ --data-series-5: #91b964;
+ --data-series-6: #d27c5c;
+ --data-series-7: #9aa4bc;
+ --data-series-8: #63b7c3;
--calendar-grid-bg: #242523;
--calendar-muted-bg: #34342f;
--calendar-today-bg: #24473f;
diff --git a/webui/src/types.ts b/webui/src/types.ts
index 2474696..a54173e 100644
--- a/webui/src/types.ts
+++ b/webui/src/types.ts
@@ -548,15 +548,60 @@ export type SearchRuntimeUiCapability = {
export type QuickAccessRailProps = PlatformRouteContext & {
tools: QuickAccessToolMetadata[];
+ launchContext: QuickAccessLaunchContext;
};
export type QuickAccessRuntimeUiCapability = {
Rail: ComponentType;
};
+export type ActiveObjectReference = {
+ ownerModule: string;
+ kind: string;
+ objectId: string;
+ tenantId: string;
+ label: string;
+ version?: string | null;
+ path?: string | null;
+};
+
+export type QuickAccessLaunchContext = {
+ contractVersion: "1";
+ origin: {
+ pathname: string;
+ search: string;
+ hash: string;
+ historyIndex: number | null;
+ };
+ tenantId: string;
+ accountId: string;
+ activeObject: ActiveObjectReference | null;
+ actingContext: {
+ assignmentId: string | null;
+ actingForAccountId: string | null;
+ } | null;
+ temporalContext: {
+ validityMode: "current" | "at" | "all";
+ validAt: string | null;
+ recordedAt: string | null;
+ };
+ viewContext: {
+ viewId: string;
+ revisionId: string | null;
+ name: string | null;
+ } | null;
+};
+
+export type QuickAccessResult = {
+ action: "created" | "selected" | "updated" | "completed";
+ reference?: ActiveObjectReference | null;
+};
+
export type QuickAccessToolRenderContext = PlatformRouteContext & {
close: () => void;
active: boolean;
+ launchContext: QuickAccessLaunchContext;
+ complete: (result: QuickAccessResult) => void;
};
export type QuickAccessToolContribution = {
diff --git a/webui/tests/launch-context.test.ts b/webui/tests/launch-context.test.ts
new file mode 100644
index 0000000..cc2723d
--- /dev/null
+++ b/webui/tests/launch-context.test.ts
@@ -0,0 +1,88 @@
+import type { AuthInfo } from "../src/types";
+import {
+ createQuickAccessLaunchContext,
+ quickAccessLaunchContextFromState,
+ quickAccessLaunchState,
+ quickAccessReturnPath
+} from "../src/platform/launchContext";
+
+
+function assert(condition: unknown, message: string): void {
+ if (!condition) throw new Error(message);
+}
+
+const auth = {
+ user: { id: "user-1", account_id: "account-1", email: "case@example.test" },
+ tenant: { id: "tenant-1", slug: "test", name: "Test" },
+ scopes: [],
+ roles: [],
+ groups: [],
+ principal: {
+ account_id: "account-1",
+ tenant_id: "tenant-1",
+ scopes: [],
+ group_ids: [],
+ auth_method: "session",
+ acting_assignment_id: "assignment-1"
+ },
+ profile_loaded: true,
+ roles_loaded: true,
+ groups_loaded: true
+} satisfies AuthInfo;
+
+const context = createQuickAccessLaunchContext({
+ pathname: "/cases/case-1",
+ search: "?tab=history",
+ hash: "#revision-4",
+ historyIndex: 7,
+ auth,
+ activeObject: {
+ ownerModule: "cases",
+ kind: "case",
+ objectId: "case-1",
+ tenantId: "tenant-1",
+ label: "V-2026-0042 · Resident parking permit",
+ version: "4"
+ },
+ temporalContext: {
+ validityMode: "at",
+ validAt: "2026-08-01T10:00:00.000Z",
+ recordedAt: "2026-08-02T10:00:00.000Z"
+ },
+ viewContext: {
+ activeViewId: "case-worker",
+ activeRevisionId: "view-revision-3",
+ activeViewName: "Case worker",
+ visibleSurfaceIds: [],
+ locked: false,
+ availableViews: [],
+ provenance: [],
+ diagnostics: []
+ }
+});
+
+assert(context.contractVersion === "1", "launch context must be explicitly versioned");
+assert(context.activeObject?.objectId === "case-1", "active object reference must survive launch");
+assert(context.actingContext?.assignmentId === "assignment-1", "acting assignment must survive launch");
+assert(context.temporalContext.validityMode === "at", "temporal selection must survive launch");
+assert(context.viewContext?.revisionId === "view-revision-3", "exact View revision must survive launch");
+assert(
+ quickAccessReturnPath(context) === "/cases/case-1?tab=history#revision-4",
+ "return path must preserve route, query and fragment"
+);
+assert(
+ quickAccessLaunchContextFromState(quickAccessLaunchState(context))?.accountId === "account-1",
+ "router state must decode a valid launch context"
+);
+assert(
+ quickAccessLaunchContextFromState({ govoplanQuickAccessLaunch: { contractVersion: "2" } }) === null,
+ "unknown launch context versions must fail closed"
+);
+
+const crossTenant = createQuickAccessLaunchContext({
+ pathname: "/cases/case-1",
+ auth,
+ activeObject: { ...context.activeObject!, tenantId: "tenant-2" },
+ temporalContext: { validityMode: "current", validAt: null, recordedAt: null }
+});
+assert(crossTenant.activeObject === null, "cross-tenant object references must be discarded");
diff --git a/webui/tsconfig.conformance.json b/webui/tsconfig.conformance.json
new file mode 100644
index 0000000..70295f2
--- /dev/null
+++ b/webui/tsconfig.conformance.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "types": ["node"]
+ },
+ "include": ["src", "conformance", "vite.conformance.config.ts"]
+}
diff --git a/webui/tsconfig.module-tests.json b/webui/tsconfig.module-tests.json
index defb8f7..efe43c0 100644
--- a/webui/tsconfig.module-tests.json
+++ b/webui/tsconfig.module-tests.json
@@ -18,9 +18,11 @@
"tests/module-capabilities.test.ts",
"tests/privacy-policy.test.ts",
"tests/help-context.test.ts",
+ "tests/launch-context.test.ts",
"tests/definition-graph.test.ts",
"src/platform/moduleLogic.ts",
"src/platform/productAreas.ts",
+ "src/platform/launchContext.ts",
"src/utils/helpContext.ts",
"src/features/privacy/policyLogic.ts",
"src/definitionGraph.ts",
diff --git a/webui/vite.conformance.config.ts b/webui/vite.conformance.config.ts
new file mode 100644
index 0000000..e5cfb6a
--- /dev/null
+++ b/webui/vite.conformance.config.ts
@@ -0,0 +1,12 @@
+import { resolve } from "node:path";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ root: resolve(import.meta.dirname, "conformance"),
+ plugins: [react()],
+ build: {
+ outDir: resolve(import.meta.dirname, "dist-conformance"),
+ emptyOutDir: true
+ }
+});