diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md
new file mode 100644
index 0000000..a4b6e70
--- /dev/null
+++ b/docs/INTERFACE_PATTERN_MIGRATION.md
@@ -0,0 +1,34 @@
+# Dashboard Interface Pattern Migration
+
+This migration applies the GovOPlaN interface pattern language to the
+view-specific summary, widget library, four-column placement grid, and widget
+configuration dialog.
+
+## Surface Inventory
+
+| Surface | Archetype | Consequence class | Contract |
+| --- | --- | --- | --- |
+| `/dashboard` | Personal workspace | Inspect current task context | Stable loading, fallback, empty, error, and help states |
+| Summary | Metric overview | Inspect module/widget/layout state | Installed, available, placed, active View, and persistence source remain distinguishable |
+| Widget library | Capability-filtered catalogue | Add or reset placements | Permission/View filtering, search, capacity reason, drag/click parity, and reversible defaults |
+| Dashboard grid | Configurable composition | Reorder, resize, configure, or remove placement | Four-column placement contract, visible drag target, guarded draft, and no provider-data mutation |
+| Widget settings | Nested definition editor | Apply presentation/query settings | Guarded local draft, contextual field help, validation reason, reset, apply, and discard |
+
+## Consequence And Availability Rules
+
+- Layouts are scoped by tenant, account, and active focused View. A focused
+ View changes the composition key but never grants access to widget data.
+- Configure mode edits a local draft. Save persists the complete layout using
+ its revision; stale concurrent saves are rejected rather than overwritten.
+- Removing a widget affects only its placement. Reset restores the defaults
+ contributed by active modules and remains reversible until Save layout.
+- Widgets are filtered by installed module, focused View surface, and declared
+ scopes. Every provider still authorizes its own requests.
+- When the saved layout is unreachable, the browser or module defaults are
+ clearly identified as a fallback. Saving promotes the current draft to the
+ account-backed layout.
+
+Backend and WebUI manifests publish matching page, summary, library, grid,
+settings, and built-in-widget surface identifiers. English and German
+catalogues cover Dashboard-owned vocabulary, contextual help resolves from
+manifest documentation, and both layout and widget-setting drafts are guarded.
diff --git a/src/govoplan_dashboard/backend/manifest.py b/src/govoplan_dashboard/backend/manifest.py
index 5c08b44..dd21a34 100644
--- a/src/govoplan_dashboard/backend/manifest.py
+++ b/src/govoplan_dashboard/backend/manifest.py
@@ -72,11 +72,51 @@ manifest = ModuleManifest(
routes=(FrontendRoute(path="/dashboard", component="DashboardPage", order=10),),
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),),
view_surfaces=(
+ ViewSurface(
+ id="dashboard.page",
+ module_id="dashboard",
+ kind="route",
+ label="Dashboard",
+ order=10,
+ ),
+ ViewSurface(
+ id="dashboard.summary",
+ module_id="dashboard",
+ kind="section",
+ label="Dashboard summary",
+ parent_id="dashboard.page",
+ order=10,
+ ),
+ ViewSurface(
+ id="dashboard.library",
+ module_id="dashboard",
+ kind="section",
+ label="Widget library",
+ parent_id="dashboard.page",
+ order=20,
+ ),
+ ViewSurface(
+ id="dashboard.grid",
+ module_id="dashboard",
+ kind="section",
+ label="Dashboard grid",
+ parent_id="dashboard.page",
+ order=30,
+ ),
+ ViewSurface(
+ id="dashboard.widget-settings",
+ module_id="dashboard",
+ kind="action",
+ label="Widget settings",
+ parent_id="dashboard.grid",
+ order=40,
+ ),
ViewSurface(
id="dashboard.widget.installed-modules",
module_id="dashboard",
kind="section",
label="Installed modules widget",
+ parent_id="dashboard.grid",
order=10,
),
),
@@ -96,6 +136,53 @@ manifest = ModuleManifest(
audience=("user", "tenant_admin", "operator"),
related_modules=("core", "ops"),
order=20,
+ metadata={
+ "help_contexts": [
+ "dashboard.page",
+ "dashboard.summary",
+ "dashboard.library",
+ "dashboard.grid",
+ "dashboard.state.browser-fallback",
+ "dashboard.state.view-specific",
+ ],
+ },
+ ),
+ DocumentationTopic(
+ id="dashboard.reference.layout-and-widgets",
+ title="Dashboard layouts and widget consequences",
+ summary="Per-user, per-tenant, and per-View widget placement, sizing, configuration, availability, and fallback semantics.",
+ body=(
+ "A Dashboard layout belongs to the active tenant, account, and focused View. "
+ "Configuring changes only a local draft until Save layout is selected; Cancel or "
+ "Discard restores the last saved arrangement. Widget removal removes only the placement, "
+ "not the module data represented by the widget. Reset restores the defaults announced by "
+ "currently active modules and remains reversible until save. A widget is offered only when "
+ "its module, focused View surface, and permission contract are available. Widgets never grant "
+ "access and providers must authorize every data request. If the server layout is unreachable, "
+ "the browser or module default can be displayed but is identified as a fallback until saved. "
+ "Concurrent saves use the layout revision and reject stale updates instead of overwriting them."
+ ),
+ layer="available",
+ documentation_types=("admin", "user"),
+ audience=("user", "tenant_admin", "operator", "module_admin"),
+ related_modules=("core", "views", "access", "ops"),
+ order=21,
+ metadata={
+ "help_contexts": [
+ "dashboard.action.configure",
+ "dashboard.action.save",
+ "dashboard.action.reset",
+ "dashboard.action.remove-widget",
+ "dashboard.field.widget-size",
+ "dashboard.field.widget-configuration",
+ ],
+ "consequence_classes": {
+ "save_layout": "Persists the complete layout for the active tenant, account, and View revision context.",
+ "reset_layout": "Replaces the draft with current module defaults and remains reversible until save.",
+ "remove_widget": "Removes only the placement from the draft; provider data is unchanged.",
+ "configure_widget": "Changes presentation and query preferences for one widget placement.",
+ },
+ },
),
),
architecture=declared_module_architecture(
diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py
new file mode 100644
index 0000000..8f7f27f
--- /dev/null
+++ b/tests/test_interface_documentation_contract.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+from pathlib import Path
+import unittest
+
+from govoplan_dashboard.backend.manifest import get_manifest
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+
+
+class DashboardInterfaceDocumentationContractTests(unittest.TestCase):
+ def test_surface_hierarchy_remains_declared(self) -> None:
+ frontend = get_manifest().frontend
+ self.assertIsNotNone(frontend)
+ surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr]
+ self.assertEqual(
+ {
+ "dashboard.page",
+ "dashboard.summary",
+ "dashboard.library",
+ "dashboard.grid",
+ "dashboard.widget-settings",
+ "dashboard.widget.installed-modules",
+ },
+ set(surfaces),
+ )
+ self.assertEqual("dashboard.page", surfaces["dashboard.library"].parent_id)
+ self.assertEqual("dashboard.page", surfaces["dashboard.grid"].parent_id)
+ self.assertEqual("dashboard.grid", surfaces["dashboard.widget-settings"].parent_id)
+
+ def test_help_and_consequence_metadata_remain_published(self) -> None:
+ topics = {topic.id: topic for topic in get_manifest().documentation}
+ home = topics["dashboard.configurable-home"]
+ reference = topics["dashboard.reference.layout-and-widgets"]
+
+ self.assertIn("dashboard.state.browser-fallback", home.metadata["help_contexts"])
+ self.assertIn("dashboard.field.widget-size", reference.metadata["help_contexts"])
+ self.assertIn("save_layout", reference.metadata["consequence_classes"])
+ self.assertIn("remove_widget", reference.metadata["consequence_classes"])
+
+ def test_webui_guards_layout_and_nested_widget_drafts(self) -> None:
+ page = (REPO_ROOT / "webui/src/features/dashboard/DashboardPage.tsx").read_text(
+ encoding="utf-8"
+ )
+ dialog = (
+ REPO_ROOT
+ / "webui/src/features/dashboard/WidgetConfigurationDialog.tsx"
+ ).read_text(encoding="utf-8")
+ library = (
+ REPO_ROOT / "webui/src/features/dashboard/WidgetLibrary.tsx"
+ ).read_text(encoding="utf-8")
+
+ self.assertIn("DocumentationHelpLink", page)
+ self.assertIn("useUnsavedDraftGuard", page)
+ self.assertIn("useUnsavedDraftGuard", dialog)
+ self.assertIn("DASHBOARD_LAYOUT_DOCUMENTATION", dialog)
+ self.assertIn("disabled={atCapacity}", library)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/webui/src/features/dashboard/DashboardPage.tsx b/webui/src/features/dashboard/DashboardPage.tsx
index d0ce2ab..1647c76 100644
--- a/webui/src/features/dashboard/DashboardPage.tsx
+++ b/webui/src/features/dashboard/DashboardPage.tsx
@@ -13,6 +13,7 @@ import {
import {
Button,
DismissibleAlert,
+ DocumentationHelpLink,
LoadingFrame,
MetricCard,
PageScrollViewport,
@@ -36,6 +37,10 @@ import {
import DashboardGrid from "./DashboardGrid";
import WidgetConfigurationDialog from "./WidgetConfigurationDialog";
import WidgetLibrary from "./WidgetLibrary";
+import {
+ DASHBOARD_DOCUMENTATION,
+ DASHBOARD_I18N
+} from "./interfacePatterns";
import {
appendPlacement,
DASHBOARD_COLUMN_COUNT,
@@ -184,8 +189,8 @@ export default function DashboardPage({
dirty,
onSave: persistLayout,
onDiscard: discardChanges,
- title: "Unsaved Dashboard layout",
- message: "Save or discard the Dashboard arrangement before leaving this page."
+ title: "i18n:govoplan-dashboard.unsaved_layout_title",
+ message: "i18n:govoplan-dashboard.unsaved_layout_message"
});
const activeLayout = configuring ? draftLayout : savedLayout;
@@ -446,25 +451,39 @@ export default function DashboardPage({
Personal workspace assembled from installed module widgets.
+
{!configuring && (
<>
-