From dd7ad4d9c77ac1cd2740b9b55bdd316e686c156a Mon Sep 17 00:00:00 2001
From: Albrecht Degering
Date: Tue, 18 Aug 2026 02:17:13 +0200
Subject: [PATCH] feat: add shared workspace layouts
---
docs/CONTEXTUAL_HELP_CONTRACT.md | 5 +-
docs/INTERFACE_PATTERN_MIGRATION.md | 12 +--
webui/package.json | 1 +
.../scripts/test-core-interface-patterns.mjs | 3 +
webui/src/components/PageLayout.tsx | 16 +++-
webui/src/components/WorkspaceLayout.tsx | 84 +++++++++++++++++++
webui/src/index.ts | 2 +
webui/src/styles/layout.css | 15 ++++
webui/tests/page-layout.test.tsx | 30 +++++++
webui/tests/workspace-layout.test.tsx | 48 +++++++++++
webui/tsconfig.component-tests.json | 2 +
11 files changed, 207 insertions(+), 11 deletions(-)
create mode 100644 webui/src/components/WorkspaceLayout.tsx
create mode 100644 webui/tests/workspace-layout.test.tsx
diff --git a/docs/CONTEXTUAL_HELP_CONTRACT.md b/docs/CONTEXTUAL_HELP_CONTRACT.md
index 6d98fc8..36b8295 100644
--- a/docs/CONTEXTUAL_HELP_CONTRACT.md
+++ b/docs/CONTEXTUAL_HELP_CONTRACT.md
@@ -55,8 +55,9 @@ than adding custom `F1` listeners:
- `TableActionGroup` action definitions carry the same identities so focused
row actions can resolve consequence-specific help.
- `PageLayout` owns the page help scope and documentation identity for ordinary
- headed pages; specialized full-canvas workspaces reuse `PageHeader` while
- keeping their route-level help identity on the owning workspace.
+ headed pages. `WorkspaceLayout` owns the full-canvas workspace scope and its
+ labelled primary/content panes; pages inside it use `PageLayout` in
+ `workspace` mode and retain their own route-level help identity.
Module routes, public routes, settings sections, and administration sections
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md
index 2f3f59b..f1cae0d 100644
--- a/docs/INTERFACE_PATTERN_MIGRATION.md
+++ b/docs/INTERFACE_PATTERN_MIGRATION.md
@@ -13,7 +13,8 @@ domain modules own their compositions.
| Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` |
| Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` |
| Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract |
-| Shared page frame | Domain-neutral headed page layout used by Core and optional modules | Scroll ownership, sticky heading, route actions, page notices, loading, narrow-layout collapse, and contextual-help identity are centralized; `AdminPageLayout` composes the same contract | `PageLayout.tsx`, `page-layout.test.tsx`, Core fallback dashboard, Dashboard module, Ops module, and `check-shared-webui-layouts.py` |
+| Shared page frame | Domain-neutral headed page layout used by Core and optional modules | Standalone, workspace, and embedded modes make inset and scroll ownership explicit; sticky heading, rich descriptions, route actions, notices, loading, narrow-layout collapse, and contextual-help identity are centralized; `AdminPageLayout` composes the same contract | `PageLayout.tsx`, `page-layout.test.tsx`, Core fallback dashboard, Dashboard module, Ops module, Campaign pages, and `check-shared-webui-layouts.py` |
+| Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Campaign workspace, Campaign module workspace, Templates workspace, Approvals list/detail workspace, and `check-shared-webui-layouts.py` |
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, route/page/field/action F1 help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests, `CONTEXTUAL_HELP_CONTRACT.md`, and module-permutation build |
## Boundary
@@ -28,7 +29,8 @@ Raw JSON remains permitted only for diagnostics, expert inspection,
interchange, or conflict evidence. It is not a primary Core configuration
editor.
-Raw page-frame debt is registered in the meta repository and may only decrease.
-New headed pages use `PageLayout`; full-canvas explorers may use `PageHeader`
-while the next shared workspace/split-pane contract is developed. Module CSS
-continues to own domain content layout, never the shared page frame.
+Raw page-frame and full-canvas workspace debt is registered in the meta
+repository and may only decrease. New headed pages use `PageLayout`; pages
+inside `WorkspaceLayout` use its `workspace` mode so the pane owns scrolling
+while the page retains the standard inset. Module CSS continues to own domain
+content layout, never the shared page or workspace frame.
diff --git a/webui/package.json b/webui/package.json
index 15a8441..e51e4a3 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -44,6 +44,7 @@
"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",
"test:page-layout": "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/page-layout.test.js",
+ "test:workspace-layout": "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/workspace-layout.test.js",
"test:people-picker": "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/people-picker.test.js",
"test:password-field": "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/password-generator.test.js",
"test:resource-access": "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/resource-access-explanation.test.js",
diff --git a/webui/scripts/test-core-interface-patterns.mjs b/webui/scripts/test-core-interface-patterns.mjs
index 5130988..1003ee4 100644
--- a/webui/scripts/test-core-interface-patterns.mjs
+++ b/webui/scripts/test-core-interface-patterns.mjs
@@ -17,6 +17,7 @@ const temporalDataMenu = read("src/layout/TemporalDataMenu.tsx");
const helpMenu = read("src/layout/HelpMenu.tsx");
const helpContext = read("src/utils/helpContext.ts");
const pageLayout = read("src/components/PageLayout.tsx");
+const workspaceLayout = read("src/components/WorkspaceLayout.tsx");
const adminPageLayout = read("src/components/admin/AdminPageLayout.tsx");
const layoutStyles = read("src/styles/layout.css");
const authGateStyles = read("src/styles/auth-gate.css");
@@ -49,6 +50,8 @@ assert.match(pageLayout, /`page-layout-\$\{mode\}`/, "shared standalone and embe
assert.match(pageLayout, /\s*
}
+ mode="workspace"
+ notices={Additional notice
}
+ >
+ Workspace content
+
+
+);
+
+assert(!workspaceMarkup.includes("page-scroll-viewport"), "workspace pages defer scrolling to the workspace content pane");
+assert(workspaceMarkup.includes("page-layout-workspace content-pad workspace-data-page"), "workspace pages keep the standard content inset");
+assert(workspaceMarkup.includes('class="version-line"'), "rich page descriptions keep their own semantic element");
+assert(!workspaceMarkup.includes("
+
+ Stable content
+
+
+);
+
+assert(headerLoadingMarkup.includes("loading-indicator"), "page headings can expose partial refresh progress");
+assert(!headerLoadingMarkup.includes('aria-busy="true"'), "partial refresh does not block the stable page body");
diff --git a/webui/tests/workspace-layout.test.tsx b/webui/tests/workspace-layout.test.tsx
new file mode 100644
index 0000000..d8a3f1a
--- /dev/null
+++ b/webui/tests/workspace-layout.test.tsx
@@ -0,0 +1,48 @@
+function assert(condition: unknown, message = "assertion failed"): void {
+ if (!condition) throw new Error(message);
+}
+
+import { renderToStaticMarkup } from "react-dom/server";
+import WorkspaceLayout from "../src/components/WorkspaceLayout";
+import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
+
+const navigationMarkup = renderToStaticMarkup(
+
+ Sections}
+ primaryLabel="Campaign sections"
+ contentLabel="Campaign content"
+ interfaceId="campaign.workspace"
+ helpModuleId="campaign"
+ >
+ Selected section
+
+
+);
+
+assert(navigationMarkup.includes("workspace-layout-navigation"), "navigation workspaces use the central navigation geometry");
+assert(navigationMarkup.includes("workspace-layout-primary-default"), "the default primary-pane width is explicit");
+assert(navigationMarkup.includes("workspace-layout-pane-contained"), "navigation panes defer scrolling to their navigation component");
+assert(navigationMarkup.includes('aria-label="Campaign sections"'), "the primary pane can be named");
+assert(navigationMarkup.includes('aria-label="Campaign content"'), "the content pane can be named");
+assert(navigationMarkup.includes('data-help-scope="workspace"'), "workspace contextual-help scope is centralized");
+assert(navigationMarkup.includes('data-interface-id="campaign.workspace"'), "workspace identity reaches the root");
+assert(navigationMarkup.includes('data-help-documentation-type="user"'), "workspace documentation type is explicit");
+
+const splitMarkup = renderToStaticMarkup(
+
+ Collection}
+ >
+ Detail
+
+
+);
+
+assert(splitMarkup.includes("workspace-layout-split"), "list-detail workspaces opt into split-pane geometry");
+assert(splitMarkup.includes("workspace-layout-primary-wide"), "split workspaces use central width variants");
+assert(splitMarkup.includes("workspace-layout-pane-scrollable"), "split primary panes can own scrolling");
+assert(splitMarkup.includes("workspace-layout-content workspace-layout-pane-contained"), "contained content panes can delegate scrolling to their children");
diff --git a/webui/tsconfig.component-tests.json b/webui/tsconfig.component-tests.json
index b16f6fd..36fc1c4 100644
--- a/webui/tsconfig.component-tests.json
+++ b/webui/tsconfig.component-tests.json
@@ -28,6 +28,7 @@
"tests/mail-components.test.tsx",
"tests/metric-card.test.tsx",
"tests/page-layout.test.tsx",
+ "tests/workspace-layout.test.tsx",
"tests/people-picker.test.tsx",
"tests/password-generator.test.tsx",
"tests/resource-access-explanation.test.tsx",
@@ -48,6 +49,7 @@
"src/components/PageLayout.tsx",
"src/components/PageScrollViewport.tsx",
"src/components/PageTitle.tsx",
+ "src/components/WorkspaceLayout.tsx",
"src/components/LoadingFrame.tsx",
"src/components/LoadingIndicator.tsx",
"src/components/people/PeoplePicker.tsx",